@idosgames/mcp 0.1.22 → 0.1.24
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.
- package/package.json +1 -1
- package/registry/host.json +2 -2
- package/registry/index.json +173 -140
- package/registry/modules/ai.json +5 -5
- package/registry/modules/board-game.json +9 -9
- package/registry/modules/boosts.json +7 -7
- package/registry/modules/character.json +34 -12
- package/registry/modules/chat.json +3 -3
- package/registry/modules/collections.json +5 -5
- package/registry/modules/coop-events.json +5 -5
- package/registry/modules/craft.json +5 -5
- package/registry/modules/deals.json +5 -5
- package/registry/modules/events.json +5 -5
- package/registry/modules/friends.json +5 -5
- package/registry/modules/idle-rpg.json +93 -54
- package/registry/modules/inventory.json +5 -5
- package/registry/modules/invite.json +5 -5
- package/registry/modules/leaderboard.json +5 -5
- package/registry/modules/lootboxes.json +6 -6
- package/registry/modules/mailbox.json +5 -5
- package/registry/modules/marketplace.json +6 -6
- package/registry/modules/mini-game.json +3 -3
- package/registry/modules/multiplayer.json +7 -7
- package/registry/modules/premium.json +5 -5
- package/registry/modules/profile.json +3 -3
- package/registry/modules/pvp.json +7 -7
- package/registry/modules/quests.json +6 -6
- package/registry/modules/rewards.json +5 -5
- package/registry/modules/season.json +5 -5
- package/registry/modules/store.json +5 -5
- package/registry/modules/tutorials.json +3 -3
- package/registry/modules/voxelcraft.json +6 -6
- package/registry/modules/workshop.json +3 -3
- package/registry/skills/blockchain-system.json +1 -1
- package/registry/skills/community-marketing-system.json +1 -1
- package/registry/skills/idosgames-getting-started.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +1 -1
- package/registry/skills/mailbox-system.json +1 -1
- package/registry/skills/match-system.json +2 -2
- package/registry/skills/multiplayer-realtime.json +2 -2
|
@@ -33,16 +33,16 @@
|
|
|
33
33
|
"version": "0.1.0"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@idosgames/core": "0.
|
|
37
|
-
"@idosgames/module-sdk": "0.8.
|
|
38
|
-
"@idosgames/react": "0.4.
|
|
36
|
+
"@idosgames/core": "0.21.0",
|
|
37
|
+
"@idosgames/module-sdk": "0.8.2",
|
|
38
|
+
"@idosgames/react": "0.4.4",
|
|
39
39
|
"react": "19.2.7"
|
|
40
40
|
},
|
|
41
|
-
"contentHash": "
|
|
41
|
+
"contentHash": "422f135f64903ca4f30425c1f2da4e7093a0f81e5877d65184fa7bc5a0eab846",
|
|
42
42
|
"files": [
|
|
43
43
|
{
|
|
44
44
|
"path": "i18n.ts",
|
|
45
|
-
"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 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 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"
|
|
45
|
+
"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"
|
|
46
46
|
},
|
|
47
47
|
{
|
|
48
48
|
"path": "index.ts",
|
|
@@ -54,11 +54,11 @@
|
|
|
54
54
|
},
|
|
55
55
|
{
|
|
56
56
|
"path": "module.ts",
|
|
57
|
-
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makePvpScreen } from \"./PvpScreen\";\nimport { hasTitleData } from \"./model\";\nimport { t } from \"./i18n\";\n\n// PvP duels as a feature of the game: open duels to join, my duels, and creating one. The fight is\n// resolved instantly by the server from the heroes' stats and the saved strategy.\nexport const pvpModule: Module = defineModule({\n id: \"pvp\",\n meta: { name: \"Arena\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"pvp\",\n label: t(\"title\"),\n icon: \"swords\",\n group: \"social\",\n order: 95,\n available: false,\n Screen: makePvpScreen(ctx.features),\n });\n ctx.features.setAvailable(\n
|
|
57
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makePvpScreen } from \"./PvpScreen\";\nimport { hasTitleData } from \"./model\";\nimport { t } from \"./i18n\";\n\n// PvP duels as a feature of the game: open duels to join, my duels, and creating one. The fight is\n// resolved instantly by the server from the heroes' stats and the saved strategy.\nexport const pvpModule: Module = defineModule({\n id: \"pvp\",\n meta: { name: \"Arena\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"pvp\",\n label: t(\"title\"),\n icon: \"swords\",\n group: \"social\",\n order: 95,\n available: false,\n Screen: makePvpScreen(ctx.features),\n });\n const decide = () =>\n ctx.features.setAvailable(\n \"pvp\",\n hasTitleData(\n configSection<Parameters<typeof hasTitleData>[0]>(\n ctx.client,\n \"Match\",\n ),\n ),\n \"no duel rules on the title (Match.InstantBattle.Rules)\",\n );\n decide();\n // The engine does not put Match into the client's title config: read it once with its own call\n // (it lands in the same config section). Without this the tab never showed on any title.\n if (!configSection(ctx.client, \"Match\"))\n void ctx.client.match.getDefinitions().then(decide);\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"pvp\" }),\n describeActions: {},\n });\n },\n});\n"
|
|
58
58
|
},
|
|
59
59
|
{
|
|
60
60
|
"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 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\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"
|
|
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"
|
|
62
62
|
},
|
|
63
63
|
{
|
|
64
64
|
"path": "module.meta.json",
|
|
@@ -34,12 +34,12 @@
|
|
|
34
34
|
"version": "0.1.0"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@idosgames/core": "0.
|
|
38
|
-
"@idosgames/module-sdk": "0.8.
|
|
39
|
-
"@idosgames/react": "0.4.
|
|
37
|
+
"@idosgames/core": "0.21.0",
|
|
38
|
+
"@idosgames/module-sdk": "0.8.2",
|
|
39
|
+
"@idosgames/react": "0.4.4",
|
|
40
40
|
"react": "19.2.7"
|
|
41
41
|
},
|
|
42
|
-
"contentHash": "
|
|
42
|
+
"contentHash": "9a0d6402a5ecbad12997c91a6c25e7c067cc75f902f7e000db3f7b09ed89d83e",
|
|
43
43
|
"files": [
|
|
44
44
|
{
|
|
45
45
|
"path": "i18n.ts",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
{
|
|
53
53
|
"path": "model.ts",
|
|
54
|
-
"content": "// Pure helpers of the quests screen: quest rows with the player's progress, the cycle tabs and the\n// quest points track. No React, no client calls — covered by model.test.ts against the module's own\n// demo settings.\n\nimport type { ResourceGrant } from \"@idosgames/core\";\nimport { grantLines, mergeLines, type ResourceLine } from \"@idosgames/react/ui\";\n\n// ── Configuration (the fields this screen reads) ─────────────────────────────────────────────\n\nexport interface QuestObjectiveView {\n ObjectiveID?: string;\n DisplayName?: string | null;\n MetricID?: string | null;\n TargetValue?: number;\n Source?: string;\n}\n\nexport interface QuestDefinitionView {\n QuestID?: string;\n Identity?: {\n DisplayName?: string | null;\n Description?: string | null;\n } | null;\n Linking?: { CycleIDs?: string[] | null } | null;\n Objectives?: Record<string, QuestObjectiveView | null> | null;\n Reward?: { Grant?: ResourceGrant | null } | null;\n}\n\nexport interface QuestMilestoneView {\n MilestoneID?: string | null;\n DisplayName?: string | null;\n RequiredProgress?: number | null;\n Rewards?: ResourceGrant | null;\n}\n\nexport interface QuestCycleView {\n CycleID?: string;\n DisplayName?: string | null;\n Milestones?: Record<string, QuestMilestoneView | null> | null;\n}\n\nexport interface QuestSection {\n Cycles?: Record<string, QuestCycleView | null> | null;\n Quests?: Record<string, QuestDefinitionView | null> | null;\n}\n\nexport interface QuestProgressView {\n Status?: string;\n Objectives?: Record<\n string,\n { ObjectiveID?: string; CurrentValue?: number; Completed?: boolean } | null\n > | null;\n}\n\nexport interface QuestStateView {\n Cycles?: Record<\n string,\n {\n Quests?: Record<string, QuestProgressView | null> | null;\n CycleEndUtc?: string | null;\n } | null\n > | null;\n PermanentQuests?: Record<string, QuestProgressView | null> | null;\n}\n\n// ── Quests ───────────────────────────────────────────────────────────────────────────────────\n\nexport interface ObjectiveRow {\n id: string;\n name: string;\n progress: number;\n target: number;\n done: boolean;\n}\n\nexport interface QuestRow {\n questID: string;\n /** undefined — a permanent quest. */\n cycleID: string | undefined;\n name: string;\n description: string;\n status: \"Active\" | \"Completed\" | \"Claimed\" | \"Expired\";\n progress: number;\n target: number;\n objectives: ObjectiveRow[];\n reward: ResourceLine[];\n}\n\n/**\n * Quests with the player's progress. One bar per quest is the SUM over its objectives against the\n * sum of targets (Unity quest window); the detail popup lists every objective.\n */\nexport function questRows(\n section: QuestSection | undefined,\n state: QuestStateView | null | undefined,\n): QuestRow[] {\n const rows: QuestRow[] = [];\n for (const [id, def] of Object.entries(section?.Quests ?? {})) {\n if (!def) continue;\n const questID = def.QuestID ?? id;\n const cycleID = def.Linking?.CycleIDs?.[0] ?? undefined;\n const progress = cycleID\n ? state?.Cycles?.[cycleID]?.Quests?.[questID]\n : state?.PermanentQuests?.[questID];\n const status = (progress?.Status ?? \"Active\") as QuestRow[\"status\"];\n\n const objectives: ObjectiveRow[] = Object.entries(def.Objectives ?? {})\n .filter(([, o]) => o)\n .map(([oid, o]) => {\n const objectiveID = o?.ObjectiveID ?? oid;\n const target = Math.max(1, Number(o?.TargetValue ?? 1));\n const p = progress?.Objectives?.[objectiveID];\n const done =\n status === \"Completed\" ||\n status === \"Claimed\" ||\n p?.Completed === true;\n return {\n id: objectiveID,\n name: o?.DisplayName || objectiveID,\n target,\n progress: done\n ? target\n : Math.min(target, Number(p?.CurrentValue ?? 0)),\n done,\n };\n });\n const target = objectives.reduce((s, o) => s + o.target, 0) || 1;\n const current = objectives.reduce((s, o) => s + o.progress, 0);\n\n rows.push({\n questID,\n cycleID,\n name: def.Identity?.DisplayName || questID,\n description: def.Identity?.Description || \"\",\n status,\n progress:\n status === \"Completed\" || status === \"Claimed\" ? target : current,\n target,\n objectives,\n reward: grantLines(def.Reward?.Grant),\n });\n }\n // Ready to claim first, then in progress, then done.\n const weight = { Completed: 0, Active: 1, Claimed: 2, Expired: 3 } as const;\n return rows.sort(\n (a, b) =>\n weight[a.status] - weight[b.status] || a.questID.localeCompare(b.questID),\n );\n}\n\n/** The quest tabs: cycles that have quests, then \"permanent\" when there are cycle-less quests. */\nexport function questTabs(\n section: QuestSection | undefined,\n rows: QuestRow[],\n): Array<{ id: string; name: string }> {\n const used = new Set(rows.map((r) => r.cycleID ?? \"\"));\n const tabs = Object.entries(section?.Cycles ?? {})\n .map(([id, c]) => ({ id: c?.CycleID ?? id, name: c?.DisplayName || id }))\n .filter((c) => used.has(c.id))\n .sort((a, b) => a.id.localeCompare(b.id));\n if (used.has(\"\")) tabs.push({ id: \"\", name: \"\" });\n return tabs;\n}\n\n/** Everything a \"Claim all\" collects (the reward preview of the batch). */\nexport function claimableRewards(rows: QuestRow[]): ResourceLine[] {\n return mergeLines(\n rows.filter((r) => r.status === \"Completed\").flatMap((r) => r.reward),\n );\n}\n\n// ── Points track ─────────────────────────────────────────────────────────────────────────────\n\nexport interface TrackMilestone {\n id: string;\n name: string;\n required: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"locked\";\n}\n\n/** Milestones of a cycle's quest points track (Unity QuestWindowMilestoneController). */\nexport function trackMilestones(\n cycle: QuestCycleView | null | undefined,\n points: number,\n claimed: readonly string[],\n): TrackMilestone[] {\n return Object.entries(cycle?.Milestones ?? {})\n .map(([id, m]) => ({ id: m?.MilestoneID ?? id, m }))\n .filter((x): x is { id: string; m: QuestMilestoneView } => !!x.m)\n .sort(\n (a, b) =>\n Number(a.m.RequiredProgress ?? 0) - Number(b.m.RequiredProgress ?? 0),\n )\n .map(({ id, m }) => {\n const required = Number(m.RequiredProgress ?? 0);\n return {\n id,\n name: m.DisplayName || id,\n required,\n reward: grantLines(m.Rewards),\n state: claimed.includes(id)\n ? \"claimed\"\n : points >= required\n ? \"ready\"\n : \"locked\",\n };\n });\n}\n\n/** Badge of the quests screen: finished quests and track rewards waiting. */\nexport function questsBadge(rows: QuestRow[], trackReady: number): number {\n return rows.filter((r) => r.status === \"Completed\").length + trackReady;\n}\n\n/** Whether the title has quests — until it does, the lobby hides the tab. */\nexport function hasTitleData(quest: QuestSection | undefined): boolean {\n return Object.keys(quest?.Quests ?? {}).length > 0;\n}\n"
|
|
54
|
+
"content": "// Pure helpers of the quests screen: quest rows with the player's progress, the cycle tabs and the\n// quest points track. No React, no client calls — covered by model.test.ts against the module's own\n// demo settings.\n\nimport type { ResourceGrant } from \"@idosgames/core\";\nimport { grantLines, mergeLines, type ResourceLine } from \"@idosgames/react/ui\";\n\n// ── Configuration (the fields this screen reads) ─────────────────────────────────────────────\n\nexport interface QuestObjectiveView {\n ObjectiveID?: string;\n DisplayName?: string | null;\n MetricID?: string | null;\n TargetValue?: number;\n Source?: string;\n}\n\nexport interface QuestDefinitionView {\n QuestID?: string;\n Identity?: {\n DisplayName?: string | null;\n Description?: string | null;\n } | null;\n Linking?: { CycleIDs?: string[] | null } | null;\n Objectives?: Record<string, QuestObjectiveView | null> | null;\n Reward?: { Grant?: ResourceGrant | null } | null;\n}\n\nexport interface QuestMilestoneView {\n MilestoneID?: string | null;\n DisplayName?: string | null;\n RequiredProgress?: number | null;\n Rewards?: ResourceGrant | null;\n}\n\nexport interface QuestCycleView {\n CycleID?: string;\n DisplayName?: string | null;\n Milestones?: Record<string, QuestMilestoneView | null> | null;\n}\n\nexport interface QuestSection {\n Cycles?: Record<string, QuestCycleView | null> | null;\n Quests?: Record<string, QuestDefinitionView | null> | null;\n}\n\nexport interface QuestProgressView {\n Status?: string;\n Objectives?: Record<\n string,\n { ObjectiveID?: string; CurrentValue?: number; Completed?: boolean } | null\n > | null;\n}\n\nexport interface QuestStateView {\n Cycles?: Record<\n string,\n {\n Quests?: Record<string, QuestProgressView | null> | null;\n CycleEndUtc?: string | null;\n } | null\n > | null;\n PermanentQuests?: Record<string, QuestProgressView | null> | null;\n}\n\n// ── Quests ───────────────────────────────────────────────────────────────────────────────────\n\nexport interface ObjectiveRow {\n id: string;\n name: string;\n progress: number;\n target: number;\n done: boolean;\n}\n\nexport interface QuestRow {\n questID: string;\n /** undefined — a permanent quest. */\n cycleID: string | undefined;\n name: string;\n description: string;\n status: \"Active\" | \"Completed\" | \"Claimed\" | \"Expired\";\n progress: number;\n target: number;\n objectives: ObjectiveRow[];\n reward: ResourceLine[];\n}\n\n/**\n * Quests with the player's progress. One bar per quest is the SUM over its objectives against the\n * sum of targets (Unity quest window); the detail popup lists every objective.\n */\nexport function questRows(\n section: QuestSection | undefined,\n state: QuestStateView | null | undefined,\n): QuestRow[] {\n const rows: QuestRow[] = [];\n for (const [id, def] of Object.entries(section?.Quests ?? {})) {\n if (!def) continue;\n const questID = def.QuestID ?? id;\n const cycleID = def.Linking?.CycleIDs?.[0] ?? undefined;\n const progress = cycleID\n ? state?.Cycles?.[cycleID]?.Quests?.[questID]\n : state?.PermanentQuests?.[questID];\n const status = (progress?.Status ?? \"Active\") as QuestRow[\"status\"];\n\n const objectives: ObjectiveRow[] = Object.entries(def.Objectives ?? {})\n .filter(([, o]) => o)\n .map(([oid, o]) => {\n const objectiveID = o?.ObjectiveID ?? oid;\n const target = Math.max(1, Number(o?.TargetValue ?? 1));\n const p = progress?.Objectives?.[objectiveID];\n const done =\n status === \"Completed\" ||\n status === \"Claimed\" ||\n p?.Completed === true;\n return {\n id: objectiveID,\n name: o?.DisplayName || objectiveID,\n target,\n progress: done\n ? target\n : Math.min(target, Number(p?.CurrentValue ?? 0)),\n done,\n };\n });\n const target = objectives.reduce((s, o) => s + o.target, 0) || 1;\n const current = objectives.reduce((s, o) => s + o.progress, 0);\n\n rows.push({\n questID,\n cycleID,\n name: def.Identity?.DisplayName || questID,\n description: def.Identity?.Description || \"\",\n status,\n progress:\n status === \"Completed\" || status === \"Claimed\" ? target : current,\n target,\n objectives,\n reward: grantLines(def.Reward?.Grant),\n });\n }\n // Ready to claim first, then in progress, then done.\n const weight = { Completed: 0, Active: 1, Claimed: 2, Expired: 3 } as const;\n return rows.sort(\n (a, b) =>\n weight[a.status] - weight[b.status] || a.questID.localeCompare(b.questID),\n );\n}\n\n/** The quest tabs: cycles that have quests, then \"permanent\" when there are cycle-less quests. */\nexport function questTabs(\n section: QuestSection | undefined,\n rows: QuestRow[],\n): Array<{ id: string; name: string }> {\n const used = new Set(rows.map((r) => r.cycleID ?? \"\"));\n const tabs = Object.entries(section?.Cycles ?? {})\n .map(([id, c]) => ({ id: c?.CycleID ?? id, name: c?.DisplayName || id }))\n .filter((c) => used.has(c.id))\n .sort((a, b) => a.id.localeCompare(b.id));\n if (used.has(\"\")) tabs.push({ id: \"\", name: \"\" });\n return tabs;\n}\n\n/** Everything a \"Claim all\" collects (the reward preview of the batch). */\nexport function claimableRewards(rows: QuestRow[]): ResourceLine[] {\n return mergeLines(\n rows.filter((r) => r.status === \"Completed\").flatMap((r) => r.reward),\n );\n}\n\n// ── Points track ─────────────────────────────────────────────────────────────────────────────\n\nexport interface TrackMilestone {\n id: string;\n name: string;\n required: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"locked\";\n}\n\n/** Milestones of a cycle's quest points track (Unity QuestWindowMilestoneController). */\nexport function trackMilestones(\n cycle: QuestCycleView | null | undefined,\n points: number,\n claimed: readonly string[],\n): TrackMilestone[] {\n return Object.entries(cycle?.Milestones ?? {})\n .map(([id, m]) => ({ id: m?.MilestoneID ?? id, m }))\n .filter((x): x is { id: string; m: QuestMilestoneView } => !!x.m)\n .sort(\n (a, b) =>\n Number(a.m.RequiredProgress ?? 0) - Number(b.m.RequiredProgress ?? 0),\n )\n .map(({ id, m }) => {\n const required = Number(m.RequiredProgress ?? 0);\n return {\n id,\n name: m.DisplayName || id,\n required,\n reward: grantLines(m.Rewards),\n state: claimed.includes(id)\n ? \"claimed\"\n : points >= required\n ? \"ready\"\n : \"locked\",\n };\n });\n}\n\n/** Badge of the quests screen: finished quests and track rewards waiting. */\nexport function questsBadge(rows: QuestRow[], trackReady: number): number {\n return rows.filter((r) => r.status === \"Completed\").length + trackReady;\n}\n\n/** Whether the title has quests — until it does, the lobby hides the tab. */\n/**\n * When a cycle's quests reset, or null when they never do. A cycle without a reset (schedule\n * AlwaysOn — a guide chain, permanent tasks) comes from the engine with CycleEndUtc =\n * DateTime.MaxValue (9999-12-31): the engine keeps it as the window's key, and the screen counted\n * down to it — \"New quests in 2912168d\".\n */\nexport function cycleResetAt(\n state: QuestStateView | null | undefined,\n cycleID: string,\n): string | null {\n const end = cycleID ? state?.Cycles?.[cycleID]?.CycleEndUtc : null;\n if (!end) return null;\n const year = new Date(end).getUTCFullYear();\n return Number.isFinite(year) && year < NEVER_YEAR ? end : null;\n}\n\n/** From this year on a date is the engine's \"never\" (DateTime.MaxValue), not a real moment. */\nconst NEVER_YEAR = 9000;\n\nexport function hasTitleData(quest: QuestSection | undefined): boolean {\n return Object.keys(quest?.Quests ?? {}).length > 0;\n}\n"
|
|
55
55
|
},
|
|
56
56
|
{
|
|
57
57
|
"path": "module.ts",
|
|
@@ -59,7 +59,7 @@
|
|
|
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 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 = current ? questState?.Cycles?.[current]?.CycleEndUtc : null;\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 const state = useUserState();\n useEffect(() => {\n void client.quest.getUserQuestState();\n }, [client]);\n useEffect(() => {\n const rows = questRows(\n configSection<QuestSection>(client, \"Quest\"),\n (state?.Quest ?? null) as QuestStateView | null,\n );\n features.setBadge(\"quests\", questsBadge(rows, 0));\n }, [client, state]);\n return null;\n };\n}\n"
|
|
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"
|
|
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.
|
|
35
|
-
"@idosgames/module-sdk": "0.8.
|
|
36
|
-
"@idosgames/react": "0.4.
|
|
34
|
+
"@idosgames/core": "0.21.0",
|
|
35
|
+
"@idosgames/module-sdk": "0.8.2",
|
|
36
|
+
"@idosgames/react": "0.4.4",
|
|
37
37
|
"react": "19.2.7"
|
|
38
38
|
},
|
|
39
|
-
"contentHash": "
|
|
39
|
+
"contentHash": "294e8e7d5b5f31f6c9af2538554694fb8d1271c9d59efb5e6b089fc01ebf9fb8",
|
|
40
40
|
"files": [
|
|
41
41
|
{
|
|
42
42
|
"path": "i18n.ts",
|
|
@@ -56,7 +56,7 @@
|
|
|
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 //
|
|
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"
|
|
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.
|
|
40
|
-
"@idosgames/module-sdk": "0.8.
|
|
41
|
-
"@idosgames/react": "0.4.
|
|
39
|
+
"@idosgames/core": "0.21.0",
|
|
40
|
+
"@idosgames/module-sdk": "0.8.2",
|
|
41
|
+
"@idosgames/react": "0.4.4",
|
|
42
42
|
"react": "19.2.7"
|
|
43
43
|
},
|
|
44
|
-
"contentHash": "
|
|
44
|
+
"contentHash": "c743cd8e2e81e0013d5a2aff5239355d39ce2fd6d9bbb2d0344bc60ec368cbed",
|
|
45
45
|
"files": [
|
|
46
46
|
{
|
|
47
47
|
"path": "i18n.ts",
|
|
@@ -61,7 +61,7 @@
|
|
|
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 return {\n get: () => seasons,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n async load(client: IDosGamesClient): Promise<void> {\n premium = () =>\n client.data.user.state?.Premium as PremiumView | undefined;\n const defs = await client.season.getDefinitions();\n const chains = defs.ok ? 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 badge();\n for (const l of listeners) l();\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 const state = useUserState();\n useEffect(() => {\n void store.load(client);\n const id = setInterval(() => void store.load(client), 5 * 60_000);\n const off = client.on(\n \"season:statusTokensGranted\",\n () => void store.load(client),\n );\n return () => {\n clearInterval(id);\n off();\n };\n }, [client]);\n useEffect(() => store.refreshBadge(), [state?.Premium]);\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 {\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"
|
|
65
65
|
},
|
|
66
66
|
{
|
|
67
67
|
"path": "module.meta.json",
|
|
@@ -36,12 +36,12 @@
|
|
|
36
36
|
"version": "0.1.1"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@idosgames/core": "0.
|
|
40
|
-
"@idosgames/module-sdk": "0.8.
|
|
41
|
-
"@idosgames/react": "0.4.
|
|
39
|
+
"@idosgames/core": "0.21.0",
|
|
40
|
+
"@idosgames/module-sdk": "0.8.2",
|
|
41
|
+
"@idosgames/react": "0.4.4",
|
|
42
42
|
"react": "19.2.7"
|
|
43
43
|
},
|
|
44
|
-
"contentHash": "
|
|
44
|
+
"contentHash": "5895e1ad0f5076111d94048769f6aa000e59d8cb1182fc298590daefd3be9818",
|
|
45
45
|
"files": [
|
|
46
46
|
{
|
|
47
47
|
"path": "i18n.ts",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
"path": "module.ts",
|
|
60
|
-
"content": "import { createElement, useEffect } from \"react\";\nimport {\n defineModule,\n type FeatureRegistry,\n type Module,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport { StoreScreen } from \"./StoreScreen\";\nimport { freeOffersWaiting, hasOffers } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The shop as a feature of the game (ctx.features): the lobby shows it as a main tab once the\n// storefront has offers. Its badge counts free offers waiting to be taken (the daily gift), kept\n// fresh by an invisible always-on panel — the screen itself is mounted only while open.\nexport const storeModule: Module = defineModule({\n id: \"store\",\n meta: { name: \"Shop\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"store\",\n label: t(\"title\"),\n icon: \"shop\",\n group: \"economy\",\n order: 10,\n primary: true,\n available: false,\n Screen: StoreScreen,\n });\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeBadgeWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"store\",\n badge: ctx.features.get(\"store\")?.badge ?? 0,\n }),\n describeActions: {},\n });\n },\n});\n\nfunction makeBadgeWatcher(features: FeatureRegistry) {\n return function StoreBadge() {\n const client = useIDosGamesClient();\n useEffect(() => {\n const update = () => {\n const front = client.store.cachedStorefront;\n features.setAvailable(\n \"store\",\n hasOffers(front),\n \"the storefront has no offers (Store section)\",\n );\n features.setBadge(\"store\", freeOffersWaiting(front));\n };\n void client.store.getStorefront().then(update);\n const
|
|
60
|
+
"content": "import { createElement, useEffect } from \"react\";\nimport {\n defineModule,\n type FeatureRegistry,\n type Module,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport { StoreScreen } from \"./StoreScreen\";\nimport { freeOffersWaiting, hasOffers } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The shop as a feature of the game (ctx.features): the lobby shows it as a main tab once the\n// storefront has offers. Its badge counts free offers waiting to be taken (the daily gift), kept\n// fresh by an invisible always-on panel — the screen itself is mounted only while open.\nexport const storeModule: Module = defineModule({\n id: \"store\",\n meta: { name: \"Shop\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"store\",\n label: t(\"title\"),\n icon: \"shop\",\n group: \"economy\",\n order: 10,\n primary: true,\n available: false,\n Screen: StoreScreen,\n });\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeBadgeWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"store\",\n badge: ctx.features.get(\"store\")?.badge ?? 0,\n }),\n describeActions: {},\n });\n },\n});\n\nfunction makeBadgeWatcher(features: FeatureRegistry) {\n return function StoreBadge() {\n const client = useIDosGamesClient();\n useEffect(() => {\n const update = () => {\n const front = client.store.cachedStorefront;\n features.setAvailable(\n \"store\",\n hasOffers(front),\n \"the storefront has no offers (Store section)\",\n );\n features.setBadge(\"store\", freeOffersWaiting(front));\n };\n void client.store.getStorefront().then(update);\n // The badge is counted from the STOREFRONT, and after a purchase the screen re-reads it: the\n // purchase's own state update comes before that, with the old storefront still cached — so the\n // gift just taken kept the badge lit until the next re-read here. Count again on every load.\n const offs = [\n client.on(\"user:anyUpdated\", update),\n client.on(\"store:storefrontLoaded\", update),\n ];\n // A free gift comes back with the next day: look again every few minutes.\n const id = setInterval(\n () => void client.store.getStorefront().then(update),\n 5 * 60_000,\n );\n return () => {\n for (const off of offs) off();\n clearInterval(id);\n };\n }, [client]);\n return createElement(\"span\", { hidden: true });\n };\n}\n"
|
|
61
61
|
},
|
|
62
62
|
{
|
|
63
63
|
"path": "StoreScreen.tsx",
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"version": "0.1.1"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@idosgames/core": "0.
|
|
36
|
-
"@idosgames/module-sdk": "0.8.
|
|
37
|
-
"@idosgames/react": "0.4.
|
|
35
|
+
"@idosgames/core": "0.21.0",
|
|
36
|
+
"@idosgames/module-sdk": "0.8.2",
|
|
37
|
+
"@idosgames/react": "0.4.4",
|
|
38
38
|
"react": "19.2.7"
|
|
39
39
|
},
|
|
40
40
|
"contentHash": "cc04f0006881944dfa7119ed02d591b48c7d650dbfdc71f981ecedac5d5998f5",
|