@idosgames/mcp 0.1.28 → 0.1.30

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 (49) hide show
  1. package/dist/cli.js +1 -1
  2. package/package.json +1 -1
  3. package/registry/host.json +11 -71
  4. package/registry/index.json +140 -140
  5. package/registry/modules/ai.json +5 -5
  6. package/registry/modules/board-game.json +4 -4
  7. package/registry/modules/boosts.json +7 -7
  8. package/registry/modules/character.json +34 -6
  9. package/registry/modules/chat.json +26 -6
  10. package/registry/modules/collections.json +23 -7
  11. package/registry/modules/coop-events.json +7 -7
  12. package/registry/modules/craft.json +7 -7
  13. package/registry/modules/deals.json +3 -3
  14. package/registry/modules/events.json +6 -6
  15. package/registry/modules/friends.json +26 -6
  16. package/registry/modules/idle-rpg.json +3 -3
  17. package/registry/modules/inventory.json +3 -3
  18. package/registry/modules/invite.json +6 -6
  19. package/registry/modules/leaderboard.json +22 -6
  20. package/registry/modules/lootboxes.json +7 -7
  21. package/registry/modules/mailbox.json +34 -6
  22. package/registry/modules/marketplace.json +35 -7
  23. package/registry/modules/mini-game.json +3 -3
  24. package/registry/modules/multiplayer.json +35 -11
  25. package/registry/modules/premium.json +7 -7
  26. package/registry/modules/profile.json +5 -5
  27. package/registry/modules/pvp.json +34 -6
  28. package/registry/modules/quests.json +6 -6
  29. package/registry/modules/rewards.json +7 -7
  30. package/registry/modules/season.json +6 -6
  31. package/registry/modules/store.json +5 -5
  32. package/registry/modules/tutorials.json +3 -3
  33. package/registry/modules/voxelcraft.json +265 -13
  34. package/registry/modules/workshop.json +50 -6
  35. package/registry/skills/authentication.json +1 -1
  36. package/registry/skills/blockchain-system.json +1 -1
  37. package/registry/skills/checkout-system.json +1 -1
  38. package/registry/skills/cloud-code.json +2 -2
  39. package/registry/skills/currency-system.json +1 -1
  40. package/registry/skills/idosgames-compose-modules.json +1 -1
  41. package/registry/skills/idosgames-getting-started.json +1 -1
  42. package/registry/skills/idosgames-module-contract.json +2 -2
  43. package/registry/skills/idosgames-project-structure.json +1 -1
  44. package/registry/skills/idosgames-ui-kit.json +2 -2
  45. package/registry/skills/marketplace-system.json +1 -1
  46. package/registry/skills/match-system.json +1 -1
  47. package/registry/skills/multiplayer-realtime.json +2 -2
  48. package/registry/skills/voxelcraft-worlds.json +2 -2
  49. package/registry/skills/workshop-system.json +2 -2
@@ -33,13 +33,41 @@
33
33
  "version": "0.1.0"
34
34
  },
35
35
  "dependencies": {
36
- "@idosgames/core": "0.21.2",
37
- "@idosgames/module-sdk": "0.10.0",
38
- "@idosgames/react": "0.4.4",
36
+ "@idosgames/core": "0.21.3",
37
+ "@idosgames/module-sdk": "0.10.1",
38
+ "@idosgames/react": "0.4.5",
39
39
  "react": "19.2.7"
40
40
  },
41
- "contentHash": "422f135f64903ca4f30425c1f2da4e7093a0f81e5877d65184fa7bc5a0eab846",
41
+ "contentHash": "8a028007e7138cbb77218f6785e7c2fb50b8fdea6f085d35293d93ec1167429f",
42
42
  "files": [
43
+ {
44
+ "path": "components/BattlePopups.tsx",
45
+ "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport { type InstantBattleResponse } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n errorText,\n Icon,\n outlined,\n panel,\n Popup,\n ResourceList,\n Tabs,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n ZONES,\n outcomeFor,\n roundLines,\n strategyOrDefault,\n type Step,\n type Zone,\n} from \"../model\";\nimport { t } from \"../i18n\";\n\nconst ZONE_LABEL: Record<Zone, Parameters<typeof t>[0]> = {\n Head: \"head\",\n Torso: \"torso\",\n Legs: \"legs\",\n};\n\nexport function StrategyPopup({ onClose }: { onClose: () => void }): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [steps, setSteps] = useState<Step[]>(() =>\n strategyOrDefault(client.data.user.state?.Match?.PvPBattleStrategy),\n );\n const [busy, setBusy] = useState(false);\n const set = (i: number, key: keyof Step, zone: Zone) =>\n setSteps((s) => s.map((st, j) => (j === i ? { ...st, [key]: zone } : st)));\n const save = async () => {\n setBusy(true);\n const res = await client.match.saveStrategy(steps);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"saved\"));\n onClose();\n };\n const zoneTabs = ZONES.map((z) => ({ id: z, label: t(ZONE_LABEL[z]) }));\n return (\n <Popup title={t(\"strategy\")} onClose={onClose} width={460}>\n <div style={{ display: \"grid\", gap: 10 }}>\n {steps.map((s, i) => (\n <div\n key={i}\n style={{ ...panel, padding: 10, display: \"grid\", gap: 6 }}\n >\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span>\n {t(\"round\")} {i + 1}\n </span>\n {steps.length > 1 ? (\n <button\n type=\"button\"\n onClick={() => setSteps((st) => st.filter((_, j) => j !== i))}\n style={{\n background: \"none\",\n border: \"none\",\n color: v.textDim,\n cursor: \"pointer\",\n }}\n >\n ✕\n </button>\n ) : null}\n </div>\n <div style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"attack\")}\n </div>\n <Tabs\n tabs={zoneTabs}\n value={s.AttackTarget}\n onChange={(z) => set(i, \"AttackTarget\", z)}\n />\n <div style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"defense\")}\n </div>\n <Tabs\n tabs={zoneTabs}\n value={s.DefenseTarget}\n onChange={(z) => set(i, \"DefenseTarget\", z)}\n />\n </div>\n ))}\n {steps.length < 10 ? (\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() =>\n setSteps((s) => [\n ...s,\n { AttackTarget: \"Head\", DefenseTarget: \"Torso\" },\n ])\n }\n >\n {t(\"addRound\")}\n </Button>\n ) : null}\n <Button tone=\"green\" busy={busy} onClick={() => void save()}>\n {t(\"save\")}\n </Button>\n </div>\n </Popup>\n );\n}\n\nconst HIT_TEXT = {\n Hit: \"hHit\",\n Critical: \"hCritical\",\n Block: \"hBlock\",\n Dodge: \"hDodge\",\n} as const;\n\nexport function ResultPopup({\n result,\n onClose,\n}: {\n result: InstantBattleResponse;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const kit = useUiKit();\n const me = client.auth.context?.userID;\n const outcome = outcomeFor(result.Battle, me);\n const rounds = roundLines(result.Battle?.BattleLog, me);\n const stagger = useStagger();\n const [shown, setShown] = useState(0);\n const reward =\n result.Battle?.NetReward?.Entries?.filter((e) => e?.CurrencyID).map(\n (e) => ({\n kind:\n e!.Type === \"CryptoCurrency\"\n ? (\"crypto\" as const)\n : (\"currency\" as const),\n id: e!.CurrencyID!,\n amount: Number(e!.Amount ?? 0),\n }),\n ) ?? [];\n useEffect(() => {\n kit.play(\n outcome === \"win\" ? \"levelUp\" : outcome === \"lose\" ? \"error\" : \"notify\",\n );\n }, []);\n // Rounds appear one by one — a short replay of the fight.\n const next = useCallback(\n () => setShown((n) => Math.min(rounds.length, n + 1)),\n [rounds.length],\n );\n useEffect(() => {\n if (shown >= rounds.length) return;\n const id = setTimeout(next, 220);\n return () => clearTimeout(id);\n }, [shown, next, rounds.length]);\n return (\n <Popup\n title={\n outcome === \"win\"\n ? t(\"won\")\n : outcome === \"lose\"\n ? t(\"lost\")\n : t(\"draw\")\n }\n onClose={onClose}\n width={460}\n variant=\"center\"\n >\n <div\n className=\"idos-bounce\"\n style={{ display: \"grid\", justifyItems: \"center\" }}\n >\n <Icon glyph={outcome === \"win\" ? \"trophy\" : \"swords\"} size={64} />\n </div>\n {outcome === \"win\" && reward.length > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <ResourceList lines={reward} size={30} />\n </div>\n ) : null}\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"rounds\")}\n </div>\n <div\n className=\"idos-scroll\"\n style={{ display: \"grid\", gap: 4, maxHeight: 260, overflowY: \"auto\" }}\n >\n {rounds.slice(0, shown).map((r, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: \"6px 10px\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n fontSize: 13,\n borderColor: r.mine ? v.green : v.red,\n ...stagger(0),\n }}\n >\n <span style={{ ...outlined, minWidth: 28 }}>{r.round}</span>\n <span style={{ ...outlined, color: r.mine ? v.green : v.red }}>\n {r.mine ? t(\"you\") : t(\"foe\")}\n </span>\n <span style={{ flex: 1 }}>\n → {t(ZONE_LABEL[r.zone])}, {t(HIT_TEXT[r.hit])}\n </span>\n <span style={{ ...outlined }}>−{r.damage}</span>\n <span\n style={{ color: v.textDim, minWidth: 54, textAlign: \"right\" }}\n >\n {r.hpLeft} {t(\"hp\")}\n </span>\n </div>\n ))}\n </div>\n <Button tone=\"blue\" onClick={onClose}>\n OK\n </Button>\n </Popup>\n );\n}\n"
46
+ },
47
+ {
48
+ "path": "components/CreatePopup.tsx",
49
+ "content": "import { useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n canAfford,\n errorText,\n Field,\n outlined,\n Popup,\n ResourceList,\n Tabs,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { clampEntry, ruleWinnerTakes, type DuelRule } from \"../model\";\nimport { t } from \"../i18n\";\nimport { HeroPicker } from \"./HeroPicker\";\nimport { type Hero } from \"../heroes\";\n\nexport function CreatePopup({\n rules,\n heroes,\n onClose,\n onCreated,\n}: {\n rules: DuelRule[];\n heroes: Hero[];\n onClose: () => void;\n onCreated: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const kit = useUiKit();\n const [ruleID, setRuleID] = useState(rules[0]!.id);\n const rule = rules.find((r) => r.id === ruleID) ?? rules[0]!;\n const [amount, setAmount] = useState(clampEntry(50, rule));\n const [hero, setHero] = useState(heroes[0]!.id);\n const [busy, setBusy] = useState(false);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n // A crypto shortfall opens the site's top-up window and then charges (`client.checkout`), so\n // the button stays enabled there; elsewhere a short crypto balance disables it.\n const cryptoBalances = client.checkout.topUpAvailable\n ? null\n : (state?.InventoryV2?.CryptoCurrencies as\n Record<string, { Amount?: number }> | undefined);\n const entryKind = rule.crypto ? (\"crypto\" as const) : (\"currency\" as const);\n const entryLine = rule.currency\n ? [{ kind: entryKind, id: rule.currency, amount }]\n : [];\n const affordable = canAfford(entryLine, balances, cryptoBalances);\n const create = async () => {\n if (!rule.currency) return;\n setBusy(true);\n const saved = client.data.user.state?.Match?.PvPBattleStrategy;\n const res = await client.match.createMatch(\n {\n Entries: [\n {\n Type: rule.crypto ? \"CryptoCurrency\" : \"VirtualCurrency\",\n CurrencyID: rule.currency,\n Amount: amount,\n },\n ],\n } as never,\n rule.id,\n hero,\n saved ?? undefined,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"purchase\");\n onCreated();\n onClose();\n };\n return (\n <Popup title={t(\"create\")} onClose={onClose} width={440}>\n {rules.length > 1 ? (\n <Tabs\n tabs={rules.map((r) => ({ id: r.id, label: r.name }))}\n value={ruleID}\n onChange={(id) => {\n setRuleID(id);\n const r = rules.find((x) => x.id === id)!;\n setAmount(clampEntry(amount, r));\n }}\n />\n ) : null}\n {rule.description ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {rule.description}\n </div>\n ) : null}\n <Field label={t(\"hero\")}>\n <HeroPicker heroes={heroes} value={hero} onChange={setHero} />\n </Field>\n <Field label={t(\"entry\")}>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n justifyContent: \"center\",\n }}\n >\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() => setAmount(clampEntry(amount - 10, rule))}\n >\n −\n </Button>\n <ResourceList lines={entryLine} size={26} dimIf={() => !affordable} />\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() => setAmount(clampEntry(amount + 10, rule))}\n >\n +\n </Button>\n </div>\n </Field>\n {rule.currency ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 6,\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n {t(\"winnerTakes\")}{\" \"}\n <ResourceList\n lines={[\n {\n kind: entryKind,\n id: rule.currency,\n amount: ruleWinnerTakes(rule, amount),\n },\n ]}\n size={18}\n />\n </div>\n ) : null}\n <Button\n tone=\"gold\"\n disabled={!affordable}\n busy={busy}\n onClick={() => void create()}\n >\n {t(\"create\")}\n </Button>\n </Popup>\n );\n}\n"
50
+ },
51
+ {
52
+ "path": "components/Duels.tsx",
53
+ "content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport { type PvPMatch } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n EmptyState,\n errorText,\n Icon,\n outlined,\n panel,\n ResourceList,\n Spinner,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { matchEntry, type DuelRule } from \"../model\";\nimport { t } from \"../i18n\";\nimport { isExpired, shortID } from \"../helpers\";\n\nfunction DuelRow({\n match,\n rules,\n index,\n right,\n onPick,\n}: {\n match: PvPMatch;\n rules: DuelRule[];\n index: number;\n right?: ReactNode;\n onPick?: () => void;\n}): ReactNode {\n const stagger = useStagger();\n const e = matchEntry(match);\n const rule = rules.find((r) => r.id === match.RuleID);\n return (\n <div\n onClick={onPick}\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 <Icon glyph=\"swords\" size={30} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {rule?.name ?? match.RuleID} · {t(\"player\")}{\" \"}\n {shortID(match.CreatorID)}\n </div>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n }}\n >\n {t(\"entry\")}{\" \"}\n {e.currency ? (\n <ResourceList\n lines={[\n {\n kind: e.crypto ? \"crypto\" : \"currency\",\n id: e.currency,\n amount: e.amount,\n },\n ]}\n size={16}\n />\n ) : null}\n {match.CreatorCharacterID ? ` · ${match.CreatorCharacterID}` : \"\"}\n </div>\n </div>\n {right}\n </div>\n );\n}\n\nexport function OpenDuels({\n rules,\n onJoin,\n}: {\n rules: DuelRule[];\n onJoin: (m: PvPMatch) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const [list, setList] = useState<PvPMatch[] | null>(null);\n useEffect(() => {\n void client.match\n .getAvailableMatches(0, 30)\n .then((r) => setList(r.ok ? (r.data.Matches ?? []) : []));\n }, [client]);\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"swords\" text={t(\"noOpen\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((m, i) => (\n <DuelRow\n key={m.MatchID}\n match={m}\n rules={rules}\n index={i}\n onPick={() => onJoin(m)}\n right={\n <Button size=\"sm\" tone=\"green\">\n {t(\"fight\")}\n </Button>\n }\n />\n ))}\n </div>\n );\n}\n\nexport function MyDuels({\n rules,\n onChange,\n}: {\n rules: DuelRule[];\n onChange: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const me = client.auth.context?.userID;\n const [list, setList] = useState<PvPMatch[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n useEffect(() => {\n void client.match\n .getMyMatches(0, 30)\n .then((r) => setList(r.ok ? (r.data.Matches ?? []) : []));\n }, [client]);\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"swords\" text={t(\"noMine\")} />;\n const cancel = async (id: string) => {\n setBusy(id);\n const res = await client.match.cancelMatch(id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n onChange();\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((m, i) => (\n <DuelRow\n key={m.MatchID}\n match={m}\n rules={rules}\n index={i}\n right={\n isExpired(m) ? (\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"expired\")}\n </span>\n ) : m.Status === \"Open\" && m.CreatorID === me ? (\n <div style={{ display: \"grid\", gap: 4, justifyItems: \"end\" }}>\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"waiting\")}\n </span>\n <Button\n size=\"sm\"\n tone=\"grey\"\n busy={busy === m.MatchID}\n onClick={() => void cancel(m.MatchID)}\n >\n {t(\"cancel\")}\n </Button>\n </div>\n ) : m.Status === \"Cancelled\" ? (\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"cancelled\")}\n </span>\n ) : m.Status === \"Completed\" ? (\n <span\n style={{\n ...outlined,\n fontSize: 13,\n color: !m.WinnerUserID\n ? v.textDim\n : m.WinnerUserID === me\n ? v.green\n : v.red,\n }}\n >\n {!m.WinnerUserID\n ? t(\"draw\")\n : m.WinnerUserID === me\n ? t(\"youWon\")\n : t(\"youLost\")}\n </span>\n ) : null\n }\n />\n ))}\n </div>\n );\n}\n"
54
+ },
55
+ {
56
+ "path": "components/HeroPicker.tsx",
57
+ "content": "import { type ReactNode } from \"react\";\nimport { formatAmount, Icon, outlined, panel, Tabs } from \"@idosgames/react/ui\";\nimport { type Hero } from \"../heroes\";\n\nexport function HeroPicker({\n heroes,\n value,\n onChange,\n}: {\n heroes: Hero[];\n value: string;\n onChange: (id: string) => void;\n}): ReactNode {\n // One hero: nothing to pick, but the player still sees who fights (tabs need two).\n if (heroes.length === 1)\n return (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 14,\n }}\n >\n <Icon glyph=\"hero\" size={22} /> {heroes[0]!.name} ·{\" \"}\n {formatAmount(heroes[0]!.power)}\n </div>\n );\n return (\n <Tabs\n tabs={heroes.map((h) => ({\n id: h.id,\n label: `${h.name} · ${formatAmount(h.power)}`,\n }))}\n value={value}\n onChange={onChange}\n />\n );\n}\n"
58
+ },
59
+ {
60
+ "path": "components/JoinPopup.tsx",
61
+ "content": "import { useState, type ReactNode } from \"react\";\nimport { type InstantBattleResponse, type PvPMatch } from \"@idosgames/core\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n canAfford,\n errorText,\n Field,\n Icon,\n outlined,\n Popup,\n ResourceList,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { matchEntry, ruleWinnerTakes, type DuelRule } from \"../model\";\nimport { t } from \"../i18n\";\nimport { HeroPicker } from \"./HeroPicker\";\nimport { shortID } from \"../helpers\";\nimport { type Hero } from \"../heroes\";\n\nexport function JoinPopup({\n match,\n rule,\n heroes,\n onClose,\n onFought,\n}: {\n match: PvPMatch;\n rule: DuelRule;\n heroes: Hero[];\n onClose: () => void;\n onFought: (r: InstantBattleResponse) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const [hero, setHero] = useState(heroes[0]!.id);\n const [busy, setBusy] = useState(false);\n const e = matchEntry(match);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n // A crypto shortfall opens the site's top-up window and then charges (`client.checkout`), so\n // the button stays enabled there; elsewhere a short crypto balance disables it.\n const cryptoBalances = client.checkout.topUpAvailable\n ? null\n : (state?.InventoryV2?.CryptoCurrencies as\n Record<string, { Amount?: number }> | undefined);\n const entryKind = e.crypto ? (\"crypto\" as const) : (\"currency\" as const);\n const entryLine = e.currency\n ? [{ kind: entryKind, id: e.currency, amount: e.amount }]\n : [];\n const affordable = canAfford(entryLine, balances, cryptoBalances);\n const fight = async () => {\n setBusy(true);\n const saved = client.data.user.state?.Match?.PvPBattleStrategy;\n const res = await client.match.instantBattle(\n match.MatchID,\n match.RuleID ?? rule.id,\n hero,\n saved ?? undefined,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n onFought(res.data);\n };\n return (\n <Popup\n title={`${rule.name} · ${t(\"player\")} ${shortID(match.CreatorID)}`}\n onClose={onClose}\n width={440}\n >\n <Field label={t(\"hero\")}>\n <HeroPicker heroes={heroes} value={hero} onChange={setHero} />\n </Field>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"entry\")}\n </div>\n <ResourceList lines={entryLine} size={28} dimIf={() => !affordable} />\n {e.currency ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n {t(\"winnerTakes\")}{\" \"}\n <ResourceList\n lines={[\n {\n kind: entryKind,\n id: e.currency,\n amount: ruleWinnerTakes(\n { ...rule, crypto: e.crypto },\n e.amount,\n ),\n },\n ]}\n size={18}\n />\n </div>\n ) : null}\n </div>\n <Button\n tone=\"green\"\n attract={affordable}\n disabled={!affordable}\n busy={busy}\n onClick={() => void fight()}\n data-tutorial-anchor=\"pvp:fight\"\n >\n <Icon glyph=\"swords\" size={18} /> {t(\"fight\")}\n </Button>\n </Popup>\n );\n}\n"
62
+ },
63
+ {
64
+ "path": "helpers.ts",
65
+ "content": "import { type PvPMatch } from \"@idosgames/core\";\n\nexport function shortID(id: string | null | undefined): string {\n if (!id) return \"?\";\n return id.length > 10 ? `${id.slice(0, 5)}…${id.slice(-4)}` : id;\n}\n\n/**\n * Срок вышел: либо движок уже вернул взнос (`Expired`), либо срок прошёл, а таймер движка\n * (раз в минуту) ещё не дошёл — принять такой оффер сервер всё равно не даст.\n */\nexport function isExpired(m: PvPMatch): boolean {\n if (m.Status === \"Expired\") return true;\n return (\n m.Status === \"Open\" &&\n !!m.ExpiresAt &&\n new Date(m.ExpiresAt).getTime() <= Date.now()\n );\n}\n"
66
+ },
67
+ {
68
+ "path": "heroes.ts",
69
+ "content": "import { useEffect, useState } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\n\nexport type Hero = { id: string; name: string; power: number };\n\nexport function useHeroes(): Hero[] | null {\n const client = useIDosGamesClient();\n const [heroes, setHeroes] = useState<Hero[] | null>(null);\n useEffect(() => {\n // Through the central cache: the heroes are fresh by the `Character` revision (the default\n // heroes the server adds at read are its state rule) — no request when nothing changed.\n void client.cache.ensureState([\"Character\"]).then(() => {\n const defs =\n (\n client.data.config.getSection(\"Character\") as\n | {\n Definitions?: Record<\n string,\n { Identity?: { DisplayName?: string | null } | null } | null\n >;\n }\n | undefined\n )?.Definitions ?? {};\n const owned = Object.values(\n client.data.user.state?.Character?.Characters ?? {},\n );\n setHeroes(\n owned\n .filter((c) => c?.CharacterID)\n .map((c) => ({\n id: c!.CharacterID!,\n name:\n defs[c!.CharacterID!]?.Identity?.DisplayName ?? c!.CharacterID!,\n power: Number(c!.Power ?? 0),\n }))\n .sort((a, b) => b.power - a.power),\n );\n });\n }, [client]);\n return heroes;\n}\n"
70
+ },
43
71
  {
44
72
  "path": "i18n.ts",
45
73
  "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Arena\",\n open: \"Open duels\",\n mine: \"My duels\",\n create: \"New duel\",\n strategy: \"Strategy\",\n noOpen: \"No open duels — create one!\",\n noMine: \"You have no duels yet.\",\n entry: \"Entry\",\n winnerTakes: \"Winner takes\",\n hero: \"Hero\",\n rule: \"Rule\",\n fight: \"Fight!\",\n cancel: \"Cancel duel\",\n cancelled: \"Cancelled\",\n expired: \"Expired — entry returned\",\n waiting: \"Waiting for an opponent\",\n won: \"Victory!\",\n lost: \"Defeat\",\n draw: \"Draw — entries returned\",\n youWon: \"You won\",\n youLost: \"You lost\",\n rounds: \"Rounds\",\n attack: \"Attack\",\n defense: \"Defence\",\n round: \"Round\",\n save: \"Save\",\n saved: \"Strategy saved\",\n addRound: \"+ round\",\n head: \"Head\",\n torso: \"Body\",\n legs: \"Legs\",\n hHit: \"hit\",\n hCritical: \"critical!\",\n hBlock: \"blocked\",\n hDodge: \"dodged\",\n you: \"You\",\n foe: \"Foe\",\n hp: \"HP\",\n player: \"Player\",\n noHero: \"You need a hero to fight.\",\n toHeroes: \"Heroes\",\n yours: \"Your duel\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Арена\",\n open: \"Доступные\",\n mine: \"Мои дуэли\",\n create: \"Новая дуэль\",\n strategy: \"Стратегия\",\n noOpen: \"Открытых дуэлей нет — создайте свою!\",\n noMine: \"У вас пока нет дуэлей.\",\n entry: \"Взнос\",\n winnerTakes: \"Победитель получит\",\n hero: \"Герой\",\n rule: \"Правило\",\n fight: \"В бой!\",\n cancel: \"Отменить дуэль\",\n cancelled: \"Отменена\",\n expired: \"Срок вышел — взнос возвращён\",\n waiting: \"Ждёт соперника\",\n won: \"Победа!\",\n lost: \"Поражение\",\n draw: \"Ничья — взносы вернулись\",\n youWon: \"Вы выиграли\",\n youLost: \"Вы проиграли\",\n rounds: \"Раунды\",\n attack: \"Удар\",\n defense: \"Защита\",\n round: \"Раунд\",\n save: \"Сохранить\",\n saved: \"Стратегия сохранена\",\n addRound: \"+ раунд\",\n head: \"Голова\",\n torso: \"Корпус\",\n legs: \"Ноги\",\n hHit: \"удар\",\n hCritical: \"крит!\",\n hBlock: \"блок\",\n hDodge: \"уворот\",\n you: \"Вы\",\n foe: \"Соперник\",\n hp: \"ОЗ\",\n player: \"Игрок\",\n noHero: \"Для боя нужен герой.\",\n toHeroes: \"Герои\",\n yours: \"Ваша дуэль\",\n});\n"
@@ -50,7 +78,7 @@
50
78
  },
51
79
  {
52
80
  "path": "model.ts",
53
- "content": "// Pure helpers of the PvP screen (Unity Alikhan/PVP/Lobby): the title's duel rules and their entry\n// bounds, the strategy (attack / defence zone per round), the outcome of a fight and its rounds.\n// The server fights the battle — nothing here computes damage. Covered by model.test.ts.\n\nexport type Zone = \"Head\" | \"Torso\" | \"Legs\";\nexport const ZONES: Zone[] = [\"Head\", \"Torso\", \"Legs\"];\nexport type Step = { AttackTarget: Zone; DefenseTarget: Zone };\n\n/** The built-in rule every title has, used when the title configures none. */\nexport const BUILT_IN_RULE = \"InstantBattle1v1\";\n\nexport interface DuelRule {\n id: string;\n name: string;\n description: string | null;\n currency: string | null;\n min: number;\n max: number;\n burnRate: number;\n}\n\ntype RuleDef = {\n RuleID?: string | null;\n DisplayName?: string | null;\n Description?: string | null;\n Economy?: { BurnRate?: number | null } | null;\n Entry?: {\n Allowed?: Array<{\n Kind?: string | null;\n CurrencyID?: string | null;\n MinAmount?: number | null;\n MaxAmount?: number | null;\n } | null> | null;\n } | null;\n};\n\n/** The duel rules and their currency entry bounds (the first currency rule of the whitelist). */\nexport function duelRules(\n defs:\n | {\n InstantBattle?: {\n Rules?: Record<string, RuleDef | null> | null;\n } | null;\n }\n | null\n | undefined,\n fallbackCurrency: string | null,\n): DuelRule[] {\n const rules = Object.entries(defs?.InstantBattle?.Rules ?? {})\n .filter(([, r]) => r)\n .map(([id, r]) => {\n const allowed = (r!.Entry?.Allowed ?? []).find(\n (a) => a?.Kind === \"VirtualCurrency\",\n );\n return {\n id: r!.RuleID || id,\n name: r!.DisplayName || id,\n description: r!.Description ?? null,\n currency: allowed?.CurrencyID ?? fallbackCurrency,\n min: Math.max(1, Number(allowed?.MinAmount ?? 1)),\n max:\n Number(allowed?.MaxAmount ?? 0) > 0\n ? Number(allowed!.MaxAmount)\n : Number.MAX_SAFE_INTEGER,\n burnRate: Number(r!.Economy?.BurnRate ?? 0),\n } satisfies DuelRule;\n });\n return rules.length > 0\n ? rules\n : [\n {\n id: BUILT_IN_RULE,\n name: \"Duel\",\n description: null,\n currency: fallbackCurrency,\n min: 1,\n max: Number.MAX_SAFE_INTEGER,\n burnRate: 0,\n },\n ];\n}\n\n/** What the winner takes: both entries minus the burn. */\nexport function winnerTakes(entry: number, burnRate: number): number {\n return Math.floor(entry * 2 * (1 - Math.min(1, Math.max(0, burnRate))));\n}\n\nexport function clampEntry(value: number, rule: DuelRule): number {\n return Math.min(rule.max, Math.max(rule.min, Math.floor(value)));\n}\n\n/** The entry of a listed match: its first currency line. */\nexport function matchEntry(match: {\n Entry?: {\n Entries?: Array<{\n CurrencyID?: string | null;\n Amount?: number | null;\n } | null> | null;\n } | null;\n}): { currency: string | null; amount: number } {\n const e = match.Entry?.Entries?.find((x) => x?.CurrencyID);\n return { currency: e?.CurrencyID ?? null, amount: Number(e?.Amount ?? 0) };\n}\n\n/** The saved strategy, or three sensible rounds to start from. */\nexport function strategyOrDefault(\n saved:\n | ReadonlyArray<{\n AttackTarget?: string | null;\n DefenseTarget?: string | null;\n }>\n | null\n | undefined,\n): Step[] {\n const steps = (saved ?? [])\n .map((s) => ({\n AttackTarget: asZone(s.AttackTarget),\n DefenseTarget: asZone(s.DefenseTarget),\n }))\n .slice(0, 10);\n return steps.length > 0\n ? steps\n : [\n { AttackTarget: \"Head\", DefenseTarget: \"Torso\" },\n { AttackTarget: \"Torso\", DefenseTarget: \"Head\" },\n { AttackTarget: \"Legs\", DefenseTarget: \"Torso\" },\n ];\n}\n\nfunction asZone(z: string | null | undefined): Zone {\n return z === \"Torso\" || z === \"Legs\" ? z : \"Head\";\n}\n\nexport type Outcome = \"win\" | \"lose\" | \"draw\";\n\nexport function outcomeFor(\n battle:\n | { WinnerUserID?: string | null; IsDraw?: boolean | null }\n | null\n | undefined,\n me: string | null | undefined,\n): Outcome {\n if (!battle || battle.IsDraw || !battle.WinnerUserID) return \"draw\";\n return battle.WinnerUserID === me ? \"win\" : \"lose\";\n}\n\nexport interface RoundLine {\n round: number;\n mine: boolean;\n zone: Zone;\n hit: \"Hit\" | \"Critical\" | \"Block\" | \"Dodge\";\n damage: number;\n hpLeft: number;\n}\n\n/** The fight round by round, from my side: whose blow, where, how it landed, HP left. */\nexport function roundLines(\n log:\n | ReadonlyArray<{\n RoundIndex?: number | null;\n AttackerID?: string | null;\n AttackZone?: string | null;\n HitType?: string | null;\n DamageDealt?: number | null;\n DefenderHpRemaining?: number | null;\n }>\n | null\n | undefined,\n me: string | null | undefined,\n): RoundLine[] {\n return (log ?? []).map((e, i) => ({\n // The engine counts rounds from 1.\n round: Number(e.RoundIndex ?? i + 1),\n mine: e.AttackerID === me,\n zone: asZone(e.AttackZone),\n hit:\n e.HitType === \"Critical\" || e.HitType === \"Block\" || e.HitType === \"Dodge\"\n ? e.HitType\n : \"Hit\",\n damage: Math.round(Number(e.DamageDealt ?? 0)),\n hpLeft: Math.max(0, Math.round(Number(e.DefenderHpRemaining ?? 0))),\n }));\n}\n\n/** Whether the title has a duel rule — until it does, the lobby hides the Arena tab. */\nexport function hasTitleData(\n section:\n | { InstantBattle?: { Rules?: Record<string, unknown> | null } | null }\n | null\n | undefined,\n): boolean {\n return Object.keys(section?.InstantBattle?.Rules ?? {}).length > 0;\n}\n"
81
+ "content": "// Pure helpers of the PvP screen (Unity Alikhan/PVP/Lobby): the title's duel rules and their entry\n// bounds, the strategy (attack / defence zone per round), the outcome of a fight and its rounds.\n// The server fights the battle — nothing here computes damage. Covered by model.test.ts.\n\nexport type Zone = \"Head\" | \"Torso\" | \"Legs\";\nexport const ZONES: Zone[] = [\"Head\", \"Torso\", \"Legs\"];\nexport type Step = { AttackTarget: Zone; DefenseTarget: Zone };\n\n/** The built-in rule every title has, used when the title configures none. */\nexport const BUILT_IN_RULE = \"InstantBattle1v1\";\n\nexport interface DuelRule {\n id: string;\n name: string;\n description: string | null;\n currency: string | null;\n /** The entry is the title's crypto (whole tokens): the winner pays the developer's trade fee instead of the burn. */\n crypto: boolean;\n /** Developer's trade fee (percent) of the crypto entry — from the currency config. 0 for in-game currencies. */\n tradeFeePercent: number;\n min: number;\n max: number;\n burnRate: number;\n}\n\ntype RuleDef = {\n RuleID?: string | null;\n DisplayName?: string | null;\n Description?: string | null;\n Economy?: { BurnRate?: number | null } | null;\n Entry?: {\n Allowed?: Array<{\n Kind?: string | null;\n CurrencyID?: string | null;\n MinAmount?: number | null;\n MaxAmount?: number | null;\n } | null> | null;\n } | null;\n};\n\n/** The duel rules and their currency entry bounds (the first currency rule of the whitelist). */\nexport function duelRules(\n defs:\n | {\n InstantBattle?: {\n Rules?: Record<string, RuleDef | null> | null;\n } | null;\n }\n | null\n | undefined,\n fallbackCurrency: string | null,\n /** Developer's trade fee (percent) of a crypto currency — `PlayerTradeFeePercent` of its config. */\n cryptoFeeOf: (currencyID: string) => number = () => 0,\n): DuelRule[] {\n const rules = Object.entries(defs?.InstantBattle?.Rules ?? {})\n .filter(([, r]) => r)\n .map(([id, r]) => {\n const allowed = (r!.Entry?.Allowed ?? []).find(\n (a) => a?.Kind === \"VirtualCurrency\" || a?.Kind === \"CryptoCurrency\",\n );\n const crypto = allowed?.Kind === \"CryptoCurrency\" && !!allowed.CurrencyID;\n return {\n id: r!.RuleID || id,\n name: r!.DisplayName || id,\n description: r!.Description ?? null,\n currency: allowed?.CurrencyID ?? fallbackCurrency,\n crypto,\n tradeFeePercent: crypto ? cryptoFeeOf(allowed!.CurrencyID!) : 0,\n min: Math.max(1, Number(allowed?.MinAmount ?? 1)),\n max:\n Number(allowed?.MaxAmount ?? 0) > 0\n ? Number(allowed!.MaxAmount)\n : Number.MAX_SAFE_INTEGER,\n burnRate: Number(r!.Economy?.BurnRate ?? 0),\n } satisfies DuelRule;\n });\n return rules.length > 0\n ? rules\n : [\n {\n id: BUILT_IN_RULE,\n name: \"Duel\",\n description: null,\n currency: fallbackCurrency,\n crypto: false,\n tradeFeePercent: 0,\n min: 1,\n max: Number.MAX_SAFE_INTEGER,\n burnRate: 0,\n },\n ];\n}\n\n/** What the winner takes: both entries minus the burn. */\nexport function winnerTakes(entry: number, burnRate: number): number {\n return Math.floor(entry * 2 * (1 - Math.min(1, Math.max(0, burnRate))));\n}\n\n/**\n * What the winner of THIS rule takes: a crypto entry pays the developer's trade fee on the pot (rounded up, capped at\n * 50% — the engine's `CryptoPlayerTrade`), an in-game currency pays the burn.\n */\nexport function ruleWinnerTakes(rule: DuelRule, entry: number): number {\n if (!rule.crypto) return winnerTakes(entry, rule.burnRate);\n const pot = entry * 2;\n const percent = Math.min(50, Math.max(0, rule.tradeFeePercent));\n return pot - Math.min(pot, Math.ceil((pot * percent) / 100));\n}\n\nexport function clampEntry(value: number, rule: DuelRule): number {\n return Math.min(rule.max, Math.max(rule.min, Math.floor(value)));\n}\n\n/** The entry of a listed match: its first currency line. */\nexport function matchEntry(match: {\n Entry?: {\n Entries?: Array<{\n Type?: string | null;\n CurrencyID?: string | null;\n Amount?: number | null;\n } | null> | null;\n } | null;\n}): { currency: string | null; amount: number; crypto: boolean } {\n const e = match.Entry?.Entries?.find((x) => x?.CurrencyID);\n return {\n currency: e?.CurrencyID ?? null,\n amount: Number(e?.Amount ?? 0),\n crypto: e?.Type === \"CryptoCurrency\",\n };\n}\n\n/** The saved strategy, or three sensible rounds to start from. */\nexport function strategyOrDefault(\n saved:\n | ReadonlyArray<{\n AttackTarget?: string | null;\n DefenseTarget?: string | null;\n }>\n | null\n | undefined,\n): Step[] {\n const steps = (saved ?? [])\n .map((s) => ({\n AttackTarget: asZone(s.AttackTarget),\n DefenseTarget: asZone(s.DefenseTarget),\n }))\n .slice(0, 10);\n return steps.length > 0\n ? steps\n : [\n { AttackTarget: \"Head\", DefenseTarget: \"Torso\" },\n { AttackTarget: \"Torso\", DefenseTarget: \"Head\" },\n { AttackTarget: \"Legs\", DefenseTarget: \"Torso\" },\n ];\n}\n\nfunction asZone(z: string | null | undefined): Zone {\n return z === \"Torso\" || z === \"Legs\" ? z : \"Head\";\n}\n\nexport type Outcome = \"win\" | \"lose\" | \"draw\";\n\nexport function outcomeFor(\n battle:\n | { WinnerUserID?: string | null; IsDraw?: boolean | null }\n | null\n | undefined,\n me: string | null | undefined,\n): Outcome {\n if (!battle || battle.IsDraw || !battle.WinnerUserID) return \"draw\";\n return battle.WinnerUserID === me ? \"win\" : \"lose\";\n}\n\nexport interface RoundLine {\n round: number;\n mine: boolean;\n zone: Zone;\n hit: \"Hit\" | \"Critical\" | \"Block\" | \"Dodge\";\n damage: number;\n hpLeft: number;\n}\n\n/** The fight round by round, from my side: whose blow, where, how it landed, HP left. */\nexport function roundLines(\n log:\n | ReadonlyArray<{\n RoundIndex?: number | null;\n AttackerID?: string | null;\n AttackZone?: string | null;\n HitType?: string | null;\n DamageDealt?: number | null;\n DefenderHpRemaining?: number | null;\n }>\n | null\n | undefined,\n me: string | null | undefined,\n): RoundLine[] {\n return (log ?? []).map((e, i) => ({\n // The engine counts rounds from 1.\n round: Number(e.RoundIndex ?? i + 1),\n mine: e.AttackerID === me,\n zone: asZone(e.AttackZone),\n hit:\n e.HitType === \"Critical\" || e.HitType === \"Block\" || e.HitType === \"Dodge\"\n ? e.HitType\n : \"Hit\",\n damage: Math.round(Number(e.DamageDealt ?? 0)),\n hpLeft: Math.max(0, Math.round(Number(e.DefenderHpRemaining ?? 0))),\n }));\n}\n\n/** Whether the title has a duel rule — until it does, the lobby hides the Arena tab. */\nexport function hasTitleData(\n section:\n | { InstantBattle?: { Rules?: Record<string, unknown> | null } | null }\n | null\n | undefined,\n): boolean {\n return Object.keys(section?.InstantBattle?.Rules ?? {}).length > 0;\n}\n"
54
82
  },
55
83
  {
56
84
  "path": "module.ts",
@@ -58,7 +86,7 @@
58
86
  },
59
87
  {
60
88
  "path": "PvpScreen.tsx",
61
- "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n InstantBattleResponse,\n MatchDefinitions,\n PvPMatch,\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 Center,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n Spinner,\n Tabs,\n canAfford,\n errorText,\n formatAmount,\n outlined,\n panel,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n ZONES,\n clampEntry,\n duelRules,\n matchEntry,\n outcomeFor,\n roundLines,\n strategyOrDefault,\n winnerTakes,\n type DuelRule,\n type Step,\n type Zone,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// PvP duels (Unity Alikhan/PVP/Lobby): open duels to join and my duels as tabs. Creating a duel\n// (rule, entry, hero), the battle strategy (attack / defence zone per round) and the fight's result\n// round by round are popups. The server fights the battle instantly; the winner takes both entries\n// minus the burn, a draw returns them.\n\ntype Tab = \"open\" | \"mine\";\ntype Hero = { id: string; name: string; power: number };\n\nfunction useHeroes(): Hero[] | null {\n const client = useIDosGamesClient();\n const [heroes, setHeroes] = useState<Hero[] | null>(null);\n useEffect(() => {\n void client.character.getUserCharacters().then((r) => {\n const defs =\n (\n client.data.config.getSection(\"Character\") as\n | {\n Definitions?: Record<\n string,\n { Identity?: { DisplayName?: string | null } | null } | null\n >;\n }\n | undefined\n )?.Definitions ?? {};\n const owned = r.ok ? Object.values(r.data.Characters ?? {}) : [];\n setHeroes(\n owned\n .filter((c) => c?.CharacterID)\n .map((c) => ({\n id: c!.CharacterID!,\n name:\n defs[c!.CharacterID!]?.Identity?.DisplayName ?? c!.CharacterID!,\n power: Number(c!.Power ?? 0),\n }))\n .sort((a, b) => b.power - a.power),\n );\n });\n }, [client]);\n return heroes;\n}\n\nexport function makePvpScreen(features: FeatureRegistry) {\n return function PvpScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const [defs, setDefs] = useState<MatchDefinitions | null | undefined>(\n undefined,\n );\n const [tab, setTab] = useState<Tab>(\"open\");\n const [creating, setCreating] = useState(false);\n const [editing, setEditing] = useState(false);\n const [joining, setJoining] = useState<PvPMatch | null>(null);\n const [result, setResult] = useState<InstantBattleResponse | null>(null);\n const [version, setVersion] = useState(0);\n const heroes = useHeroes();\n useEffect(() => {\n void client.match\n .getDefinitions()\n .then((r) => setDefs(r.ok ? r.data : null));\n }, [client]);\n const firstCurrency =\n Object.keys(\n (\n client.data.config.getSection(\"Currency\") as\n { VirtualCurrencies?: Record<string, unknown> } | undefined\n )?.VirtualCurrencies ?? {},\n )[0] ?? null;\n const rules = duelRules(defs as never, firstCurrency);\n const refresh = () => setVersion((n) => n + 1);\n\n if (defs === undefined || !heroes)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (heroes.length === 0)\n return (\n <div style={{ display: \"grid\", gap: 12, justifyItems: \"center\" }}>\n <EmptyState glyph=\"swords\" text={t(\"noHero\")} />\n {features.get(\"character\") ? (\n <Button tone=\"blue\" onClick={() => features.open(\"character\")}>\n {t(\"toHeroes\")}\n </Button>\n ) : null}\n </div>\n );\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <div style={{ display: \"flex\", gap: 8, flexWrap: \"wrap\" }}>\n <Button\n tone=\"gold\"\n attract\n onClick={() => setCreating(true)}\n style={{ flex: 1 }}\n data-tutorial-anchor=\"pvp:create\"\n >\n <Icon glyph=\"swords\" size={18} /> {t(\"create\")}\n </Button>\n <Button tone=\"blue\" onClick={() => setEditing(true)}>\n <Icon glyph=\"gear\" size={18} /> {t(\"strategy\")}\n </Button>\n </div>\n <Tabs\n tabs={[\n { id: \"open\", label: t(\"open\") },\n { id: \"mine\", label: t(\"mine\") },\n ]}\n value={tab}\n onChange={setTab}\n />\n <div key={`${tab}:${version}`}>\n {tab === \"open\" ? (\n <OpenDuels rules={rules} onJoin={setJoining} />\n ) : (\n <MyDuels rules={rules} onChange={refresh} />\n )}\n </div>\n {creating ? (\n <CreatePopup\n rules={rules}\n heroes={heroes}\n onClose={() => setCreating(false)}\n onCreated={() => {\n setTab(\"mine\");\n refresh();\n }}\n />\n ) : null}\n {editing ? <StrategyPopup onClose={() => setEditing(false)} /> : null}\n {joining ? (\n <JoinPopup\n match={joining}\n rule={rules.find((r) => r.id === joining.RuleID) ?? rules[0]!}\n heroes={heroes}\n onClose={() => setJoining(null)}\n onFought={(res) => {\n setJoining(null);\n setResult(res);\n refresh();\n }}\n />\n ) : null}\n {result ? (\n <ResultPopup result={result} onClose={() => setResult(null)} />\n ) : null}\n </div>\n );\n };\n}\n\nfunction shortID(id: string | null | undefined): string {\n if (!id) return \"?\";\n return id.length > 10 ? `${id.slice(0, 5)}…${id.slice(-4)}` : id;\n}\n\nfunction DuelRow({\n match,\n rules,\n index,\n right,\n onPick,\n}: {\n match: PvPMatch;\n rules: DuelRule[];\n index: number;\n right?: ReactNode;\n onPick?: () => void;\n}): ReactNode {\n const stagger = useStagger();\n const e = matchEntry(match);\n const rule = rules.find((r) => r.id === match.RuleID);\n return (\n <div\n onClick={onPick}\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 <Icon glyph=\"swords\" size={30} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {rule?.name ?? match.RuleID} · {t(\"player\")}{\" \"}\n {shortID(match.CreatorID)}\n </div>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n }}\n >\n {t(\"entry\")}{\" \"}\n {e.currency ? (\n <ResourceList\n lines={[{ kind: \"currency\", id: e.currency, amount: e.amount }]}\n size={16}\n />\n ) : null}\n {match.CreatorCharacterID ? ` · ${match.CreatorCharacterID}` : \"\"}\n </div>\n </div>\n {right}\n </div>\n );\n}\n\nfunction OpenDuels({\n rules,\n onJoin,\n}: {\n rules: DuelRule[];\n onJoin: (m: PvPMatch) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const [list, setList] = useState<PvPMatch[] | null>(null);\n useEffect(() => {\n void client.match\n .getAvailableMatches(0, 30)\n .then((r) => setList(r.ok ? (r.data.Matches ?? []) : []));\n }, [client]);\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"swords\" text={t(\"noOpen\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((m, i) => (\n <DuelRow\n key={m.MatchID}\n match={m}\n rules={rules}\n index={i}\n onPick={() => onJoin(m)}\n right={\n <Button size=\"sm\" tone=\"green\">\n {t(\"fight\")}\n </Button>\n }\n />\n ))}\n </div>\n );\n}\n\nfunction MyDuels({\n rules,\n onChange,\n}: {\n rules: DuelRule[];\n onChange: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const me = client.auth.context?.userID;\n const [list, setList] = useState<PvPMatch[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n useEffect(() => {\n void client.match\n .getMyMatches(0, 30)\n .then((r) => setList(r.ok ? (r.data.Matches ?? []) : []));\n }, [client]);\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"swords\" text={t(\"noMine\")} />;\n const cancel = async (id: string) => {\n setBusy(id);\n const res = await client.match.cancelMatch(id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n onChange();\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((m, i) => (\n <DuelRow\n key={m.MatchID}\n match={m}\n rules={rules}\n index={i}\n right={\n isExpired(m) ? (\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"expired\")}\n </span>\n ) : m.Status === \"Open\" && m.CreatorID === me ? (\n <div style={{ display: \"grid\", gap: 4, justifyItems: \"end\" }}>\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"waiting\")}\n </span>\n <Button\n size=\"sm\"\n tone=\"grey\"\n busy={busy === m.MatchID}\n onClick={() => void cancel(m.MatchID)}\n >\n {t(\"cancel\")}\n </Button>\n </div>\n ) : m.Status === \"Cancelled\" ? (\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"cancelled\")}\n </span>\n ) : m.Status === \"Completed\" ? (\n <span\n style={{\n ...outlined,\n fontSize: 13,\n color: !m.WinnerUserID\n ? v.textDim\n : m.WinnerUserID === me\n ? v.green\n : v.red,\n }}\n >\n {!m.WinnerUserID\n ? t(\"draw\")\n : m.WinnerUserID === me\n ? t(\"youWon\")\n : t(\"youLost\")}\n </span>\n ) : null\n }\n />\n ))}\n </div>\n );\n}\n\n/**\n * Срок вышел: либо движок уже вернул взнос (`Expired`), либо срок прошёл, а таймер движка\n * (раз в минуту) ещё не дошёл — принять такой оффер сервер всё равно не даст.\n */\nfunction isExpired(m: PvPMatch): boolean {\n if (m.Status === \"Expired\") return true;\n return (\n m.Status === \"Open\" &&\n !!m.ExpiresAt &&\n new Date(m.ExpiresAt).getTime() <= Date.now()\n );\n}\n\nfunction HeroPicker({\n heroes,\n value,\n onChange,\n}: {\n heroes: Hero[];\n value: string;\n onChange: (id: string) => void;\n}): ReactNode {\n // One hero: nothing to pick, but the player still sees who fights (tabs need two).\n if (heroes.length === 1)\n return (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 14,\n }}\n >\n <Icon glyph=\"hero\" size={22} /> {heroes[0]!.name} ·{\" \"}\n {formatAmount(heroes[0]!.power)}\n </div>\n );\n return (\n <Tabs\n tabs={heroes.map((h) => ({\n id: h.id,\n label: `${h.name} · ${formatAmount(h.power)}`,\n }))}\n value={value}\n onChange={onChange}\n />\n );\n}\n\nfunction CreatePopup({\n rules,\n heroes,\n onClose,\n onCreated,\n}: {\n rules: DuelRule[];\n heroes: Hero[];\n onClose: () => void;\n onCreated: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const kit = useUiKit();\n const [ruleID, setRuleID] = useState(rules[0]!.id);\n const rule = rules.find((r) => r.id === ruleID) ?? rules[0]!;\n const [amount, setAmount] = useState(clampEntry(50, rule));\n const [hero, setHero] = useState(heroes[0]!.id);\n const [busy, setBusy] = useState(false);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const entryLine = rule.currency\n ? [{ kind: \"currency\" as const, id: rule.currency, amount }]\n : [];\n const affordable = canAfford(entryLine, balances);\n const create = async () => {\n if (!rule.currency) return;\n setBusy(true);\n const saved = client.data.user.state?.Match?.PvPBattleStrategy;\n const res = await client.match.createMatch(\n {\n Entries: [\n {\n Type: \"VirtualCurrency\",\n CurrencyID: rule.currency,\n Amount: amount,\n },\n ],\n } as never,\n rule.id,\n hero,\n saved ?? undefined,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"purchase\");\n onCreated();\n onClose();\n };\n return (\n <Popup title={t(\"create\")} onClose={onClose} width={440}>\n {rules.length > 1 ? (\n <Tabs\n tabs={rules.map((r) => ({ id: r.id, label: r.name }))}\n value={ruleID}\n onChange={(id) => {\n setRuleID(id);\n const r = rules.find((x) => x.id === id)!;\n setAmount(clampEntry(amount, r));\n }}\n />\n ) : null}\n {rule.description ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {rule.description}\n </div>\n ) : null}\n <Field label={t(\"hero\")}>\n <HeroPicker heroes={heroes} value={hero} onChange={setHero} />\n </Field>\n <Field label={t(\"entry\")}>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n justifyContent: \"center\",\n }}\n >\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() => setAmount(clampEntry(amount - 10, rule))}\n >\n −\n </Button>\n <ResourceList lines={entryLine} size={26} dimIf={() => !affordable} />\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() => setAmount(clampEntry(amount + 10, rule))}\n >\n +\n </Button>\n </div>\n </Field>\n {rule.currency ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 6,\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n {t(\"winnerTakes\")}{\" \"}\n <ResourceList\n lines={[\n {\n kind: \"currency\",\n id: rule.currency,\n amount: winnerTakes(amount, rule.burnRate),\n },\n ]}\n size={18}\n />\n </div>\n ) : null}\n <Button\n tone=\"gold\"\n disabled={!affordable}\n busy={busy}\n onClick={() => void create()}\n >\n {t(\"create\")}\n </Button>\n </Popup>\n );\n}\n\nfunction JoinPopup({\n match,\n rule,\n heroes,\n onClose,\n onFought,\n}: {\n match: PvPMatch;\n rule: DuelRule;\n heroes: Hero[];\n onClose: () => void;\n onFought: (r: InstantBattleResponse) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const [hero, setHero] = useState(heroes[0]!.id);\n const [busy, setBusy] = useState(false);\n const e = matchEntry(match);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const entryLine = e.currency\n ? [{ kind: \"currency\" as const, id: e.currency, amount: e.amount }]\n : [];\n const affordable = canAfford(entryLine, balances);\n const fight = async () => {\n setBusy(true);\n const saved = client.data.user.state?.Match?.PvPBattleStrategy;\n const res = await client.match.instantBattle(\n match.MatchID,\n match.RuleID ?? rule.id,\n hero,\n saved ?? undefined,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n onFought(res.data);\n };\n return (\n <Popup\n title={`${rule.name} · ${t(\"player\")} ${shortID(match.CreatorID)}`}\n onClose={onClose}\n width={440}\n >\n <Field label={t(\"hero\")}>\n <HeroPicker heroes={heroes} value={hero} onChange={setHero} />\n </Field>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"entry\")}\n </div>\n <ResourceList lines={entryLine} size={28} dimIf={() => !affordable} />\n {e.currency ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n {t(\"winnerTakes\")}{\" \"}\n <ResourceList\n lines={[\n {\n kind: \"currency\",\n id: e.currency,\n amount: winnerTakes(e.amount, rule.burnRate),\n },\n ]}\n size={18}\n />\n </div>\n ) : null}\n </div>\n <Button\n tone=\"green\"\n attract={affordable}\n disabled={!affordable}\n busy={busy}\n onClick={() => void fight()}\n data-tutorial-anchor=\"pvp:fight\"\n >\n <Icon glyph=\"swords\" size={18} /> {t(\"fight\")}\n </Button>\n </Popup>\n );\n}\n\nconst ZONE_LABEL: Record<Zone, Parameters<typeof t>[0]> = {\n Head: \"head\",\n Torso: \"torso\",\n Legs: \"legs\",\n};\n\nfunction StrategyPopup({ onClose }: { onClose: () => void }): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [steps, setSteps] = useState<Step[]>(() =>\n strategyOrDefault(client.data.user.state?.Match?.PvPBattleStrategy),\n );\n const [busy, setBusy] = useState(false);\n const set = (i: number, key: keyof Step, zone: Zone) =>\n setSteps((s) => s.map((st, j) => (j === i ? { ...st, [key]: zone } : st)));\n const save = async () => {\n setBusy(true);\n const res = await client.match.saveStrategy(steps);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"saved\"));\n onClose();\n };\n const zoneTabs = ZONES.map((z) => ({ id: z, label: t(ZONE_LABEL[z]) }));\n return (\n <Popup title={t(\"strategy\")} onClose={onClose} width={460}>\n <div style={{ display: \"grid\", gap: 10 }}>\n {steps.map((s, i) => (\n <div\n key={i}\n style={{ ...panel, padding: 10, display: \"grid\", gap: 6 }}\n >\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span>\n {t(\"round\")} {i + 1}\n </span>\n {steps.length > 1 ? (\n <button\n type=\"button\"\n onClick={() => setSteps((st) => st.filter((_, j) => j !== i))}\n style={{\n background: \"none\",\n border: \"none\",\n color: v.textDim,\n cursor: \"pointer\",\n }}\n >\n ✕\n </button>\n ) : null}\n </div>\n <div style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"attack\")}\n </div>\n <Tabs\n tabs={zoneTabs}\n value={s.AttackTarget}\n onChange={(z) => set(i, \"AttackTarget\", z)}\n />\n <div style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"defense\")}\n </div>\n <Tabs\n tabs={zoneTabs}\n value={s.DefenseTarget}\n onChange={(z) => set(i, \"DefenseTarget\", z)}\n />\n </div>\n ))}\n {steps.length < 10 ? (\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() =>\n setSteps((s) => [\n ...s,\n { AttackTarget: \"Head\", DefenseTarget: \"Torso\" },\n ])\n }\n >\n {t(\"addRound\")}\n </Button>\n ) : null}\n <Button tone=\"green\" busy={busy} onClick={() => void save()}>\n {t(\"save\")}\n </Button>\n </div>\n </Popup>\n );\n}\n\nconst HIT_TEXT = {\n Hit: \"hHit\",\n Critical: \"hCritical\",\n Block: \"hBlock\",\n Dodge: \"hDodge\",\n} as const;\n\nfunction ResultPopup({\n result,\n onClose,\n}: {\n result: InstantBattleResponse;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const kit = useUiKit();\n const me = client.auth.context?.userID;\n const outcome = outcomeFor(result.Battle, me);\n const rounds = roundLines(result.Battle?.BattleLog, me);\n const stagger = useStagger();\n const [shown, setShown] = useState(0);\n const reward =\n result.Battle?.NetReward?.Entries?.filter((e) => e?.CurrencyID).map(\n (e) => ({\n kind: \"currency\" as const,\n id: e!.CurrencyID!,\n amount: Number(e!.Amount ?? 0),\n }),\n ) ?? [];\n useEffect(() => {\n kit.play(\n outcome === \"win\" ? \"levelUp\" : outcome === \"lose\" ? \"error\" : \"notify\",\n );\n }, []);\n // Rounds appear one by one — a short replay of the fight.\n const next = useCallback(\n () => setShown((n) => Math.min(rounds.length, n + 1)),\n [rounds.length],\n );\n useEffect(() => {\n if (shown >= rounds.length) return;\n const id = setTimeout(next, 220);\n return () => clearTimeout(id);\n }, [shown, next, rounds.length]);\n return (\n <Popup\n title={\n outcome === \"win\"\n ? t(\"won\")\n : outcome === \"lose\"\n ? t(\"lost\")\n : t(\"draw\")\n }\n onClose={onClose}\n width={460}\n variant=\"center\"\n >\n <div\n className=\"idos-bounce\"\n style={{ display: \"grid\", justifyItems: \"center\" }}\n >\n <Icon glyph={outcome === \"win\" ? \"trophy\" : \"swords\"} size={64} />\n </div>\n {outcome === \"win\" && reward.length > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <ResourceList lines={reward} size={30} />\n </div>\n ) : null}\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"rounds\")}\n </div>\n <div\n className=\"idos-scroll\"\n style={{ display: \"grid\", gap: 4, maxHeight: 260, overflowY: \"auto\" }}\n >\n {rounds.slice(0, shown).map((r, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: \"6px 10px\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n fontSize: 13,\n borderColor: r.mine ? v.green : v.red,\n ...stagger(0),\n }}\n >\n <span style={{ ...outlined, minWidth: 28 }}>{r.round}</span>\n <span style={{ ...outlined, color: r.mine ? v.green : v.red }}>\n {r.mine ? t(\"you\") : t(\"foe\")}\n </span>\n <span style={{ flex: 1 }}>\n → {t(ZONE_LABEL[r.zone])}, {t(HIT_TEXT[r.hit])}\n </span>\n <span style={{ ...outlined }}>−{r.damage}</span>\n <span\n style={{ color: v.textDim, minWidth: 54, textAlign: \"right\" }}\n >\n {r.hpLeft} {t(\"hp\")}\n </span>\n </div>\n ))}\n </div>\n <Button tone=\"blue\" onClick={onClose}>\n OK\n </Button>\n </Popup>\n );\n}\n\nfunction Field({\n label,\n children,\n}: {\n label: string;\n children: ReactNode;\n}): ReactNode {\n return (\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>{label}</div>\n {children}\n </div>\n );\n}\n"
89
+ "content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport {\n type InstantBattleResponse,\n type MatchDefinitions,\n type PvPMatch,\n} from \"@idosgames/core\";\nimport {\n type FeatureRegistry,\n type FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n EmptyState,\n Icon,\n Spinner,\n Tabs,\n} from \"@idosgames/react/ui\";\nimport { duelRules } from \"./model\";\nimport { t } from \"./i18n\";\nimport { ResultPopup, StrategyPopup } from \"./components/BattlePopups\";\nimport { CreatePopup } from \"./components/CreatePopup\";\nimport { MyDuels, OpenDuels } from \"./components/Duels\";\nimport { JoinPopup } from \"./components/JoinPopup\";\nimport { useHeroes } from \"./heroes\";\n\n// PvP duels (Unity Alikhan/PVP/Lobby): open duels to join and my duels as tabs. Creating a duel\n// (rule, entry, hero), the battle strategy (attack / defence zone per round) and the fight's result\n// round by round are popups. The server fights the battle instantly; the winner takes both entries\n// minus the burn, a draw returns them.\n\ntype Tab = \"open\" | \"mine\";\n\nexport function makePvpScreen(features: FeatureRegistry) {\n return function PvpScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const [defs, setDefs] = useState<MatchDefinitions | null | undefined>(\n undefined,\n );\n const [tab, setTab] = useState<Tab>(\"open\");\n const [creating, setCreating] = useState(false);\n const [editing, setEditing] = useState(false);\n const [joining, setJoining] = useState<PvPMatch | null>(null);\n const [result, setResult] = useState<InstantBattleResponse | null>(null);\n const [version, setVersion] = useState(0);\n const heroes = useHeroes();\n useEffect(() => {\n void client.match\n .getDefinitions()\n .then((r) => setDefs(r.ok ? r.data : null));\n }, [client]);\n const firstCurrency =\n Object.keys(\n (\n client.data.config.getSection(\"Currency\") as\n { VirtualCurrencies?: Record<string, unknown> } | undefined\n )?.VirtualCurrencies ?? {},\n )[0] ?? null;\n const cryptoDefs =\n (\n client.data.config.getSection(\"Currency\") as\n | {\n CryptoCurrencies?: Record<\n string,\n { PlayerTradeFeePercent?: number | string | null } | null\n >;\n }\n | undefined\n )?.CryptoCurrencies ?? {};\n const rules = duelRules(\n defs as never,\n firstCurrency,\n (id) => Number(cryptoDefs[id]?.PlayerTradeFeePercent ?? 0) || 0,\n );\n const refresh = () => setVersion((n) => n + 1);\n\n if (defs === undefined || !heroes)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (heroes.length === 0)\n return (\n <div style={{ display: \"grid\", gap: 12, justifyItems: \"center\" }}>\n <EmptyState glyph=\"swords\" text={t(\"noHero\")} />\n {features.get(\"character\") ? (\n <Button tone=\"blue\" onClick={() => features.open(\"character\")}>\n {t(\"toHeroes\")}\n </Button>\n ) : null}\n </div>\n );\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <div style={{ display: \"flex\", gap: 8, flexWrap: \"wrap\" }}>\n <Button\n tone=\"gold\"\n attract\n onClick={() => setCreating(true)}\n style={{ flex: 1 }}\n data-tutorial-anchor=\"pvp:create\"\n >\n <Icon glyph=\"swords\" size={18} /> {t(\"create\")}\n </Button>\n <Button tone=\"blue\" onClick={() => setEditing(true)}>\n <Icon glyph=\"gear\" size={18} /> {t(\"strategy\")}\n </Button>\n </div>\n <Tabs\n tabs={[\n { id: \"open\", label: t(\"open\") },\n { id: \"mine\", label: t(\"mine\") },\n ]}\n value={tab}\n onChange={setTab}\n />\n <div key={`${tab}:${version}`}>\n {tab === \"open\" ? (\n <OpenDuels rules={rules} onJoin={setJoining} />\n ) : (\n <MyDuels rules={rules} onChange={refresh} />\n )}\n </div>\n {creating ? (\n <CreatePopup\n rules={rules}\n heroes={heroes}\n onClose={() => setCreating(false)}\n onCreated={() => {\n setTab(\"mine\");\n refresh();\n }}\n />\n ) : null}\n {editing ? <StrategyPopup onClose={() => setEditing(false)} /> : null}\n {joining ? (\n <JoinPopup\n match={joining}\n rule={rules.find((r) => r.id === joining.RuleID) ?? rules[0]!}\n heroes={heroes}\n onClose={() => setJoining(null)}\n onFought={(res) => {\n setJoining(null);\n setResult(res);\n refresh();\n }}\n />\n ) : null}\n {result ? (\n <ResultPopup result={result} onClose={() => setResult(null)} />\n ) : null}\n </div>\n );\n };\n}\n"
62
90
  },
63
91
  {
64
92
  "path": "module.meta.json",
@@ -34,12 +34,12 @@
34
34
  "version": "0.1.0"
35
35
  },
36
36
  "dependencies": {
37
- "@idosgames/core": "0.21.2",
38
- "@idosgames/module-sdk": "0.10.0",
39
- "@idosgames/react": "0.4.4",
37
+ "@idosgames/core": "0.21.3",
38
+ "@idosgames/module-sdk": "0.10.1",
39
+ "@idosgames/react": "0.4.5",
40
40
  "react": "19.2.7"
41
41
  },
42
- "contentHash": "9a0d6402a5ecbad12997c91a6c25e7c067cc75f902f7e000db3f7b09ed89d83e",
42
+ "contentHash": "f6c5246128abcaeaa48f16f0ae9cd32ab4e8e7c97d1792f060b4c58b011d291f",
43
43
  "files": [
44
44
  {
45
45
  "path": "i18n.ts",
@@ -55,11 +55,11 @@
55
55
  },
56
56
  {
57
57
  "path": "module.ts",
58
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { QuestsScreen, makeQuestsWatcher } from \"./QuestsScreen\";\nimport { hasTitleData, type QuestSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Quests as a feature of the game: cycles as tabs, the quest points track, claim all. The watcher\n// keeps the badge (finished quests) — only on a title with quests.\nexport const questsModule: Module = defineModule({\n id: \"quests\",\n meta: { name: \"Quests\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"quests\",\n label: t(\"title\"),\n icon: \"quest\",\n group: \"events\",\n order: 41,\n primary: true,\n available: false,\n Screen: QuestsScreen,\n });\n const enabled = hasTitleData(\n configSection<QuestSection>(ctx.client, \"Quest\"),\n );\n ctx.features.setAvailable(\n \"quests\",\n enabled,\n \"no quests on the title (Quest)\",\n );\n if (enabled)\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeQuestsWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"quests\" }),\n describeActions: {},\n });\n },\n});\n"
58
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { QuestsScreen, watchQuests } from \"./QuestsScreen\";\nimport { hasTitleData, type QuestSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Quests as a feature of the game: cycles as tabs, the quest points track, claim all. The watcher\n// keeps the badge (finished quests) — only on a title with quests.\nexport const questsModule: Module = defineModule({\n id: \"quests\",\n meta: { name: \"Quests\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"quests\",\n label: t(\"title\"),\n icon: \"quest\",\n group: \"events\",\n order: 41,\n primary: true,\n available: false,\n Screen: QuestsScreen,\n });\n const enabled = hasTitleData(\n configSection<QuestSection>(ctx.client, \"Quest\"),\n );\n ctx.features.setAvailable(\n \"quests\",\n enabled,\n \"no quests on the title (Quest)\",\n );\n if (enabled) watchQuests(ctx);\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"quests\" }),\n describeActions: {},\n });\n },\n});\n"
59
59
  },
60
60
  {
61
61
  "path": "QuestsScreen.tsx",
62
- "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport type { QuestPointsTrackView } 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 EmptyState,\n Icon,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Tabs,\n Timer,\n configSection,\n errorText,\n grantedBy,\n mergeLines,\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 claimableRewards,\n cycleResetAt,\n questRows,\n questTabs,\n questsBadge,\n trackMilestones,\n type QuestRow,\n type QuestSection,\n type QuestStateView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Quests (Unity Runtime/UI/Quests): the title's quests by cycle — daily, weekly, achievements — with\n// the quest points track on top. A quest's goals and reward open in a popup; \"Claim all\" collects\n// every finished quest.\n\nexport function QuestsScreen(_: FeatureScreenProps): ReactNode {\n return <Quests />;\n}\n\nfunction Quests(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const stagger = useStagger();\n const now = useNow();\n const [tracks, setTracks] = useState<Record<string, QuestPointsTrackView>>(\n {},\n );\n const [tab, setTab] = useState<string | null>(null);\n const [detail, setDetail] = useState<QuestRow | null>(null);\n const [busyAll, setBusyAll] = useState(false);\n\n const load = useCallback(async () => {\n const res = await client.quest.getUserQuestState();\n if (res.ok)\n setTracks(\n (res.data.PointsTracks ?? {}) as Record<string, QuestPointsTrackView>,\n );\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n const section = configSection<QuestSection>(client, \"Quest\");\n const questState = (state?.Quest ?? null) as QuestStateView | null;\n const rows = questRows(section, questState);\n if (rows.length === 0)\n return <EmptyState glyph=\"quest\" text={t(\"noQuests\")} />;\n\n const tabs = questTabs(section, rows);\n const current =\n tab !== null && tabs.some((x) => x.id === tab) ? tab : (tabs[0]?.id ?? \"\");\n const shown = rows.filter((r) => (r.cycleID ?? \"\") === current);\n const cycleEnd = cycleResetAt(questState, current);\n const ready = shown.filter((r) => r.status === \"Completed\");\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.quest.claimQuestRewardsBatch(\n ready.map((r) => ({ QuestID: r.questID, CycleID: r.cycleID })),\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(\n mergeLines(\n res.data.flatMap((item) => (item.Success ? grantedBy(item.Data) : [])),\n ),\n );\n void load();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <SectionTitle\n right={<Timer at={cycleEnd} now={now} label={t(\"questsReset\")} />}\n >\n {t(\"quests\")}\n </SectionTitle>\n <Tabs\n tabs={tabs.map((x) => ({\n id: x.id,\n label: x.id ? catalog.localize(x.name) : t(\"permanent\"),\n badge: rows.filter(\n (r) => (r.cycleID ?? \"\") === x.id && r.status === \"Completed\",\n ).length,\n }))}\n value={current}\n onChange={setTab}\n />\n {current ? (\n <PointsTrack\n cycleID={current}\n section={section}\n track={tracks[current]}\n onClaimed={load}\n />\n ) : null}\n {ready.length >= 2 ? (\n <Button\n tone=\"gold\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n >\n {t(\"claimAll\")}{\" \"}\n <ResourceList lines={claimableRewards(ready)} size={18} />\n </Button>\n ) : null}\n <div style={{ display: \"grid\", gap: 10 }}>\n {shown.map((row, i) => (\n <Card\n key={`${row.cycleID ?? \"\"}:${row.questID}`}\n highlight={row.status === \"Completed\"}\n onClick={() => setDetail(row)}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"auto 1fr auto\",\n alignItems: \"center\",\n gap: 12,\n opacity: row.status === \"Claimed\" ? 0.6 : 1,\n ...stagger(i),\n }}\n >\n <Icon glyph=\"quest\" size={38} />\n <div style={{ display: \"grid\", gap: 6, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(row.name)}\n </div>\n <ProgressBar\n value={row.progress}\n max={row.target}\n tone={row.status === \"Completed\" ? \"gold\" : \"green\"}\n />\n <ResourceList lines={row.reward} />\n </div>\n {row.status === \"Claimed\" ? (\n <Icon glyph=\"check\" size={34} />\n ) : row.status === \"Completed\" ? (\n <Icon glyph=\"gift\" size={34} />\n ) : (\n <span />\n )}\n </Card>\n ))}\n </div>\n {detail ? (\n <QuestDetail\n row={\n rows.find(\n (r) =>\n r.questID === detail.questID && r.cycleID === detail.cycleID,\n ) ?? detail\n }\n onClose={() => setDetail(null)}\n onClaimed={load}\n />\n ) : null}\n </div>\n );\n}\n\nfunction QuestDetail({\n row,\n onClose,\n onClaimed,\n}: {\n row: QuestRow;\n onClose: () => void;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const catalog = useCatalog();\n return (\n <Popup title={catalog.localize(row.name)} onClose={onClose}>\n {row.description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n color: v.textDim,\n fontSize: 14,\n textAlign: \"center\",\n }}\n >\n {catalog.localize(row.description)}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 10 }}>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"objectives\")}\n </div>\n {row.objectives.map((o) => (\n <div key={o.id} style={{ display: \"grid\", gap: 4 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 14,\n }}\n >\n <span>{catalog.localize(o.name)}</span>\n {o.done ? <Icon glyph=\"check\" size={20} /> : null}\n </div>\n <ProgressBar\n value={o.progress}\n max={o.target}\n tone={o.done ? \"gold\" : \"green\"}\n />\n </div>\n ))}\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"reward\")}\n </div>\n <ResourceList lines={row.reward} size={26} gap={14} />\n </div>\n {row.status === \"Completed\" ? (\n <ClaimQuest row={row} onClaimed={onClaimed} />\n ) : row.status === \"Claimed\" ? (\n <div style={{ ...outlined, textAlign: \"center\", color: v.green }}>\n {t(\"done\")}\n </div>\n ) : null}\n </Popup>\n );\n}\n\nfunction ClaimQuest({\n row,\n onClaimed,\n}: {\n row: QuestRow;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\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 return (\n <div ref={ref}>\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n style={{ width: \"100%\" }}\n onClick={() => {\n setBusy(true);\n void client.quest\n .claimQuestReward(row.questID, row.cycleID)\n .then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.fx.burst(ref.current);\n close();\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : row.reward);\n void onClaimed();\n });\n }}\n >\n {t(\"claim\")}\n </Button>\n </div>\n );\n}\n\nfunction PointsTrack({\n cycleID,\n section,\n track,\n onClaimed,\n}: {\n cycleID: string;\n section: QuestSection | undefined;\n track: QuestPointsTrackView | undefined;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [open, setOpen] = useState<string | null>(null);\n const cycle = section?.Cycles?.[cycleID];\n const points = Number(track?.PointsCurrent ?? track?.PointsTotalEarned ?? 0);\n const milestones = trackMilestones(\n cycle,\n points,\n track?.ClaimedPointMilestoneIDs ?? [],\n );\n if (milestones.length === 0) return null;\n const max = milestones[milestones.length - 1]?.required ?? 1;\n const picked = milestones.find((m) => m.id === open);\n\n return (\n <div style={{ ...panel, padding: \"12px 14px\", display: \"grid\", gap: 10 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span>{t(\"points\")}</span>\n <span style={{ color: v.gold }}>\n {points} / {max}\n </span>\n </div>\n <div style={{ position: \"relative\", height: 44 }}>\n <div style={{ position: \"absolute\", left: 0, right: 0, top: 16 }}>\n <ProgressBar\n value={Math.min(points, max)}\n max={max}\n tone=\"gold\"\n label=\"\"\n height={12}\n />\n </div>\n {milestones.map((m) => (\n <button\n key={m.id}\n type=\"button\"\n className={`idos-press ${m.state === \"ready\" ? \"idos-bounce\" : \"\"}`}\n onClick={() => setOpen(m.id)}\n aria-label={catalog.localize(m.name)}\n style={{\n position: \"absolute\",\n left: `calc(${(m.required / max) * 100}% - 20px)`,\n top: 0,\n width: 40,\n height: 40,\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: \"pointer\",\n opacity: m.state === \"claimed\" ? 0.55 : 1,\n }}\n >\n <Icon glyph={m.state === \"claimed\" ? \"check\" : \"chest\"} size={36} />\n </button>\n ))}\n </div>\n {picked ? (\n <Popup\n title={catalog.localize(picked.name)}\n onClose={() => setOpen(null)}\n width={360}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <Icon glyph=\"chest\" size={72} />\n <ResourceList lines={picked.reward} size={26} gap={14} />\n <ProgressBar\n value={Math.min(points, picked.required)}\n max={picked.required}\n tone=\"gold\"\n />\n </div>\n {picked.state === \"ready\" ? (\n <ClaimMilestone\n onClaim={async () => {\n const res = await client.quest.claimMilestoneReward(\n cycleID,\n picked.id,\n );\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return false;\n }\n celebrate(\n grantedBy(res.data).length > 0\n ? grantedBy(res.data)\n : picked.reward,\n );\n await onClaimed();\n return true;\n }}\n />\n ) : null}\n </Popup>\n ) : null}\n </div>\n );\n}\n\nfunction ClaimMilestone({\n onClaim,\n}: {\n onClaim: () => Promise<boolean>;\n}): ReactNode {\n const close = usePopupClose();\n const [busy, setBusy] = useState(false);\n return (\n <Button\n tone=\"gold\"\n attract\n busy={busy}\n onClick={() => {\n setBusy(true);\n void onClaim().then((ok) => {\n setBusy(false);\n if (ok) close();\n });\n }}\n >\n {t(\"claim\")}\n </Button>\n );\n}\n\n// ── Always-on: the badge ─────────────────────────────────────────────────────────────────────\n\n/**\n * Keeps the badge of the quests screen (finished quests) from the cached state. Registered only on\n * a title with quests, so a title without them makes no quest request on start.\n */\nexport function makeQuestsWatcher(features: FeatureRegistry) {\n return function QuestsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n useEffect(() => {\n // Subscribed directly: the cache patches the quest state in place, so an effect keyed on the\n // state object would not re-run after a claim and the badge would stay lit.\n const update = () => {\n const rows = questRows(\n configSection<QuestSection>(client, \"Quest\"),\n (client.data.user.state?.Quest ?? null) as QuestStateView | null,\n );\n features.setBadge(\"quests\", questsBadge(rows, 0));\n };\n update();\n const off = client.on(\"user:anyUpdated\", update);\n // From the login state; the server only when a quest cycle has ended (the central cache's\n // rule for Quest). The check is local, so it runs every minute: a day that turns mid-session\n // brings the new quests (and the badge) without reopening anything.\n const check = () => void client.cache.ensureState([\"Quest\"]);\n check();\n const id = setInterval(check, 60_000);\n return () => {\n off();\n clearInterval(id);\n };\n }, [client]);\n return null;\n };\n}\n"
62
+ "content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport type { QuestPointsTrackView } from \"@idosgames/core\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Tabs,\n Timer,\n configSection,\n errorText,\n grantedBy,\n mergeLines,\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 claimableRewards,\n cycleResetAt,\n questRows,\n questTabs,\n questsBadge,\n trackMilestones,\n type QuestRow,\n type QuestSection,\n type QuestStateView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Quests (Unity Runtime/UI/Quests): the title's quests by cycle — daily, weekly, achievements — with\n// the quest points track on top. A quest's goals and reward open in a popup; \"Claim all\" collects\n// every finished quest.\n\nexport function QuestsScreen(_: FeatureScreenProps): ReactNode {\n return <Quests />;\n}\n\nfunction Quests(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const stagger = useStagger();\n const now = useNow();\n const [tracks, setTracks] = useState<Record<string, QuestPointsTrackView>>(\n {},\n );\n const [tab, setTab] = useState<string | null>(null);\n const [detail, setDetail] = useState<QuestRow | null>(null);\n const [busyAll, setBusyAll] = useState(false);\n\n const load = useCallback(async () => {\n const res = await client.quest.getUserQuestState();\n if (res.ok)\n setTracks(\n (res.data.PointsTracks ?? {}) as Record<string, QuestPointsTrackView>,\n );\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n const section = configSection<QuestSection>(client, \"Quest\");\n const questState = (state?.Quest ?? null) as QuestStateView | null;\n const rows = questRows(section, questState);\n if (rows.length === 0)\n return <EmptyState glyph=\"quest\" text={t(\"noQuests\")} />;\n\n const tabs = questTabs(section, rows);\n const current =\n tab !== null && tabs.some((x) => x.id === tab) ? tab : (tabs[0]?.id ?? \"\");\n const shown = rows.filter((r) => (r.cycleID ?? \"\") === current);\n const cycleEnd = cycleResetAt(questState, current);\n const ready = shown.filter((r) => r.status === \"Completed\");\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.quest.claimQuestRewardsBatch(\n ready.map((r) => ({ QuestID: r.questID, CycleID: r.cycleID })),\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(\n mergeLines(\n res.data.flatMap((item) => (item.Success ? grantedBy(item.Data) : [])),\n ),\n );\n void load();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <SectionTitle\n right={<Timer at={cycleEnd} now={now} label={t(\"questsReset\")} />}\n >\n {t(\"quests\")}\n </SectionTitle>\n <Tabs\n tabs={tabs.map((x) => ({\n id: x.id,\n label: x.id ? catalog.localize(x.name) : t(\"permanent\"),\n badge: rows.filter(\n (r) => (r.cycleID ?? \"\") === x.id && r.status === \"Completed\",\n ).length,\n }))}\n value={current}\n onChange={setTab}\n />\n {current ? (\n <PointsTrack\n cycleID={current}\n section={section}\n track={tracks[current]}\n onClaimed={load}\n />\n ) : null}\n {ready.length >= 2 ? (\n <Button\n tone=\"gold\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n >\n {t(\"claimAll\")}{\" \"}\n <ResourceList lines={claimableRewards(ready)} size={18} />\n </Button>\n ) : null}\n <div style={{ display: \"grid\", gap: 10 }}>\n {shown.map((row, i) => (\n <Card\n key={`${row.cycleID ?? \"\"}:${row.questID}`}\n highlight={row.status === \"Completed\"}\n onClick={() => setDetail(row)}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"auto 1fr auto\",\n alignItems: \"center\",\n gap: 12,\n opacity: row.status === \"Claimed\" ? 0.6 : 1,\n ...stagger(i),\n }}\n >\n <Icon glyph=\"quest\" size={38} />\n <div style={{ display: \"grid\", gap: 6, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(row.name)}\n </div>\n <ProgressBar\n value={row.progress}\n max={row.target}\n tone={row.status === \"Completed\" ? \"gold\" : \"green\"}\n />\n <ResourceList lines={row.reward} />\n </div>\n {row.status === \"Claimed\" ? (\n <Icon glyph=\"check\" size={34} />\n ) : row.status === \"Completed\" ? (\n <Icon glyph=\"gift\" size={34} />\n ) : (\n <span />\n )}\n </Card>\n ))}\n </div>\n {detail ? (\n <QuestDetail\n row={\n rows.find(\n (r) =>\n r.questID === detail.questID && r.cycleID === detail.cycleID,\n ) ?? detail\n }\n onClose={() => setDetail(null)}\n onClaimed={load}\n />\n ) : null}\n </div>\n );\n}\n\nfunction QuestDetail({\n row,\n onClose,\n onClaimed,\n}: {\n row: QuestRow;\n onClose: () => void;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const catalog = useCatalog();\n return (\n <Popup title={catalog.localize(row.name)} onClose={onClose}>\n {row.description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n color: v.textDim,\n fontSize: 14,\n textAlign: \"center\",\n }}\n >\n {catalog.localize(row.description)}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 10 }}>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"objectives\")}\n </div>\n {row.objectives.map((o) => (\n <div key={o.id} style={{ display: \"grid\", gap: 4 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 14,\n }}\n >\n <span>{catalog.localize(o.name)}</span>\n {o.done ? <Icon glyph=\"check\" size={20} /> : null}\n </div>\n <ProgressBar\n value={o.progress}\n max={o.target}\n tone={o.done ? \"gold\" : \"green\"}\n />\n </div>\n ))}\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"reward\")}\n </div>\n <ResourceList lines={row.reward} size={26} gap={14} />\n </div>\n {row.status === \"Completed\" ? (\n <ClaimQuest row={row} onClaimed={onClaimed} />\n ) : row.status === \"Claimed\" ? (\n <div style={{ ...outlined, textAlign: \"center\", color: v.green }}>\n {t(\"done\")}\n </div>\n ) : null}\n </Popup>\n );\n}\n\nfunction ClaimQuest({\n row,\n onClaimed,\n}: {\n row: QuestRow;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\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 return (\n <div ref={ref}>\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n style={{ width: \"100%\" }}\n onClick={() => {\n setBusy(true);\n void client.quest\n .claimQuestReward(row.questID, row.cycleID)\n .then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.fx.burst(ref.current);\n close();\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : row.reward);\n void onClaimed();\n });\n }}\n >\n {t(\"claim\")}\n </Button>\n </div>\n );\n}\n\nfunction PointsTrack({\n cycleID,\n section,\n track,\n onClaimed,\n}: {\n cycleID: string;\n section: QuestSection | undefined;\n track: QuestPointsTrackView | undefined;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [open, setOpen] = useState<string | null>(null);\n const cycle = section?.Cycles?.[cycleID];\n const points = Number(track?.PointsCurrent ?? track?.PointsTotalEarned ?? 0);\n const milestones = trackMilestones(\n cycle,\n points,\n track?.ClaimedPointMilestoneIDs ?? [],\n );\n if (milestones.length === 0) return null;\n const max = milestones[milestones.length - 1]?.required ?? 1;\n const picked = milestones.find((m) => m.id === open);\n\n return (\n <div style={{ ...panel, padding: \"12px 14px\", display: \"grid\", gap: 10 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span>{t(\"points\")}</span>\n <span style={{ color: v.gold }}>\n {points} / {max}\n </span>\n </div>\n <div style={{ position: \"relative\", height: 44 }}>\n <div style={{ position: \"absolute\", left: 0, right: 0, top: 16 }}>\n <ProgressBar\n value={Math.min(points, max)}\n max={max}\n tone=\"gold\"\n label=\"\"\n height={12}\n />\n </div>\n {milestones.map((m) => (\n <button\n key={m.id}\n type=\"button\"\n className={`idos-press ${m.state === \"ready\" ? \"idos-bounce\" : \"\"}`}\n onClick={() => setOpen(m.id)}\n aria-label={catalog.localize(m.name)}\n style={{\n position: \"absolute\",\n left: `calc(${(m.required / max) * 100}% - 20px)`,\n top: 0,\n width: 40,\n height: 40,\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: \"pointer\",\n opacity: m.state === \"claimed\" ? 0.55 : 1,\n }}\n >\n <Icon glyph={m.state === \"claimed\" ? \"check\" : \"chest\"} size={36} />\n </button>\n ))}\n </div>\n {picked ? (\n <Popup\n title={catalog.localize(picked.name)}\n onClose={() => setOpen(null)}\n width={360}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <Icon glyph=\"chest\" size={72} />\n <ResourceList lines={picked.reward} size={26} gap={14} />\n <ProgressBar\n value={Math.min(points, picked.required)}\n max={picked.required}\n tone=\"gold\"\n />\n </div>\n {picked.state === \"ready\" ? (\n <ClaimMilestone\n onClaim={async () => {\n const res = await client.quest.claimMilestoneReward(\n cycleID,\n picked.id,\n );\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return false;\n }\n celebrate(\n grantedBy(res.data).length > 0\n ? grantedBy(res.data)\n : picked.reward,\n );\n await onClaimed();\n return true;\n }}\n />\n ) : null}\n </Popup>\n ) : null}\n </div>\n );\n}\n\nfunction ClaimMilestone({\n onClaim,\n}: {\n onClaim: () => Promise<boolean>;\n}): ReactNode {\n const close = usePopupClose();\n const [busy, setBusy] = useState(false);\n return (\n <Button\n tone=\"gold\"\n attract\n busy={busy}\n onClick={() => {\n setBusy(true);\n void onClaim().then((ok) => {\n setBusy(false);\n if (ok) close();\n });\n }}\n >\n {t(\"claim\")}\n </Button>\n );\n}\n\n// ── Always-on: the badge ─────────────────────────────────────────────────────────────────────\n\n/**\n * Keeps the badge of the quests screen (finished quests) from the cached state. Registered only on\n * a title with quests, so a title without them makes no quest request on start.\n */\nexport function watchQuests(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n // Subscribed directly: the cache patches the quest state in place, so an effect keyed on the\n // state object would not re-run after a claim and the badge would stay lit.\n const update = () => {\n const rows = questRows(\n configSection<QuestSection>(client, \"Quest\"),\n (client.data.user.state?.Quest ?? null) as QuestStateView | null,\n );\n features.setBadge(\"quests\", questsBadge(rows, 0));\n };\n update();\n // From the login state; the server only when a quest cycle has ended (the central cache's\n // rule for Quest). The check is local, so it runs every minute: a day that turns mid-session\n // brings the new quests (and the badge) without reopening anything.\n ctx.every(60_000, () => void client.cache.ensureState([\"Quest\"]));\n return client.on(\"user:anyUpdated\", update);\n });\n}\n"
63
63
  },
64
64
  {
65
65
  "path": "module.meta.json",
@@ -31,12 +31,12 @@
31
31
  "version": "0.1.1"
32
32
  },
33
33
  "dependencies": {
34
- "@idosgames/core": "0.21.2",
35
- "@idosgames/module-sdk": "0.10.0",
36
- "@idosgames/react": "0.4.4",
34
+ "@idosgames/core": "0.21.3",
35
+ "@idosgames/module-sdk": "0.10.1",
36
+ "@idosgames/react": "0.4.5",
37
37
  "react": "19.2.7"
38
38
  },
39
- "contentHash": "294e8e7d5b5f31f6c9af2538554694fb8d1271c9d59efb5e6b089fc01ebf9fb8",
39
+ "contentHash": "9db6f1aa220a6ea86646a5973d683d7f032a4074e2323cf0c1eab73a4b2e537a",
40
40
  "files": [
41
41
  {
42
42
  "path": "i18n.ts",
@@ -48,15 +48,15 @@
48
48
  },
49
49
  {
50
50
  "path": "model.ts",
51
- "content": "// Pure helpers of the daily rewards screen: the calendar arithmetic. No React, no client calls —\n// covered by model.test.ts against the module's own demo settings.\n\nimport type { ResourceGrant } from \"@idosgames/core\";\nimport type { Layout } from \"@idosgames/react/ui\";\n\n// ── Configuration (the fields this screen reads) ─────────────────────────────────────────────\n\nexport interface DailyDayView {\n DayNumber?: number;\n Rewards?: ResourceGrant | null;\n IsMilestone?: boolean;\n}\n\nexport interface DailyCalendarView {\n CalendarID?: string;\n DisplayName?: string | null;\n Days?: DailyDayView[] | null;\n IsLooping?: boolean;\n ClaimMode?: string;\n ClaimCooldownSeconds?: number;\n}\n\nexport interface RewardSection {\n DailyCalendars?: Record<string, DailyCalendarView | null> | null;\n}\n\n// ── Daily calendar ───────────────────────────────────────────────────────────────────────────\n\nexport function pickCalendar(\n section: RewardSection | undefined,\n): (DailyCalendarView & { CalendarID: string }) | null {\n const all = Object.entries(section?.DailyCalendars ?? {})\n .map(([id, def]) =>\n def ? { ...def, CalendarID: def.CalendarID ?? id } : null,\n )\n .filter(\n (d): d is DailyCalendarView & { CalendarID: string } =>\n d !== null && (d.Days?.length ?? 0) > 0,\n )\n .sort((a, b) => a.CalendarID.localeCompare(b.CalendarID));\n return all[0] ?? null;\n}\n\nexport interface DailyView {\n days: Array<{ day: DailyDayView; state: \"collected\" | \"today\" | \"locked\" }>;\n canClaim: boolean;\n collected: number;\n}\n\n/**\n * Calendar state for drawing — the rule of the Unity DailyBonusWindowController: never claimed →\n * claimable; \"CalendarDayUtc\" → once per UTC date; \"SlidingWindow\" → after the cooldown. The\n * server is the authority — this only decides which card glows.\n */\nexport function dailyView(\n calendar: DailyCalendarView,\n state: { CollectedDays?: number; LastClaimAt?: string } | null | undefined,\n now: Date,\n): DailyView {\n const days = [...(calendar.Days ?? [])].sort(\n (a, b) => (a.DayNumber ?? 0) - (b.DayNumber ?? 0),\n );\n const total = days.length;\n const collectedAll = Math.max(0, Number(state?.CollectedDays ?? 0));\n\n const last = state?.LastClaimAt ? new Date(state.LastClaimAt) : null;\n const neverClaimed =\n !last || Number.isNaN(last.getTime()) || last.getUTCFullYear() < 2000;\n let canClaim: boolean;\n if (neverClaimed) canClaim = true;\n else if (calendar.ClaimMode === \"SlidingWindow\")\n canClaim =\n (now.getTime() - last.getTime()) / 1000 >=\n (calendar.ClaimCooldownSeconds ?? 0);\n else canClaim = utcDate(last) < utcDate(now);\n\n const finished = calendar.IsLooping === false && collectedAll >= total;\n if (finished) canClaim = false;\n\n const collected =\n total === 0\n ? 0\n : calendar.IsLooping === false\n ? Math.min(collectedAll, total)\n : collectedAll % total;\n // A loop that just wrapped shows the whole row as collected until the next claim opens.\n const shownCollected =\n !canClaim && collected === 0 && collectedAll > 0 ? total : collected;\n\n return {\n days: days.map((day, i) => ({\n day,\n state:\n i < shownCollected\n ? \"collected\"\n : i === shownCollected && canClaim\n ? \"today\"\n : \"locked\",\n })),\n canClaim,\n collected: shownCollected,\n };\n}\n\nfunction utcDate(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * Columns of the calendar: the week in one row only on a tablet, where the calendar has the whole\n * width. A phone is too narrow, and on a desktop the calendar shares the screen with the quests\n * (two columns) — rows of four there, like in the popup.\n */\nexport function calendarColumns(\n layout: Layout,\n compact: boolean,\n days: number,\n): number {\n if (compact || layout !== \"tablet\") return Math.min(4, Math.max(1, days));\n return Math.min(7, Math.max(1, days));\n}\n\n/** How many columns the LAST card spans, so a short final row is filled (the big day 7). */\nexport function lastCardSpan(days: number, columns: number): number {\n const rest = days % columns;\n return rest === 0 ? 1 : columns - rest + 1;\n}\n\n/** Whether the title has a daily calendar — until it does, the lobby hides the tab. */\nexport function hasTitleData(reward: RewardSection | undefined): boolean {\n return Object.keys(reward?.DailyCalendars ?? {}).length > 0;\n}\n"
51
+ "content": "// Pure helpers of the daily rewards screen: the calendar arithmetic. No React, no client calls —\n// covered by model.test.ts against the module's own demo settings.\n\nimport type {\n DailyCalendarDefinition,\n DailyRewardDay,\n RewardDefinitions,\n} from \"@idosgames/core\";\nimport type { Layout } from \"@idosgames/react/ui\";\n\n// ── Daily calendar ───────────────────────────────────────────────────────────────────────────\n\nexport function pickCalendar(\n section: RewardDefinitions | undefined,\n): (DailyCalendarDefinition & { CalendarID: string }) | null {\n const all = Object.entries(section?.DailyCalendars ?? {})\n // A calendar the server sends as null is skipped, not a crash.\n .map(([id, def]) =>\n def ? { ...def, CalendarID: def.CalendarID ?? id } : null,\n )\n .filter(\n (d): d is NonNullable<typeof d> =>\n d !== null && (d.Days?.length ?? 0) > 0,\n )\n .sort((a, b) => a.CalendarID.localeCompare(b.CalendarID));\n return all[0] ?? null;\n}\n\nexport interface DailyView {\n days: Array<{ day: DailyRewardDay; state: \"collected\" | \"today\" | \"locked\" }>;\n canClaim: boolean;\n collected: number;\n}\n\n/**\n * Calendar state for drawing — the rule of the Unity DailyBonusWindowController: never claimed →\n * claimable; \"CalendarDayUtc\" → once per UTC date; \"SlidingWindow\" → after the cooldown. The\n * server is the authority — this only decides which card glows.\n */\nexport function dailyView(\n calendar: DailyCalendarDefinition,\n state: { CollectedDays?: number; LastClaimAt?: string } | null | undefined,\n now: Date,\n): DailyView {\n const days = [...(calendar.Days ?? [])].sort(\n (a, b) => (a.DayNumber ?? 0) - (b.DayNumber ?? 0),\n );\n const total = days.length;\n const collectedAll = Math.max(0, Number(state?.CollectedDays ?? 0));\n\n const last = state?.LastClaimAt ? new Date(state.LastClaimAt) : null;\n const neverClaimed =\n !last || Number.isNaN(last.getTime()) || last.getUTCFullYear() < 2000;\n let canClaim: boolean;\n if (neverClaimed) canClaim = true;\n else if (calendar.ClaimMode === \"SlidingWindow\")\n canClaim =\n (now.getTime() - last.getTime()) / 1000 >=\n (calendar.ClaimCooldownSeconds ?? 0);\n else canClaim = utcDate(last) < utcDate(now);\n\n const finished = calendar.IsLooping === false && collectedAll >= total;\n if (finished) canClaim = false;\n\n const collected =\n total === 0\n ? 0\n : calendar.IsLooping === false\n ? Math.min(collectedAll, total)\n : collectedAll % total;\n // A loop that just wrapped shows the whole row as collected until the next claim opens.\n const shownCollected =\n !canClaim && collected === 0 && collectedAll > 0 ? total : collected;\n\n return {\n days: days.map((day, i) => ({\n day,\n state:\n i < shownCollected\n ? \"collected\"\n : i === shownCollected && canClaim\n ? \"today\"\n : \"locked\",\n })),\n canClaim,\n collected: shownCollected,\n };\n}\n\nfunction utcDate(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * Columns of the calendar: the week in one row only on a tablet, where the calendar has the whole\n * width. A phone is too narrow, and on a desktop the calendar shares the screen with the quests\n * (two columns) — rows of four there, like in the popup.\n */\nexport function calendarColumns(\n layout: Layout,\n compact: boolean,\n days: number,\n): number {\n if (compact || layout !== \"tablet\") return Math.min(4, Math.max(1, days));\n return Math.min(7, Math.max(1, days));\n}\n\n/** How many columns the LAST card spans, so a short final row is filled (the big day 7). */\nexport function lastCardSpan(days: number, columns: number): number {\n const rest = days % columns;\n return rest === 0 ? 1 : columns - rest + 1;\n}\n\n/** Whether the title has a daily calendar — until it does, the lobby hides the tab. */\nexport function hasTitleData(reward: RewardDefinitions | undefined): boolean {\n return Object.keys(reward?.DailyCalendars ?? {}).length > 0;\n}\n"
52
52
  },
53
53
  {
54
54
  "path": "module.ts",
55
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { RewardsScreen, makeRewardsWatcher } from \"./RewardsScreen\";\nimport { hasTitleData, type RewardSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Daily rewards as a feature of the game: the login calendar. The always-on watcher greets the\n// player with the daily bonus once per session and keeps the badge.\nexport const rewardsModule: Module = defineModule({\n id: \"rewards\",\n meta: { name: \"Rewards\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"rewards\",\n label: t(\"title\"),\n icon: \"gift\",\n group: \"events\",\n order: 40,\n primary: true,\n available: false,\n Screen: RewardsScreen,\n });\n const enabled = hasTitleData(\n configSection<RewardSection>(ctx.client, \"Reward\"),\n );\n ctx.features.setAvailable(\n \"rewards\",\n enabled,\n \"no daily calendar on the title (Reward)\",\n );\n // The greeting reads the rewards state — only where there is a calendar (no startup request\n // for a feature the title does not have: the engine's per-player limit answers a burst 429).\n if (enabled)\n ctx.registerPanel({\n id: \"daily\",\n slot: \"modal\",\n activeOnly: false,\n component: makeRewardsWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"rewards\" }),\n describeActions: {},\n });\n },\n});\n"
55
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { RewardsScreen, makeRewardsWatcher } from \"./RewardsScreen\";\nimport { hasTitleData } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Daily rewards as a feature of the game: the login calendar. The always-on watcher greets the\n// player with the daily bonus once per session and keeps the badge.\nexport const rewardsModule: Module = defineModule({\n id: \"rewards\",\n meta: { name: \"Rewards\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"rewards\",\n label: t(\"title\"),\n icon: \"gift\",\n group: \"events\",\n order: 40,\n primary: true,\n available: false,\n Screen: RewardsScreen,\n });\n const enabled = hasTitleData(configSection(ctx.client, \"Reward\"));\n ctx.features.setAvailable(\n \"rewards\",\n enabled,\n \"no daily calendar on the title (Reward)\",\n );\n // The greeting reads the rewards state — only where there is a calendar (no startup request\n // for a feature the title does not have: the engine's per-player limit answers a burst 429).\n if (enabled)\n ctx.registerPanel({\n id: \"daily\",\n slot: \"modal\",\n activeOnly: false,\n component: makeRewardsWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"rewards\" }),\n describeActions: {},\n });\n },\n});\n"
56
56
  },
57
57
  {
58
58
  "path": "RewardsScreen.tsx",
59
- "content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n configSection,\n EmptyState,\n errorText,\n grantedBy,\n grantLines,\n Icon,\n outlined,\n panel,\n Popup,\n SectionTitle,\n tokenGlyph,\n toneSurface,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n calendarColumns,\n dailyView,\n lastCardSpan,\n pickCalendar,\n type RewardSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Daily rewards (Unity Alikhan/Dailies): the login calendar — collected / today / locked cards, the\n// last one a big milestone. Quests are a system of their own (the quests module).\n\nexport function RewardsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n\n // The day turns by the clock (counted locally); opening the screen re-checks the state against\n // changes made elsewhere through the central cache.\n useEffect(() => {\n void client.cache.ensureState([\"Reward\"], { maxAgeMs: 60_000 });\n }, [client]);\n\n return <DailyCalendar rewardState={state?.Reward ?? null} />;\n}\n\n// ── Daily calendar ───────────────────────────────────────────────────────────────────────────\n\ntype RewardState = {\n DailyCalendars?: Record<\n string,\n { CollectedDays?: number; LastClaimAt?: string } | undefined\n >;\n} | null;\n\nexport function DailyCalendar({\n rewardState,\n compact = false,\n onClaimed,\n}: {\n rewardState: RewardState;\n compact?: boolean;\n onClaimed?: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const layout = useGameLayout();\n const kit = useUiKit();\n const stagger = useStagger();\n const calendar = pickCalendar(configSection<RewardSection>(client, \"Reward\"));\n const todayRef = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n\n if (!calendar)\n return compact ? null : (\n <EmptyState glyph=\"calendar\" text={t(\"noCalendar\")} />\n );\n\n const view = dailyView(\n calendar,\n rewardState?.DailyCalendars?.[calendar.CalendarID],\n new Date(),\n );\n const columns = calendarColumns(layout, compact, view.days.length);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.reward.claimDailyReward(calendar.CalendarID);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.fx.burst(todayRef.current);\n const today = view.days.find((d) => d.state === \"today\");\n const granted = grantedBy(res.data);\n onClaimed?.();\n celebrate(granted.length > 0 ? granted : grantLines(today?.day.Rewards));\n };\n\n return (\n <div>\n {compact ? null : (\n <SectionTitle>\n {catalog.localize(calendar.DisplayName ?? t(\"daily\"))}\n </SectionTitle>\n )}\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 12 }}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,\n gap: 8,\n }}\n >\n {view.days.map(({ day, state }, i) => {\n const lines = grantLines(day.Rewards);\n const main = lines[0];\n const span =\n i === view.days.length - 1\n ? lastCardSpan(view.days.length, columns)\n : 1;\n const today = state === \"today\";\n return (\n <div\n key={day.DayNumber ?? i}\n ref={today ? todayRef : undefined}\n className={today ? \"idos-glow\" : undefined}\n style={{\n gridColumn: span > 1 ? `span ${span}` : undefined,\n borderRadius: 14,\n padding: \"8px 4px\",\n display: \"grid\",\n justifyItems: \"center\",\n gap: 4,\n position: \"relative\",\n ...(state === \"collected\"\n ? toneSurface(\"green\")\n : today\n ? toneSurface(\"gold\")\n : day.IsMilestone\n ? toneSurface(\"blue\")\n : { background: v.wellSoft }),\n border: today ? `2px solid ${v.text}` : `2px solid ${v.line}`,\n ...stagger(i),\n }}\n >\n <span style={{ ...outlined, fontSize: 12 }}>\n {t(\"day\")} {day.DayNumber ?? i + 1}\n </span>\n <div\n className={today ? \"idos-bounce\" : undefined}\n style={{ display: \"flex\", gap: 2 }}\n >\n {lines.slice(0, 2).map((line) => (\n <Icon\n key={`${line.kind}:${line.id}`}\n glyph={\n line.kind === \"token\"\n ? tokenGlyph(line)\n : catalog.iconOf(line)\n }\n size={day.IsMilestone ? 36 : 30}\n />\n ))}\n </div>\n {main ? (\n <span style={{ ...outlined, fontSize: 12 }}>\n ×{main.amount}\n </span>\n ) : null}\n {state === \"collected\" ? (\n <span style={{ position: \"absolute\", top: -6, right: -6 }}>\n <Icon glyph=\"check\" size={22} />\n </span>\n ) : null}\n </div>\n );\n })}\n </div>\n {view.canClaim ? (\n <Button\n busy={busy}\n attract\n size=\"lg\"\n onClick={() => void claim()}\n data-tutorial-anchor=\"rewards:daily-claim\"\n >\n <Icon glyph=\"gift\" size={22} /> {t(\"claim\")}\n </Button>\n ) : (\n <div\n style={{\n ...outlined,\n textAlign: \"center\",\n fontSize: 13,\n color: v.textDim,\n fontWeight: 700,\n }}\n >\n {t(\"comeBackTomorrow\")}\n </div>\n )}\n </div>\n </div>\n );\n}\n\n// ── Always-on: the daily bonus greets the player, and the badge ─────────────────────────────\n\n/**\n * Like the Unity DailyBonusAutoOpener: a claimable daily reward pops up once per session. Also keeps\n * the badge of the rewards screen (the daily bonus waiting).\n */\nexport function makeRewardsWatcher(features: FeatureRegistry) {\n return function RewardsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [popup, setPopup] = useState(false);\n const shownOnce = useRef(false);\n\n useEffect(() => {\n let cancelled = false;\n // The calendar is in the login state; the central cache asks the server only when the title\n // tracks comeback presence on read.\n void client.cache.ensureState([\"Reward\"]).then((res) => {\n if (cancelled || !res.ok || shownOnce.current) return;\n const calendar = pickCalendar(\n configSection<RewardSection>(client, \"Reward\"),\n );\n if (!calendar) return;\n const s = (client.data.user.state?.Reward as RewardState | undefined)\n ?.DailyCalendars?.[calendar.CalendarID];\n if (dailyView(calendar, s, new Date()).canClaim) {\n shownOnce.current = true;\n setPopup(true);\n }\n });\n return () => {\n cancelled = true;\n };\n }, [client]);\n\n // Badge from the cached state, recomputed on every change of it. Subscribed directly: the cache\n // patches the state in place, so an effect keyed on the state object would not re-run after a\n // claim and the badge would stay lit. The minute timer opens the next day's claim.\n useEffect(() => {\n const update = () => {\n const calendar = pickCalendar(\n configSection<RewardSection>(client, \"Reward\"),\n );\n const dailyReady = calendar\n ? dailyView(\n calendar,\n (client.data.user.state?.Reward as RewardState)?.DailyCalendars?.[\n calendar.CalendarID\n ],\n new Date(),\n ).canClaim\n : false;\n features.setBadge(\"rewards\", dailyReady ? 1 : 0);\n };\n update();\n const off = client.on(\"user:anyUpdated\", update);\n const id = setInterval(update, 60_000);\n return () => {\n off();\n clearInterval(id);\n };\n }, [client]);\n\n if (!popup) return null;\n return (\n <Popup\n title={t(\"daily\")}\n onClose={() => setPopup(false)}\n width={440}\n variant=\"center\"\n queued\n >\n <DailyCalendar\n compact\n rewardState={(state?.Reward ?? null) as RewardState}\n onClaimed={() => setPopup(false)}\n />\n </Popup>\n );\n };\n}\n"
59
+ "content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n configSection,\n EmptyState,\n errorText,\n grantedBy,\n grantLines,\n Icon,\n outlined,\n panel,\n Popup,\n SectionTitle,\n tokenGlyph,\n toneSurface,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n calendarColumns,\n dailyView,\n lastCardSpan,\n pickCalendar,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Daily rewards (Unity Alikhan/Dailies): the login calendar — collected / today / locked cards, the\n// last one a big milestone. Quests are a system of their own (the quests module).\n\nexport function RewardsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n\n // The day turns by the clock (counted locally); opening the screen re-checks the state against\n // changes made elsewhere through the central cache.\n useEffect(() => {\n void client.cache.ensureState([\"Reward\"], { maxAgeMs: 60_000 });\n }, [client]);\n\n return <DailyCalendar rewardState={state?.Reward ?? null} />;\n}\n\n// ── Daily calendar ───────────────────────────────────────────────────────────────────────────\n\ntype RewardState = {\n DailyCalendars?: Record<\n string,\n { CollectedDays?: number; LastClaimAt?: string } | undefined\n >;\n} | null;\n\nexport function DailyCalendar({\n rewardState,\n compact = false,\n onClaimed,\n}: {\n rewardState: RewardState;\n compact?: boolean;\n onClaimed?: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const layout = useGameLayout();\n const kit = useUiKit();\n const stagger = useStagger();\n const calendar = pickCalendar(configSection(client, \"Reward\"));\n const todayRef = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n\n if (!calendar)\n return compact ? null : (\n <EmptyState glyph=\"calendar\" text={t(\"noCalendar\")} />\n );\n\n const view = dailyView(\n calendar,\n rewardState?.DailyCalendars?.[calendar.CalendarID],\n new Date(),\n );\n const columns = calendarColumns(layout, compact, view.days.length);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.reward.claimDailyReward(calendar.CalendarID);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.fx.burst(todayRef.current);\n const today = view.days.find((d) => d.state === \"today\");\n const granted = grantedBy(res.data);\n onClaimed?.();\n celebrate(granted.length > 0 ? granted : grantLines(today?.day.Rewards));\n };\n\n return (\n <div>\n {compact ? null : (\n <SectionTitle>\n {catalog.localize(calendar.DisplayName ?? t(\"daily\"))}\n </SectionTitle>\n )}\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 12 }}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,\n gap: 8,\n }}\n >\n {view.days.map(({ day, state }, i) => {\n const lines = grantLines(day.Rewards);\n const main = lines[0];\n const span =\n i === view.days.length - 1\n ? lastCardSpan(view.days.length, columns)\n : 1;\n const today = state === \"today\";\n return (\n <div\n key={day.DayNumber ?? i}\n ref={today ? todayRef : undefined}\n className={today ? \"idos-glow\" : undefined}\n style={{\n gridColumn: span > 1 ? `span ${span}` : undefined,\n borderRadius: 14,\n padding: \"8px 4px\",\n display: \"grid\",\n justifyItems: \"center\",\n gap: 4,\n position: \"relative\",\n ...(state === \"collected\"\n ? toneSurface(\"green\")\n : today\n ? toneSurface(\"gold\")\n : day.IsMilestone\n ? toneSurface(\"blue\")\n : { background: v.wellSoft }),\n border: today ? `2px solid ${v.text}` : `2px solid ${v.line}`,\n ...stagger(i),\n }}\n >\n <span style={{ ...outlined, fontSize: 12 }}>\n {t(\"day\")} {day.DayNumber ?? i + 1}\n </span>\n <div\n className={today ? \"idos-bounce\" : undefined}\n style={{ display: \"flex\", gap: 2 }}\n >\n {lines.slice(0, 2).map((line) => (\n <Icon\n key={`${line.kind}:${line.id}`}\n glyph={\n line.kind === \"token\"\n ? tokenGlyph(line)\n : catalog.iconOf(line)\n }\n size={day.IsMilestone ? 36 : 30}\n />\n ))}\n </div>\n {main ? (\n <span style={{ ...outlined, fontSize: 12 }}>\n ×{main.amount}\n </span>\n ) : null}\n {state === \"collected\" ? (\n <span style={{ position: \"absolute\", top: -6, right: -6 }}>\n <Icon glyph=\"check\" size={22} />\n </span>\n ) : null}\n </div>\n );\n })}\n </div>\n {view.canClaim ? (\n <Button\n busy={busy}\n attract\n size=\"lg\"\n onClick={() => void claim()}\n data-tutorial-anchor=\"rewards:daily-claim\"\n >\n <Icon glyph=\"gift\" size={22} /> {t(\"claim\")}\n </Button>\n ) : (\n <div\n style={{\n ...outlined,\n textAlign: \"center\",\n fontSize: 13,\n color: v.textDim,\n fontWeight: 700,\n }}\n >\n {t(\"comeBackTomorrow\")}\n </div>\n )}\n </div>\n </div>\n );\n}\n\n// ── Always-on: the daily bonus greets the player, and the badge ─────────────────────────────\n\n/**\n * Like the Unity DailyBonusAutoOpener: a claimable daily reward pops up once per session. Also keeps\n * the badge of the rewards screen (the daily bonus waiting).\n */\nexport function makeRewardsWatcher(features: FeatureRegistry) {\n return function RewardsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [popup, setPopup] = useState(false);\n const shownOnce = useRef(false);\n\n useEffect(() => {\n let cancelled = false;\n // The calendar is in the login state; the central cache asks the server only when the title\n // tracks comeback presence on read.\n void client.cache.ensureState([\"Reward\"]).then((res) => {\n if (cancelled || !res.ok || shownOnce.current) return;\n const calendar = pickCalendar(configSection(client, \"Reward\"));\n if (!calendar) return;\n const s = (client.data.user.state?.Reward as RewardState | undefined)\n ?.DailyCalendars?.[calendar.CalendarID];\n if (dailyView(calendar, s, new Date()).canClaim) {\n shownOnce.current = true;\n setPopup(true);\n }\n });\n return () => {\n cancelled = true;\n };\n }, [client]);\n\n // Badge from the cached state, recomputed on every change of it. Subscribed directly: the cache\n // patches the state in place, so an effect keyed on the state object would not re-run after a\n // claim and the badge would stay lit. The minute timer opens the next day's claim.\n useEffect(() => {\n const update = () => {\n const calendar = pickCalendar(configSection(client, \"Reward\"));\n const dailyReady = calendar\n ? dailyView(\n calendar,\n (client.data.user.state?.Reward as RewardState)?.DailyCalendars?.[\n calendar.CalendarID\n ],\n new Date(),\n ).canClaim\n : false;\n features.setBadge(\"rewards\", dailyReady ? 1 : 0);\n };\n update();\n const off = client.on(\"user:anyUpdated\", update);\n const id = setInterval(update, 60_000);\n return () => {\n off();\n clearInterval(id);\n };\n }, [client]);\n\n if (!popup) return null;\n return (\n <Popup\n title={t(\"daily\")}\n onClose={() => setPopup(false)}\n width={440}\n variant=\"center\"\n queued\n >\n <DailyCalendar\n compact\n rewardState={(state?.Reward ?? null) as RewardState}\n onClaimed={() => setPopup(false)}\n />\n </Popup>\n );\n };\n}\n"
60
60
  },
61
61
  {
62
62
  "path": "module.meta.json",
@@ -36,12 +36,12 @@
36
36
  "version": "0.1.1"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.21.2",
40
- "@idosgames/module-sdk": "0.10.0",
41
- "@idosgames/react": "0.4.4",
39
+ "@idosgames/core": "0.21.3",
40
+ "@idosgames/module-sdk": "0.10.1",
41
+ "@idosgames/react": "0.4.5",
42
42
  "react": "19.2.7"
43
43
  },
44
- "contentHash": "c743cd8e2e81e0013d5a2aff5239355d39ce2fd6d9bbb2d0344bc60ec368cbed",
44
+ "contentHash": "8ce82e83a34b5d8db956420658d2a2c5adb90e96fbbe824b3c35cac8d733d318",
45
45
  "files": [
46
46
  {
47
47
  "path": "i18n.ts",
@@ -57,11 +57,11 @@
57
57
  },
58
58
  {
59
59
  "path": "module.ts",
60
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport {\n createSeasonStore,\n makeSeasonScreen,\n makeSeasonWatcher,\n} from \"./SeasonScreen\";\nimport { t } from \"./i18n\";\n\n// Seasons as a feature of the game. The always-on watcher keeps the badge current (tier rewards\n// waiting) and reloads after status tokens are granted; the screen shows each chain's active season.\nexport const seasonModule: Module = defineModule({\n id: \"season\",\n meta: { name: \"Season\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n const store = createSeasonStore(ctx.features);\n ctx.features.register({\n id: \"season\",\n label: t(\"title\"),\n icon: \"trophy\",\n group: \"events\",\n order: 26,\n available: false,\n Screen: makeSeasonScreen(store),\n });\n // The tab shows while a season is running — the watcher loads them at start.\n store.subscribe(() =>\n ctx.features.setAvailable(\n \"season\",\n (store.get()?.length ?? 0) > 0,\n \"no active season right now (Season chains)\",\n ),\n );\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeSeasonWatcher(store),\n });\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"season\",\n chains: store.get()?.map((s) => s.info.SeasonChainID) ?? [],\n }),\n describeActions: {},\n });\n },\n});\n"
60
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport {\n createSeasonStore,\n makeSeasonScreen,\n watchSeason,\n} from \"./SeasonScreen\";\nimport { t } from \"./i18n\";\n\n// Seasons as a feature of the game. The always-on watcher keeps the badge current (tier rewards\n// waiting) and reloads after status tokens are granted; the screen shows each chain's active season.\nexport const seasonModule: Module = defineModule({\n id: \"season\",\n meta: { name: \"Season\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n const store = createSeasonStore(ctx.features);\n ctx.features.register({\n id: \"season\",\n label: t(\"title\"),\n icon: \"trophy\",\n group: \"events\",\n order: 26,\n available: false,\n Screen: makeSeasonScreen(store),\n });\n // The tab shows while a season is running — the watcher loads them at start.\n store.subscribe(() =>\n ctx.features.setAvailable(\n \"season\",\n (store.get()?.length ?? 0) > 0,\n \"no active season right now (Season chains)\",\n ),\n );\n watchSeason(ctx, store);\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"season\",\n chains: store.get()?.map((s) => s.info.SeasonChainID) ?? [],\n }),\n describeActions: {},\n });\n },\n});\n"
61
61
  },
62
62
  {
63
63
  "path": "SeasonScreen.tsx",
64
- "content": "import {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ActiveSeasonInfo, 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 errorText,\n formatAmount,\n grantedBy,\n Icon,\n outlined,\n panel,\n ProgressBar,\n ResourceList,\n Spinner,\n Tabs,\n Timer,\n toneSurface,\n useCatalog,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n claimedLines,\n readyTiers,\n seasonBadge,\n seasonTokens,\n tierProgress,\n tierRows,\n type PremiumView,\n type TierRow,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Seasons (Unity Core/Window MilestoneRewards): the active season of every chain — its timer, the\n// status bar toward the next tier and the tier ladder. Each tier pays once; a VIP gets a second\n// prize on the tiers claimed while the pass is active. The VIP pass is a popup.\n\ntype SeasonEntry = { info: ActiveSeasonInfo };\n\n/** Active seasons of every chain, shared by the screen and the badge watcher. */\nexport function createSeasonStore(features: FeatureRegistry) {\n let seasons: SeasonEntry[] | null = null;\n const listeners = new Set<() => void>();\n let premium: () => PremiumView | null | undefined = () => null;\n const badge = () =>\n features.setBadge(\n \"season\",\n seasons\n ? seasonBadge(\n seasons.map((s) => s.info),\n premium(),\n Date.now(),\n )\n : 0,\n );\n // One read at a time; a load asked for while one is in flight runs again right after it (the\n // screen, the watcher and the reload after a claim overlap — see the events store).\n let loading: Promise<void> | null = null;\n let again = false;\n const readOnce = async (client: IDosGamesClient) => {\n const defs = await client.season.getDefinitions();\n if (!defs.ok) {\n // A failed read keeps what is on screen; only the very first one ends the spinner empty.\n seasons ??= [];\n } else {\n const chains = Object.keys(defs.data.Chains ?? {});\n const active = await Promise.all(\n chains.map((c) => client.season.getActiveSeason(c)),\n );\n seasons = active.flatMap((r) => (r.ok ? [{ info: r.data }] : []));\n }\n badge();\n for (const l of listeners) l();\n };\n return {\n get: () => seasons,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n load(client: IDosGamesClient): Promise<void> {\n premium = () =>\n client.data.user.state?.Premium as PremiumView | undefined;\n if (loading) {\n again = true;\n return loading;\n }\n loading = (async () => {\n do {\n again = false;\n await readOnce(client);\n } while (again);\n })().finally(() => {\n loading = null;\n });\n return loading;\n },\n refreshBadge: badge,\n /** The VIP system is installed and has a pass on the title — the VIP prize can be bought. */\n vipOnSale: () => features.get(\"premium\")?.available === true,\n openVip: (premiumID: string) => features.open(\"premium\", { premiumID }),\n };\n}\nexport type SeasonStore = ReturnType<typeof createSeasonStore>;\n\nexport function makeSeasonScreen(store: SeasonStore) {\n return function SeasonScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const seasons = 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 (!seasons)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (seasons.length === 0)\n return <EmptyState glyph=\"trophy\" text={t(\"noSeason\")} />;\n const current =\n seasons.find((s) => s.info.SeasonChainID === tab) ?? seasons[0]!;\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Tabs\n tabs={seasons.map((s) => ({\n id: s.info.SeasonChainID,\n label: catalog.localize(\n s.info.Season?.DisplayName ?? s.info.SeasonChainID,\n ),\n }))}\n value={current.info.SeasonChainID}\n onChange={setTab}\n />\n <SeasonView\n key={current.info.SeasonChainID}\n info={current.info}\n store={store}\n />\n </div>\n );\n };\n}\n\nexport function makeSeasonWatcher(store: SeasonStore) {\n return function SeasonWatcher(): ReactNode {\n const client = useIDosGamesClient();\n useEffect(() => {\n void store.load(client);\n const id = setInterval(() => void store.load(client), 5 * 60_000);\n // Season points also arrive from other systems (a board roll, a quest) as event tokens: re-read\n // soon after, a tier may have been reached.\n let soon: ReturnType<typeof setTimeout> | null = null;\n const offs = [\n client.on(\"season:statusTokensGranted\", () => void store.load(client)),\n client.on(\"user:eventTokenUpdated\", () => {\n if (!store.get()?.length) return; // no season running — nothing to re-read\n if (soon) clearTimeout(soon);\n soon = setTimeout(() => void store.load(client), 2_000);\n }),\n // Subscribed directly: the premium state may be patched in place.\n client.on(\"user:anyUpdated\", () => store.refreshBadge()),\n ];\n return () => {\n clearInterval(id);\n if (soon) clearTimeout(soon);\n for (const off of offs) off();\n };\n }, [client]);\n return null;\n };\n}\n\nfunction SeasonView({\n info,\n store,\n}: {\n info: ActiveSeasonInfo;\n store: SeasonStore;\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 [busyAll, setBusyAll] = useState(false);\n const premium = state?.Premium as PremiumView | undefined;\n const rows = tierRows(info, premium, now);\n const cached = (\n state?.EventToken?.Season as\n Record<string, { Balance?: { TotalEarned?: number } }> | undefined\n )?.[info.SeasonChainID]?.Balance?.TotalEarned;\n const tokens = seasonTokens(info, cached);\n const bar = tierProgress(rows, tokens);\n const ready = readyTiers(rows);\n const vipID = rows.find((r) => r.vipPremiumID)?.vipPremiumID ?? null;\n const vipHeld = rows.some((r) => r.vipHeld);\n const currentRow = rows.find((r) => r.current);\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.season.claimTierRewardsBatch(\n info.SeasonChainID,\n ready,\n );\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 : rows\n .filter((r) => r.state === \"ready\")\n .flatMap((r) => [...r.reward, ...(r.vipHeld ? r.vip : [])]),\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 ...toneSurface(\"blue\"),\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon glyph={info.Season?.AssetPaths?.icon ?? \"trophy\"} size={46} />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 18 }}>\n {catalog.localize(info.Season?.DisplayName ?? info.SeasonChainID)}\n </div>\n <Timer at={info.ComputedEndUtc} now={now} label={t(\"endsIn\")} />\n </div>\n {currentRow ? (\n <div style={{ textAlign: \"right\", ...outlined }}>\n <div style={{ fontSize: 11, color: v.textDim }}>{t(\"tier\")}</div>\n <div style={{ fontSize: 20 }}>\n {currentRow.name\n ? catalog.localize(currentRow.name)\n : currentRow.tier}\n </div>\n </div>\n ) : null}\n </div>\n {info.Season?.Description ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n fontWeight: 600,\n color: v.textDim,\n }}\n >\n {catalog.localize(info.Season.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 {t(\"status\")}: {formatAmount(tokens)}\n </span>\n <span>\n {bar.next\n ? `${t(\"nextTier\")} ${formatAmount(bar.goal)}`\n : t(\"maxTier\")}\n </span>\n </div>\n <ProgressBar\n value={bar.next ? tokens : 1}\n max={bar.next ? Math.max(1, bar.goal) : 1}\n tone=\"gold\"\n />\n </div>\n </Card>\n\n {vipID && (vipHeld || store.vipOnSale()) ? (\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={() => store.openVip(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=\"season: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 <TierCard\n key={row.tier}\n info={info}\n row={row}\n store={store}\n style={stagger(i)}\n />\n ))}\n </div>\n </div>\n );\n}\n\nfunction TierCard({\n info,\n row,\n store,\n style,\n}: {\n info: ActiveSeasonInfo;\n row: TierRow;\n store: SeasonStore;\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\n const claim = async () => {\n setBusy(true);\n const res = await client.season.claimTierReward(\n info.SeasonChainID,\n row.tier,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"levelUp\");\n kit.fx.burst(ref.current);\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0 ? got : [...row.reward, ...(row.vipHeld ? row.vip : [])],\n );\n await store.load(client);\n };\n\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:\n row.state === \"ready\"\n ? `2px solid ${v.gold}`\n : row.current\n ? `2px solid ${v.blue}`\n : panel.border,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", minWidth: 56 }}>\n <Icon glyph={row.state === \"locked\" ? \"lock\" : \"trophy\"} size={24} />\n <span style={{ ...outlined, fontSize: 12 }}>\n {row.name ? catalog.localize(row.name) : `${t(\"tier\")} ${row.tier}`}\n </span>\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {formatAmount(row.required)}\n </span>\n </div>\n <div style={{ display: \"grid\", gap: 6, minWidth: 0 }}>\n {row.current ? (\n <div style={{ ...outlined, fontSize: 11, color: v.blue }}>\n {t(\"current\")}\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.vipHeld && row.state !== \"claimed\" ? 1 : 0.5,\n }}\n >\n <Icon glyph=\"crown\" size={18} />\n <ResourceList lines={row.vip} size={20} />\n </div>\n ) : null}\n </div>\n <div>\n {row.state === \"ready\" ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n data-tutorial-anchor={`season:claim:${row.tier}`}\n >\n {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"
64
+ "content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ActiveSeasonInfo, 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 errorText,\n formatAmount,\n grantedBy,\n Icon,\n outlined,\n panel,\n ProgressBar,\n ResourceList,\n Spinner,\n Tabs,\n Timer,\n toneSurface,\n useCatalog,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n claimedLines,\n readyTiers,\n seasonBadge,\n seasonTokens,\n tierProgress,\n tierRows,\n type PremiumView,\n type TierRow,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Seasons (Unity Core/Window MilestoneRewards): the active season of every chain — its timer, the\n// status bar toward the next tier and the tier ladder. Each tier pays once; a VIP gets a second\n// prize on the tiers claimed while the pass is active. The VIP pass is a popup.\n\ntype SeasonEntry = { info: ActiveSeasonInfo };\n\n/** Active seasons of every chain, shared by the screen and the badge watcher. */\nexport function createSeasonStore(features: FeatureRegistry) {\n let seasons: SeasonEntry[] | null = null;\n const listeners = new Set<() => void>();\n let premium: () => PremiumView | null | undefined = () => null;\n const badge = () =>\n features.setBadge(\n \"season\",\n seasons\n ? seasonBadge(\n seasons.map((s) => s.info),\n premium(),\n Date.now(),\n )\n : 0,\n );\n // One read at a time; a load asked for while one is in flight runs again right after it (the\n // screen, the watcher and the reload after a claim overlap — see the events store).\n let loading: Promise<void> | null = null;\n let again = false;\n const readOnce = async (client: IDosGamesClient) => {\n const defs = await client.season.getDefinitions();\n if (!defs.ok) {\n // A failed read keeps what is on screen; only the very first one ends the spinner empty.\n seasons ??= [];\n } else {\n const chains = Object.keys(defs.data.Chains ?? {});\n const active = await Promise.all(\n chains.map((c) => client.season.getActiveSeason(c)),\n );\n seasons = active.flatMap((r) => (r.ok ? [{ info: r.data }] : []));\n }\n badge();\n for (const l of listeners) l();\n };\n return {\n get: () => seasons,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n load(client: IDosGamesClient): Promise<void> {\n premium = () =>\n client.data.user.state?.Premium as PremiumView | undefined;\n if (loading) {\n again = true;\n return loading;\n }\n loading = (async () => {\n do {\n again = false;\n await readOnce(client);\n } while (again);\n })().finally(() => {\n loading = null;\n });\n return loading;\n },\n refreshBadge: badge,\n /** The VIP system is installed and has a pass on the title — the VIP prize can be bought. */\n vipOnSale: () => features.get(\"premium\")?.available === true,\n openVip: (premiumID: string) => features.open(\"premium\", { premiumID }),\n };\n}\nexport type SeasonStore = ReturnType<typeof createSeasonStore>;\n\nexport function makeSeasonScreen(store: SeasonStore) {\n return function SeasonScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const seasons = 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 (!seasons)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (seasons.length === 0)\n return <EmptyState glyph=\"trophy\" text={t(\"noSeason\")} />;\n const current =\n seasons.find((s) => s.info.SeasonChainID === tab) ?? seasons[0]!;\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Tabs\n tabs={seasons.map((s) => ({\n id: s.info.SeasonChainID,\n label: catalog.localize(\n s.info.Season?.DisplayName ?? s.info.SeasonChainID,\n ),\n }))}\n value={current.info.SeasonChainID}\n onChange={setTab}\n />\n <SeasonView\n key={current.info.SeasonChainID}\n info={current.info}\n store={store}\n />\n </div>\n );\n };\n}\n\nexport function watchSeason(ctx: ModuleContext, store: SeasonStore): void {\n const { client } = ctx;\n ctx.effect(() => {\n ctx.every(5 * 60_000, () => void store.load(client));\n // Season points also arrive from other systems (a board roll, a quest) as event tokens: re-read\n // soon after, a tier may have been reached.\n let soon: ReturnType<typeof setTimeout> | null = null;\n const offs = [\n client.on(\"season:statusTokensGranted\", () => void store.load(client)),\n client.on(\"user:eventTokenUpdated\", () => {\n if (!store.get()?.length) return; // no season running — nothing to re-read\n if (soon) clearTimeout(soon);\n soon = setTimeout(() => void store.load(client), 2_000);\n }),\n // Subscribed directly: the premium state may be patched in place.\n client.on(\"user:anyUpdated\", () => store.refreshBadge()),\n ];\n return () => {\n if (soon) clearTimeout(soon);\n for (const off of offs) off();\n };\n });\n}\n\nfunction SeasonView({\n info,\n store,\n}: {\n info: ActiveSeasonInfo;\n store: SeasonStore;\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 [busyAll, setBusyAll] = useState(false);\n const premium = state?.Premium as PremiumView | undefined;\n const rows = tierRows(info, premium, now);\n const cached = (\n state?.EventToken?.Season as\n Record<string, { Balance?: { TotalEarned?: number } }> | undefined\n )?.[info.SeasonChainID]?.Balance?.TotalEarned;\n const tokens = seasonTokens(info, cached);\n const bar = tierProgress(rows, tokens);\n const ready = readyTiers(rows);\n const vipID = rows.find((r) => r.vipPremiumID)?.vipPremiumID ?? null;\n const vipHeld = rows.some((r) => r.vipHeld);\n const currentRow = rows.find((r) => r.current);\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.season.claimTierRewardsBatch(\n info.SeasonChainID,\n ready,\n );\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 : rows\n .filter((r) => r.state === \"ready\")\n .flatMap((r) => [...r.reward, ...(r.vipHeld ? r.vip : [])]),\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 ...toneSurface(\"blue\"),\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon glyph={info.Season?.AssetPaths?.icon ?? \"trophy\"} size={46} />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 18 }}>\n {catalog.localize(info.Season?.DisplayName ?? info.SeasonChainID)}\n </div>\n <Timer at={info.ComputedEndUtc} now={now} label={t(\"endsIn\")} />\n </div>\n {currentRow ? (\n <div style={{ textAlign: \"right\", ...outlined }}>\n <div style={{ fontSize: 11, color: v.textDim }}>{t(\"tier\")}</div>\n <div style={{ fontSize: 20 }}>\n {currentRow.name\n ? catalog.localize(currentRow.name)\n : currentRow.tier}\n </div>\n </div>\n ) : null}\n </div>\n {info.Season?.Description ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n fontWeight: 600,\n color: v.textDim,\n }}\n >\n {catalog.localize(info.Season.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 {t(\"status\")}: {formatAmount(tokens)}\n </span>\n <span>\n {bar.next\n ? `${t(\"nextTier\")} ${formatAmount(bar.goal)}`\n : t(\"maxTier\")}\n </span>\n </div>\n <ProgressBar\n value={bar.next ? tokens : 1}\n max={bar.next ? Math.max(1, bar.goal) : 1}\n tone=\"gold\"\n />\n </div>\n </Card>\n\n {vipID && (vipHeld || store.vipOnSale()) ? (\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={() => store.openVip(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=\"season: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 <TierCard\n key={row.tier}\n info={info}\n row={row}\n store={store}\n style={stagger(i)}\n />\n ))}\n </div>\n </div>\n );\n}\n\nfunction TierCard({\n info,\n row,\n store,\n style,\n}: {\n info: ActiveSeasonInfo;\n row: TierRow;\n store: SeasonStore;\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\n const claim = async () => {\n setBusy(true);\n const res = await client.season.claimTierReward(\n info.SeasonChainID,\n row.tier,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"levelUp\");\n kit.fx.burst(ref.current);\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0 ? got : [...row.reward, ...(row.vipHeld ? row.vip : [])],\n );\n await store.load(client);\n };\n\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:\n row.state === \"ready\"\n ? `2px solid ${v.gold}`\n : row.current\n ? `2px solid ${v.blue}`\n : panel.border,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", minWidth: 56 }}>\n <Icon glyph={row.state === \"locked\" ? \"lock\" : \"trophy\"} size={24} />\n <span style={{ ...outlined, fontSize: 12 }}>\n {row.name ? catalog.localize(row.name) : `${t(\"tier\")} ${row.tier}`}\n </span>\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {formatAmount(row.required)}\n </span>\n </div>\n <div style={{ display: \"grid\", gap: 6, minWidth: 0 }}>\n {row.current ? (\n <div style={{ ...outlined, fontSize: 11, color: v.blue }}>\n {t(\"current\")}\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.vipHeld && row.state !== \"claimed\" ? 1 : 0.5,\n }}\n >\n <Icon glyph=\"crown\" size={18} />\n <ResourceList lines={row.vip} size={20} />\n </div>\n ) : null}\n </div>\n <div>\n {row.state === \"ready\" ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n data-tutorial-anchor={`season:claim:${row.tier}`}\n >\n {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"
65
65
  },
66
66
  {
67
67
  "path": "module.meta.json",