@idosgames/mcp 0.1.28 → 0.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/cli.js +1 -1
  2. package/package.json +1 -1
  3. package/registry/host.json +11 -71
  4. package/registry/index.json +140 -140
  5. package/registry/modules/ai.json +5 -5
  6. package/registry/modules/board-game.json +4 -4
  7. package/registry/modules/boosts.json +7 -7
  8. package/registry/modules/character.json +34 -6
  9. package/registry/modules/chat.json +26 -6
  10. package/registry/modules/collections.json +23 -7
  11. package/registry/modules/coop-events.json +7 -7
  12. package/registry/modules/craft.json +7 -7
  13. package/registry/modules/deals.json +3 -3
  14. package/registry/modules/events.json +6 -6
  15. package/registry/modules/friends.json +26 -6
  16. package/registry/modules/idle-rpg.json +3 -3
  17. package/registry/modules/inventory.json +3 -3
  18. package/registry/modules/invite.json +6 -6
  19. package/registry/modules/leaderboard.json +22 -6
  20. package/registry/modules/lootboxes.json +7 -7
  21. package/registry/modules/mailbox.json +34 -6
  22. package/registry/modules/marketplace.json +35 -7
  23. package/registry/modules/mini-game.json +3 -3
  24. package/registry/modules/multiplayer.json +35 -11
  25. package/registry/modules/premium.json +7 -7
  26. package/registry/modules/profile.json +5 -5
  27. package/registry/modules/pvp.json +34 -6
  28. package/registry/modules/quests.json +6 -6
  29. package/registry/modules/rewards.json +7 -7
  30. package/registry/modules/season.json +6 -6
  31. package/registry/modules/store.json +5 -5
  32. package/registry/modules/tutorials.json +3 -3
  33. package/registry/modules/voxelcraft.json +265 -13
  34. package/registry/modules/workshop.json +50 -6
  35. package/registry/skills/authentication.json +1 -1
  36. package/registry/skills/blockchain-system.json +1 -1
  37. package/registry/skills/checkout-system.json +1 -1
  38. package/registry/skills/cloud-code.json +2 -2
  39. package/registry/skills/currency-system.json +1 -1
  40. package/registry/skills/idosgames-compose-modules.json +1 -1
  41. package/registry/skills/idosgames-getting-started.json +1 -1
  42. package/registry/skills/idosgames-module-contract.json +2 -2
  43. package/registry/skills/idosgames-project-structure.json +1 -1
  44. package/registry/skills/idosgames-ui-kit.json +2 -2
  45. package/registry/skills/marketplace-system.json +1 -1
  46. package/registry/skills/match-system.json +1 -1
  47. package/registry/skills/multiplayer-realtime.json +2 -2
  48. package/registry/skills/voxelcraft-worlds.json +2 -2
  49. package/registry/skills/workshop-system.json +2 -2
@@ -30,16 +30,16 @@
30
30
  "version": "0.1.1"
31
31
  },
32
32
  "dependencies": {
33
- "@idosgames/core": "0.21.2",
34
- "@idosgames/module-sdk": "0.10.0",
35
- "@idosgames/react": "0.4.4",
33
+ "@idosgames/core": "0.21.3",
34
+ "@idosgames/module-sdk": "0.10.1",
35
+ "@idosgames/react": "0.4.5",
36
36
  "react": "19.2.7"
37
37
  },
38
- "contentHash": "dc80659403c6c5ec016f2bfc09bb11dac17ec7f532ffb6a38f4b5b4544536ab5",
38
+ "contentHash": "19bb93619910a4551d647be57c171b1831e86757e965a79fa54bdd1847161163",
39
39
  "files": [
40
40
  {
41
41
  "path": "AiScreen.tsx",
42
- "content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type { AIGenerationView, IDosGamesClient } from \"@idosgames/core\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n ResourceList,\n Spinner,\n Tabs,\n canAfford,\n configSection,\n errorText,\n outlined,\n panel,\n useCatalog,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n featureRows,\n resultKind,\n type AISection,\n type FeatureRow,\n type Modality,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// AI studio (engine AI): the AI features the publisher opened to players — text, pictures, speech,\n// music, video, 3D. The player writes what they want, pays the feature's price (or nothing), and the\n// result comes back from a job the server runs (polled with waitForGeneration). A feature tagged\n// \"hidden\" is for the game's own code (NPC lines and the like) and does not show here.\n\ntype Tab = \"create\" | \"history\";\n\nexport function AiScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const [tab, setTab] = useState<Tab>(\"create\");\n const [picked, setPicked] = useState<string | null>(null);\n const features = featureRows(configSection<AISection>(client, \"AI\"));\n if (features.length === 0)\n return <EmptyState glyph=\"bulb\" text={t(\"noFeatures\")} />;\n const current = features.find((f) => f.key === picked) ?? features[0]!;\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <Tabs\n tabs={[\n { id: \"create\", label: t(\"create\") },\n { id: \"history\", label: t(\"history\") },\n ]}\n value={tab}\n onChange={setTab}\n />\n {tab === \"create\" ? (\n <>\n {features.length > 1 ? (\n <div style={{ display: \"flex\", gap: 6, flexWrap: \"wrap\" }}>\n {features.map((f) => (\n <Button\n key={f.key}\n size=\"sm\"\n tone={f.key === current.key ? \"gold\" : \"grey\"}\n onClick={() => setPicked(f.key)}\n >\n {catalog.localize(f.key)} · {t(`m_${f.modality}`)}\n </Button>\n ))}\n </div>\n ) : null}\n <Create key={current.key} feature={current} />\n </>\n ) : (\n <History />\n )}\n </div>\n );\n}\n\n/** Starts a job for a feature by its modality and waits for the result. */\nfunction run(\n client: IDosGamesClient,\n feature: FeatureRow,\n prompt: string,\n optionID: string | undefined,\n): Promise<\n { ok: true; data: AIGenerationView } | { ok: false; error: string }\n> {\n const options = {\n selectedOptionID: optionID,\n relatedEntityID: crypto.randomUUID(),\n };\n const byModality: Record<\n Modality,\n () => ReturnType<IDosGamesClient[\"ai\"][\"generateText\"]>\n > = {\n Text: () =>\n client.ai.generateText(feature.key, { Prompt: prompt }, options),\n Image: () =>\n client.ai.generateImage(feature.key, { Prompt: prompt }, options),\n Audio: () =>\n client.ai.generateAudio(feature.key, { Text: prompt }, options),\n Music: () =>\n client.ai.generateMusic(feature.key, { Prompt: prompt }, options),\n Video: () =>\n client.ai.generateVideo(feature.key, { Prompt: prompt }, options),\n ThreeD: () =>\n client.ai.generateThreeD(feature.key, { Prompt: prompt }, options),\n };\n return byModality[feature.modality]().then((started) =>\n started.ok\n ? client.ai.waitForGeneration(started.data, { timeoutMs: 10 * 60_000 })\n : started,\n );\n}\n\nfunction Create({ feature }: { feature: FeatureRow }): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const [prompt, setPrompt] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [result, setResult] = useState<AIGenerationView | null>(null);\n const alive = useRef(true);\n useEffect(\n () => () => {\n alive.current = false;\n },\n [],\n );\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const option =\n feature.options.find((o) => canAfford(o.price, balances)) ??\n feature.options[0];\n const affordable = !option || canAfford(option.price, balances);\n\n const go = async () => {\n setBusy(true);\n setResult(null);\n const res = await run(client, feature, prompt.trim(), option?.id);\n if (!alive.current) return;\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (res.data.Status !== \"completed\")\n return toast(res.data.Error?.Message || t(\"failed\"), \"error\");\n setResult(res.data);\n };\n\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n <textarea\n value={prompt}\n maxLength={feature.maxChars > 0 ? feature.maxChars : undefined}\n placeholder={\n feature.modality === \"Audio\" ? t(\"sayWhat\") : t(\"describe\")\n }\n onChange={(e) => setPrompt(e.target.value)}\n rows={4}\n style={{\n ...panel,\n padding: 12,\n color: v.text,\n fontSize: 15,\n resize: \"vertical\",\n width: \"100%\",\n boxSizing: \"border-box\",\n }}\n />\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract={!!prompt.trim() && affordable}\n disabled={!prompt.trim() || !affordable}\n busy={busy}\n onClick={() => void go()}\n data-tutorial-anchor={`ai:${feature.key}`}\n >\n {busy ? t(\"working\") : t(\"generate\")}{\" \"}\n {option ? <ResourceList lines={option.price} size={18} /> : null}\n </Button>\n {busy ? (\n <Center>\n <Spinner />\n </Center>\n ) : null}\n {result ? <Result generation={result} /> : null}\n </div>\n );\n}\n\nfunction Result({ generation }: { generation: AIGenerationView }): ReactNode {\n return (\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 10 }}>\n {generation.OutputText ? (\n <div style={{ color: v.text, fontSize: 15, whiteSpace: \"pre-wrap\" }}>\n {generation.OutputText}\n </div>\n ) : null}\n {(generation.Assets ?? []).map((a, i) => {\n const kind = resultKind(a.ContentType);\n const url = a.Url ?? \"\";\n return kind === \"image\" ? (\n <img\n key={i}\n src={url}\n alt=\"\"\n style={{ width: \"100%\", borderRadius: 12 }}\n />\n ) : kind === \"audio\" ? (\n <audio key={i} src={url} controls style={{ width: \"100%\" }} />\n ) : kind === \"video\" ? (\n <video\n key={i}\n src={url}\n controls\n style={{ width: \"100%\", borderRadius: 12 }}\n />\n ) : (\n <a\n key={i}\n href={url}\n target=\"_blank\"\n rel=\"noreferrer\"\n style={{ ...outlined, color: v.gold, fontSize: 14 }}\n >\n {t(\"download\")}\n </a>\n );\n })}\n </div>\n );\n}\n\nfunction History(): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const [items, setItems] = useState<AIGenerationView[] | null>(null);\n const [open, setOpen] = useState<string | null>(null);\n useEffect(() => {\n void client.ai\n .listGenerations({ Limit: 20 })\n .then((r) => setItems(r.ok ? (r.data.Data ?? []) : []));\n }, [client]);\n if (!items)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (items.length === 0)\n return <EmptyState glyph=\"bulb\" text={t(\"noHistory\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {items.map((g, i) => (\n <Card\n key={g.GenerationID}\n onClick={() =>\n setOpen(open === g.GenerationID ? null : g.GenerationID)\n }\n style={{ display: \"grid\", gap: 8, ...stagger(i) }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n <Icon glyph=\"bulb\" size={26} />\n <span style={{ ...outlined, fontSize: 14, flex: 1 }}>\n {catalog.localize(g.FeatureKey ?? \"\")}\n </span>\n <span\n style={{\n ...outlined,\n fontSize: 12,\n color:\n g.Status === \"completed\"\n ? v.green\n : g.Status === \"failed\"\n ? v.textDim\n : v.gold,\n }}\n >\n {t(\n `s_${g.Status === \"completed\" || g.Status === \"failed\" ? g.Status : \"running\"}`,\n )}\n </span>\n </div>\n {open === g.GenerationID && g.Status === \"completed\" ? (\n <Result generation={g} />\n ) : null}\n </Card>\n ))}\n </div>\n );\n}\n"
42
+ "content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type { AIGenerationView, IDosGamesClient } from \"@idosgames/core\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n canAfford,\n Card,\n Center,\n configSection,\n EmptyState,\n errorText,\n Icon,\n outlined,\n panel,\n ResourceList,\n Spinner,\n Tabs,\n TextArea,\n useCatalog,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n featureRows,\n resultKind,\n type AISection,\n type FeatureRow,\n type Modality,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// AI studio (engine AI): the AI features the publisher opened to players — text, pictures, speech,\n// music, video, 3D. The player writes what they want, pays the feature's price (or nothing), and the\n// result comes back from a job the server runs (polled with waitForGeneration). A feature tagged\n// \"hidden\" is for the game's own code (NPC lines and the like) and does not show here.\n\ntype Tab = \"create\" | \"history\";\n\nexport function AiScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const [tab, setTab] = useState<Tab>(\"create\");\n const [picked, setPicked] = useState<string | null>(null);\n const features = featureRows(configSection<AISection>(client, \"AI\"));\n if (features.length === 0)\n return <EmptyState glyph=\"bulb\" text={t(\"noFeatures\")} />;\n const current = features.find((f) => f.key === picked) ?? features[0]!;\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <Tabs\n tabs={[\n { id: \"create\", label: t(\"create\") },\n { id: \"history\", label: t(\"history\") },\n ]}\n value={tab}\n onChange={setTab}\n />\n {tab === \"create\" ? (\n <>\n {features.length > 1 ? (\n <div style={{ display: \"flex\", gap: 6, flexWrap: \"wrap\" }}>\n {features.map((f) => (\n <Button\n key={f.key}\n size=\"sm\"\n tone={f.key === current.key ? \"gold\" : \"grey\"}\n onClick={() => setPicked(f.key)}\n >\n {catalog.localize(f.key)} · {t(`m_${f.modality}`)}\n </Button>\n ))}\n </div>\n ) : null}\n <Create key={current.key} feature={current} />\n </>\n ) : (\n <History />\n )}\n </div>\n );\n}\n\n/** Starts a job for a feature by its modality and waits for the result. */\nfunction run(\n client: IDosGamesClient,\n feature: FeatureRow,\n prompt: string,\n optionID: string | undefined,\n): Promise<\n { ok: true; data: AIGenerationView } | { ok: false; error: string }\n> {\n const options = {\n selectedOptionID: optionID,\n relatedEntityID: crypto.randomUUID(),\n };\n const byModality: Record<\n Modality,\n () => ReturnType<IDosGamesClient[\"ai\"][\"generateText\"]>\n > = {\n Text: () =>\n client.ai.generateText(feature.key, { Prompt: prompt }, options),\n Image: () =>\n client.ai.generateImage(feature.key, { Prompt: prompt }, options),\n Audio: () =>\n client.ai.generateAudio(feature.key, { Text: prompt }, options),\n Music: () =>\n client.ai.generateMusic(feature.key, { Prompt: prompt }, options),\n Video: () =>\n client.ai.generateVideo(feature.key, { Prompt: prompt }, options),\n ThreeD: () =>\n client.ai.generateThreeD(feature.key, { Prompt: prompt }, options),\n };\n return byModality[feature.modality]().then((started) =>\n started.ok\n ? client.ai.waitForGeneration(started.data, { timeoutMs: 10 * 60_000 })\n : started,\n );\n}\n\nfunction Create({ feature }: { feature: FeatureRow }): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const [prompt, setPrompt] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [result, setResult] = useState<AIGenerationView | null>(null);\n const alive = useRef(true);\n useEffect(\n () => () => {\n alive.current = false;\n },\n [],\n );\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const option =\n feature.options.find((o) => canAfford(o.price, balances)) ??\n feature.options[0];\n const affordable = !option || canAfford(option.price, balances);\n\n const go = async () => {\n setBusy(true);\n setResult(null);\n const res = await run(client, feature, prompt.trim(), option?.id);\n if (!alive.current) return;\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (res.data.Status !== \"completed\")\n return toast(res.data.Error?.Message || t(\"failed\"), \"error\");\n setResult(res.data);\n };\n\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n <TextArea\n value={prompt}\n maxLength={feature.maxChars > 0 ? feature.maxChars : undefined}\n placeholder={\n feature.modality === \"Audio\" ? t(\"sayWhat\") : t(\"describe\")\n }\n onChange={(e) => setPrompt(e.target.value)}\n rows={4}\n style={{ padding: 12, width: \"100%\" }}\n />\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract={!!prompt.trim() && affordable}\n disabled={!prompt.trim() || !affordable}\n busy={busy}\n onClick={() => void go()}\n data-tutorial-anchor={`ai:${feature.key}`}\n >\n {busy ? t(\"working\") : t(\"generate\")}{\" \"}\n {option ? <ResourceList lines={option.price} size={18} /> : null}\n </Button>\n {busy ? (\n <Center>\n <Spinner />\n </Center>\n ) : null}\n {result ? <Result generation={result} /> : null}\n </div>\n );\n}\n\nfunction Result({ generation }: { generation: AIGenerationView }): ReactNode {\n return (\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 10 }}>\n {generation.OutputText ? (\n <div style={{ color: v.text, fontSize: 15, whiteSpace: \"pre-wrap\" }}>\n {generation.OutputText}\n </div>\n ) : null}\n {(generation.Assets ?? []).map((a, i) => {\n const kind = resultKind(a.ContentType);\n const url = a.Url ?? \"\";\n return kind === \"image\" ? (\n <img\n key={i}\n src={url}\n alt=\"\"\n style={{ width: \"100%\", borderRadius: 12 }}\n />\n ) : kind === \"audio\" ? (\n <audio key={i} src={url} controls style={{ width: \"100%\" }} />\n ) : kind === \"video\" ? (\n <video\n key={i}\n src={url}\n controls\n style={{ width: \"100%\", borderRadius: 12 }}\n />\n ) : (\n <a\n key={i}\n href={url}\n target=\"_blank\"\n rel=\"noreferrer\"\n style={{ ...outlined, color: v.gold, fontSize: 14 }}\n >\n {t(\"download\")}\n </a>\n );\n })}\n </div>\n );\n}\n\nfunction History(): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const [items, setItems] = useState<AIGenerationView[] | null>(null);\n const [open, setOpen] = useState<string | null>(null);\n useEffect(() => {\n void client.ai\n .listGenerations({ Limit: 20 })\n .then((r) => setItems(r.ok ? (r.data.Data ?? []) : []));\n }, [client]);\n if (!items)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (items.length === 0)\n return <EmptyState glyph=\"bulb\" text={t(\"noHistory\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {items.map((g, i) => (\n <Card\n key={g.GenerationID}\n onClick={() =>\n setOpen(open === g.GenerationID ? null : g.GenerationID)\n }\n style={{ display: \"grid\", gap: 8, ...stagger(i) }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n <Icon glyph=\"bulb\" size={26} />\n <span style={{ ...outlined, fontSize: 14, flex: 1 }}>\n {catalog.localize(g.FeatureKey ?? \"\")}\n </span>\n <span\n style={{\n ...outlined,\n fontSize: 12,\n color:\n g.Status === \"completed\"\n ? v.green\n : g.Status === \"failed\"\n ? v.textDim\n : v.gold,\n }}\n >\n {t(\n `s_${g.Status === \"completed\" || g.Status === \"failed\" ? g.Status : \"running\"}`,\n )}\n </span>\n </div>\n {open === g.GenerationID && g.Status === \"completed\" ? (\n <Result generation={g} />\n ) : null}\n </Card>\n ))}\n </div>\n );\n}\n"
43
43
  },
44
44
  {
45
45
  "path": "i18n.ts",
@@ -45,10 +45,10 @@
45
45
  }
46
46
  },
47
47
  "dependencies": {
48
- "@idosgames/core": "0.21.2",
49
- "@idosgames/module-sdk": "0.10.0",
50
- "@idosgames/react": "0.4.4",
51
- "@idosgames/wallet": "0.6.2",
48
+ "@idosgames/core": "0.21.3",
49
+ "@idosgames/module-sdk": "0.10.1",
50
+ "@idosgames/react": "0.4.5",
51
+ "@idosgames/wallet": "0.6.3",
52
52
  "@tanstack/react-query": "5.101.2",
53
53
  "react": "19.2.7",
54
54
  "three": "0.185.1",
@@ -31,16 +31,16 @@
31
31
  "version": "0.1.0"
32
32
  },
33
33
  "dependencies": {
34
- "@idosgames/core": "0.21.2",
35
- "@idosgames/module-sdk": "0.10.0",
36
- "@idosgames/react": "0.4.4",
34
+ "@idosgames/core": "0.21.3",
35
+ "@idosgames/module-sdk": "0.10.1",
36
+ "@idosgames/react": "0.4.5",
37
37
  "react": "19.2.7"
38
38
  },
39
- "contentHash": "eee31d7c59cc94428199e9c8a5fcc4d46b700785a25e5e01503034b4a6b52135",
39
+ "contentHash": "b1e963d2fbf601f145048b02f5b484056aa493f256c64bfaa6db3cd96280122b",
40
40
  "files": [
41
41
  {
42
42
  "path": "BoostsScreen.tsx",
43
- "content": "import {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ActiveBoostWindowInfo } from \"@idosgames/core\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n ResourceList,\n SectionTitle,\n Timer,\n canAfford,\n configSection,\n errorText,\n outlined,\n useCatalog,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n boostRows,\n durationParts,\n effectText,\n runningBoosts,\n type ActiveBoostView,\n type BoostEffectView,\n type BoostRow,\n type BoostSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Boosts (engine TimedBoost): what runs now — the player's own boosts with their timers or charges,\n// and the title's global windows (a happy hour, a phase of a boost chain) — then the boosts the\n// player can activate, free or for currency. The server applies every boost; the screen only shows.\n\nexport function BoostsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const now = useNow();\n const stagger = useStagger();\n const [windows, setWindows] = useState<ActiveBoostWindowInfo[]>([]);\n const section = configSection<BoostSection>(client, \"TimedBoost\");\n const rows = boostRows(section);\n const running = runningBoosts(\n (state?.TimedBoost as { Active?: Record<string, ActiveBoostView> } | null)\n ?.Active,\n now,\n );\n const liveWindows = windows.filter(\n (w) => !w.EndUtc || new Date(w.EndUtc).getTime() > now,\n );\n\n // Fresh on open: expired boosts drop out, global windows are resolved by the server.\n useEffect(() => {\n void client.timedBoost.getActive();\n void client.timedBoost\n .getActiveWindows()\n .then((r) => r.ok && setWindows(r.data.Windows ?? []));\n }, [client]);\n\n if (rows.length === 0 && running.length === 0 && liveWindows.length === 0)\n return <EmptyState glyph=\"energy\" text={t(\"noBoosts\")} />;\n\n const nameOf = (boostID: string | undefined) =>\n catalog.localize(\n rows.find((r) => r.boostID === boostID)?.name ?? boostID ?? \"\",\n );\n\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n {running.length + liveWindows.length > 0 ? (\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle>{t(\"running\")}</SectionTitle>\n {liveWindows.map((w, i) => (\n <RunningCard\n key={`w:${w.SourceID}:${w.PhaseID ?? \"\"}`}\n name={catalog.localize(w.DisplayName ?? w.SourceID ?? \"\")}\n effects={w.Effects ?? []}\n until={w.EndUtc ?? null}\n charges={null}\n global\n style={stagger(i)}\n />\n ))}\n {running.map((b, i) => (\n <RunningCard\n key={b.InstanceID}\n name={nameOf(b.BoostID)}\n effects={b.EffectSnapshot ? [b.EffectSnapshot] : []}\n until={b.ExpiresAtUtc ?? null}\n charges={b.RemainingCharges ?? null}\n style={stagger(liveWindows.length + i)}\n />\n ))}\n </div>\n ) : null}\n {rows.length > 0 ? (\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle>{t(\"boosts\")}</SectionTitle>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(220px, 1fr))\",\n gap: 10,\n }}\n >\n {rows.map((row, i) => (\n <BoostCard key={row.boostID} row={row} style={stagger(i)} />\n ))}\n </div>\n </div>\n ) : null}\n </div>\n );\n}\n\nfunction EffectLine({ effect }: { effect: BoostEffectView }): ReactNode {\n const e = effectText(effect);\n return (\n <span style={{ ...outlined, fontSize: 14, color: v.gold }}>\n {e.size} {e.target === \"custom\" ? \"\" : t(`target_${e.target}`)}\n </span>\n );\n}\n\nfunction RunningCard({\n name,\n effects,\n until,\n charges,\n global,\n style,\n}: {\n name: string;\n effects: BoostEffectView[];\n until: string | null;\n charges: number | null;\n global?: boolean;\n style: CSSProperties;\n}): ReactNode {\n const now = useNow();\n return (\n <Card\n highlight\n style={{\n display: \"grid\",\n gridTemplateColumns: \"auto 1fr\",\n alignItems: \"center\",\n gap: 12,\n ...style,\n }}\n >\n <div className=\"idos-bounce\">\n <Icon glyph={global ? \"clock\" : \"energy\"} size={40} />\n </div>\n <div style={{ display: \"grid\", gap: 4, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {name}\n {global ? (\n <span style={{ color: v.textDim, fontSize: 12 }}>\n {\" \"}\n · {t(\"forEveryone\")}\n </span>\n ) : null}\n </div>\n <div style={{ display: \"flex\", gap: 10, flexWrap: \"wrap\" }}>\n {effects.map((e, i) => (\n <EffectLine key={i} effect={e} />\n ))}\n </div>\n {until ? <Timer at={until} now={now} label={t(\"endsIn\")} /> : null}\n {charges != null ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"charges\")}: {charges}\n </div>\n ) : null}\n </div>\n </Card>\n );\n}\n\nfunction BoostCard({\n row,\n style,\n}: {\n row: BoostRow;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const { h, m } = durationParts(row.durationSeconds);\n\n const activate = async (optionID: string | undefined) => {\n setBusy(optionID ?? \"free\");\n const res = await client.timedBoost.activate(\n row.boostID,\n optionID ? { selectedOptionID: optionID } : undefined,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n toast(t(\"activated\"), \"success\");\n };\n\n return (\n <div ref={ref} style={style}>\n <Card style={{ display: \"grid\", gap: 8, justifyItems: \"center\" }}>\n <Icon glyph={row.icon} size={52} />\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {catalog.localize(row.name)}\n </div>\n {row.effect ? <EffectLine effect={row.effect} /> : null}\n {row.description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n fontSize: 12,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {catalog.localize(row.description)}\n </div>\n ) : null}\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {row.durationSeconds > 0\n ? `${h > 0 ? `${h} ${t(\"hours\")} ` : \"\"}${m > 0 ? `${m} ${t(\"minutes\")}` : \"\"}`\n : null}\n {row.charges > 0 ? ` · ${t(\"charges\")}: ${row.charges}` : null}\n </div>\n {/* A free option (often a one-time welcome boost) next to the paid ones, not instead of\n them: once it is used up the player still has a way to buy the boost. */}\n {row.free ? (\n <Button\n tone=\"green\"\n attract\n busy={busy === (row.freeOptionID ?? \"free\")}\n onClick={() => void activate(row.freeOptionID)}\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`boost:${row.boostID}`}\n >\n {t(\"activate\")}\n </Button>\n ) : null}\n {row.options.map((o) => {\n const affordable = canAfford(o.price, balances);\n return (\n <Button\n key={o.id}\n tone=\"gold\"\n disabled={!affordable}\n busy={busy === o.id}\n onClick={() => void activate(o.id)}\n style={{ width: \"100%\" }}\n data-tutorial-anchor={\n row.free ? undefined : `boost:${row.boostID}`\n }\n >\n <ResourceList\n lines={o.price}\n size={18}\n dimIf={() => !affordable}\n />\n </Button>\n );\n })}\n </Card>\n </div>\n );\n}\n"
43
+ "content": "import {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ActiveBoostWindowInfo } from \"@idosgames/core\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n ResourceList,\n SectionTitle,\n Timer,\n canAfford,\n configSection,\n errorText,\n outlined,\n useCatalog,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n boostRows,\n durationParts,\n effectText,\n runningBoosts,\n type ActiveBoostView,\n type BoostEffectView,\n type BoostRow,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Boosts (engine TimedBoost): what runs now — the player's own boosts with their timers or charges,\n// and the title's global windows (a happy hour, a phase of a boost chain) — then the boosts the\n// player can activate, free or for currency. The server applies every boost; the screen only shows.\n\nexport function BoostsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const now = useNow();\n const stagger = useStagger();\n const [windows, setWindows] = useState<ActiveBoostWindowInfo[]>([]);\n const section = configSection(client, \"TimedBoost\");\n const rows = boostRows(section);\n const running = runningBoosts(\n (state?.TimedBoost as { Active?: Record<string, ActiveBoostView> } | null)\n ?.Active,\n now,\n );\n const liveWindows = windows.filter(\n (w) => !w.EndUtc || new Date(w.EndUtc).getTime() > now,\n );\n\n // Fresh on open: expired boosts drop out, global windows are resolved by the server.\n useEffect(() => {\n void client.timedBoost.getActive();\n void client.timedBoost\n .getActiveWindows()\n .then((r) => r.ok && setWindows(r.data.Windows ?? []));\n }, [client]);\n\n if (rows.length === 0 && running.length === 0 && liveWindows.length === 0)\n return <EmptyState glyph=\"energy\" text={t(\"noBoosts\")} />;\n\n const nameOf = (boostID: string | undefined) =>\n catalog.localize(\n rows.find((r) => r.boostID === boostID)?.name ?? boostID ?? \"\",\n );\n\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n {running.length + liveWindows.length > 0 ? (\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle>{t(\"running\")}</SectionTitle>\n {liveWindows.map((w, i) => (\n <RunningCard\n key={`w:${w.SourceID}:${w.PhaseID ?? \"\"}`}\n name={catalog.localize(w.DisplayName ?? w.SourceID ?? \"\")}\n effects={w.Effects ?? []}\n until={w.EndUtc ?? null}\n charges={null}\n global\n style={stagger(i)}\n />\n ))}\n {running.map((b, i) => (\n <RunningCard\n key={b.InstanceID}\n name={nameOf(b.BoostID)}\n effects={b.EffectSnapshot ? [b.EffectSnapshot] : []}\n until={b.ExpiresAtUtc ?? null}\n charges={b.RemainingCharges ?? null}\n style={stagger(liveWindows.length + i)}\n />\n ))}\n </div>\n ) : null}\n {rows.length > 0 ? (\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle>{t(\"boosts\")}</SectionTitle>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(220px, 1fr))\",\n gap: 10,\n }}\n >\n {rows.map((row, i) => (\n <BoostCard key={row.boostID} row={row} style={stagger(i)} />\n ))}\n </div>\n </div>\n ) : null}\n </div>\n );\n}\n\nfunction EffectLine({ effect }: { effect: BoostEffectView }): ReactNode {\n const e = effectText(effect);\n return (\n <span style={{ ...outlined, fontSize: 14, color: v.gold }}>\n {e.size} {e.target === \"custom\" ? \"\" : t(`target_${e.target}`)}\n </span>\n );\n}\n\nfunction RunningCard({\n name,\n effects,\n until,\n charges,\n global,\n style,\n}: {\n name: string;\n effects: BoostEffectView[];\n until: string | null;\n charges: number | null;\n global?: boolean;\n style: CSSProperties;\n}): ReactNode {\n const now = useNow();\n return (\n <Card\n highlight\n style={{\n display: \"grid\",\n gridTemplateColumns: \"auto 1fr\",\n alignItems: \"center\",\n gap: 12,\n ...style,\n }}\n >\n <div className=\"idos-bounce\">\n <Icon glyph={global ? \"clock\" : \"energy\"} size={40} />\n </div>\n <div style={{ display: \"grid\", gap: 4, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {name}\n {global ? (\n <span style={{ color: v.textDim, fontSize: 12 }}>\n {\" \"}\n · {t(\"forEveryone\")}\n </span>\n ) : null}\n </div>\n <div style={{ display: \"flex\", gap: 10, flexWrap: \"wrap\" }}>\n {effects.map((e, i) => (\n <EffectLine key={i} effect={e} />\n ))}\n </div>\n {until ? <Timer at={until} now={now} label={t(\"endsIn\")} /> : null}\n {charges != null ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"charges\")}: {charges}\n </div>\n ) : null}\n </div>\n </Card>\n );\n}\n\nfunction BoostCard({\n row,\n style,\n}: {\n row: BoostRow;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const { h, m } = durationParts(row.durationSeconds);\n\n const activate = async (optionID: string | undefined) => {\n setBusy(optionID ?? \"free\");\n const res = await client.timedBoost.activate(\n row.boostID,\n optionID ? { selectedOptionID: optionID } : undefined,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n toast(t(\"activated\"), \"success\");\n };\n\n return (\n <div ref={ref} style={style}>\n <Card style={{ display: \"grid\", gap: 8, justifyItems: \"center\" }}>\n <Icon glyph={row.icon} size={52} />\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {catalog.localize(row.name)}\n </div>\n {row.effect ? <EffectLine effect={row.effect} /> : null}\n {row.description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n fontSize: 12,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {catalog.localize(row.description)}\n </div>\n ) : null}\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {row.durationSeconds > 0\n ? `${h > 0 ? `${h} ${t(\"hours\")} ` : \"\"}${m > 0 ? `${m} ${t(\"minutes\")}` : \"\"}`\n : null}\n {row.charges > 0 ? ` · ${t(\"charges\")}: ${row.charges}` : null}\n </div>\n {/* A free option (often a one-time welcome boost) next to the paid ones, not instead of\n them: once it is used up the player still has a way to buy the boost. */}\n {row.free ? (\n <Button\n tone=\"green\"\n attract\n busy={busy === (row.freeOptionID ?? \"free\")}\n onClick={() => void activate(row.freeOptionID)}\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`boost:${row.boostID}`}\n >\n {t(\"activate\")}\n </Button>\n ) : null}\n {row.options.map((o) => {\n const affordable = canAfford(o.price, balances);\n return (\n <Button\n key={o.id}\n tone=\"gold\"\n disabled={!affordable}\n busy={busy === o.id}\n onClick={() => void activate(o.id)}\n style={{ width: \"100%\" }}\n data-tutorial-anchor={\n row.free ? undefined : `boost:${row.boostID}`\n }\n >\n <ResourceList\n lines={o.price}\n size={18}\n dimIf={() => !affordable}\n />\n </Button>\n );\n })}\n </Card>\n </div>\n );\n}\n"
44
44
  },
45
45
  {
46
46
  "path": "i18n.ts",
@@ -52,11 +52,11 @@
52
52
  },
53
53
  {
54
54
  "path": "model.ts",
55
- "content": "// Pure helpers of the boosts screen — covered by model.test.ts. The server applies boosts; these\n// only decide what the screen draws.\n\nimport { costLines, type ResourceLine } from \"@idosgames/react/ui\";\n\nexport interface BoostEffectView {\n Target?: string | null;\n Operation?: string | null;\n Value?: number | null;\n}\n\nexport interface BoostDefinitionView {\n BoostID?: string | null;\n DisplayName?: string | null;\n Description?: string | null;\n AssetPaths?: Record<string, string> | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Cost?: unknown } | null\n > | null;\n Effect?: BoostEffectView | null;\n DurationSeconds?: number | null;\n Charges?: number | null;\n}\n\n/** The title's TimedBoost section, as far as the boosts screen cares. */\nexport interface BoostSection {\n Definitions?: Record<string, BoostDefinitionView | null> | null;\n ScheduledBoosts?: Record<string, unknown> | null;\n BoostChains?: Record<string, unknown> | null;\n}\n\n/** A boost running for the player (UserState.TimedBoost.Active). */\nexport interface ActiveBoostView {\n InstanceID?: string;\n BoostID?: string;\n ExpiresAtUtc?: string | null;\n RemainingCharges?: number | null;\n EffectSnapshot?: BoostEffectView | null;\n}\n\n/** What an effect touches, as the screen names it (engine EventModifierTarget, grouped). */\nexport type EffectTarget = \"all\" | \"quest\" | \"board\" | \"cost\" | \"custom\";\n\n/** What the effect reads as: the size (\"×2\", \"+50%\", \"+10\") and what it touches. */\nexport function effectText(effect: BoostEffectView | null | undefined): {\n size: string;\n target: EffectTarget;\n} {\n const value = Number(effect?.Value ?? 0);\n const op = effect?.Operation ?? \"Multiply\";\n const size =\n op === \"AddPercent\"\n ? `+${value}%`\n : op === \"AddFlat\"\n ? `${value >= 0 ? \"+\" : \"\"}${value}`\n : `×${value}`;\n const t = effect?.Target ?? \"\";\n const target: EffectTarget =\n t === \"AllReward\"\n ? \"all\"\n : t === \"QuestReward\"\n ? \"quest\"\n : t.endsWith(\"Cost\")\n ? \"cost\"\n : t.startsWith(\"Board\")\n ? \"board\"\n : \"custom\";\n return { size, target };\n}\n\nexport interface BoostRow {\n boostID: string;\n name: string;\n description: string;\n icon: string;\n effect: BoostEffectView | null;\n durationSeconds: number;\n charges: number;\n /** Ways to activate with currencies/items (a price paid in a store is not sold here). */\n options: Array<{ id: string; price: ResourceLine[] }>;\n /** No price at all — activates for nothing. */\n free: boolean;\n /** The free price option to activate with (an option whose cost is empty); undefined — none named. */\n freeOptionID?: string;\n}\n\n/** The boosts the player can activate, by id. */\nexport function boostRows(\n section: BoostSection | null | undefined,\n): BoostRow[] {\n const rows: BoostRow[] = [];\n for (const [id, def] of Object.entries(section?.Definitions ?? {})) {\n if (!def) continue;\n const priced = Object.entries(def.PriceOptions ?? {}).filter(([, o]) => o);\n const all = priced.map(([oid, o]) => {\n const lines = costLines(o?.Cost as never);\n return {\n id: o?.OptionID || oid,\n price: lines.filter((l) => l.kind !== \"other\"),\n // An option with an EMPTY cost is free (the demo \"welcome boost\"); one paid only in a store\n // (\"other\" lines) is not — and is not sold here.\n free: lines.length === 0,\n };\n });\n const options = all\n .filter((o) => o.price.length > 0)\n .map(({ id, price }) => ({ id, price }));\n const freeOption = all.find((o) => o.free);\n rows.push({\n boostID: def.BoostID || id,\n name: def.DisplayName || def.BoostID || id,\n description: def.Description || \"\",\n icon: def.AssetPaths?.icon ?? \"energy\",\n effect: def.Effect ?? null,\n durationSeconds: Math.max(0, Number(def.DurationSeconds ?? 0)),\n charges: Math.max(0, Number(def.Charges ?? 0)),\n options,\n free: priced.length === 0 || freeOption !== undefined,\n ...(freeOption ? { freeOptionID: freeOption.id } : {}),\n });\n }\n return rows.sort((a, b) => a.boostID.localeCompare(b.boostID));\n}\n\n/** Boosts running now: not expired and with charges left (the server filters the same on read). */\nexport function runningBoosts(\n active: Record<string, ActiveBoostView | null | undefined> | null | undefined,\n nowMs: number,\n): ActiveBoostView[] {\n return Object.values(active ?? {})\n .filter((b): b is ActiveBoostView => !!b)\n .filter(\n (b) =>\n (!b.ExpiresAtUtc || new Date(b.ExpiresAtUtc).getTime() > nowMs) &&\n (b.RemainingCharges == null || b.RemainingCharges > 0),\n )\n .sort(\n (a, b) =>\n new Date(a.ExpiresAtUtc ?? 0).getTime() -\n new Date(b.ExpiresAtUtc ?? 0).getTime(),\n );\n}\n\n/** Hours and minutes of a boost's duration (seconds). */\nexport function durationParts(seconds: number): { h: number; m: number } {\n const total = Math.round(seconds / 60);\n return { h: Math.floor(total / 60), m: total % 60 };\n}\n\n/** Whether the title has boosts — personal ones or global windows. */\nexport function hasTitleData(section: BoostSection | undefined): boolean {\n return (\n Object.values(section?.Definitions ?? {}).some(Boolean) ||\n Object.keys(section?.ScheduledBoosts ?? {}).length > 0 ||\n Object.keys(section?.BoostChains ?? {}).length > 0\n );\n}\n"
55
+ "content": "// Pure helpers of the boosts screen — covered by model.test.ts. The server applies boosts; these\n// only decide what the screen draws.\n\nimport { costLines, type ResourceLine } from \"@idosgames/react/ui\";\nimport type { TimedBoostDefinitions } from \"@idosgames/core\";\n\nexport interface BoostEffectView {\n Target?: string | null;\n Operation?: string | null;\n Value?: number | null;\n}\n\nexport interface BoostDefinitionView {\n BoostID?: string | null;\n DisplayName?: string | null;\n Description?: string | null;\n AssetPaths?: Record<string, string> | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Cost?: unknown } | null\n > | null;\n Effect?: BoostEffectView | null;\n DurationSeconds?: number | null;\n Charges?: number | null;\n}\n\n/** A boost running for the player (UserState.TimedBoost.Active). */\nexport interface ActiveBoostView {\n InstanceID?: string;\n BoostID?: string;\n ExpiresAtUtc?: string | null;\n RemainingCharges?: number | null;\n EffectSnapshot?: BoostEffectView | null;\n}\n\n/** What an effect touches, as the screen names it (engine EventModifierTarget, grouped). */\nexport type EffectTarget = \"all\" | \"quest\" | \"board\" | \"cost\" | \"custom\";\n\n/** What the effect reads as: the size (\"×2\", \"+50%\", \"+10\") and what it touches. */\nexport function effectText(effect: BoostEffectView | null | undefined): {\n size: string;\n target: EffectTarget;\n} {\n const value = Number(effect?.Value ?? 0);\n const op = effect?.Operation ?? \"Multiply\";\n const size =\n op === \"AddPercent\"\n ? `+${value}%`\n : op === \"AddFlat\"\n ? `${value >= 0 ? \"+\" : \"\"}${value}`\n : `×${value}`;\n const t = effect?.Target ?? \"\";\n const target: EffectTarget =\n t === \"AllReward\"\n ? \"all\"\n : t === \"QuestReward\"\n ? \"quest\"\n : t.endsWith(\"Cost\")\n ? \"cost\"\n : t.startsWith(\"Board\")\n ? \"board\"\n : \"custom\";\n return { size, target };\n}\n\nexport interface BoostRow {\n boostID: string;\n name: string;\n description: string;\n icon: string;\n effect: BoostEffectView | null;\n durationSeconds: number;\n charges: number;\n /** Ways to activate with currencies/items (a price paid in a store is not sold here). */\n options: Array<{ id: string; price: ResourceLine[] }>;\n /** No price at all — activates for nothing. */\n free: boolean;\n /** The free price option to activate with (an option whose cost is empty); undefined — none named. */\n freeOptionID?: string;\n}\n\n/** The boosts the player can activate, by id. */\nexport function boostRows(\n section: TimedBoostDefinitions | null | undefined,\n): BoostRow[] {\n const rows: BoostRow[] = [];\n for (const [id, def] of Object.entries(section?.Definitions ?? {})) {\n if (!def) continue;\n const priced = Object.entries(def.PriceOptions ?? {}).filter(([, o]) => o);\n const all = priced.map(([oid, o]) => {\n const lines = costLines(o?.Cost as never);\n return {\n id: o?.OptionID || oid,\n price: lines.filter((l) => l.kind !== \"other\"),\n // An option with an EMPTY cost is free (the demo \"welcome boost\"); one paid only in a store\n // (\"other\" lines) is not — and is not sold here.\n free: lines.length === 0,\n };\n });\n const options = all\n .filter((o) => o.price.length > 0)\n .map(({ id, price }) => ({ id, price }));\n const freeOption = all.find((o) => o.free);\n rows.push({\n boostID: def.BoostID || id,\n name: def.DisplayName || def.BoostID || id,\n description: def.Description || \"\",\n icon: def.AssetPaths?.icon ?? \"energy\",\n effect: def.Effect ?? null,\n durationSeconds: Math.max(0, Number(def.DurationSeconds ?? 0)),\n charges: Math.max(0, Number(def.Charges ?? 0)),\n options,\n free: priced.length === 0 || freeOption !== undefined,\n ...(freeOption ? { freeOptionID: freeOption.id } : {}),\n });\n }\n return rows.sort((a, b) => a.boostID.localeCompare(b.boostID));\n}\n\n/** Boosts running now: not expired and with charges left (the server filters the same on read). */\nexport function runningBoosts(\n active: Record<string, ActiveBoostView | null | undefined> | null | undefined,\n nowMs: number,\n): ActiveBoostView[] {\n return Object.values(active ?? {})\n .filter((b): b is ActiveBoostView => !!b)\n .filter(\n (b) =>\n (!b.ExpiresAtUtc || new Date(b.ExpiresAtUtc).getTime() > nowMs) &&\n (b.RemainingCharges == null || b.RemainingCharges > 0),\n )\n .sort(\n (a, b) =>\n new Date(a.ExpiresAtUtc ?? 0).getTime() -\n new Date(b.ExpiresAtUtc ?? 0).getTime(),\n );\n}\n\n/** Hours and minutes of a boost's duration (seconds). */\nexport function durationParts(seconds: number): { h: number; m: number } {\n const total = Math.round(seconds / 60);\n return { h: Math.floor(total / 60), m: total % 60 };\n}\n\n/** Whether the title has boosts — personal ones or global windows. */\nexport function hasTitleData(\n section: TimedBoostDefinitions | undefined,\n): boolean {\n return (\n Object.values(section?.Definitions ?? {}).some(Boolean) ||\n Object.keys(section?.ScheduledBoosts ?? {}).length > 0 ||\n Object.keys(section?.BoostChains ?? {}).length > 0\n );\n}\n"
56
56
  },
57
57
  {
58
58
  "path": "module.ts",
59
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { BoostsScreen } from \"./BoostsScreen\";\nimport { hasTitleData, type BoostSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Boosts as a feature of the game: what runs now (the player's own boosts and the title's global\n// windows) and what can be activated. No badge: a running boost asks nothing of the player.\nexport const boostsModule: Module = defineModule({\n id: \"boosts\",\n meta: { name: \"Boosts\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"boosts\",\n label: t(\"title\"),\n icon: \"energy\",\n group: \"economy\",\n order: 58,\n available: false,\n Screen: BoostsScreen,\n });\n const decide = () =>\n ctx.features.setAvailable(\n \"boosts\",\n hasTitleData(configSection<BoostSection>(ctx.client, \"TimedBoost\")),\n \"no boosts on the title (TimedBoost)\",\n );\n decide();\n // The engine does not put TimedBoost into the client's title config: read it once with its own\n // call (it lands in the same config section, so the screen draws from it too). Without this the\n // tab never showed on any title.\n if (!configSection(ctx.client, \"TimedBoost\"))\n void ctx.client.timedBoost.getDefinitions().then(decide);\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"boosts\" }),\n describeActions: {},\n });\n },\n});\n"
59
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { BoostsScreen } from \"./BoostsScreen\";\nimport { hasTitleData } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Boosts as a feature of the game: what runs now (the player's own boosts and the title's global\n// windows) and what can be activated. No badge: a running boost asks nothing of the player.\nexport const boostsModule: Module = defineModule({\n id: \"boosts\",\n meta: { name: \"Boosts\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"boosts\",\n label: t(\"title\"),\n icon: \"energy\",\n group: \"economy\",\n order: 58,\n available: false,\n Screen: BoostsScreen,\n });\n const decide = () =>\n ctx.features.setAvailable(\n \"boosts\",\n hasTitleData(configSection(ctx.client, \"TimedBoost\")),\n \"no boosts on the title (TimedBoost)\",\n );\n decide();\n // The engine does not put TimedBoost into the client's title config: read it once with its own\n // call (it lands in the same config section, so the screen draws from it too). Without this the\n // tab never showed on any title.\n if (!configSection(ctx.client, \"TimedBoost\"))\n void ctx.client.timedBoost.getDefinitions().then(decide);\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"boosts\" }),\n describeActions: {},\n });\n },\n});\n"
60
60
  },
61
61
  {
62
62
  "path": "module.meta.json",
@@ -53,16 +53,44 @@
53
53
  }
54
54
  },
55
55
  "dependencies": {
56
- "@idosgames/core": "0.21.2",
57
- "@idosgames/module-sdk": "0.10.0",
58
- "@idosgames/react": "0.4.4",
56
+ "@idosgames/core": "0.21.3",
57
+ "@idosgames/module-sdk": "0.10.1",
58
+ "@idosgames/react": "0.4.5",
59
59
  "react": "19.2.7"
60
60
  },
61
- "contentHash": "74f1f94300a0d2c58d86f437427cff082561fa0af8ab1deb88a6bf5fcd358f21",
61
+ "contentHash": "52367cd725e76329264352e4e498cca75415e335d58a941e0cf4751e5f49d6a4",
62
62
  "files": [
63
63
  {
64
64
  "path": "CharacterScreen.tsx",
65
- "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 Badge,\n Button,\n Card,\n EmptyState,\n Icon,\n ItemTile,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Tabs,\n canAfford,\n configSection,\n costLines,\n errorText,\n formatAmount,\n outlined,\n panel,\n useCatalog,\n useGameLayout,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\nimport {\n SKIN_KEY,\n bestLoadout,\n breakdown,\n fitProblem,\n gearPieces,\n itemLevelCost,\n pieceBonuses,\n rankCost,\n resolveLadder,\n resolveSlots,\n resolveStats,\n roster,\n statCost,\n statMaxLevel,\n statNumber,\n unequipAllSlots,\n unmetRequirements,\n upgradeAllPlan,\n type CharacterModelView,\n type CharacterSection,\n type GearPiece,\n type InstanceView,\n type ItemDefWithGear,\n type RosterEntry,\n type SlotView,\n type StatView,\n} from \"./model\";\nimport { t } from \"./i18n\";\nimport { readSelected, resolveSelected, saveSelected } from \"./selection\";\n\n// Heroes (Unity: the Hero button → CharacterListPanel, CharacterDetailPanel, EquipmentPanel). The\n// screen is the equipment hub of the selected hero — portrait, rank and Power, the slots, \"Equip\n// best\", and the gear the player owns. Everything with more data opens as a popup: the hero list\n// (select / unlock), the stats (upgrade, and \"where does this number come from\"), a piece of gear\n// (equip / take off / level up), a slot's fitting gear, and skins.\n\n/**\n * `onSelect` — the player picked a hero to play with; the module turns it into the\n * `character:hero-selected@1` event for the game.\n */\nexport function makeCharacterScreen(\n features: FeatureRegistry,\n onSelect: (characterId: string) => void,\n) {\n return function CharacterScreen({ args }: FeatureScreenProps): ReactNode {\n return (\n <Heroes\n features={features}\n onSelect={onSelect}\n openInstance={\n typeof args?.itemInstanceID === \"string\" ? args.itemInstanceID : null\n }\n />\n );\n };\n}\n\ntype PopupState =\n | { kind: \"heroes\" }\n | { kind: \"stats\" }\n | { kind: \"skins\" }\n | { kind: \"slot\"; slot: SlotView }\n | { kind: \"piece\"; instanceID: string }\n | null;\n\nfunction useData() {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n // Definitions arrive with the title config; a title whose config did not carry them fetches them\n // once — and the screen redraws when they land (the section cache emits no user-state event).\n const [, loaded] = useState(0);\n const section = configSection<CharacterSection>(client, \"Character\");\n useEffect(() => {\n if (!section)\n void client.character\n .getCharacterDefinitions()\n .then(() => loaded((n) => n + 1));\n // Heroes and gear (with their instances) are in the login state and every reply keeps them\n // fresh. Opening the screen re-checks them against changes made elsewhere (another device) —\n // through the central cache: nothing at all while they were confirmed a minute ago.\n void client.cache.ensureState([\"Character\", \"InventoryV2\"], {\n maxAgeMs: 60_000,\n });\n }, [client, section]);\n const owned = (state?.Character?.Characters ?? {}) as Record<\n string,\n CharacterModelView\n >;\n const inventory = state?.InventoryV2 as\n | {\n UnstackableItems?: Record<string, InstanceView | null>;\n VirtualCurrencies?: Record<string, { Amount?: number }>;\n }\n | undefined;\n const items = catalog.items as Map<string, ItemDefWithGear>;\n // Not memoized: the SDK cache updates UnstackableItems IN PLACE (an equip flips EquippedSlot on\n // the same object), so a memo keyed by the object would keep showing the old state.\n const pieces = gearPieces(inventory?.UnstackableItems, items);\n return {\n client,\n section,\n owned,\n inventory,\n items,\n pieces,\n balances: inventory?.VirtualCurrencies,\n };\n}\n\nfunction Heroes({\n features,\n onSelect,\n openInstance,\n}: {\n features: FeatureRegistry;\n onSelect: (characterId: string) => void;\n openInstance: string | null;\n}): ReactNode {\n const { client, section, owned, items, pieces, balances } = useData();\n const catalog = useCatalog();\n const layout = useGameLayout();\n const toast = useToast();\n const kit = useUiKit();\n const cardRef = useRef<HTMLDivElement>(null);\n const [popup, setPopup] = useState<PopupState>(\n openInstance ? { kind: \"piece\", instanceID: openInstance } : null,\n );\n const [busy, setBusy] = useState<string | null>(null);\n const [selectedID, setSelectedID] = useState<string | null>(readSelected);\n\n const list = roster(section, owned);\n if (list.length === 0)\n return <EmptyState glyph=\"hero\" text={t(\"noHeroes\")} />;\n const hero = resolveSelected(list, selectedID)!;\n const select = (id: string) => {\n setSelectedID(id);\n saveSelected(id);\n onSelect(id);\n };\n\n const ladder = resolveLadder(section, hero.def);\n const nextRank = rankCost(ladder, hero.rank);\n const slots = resolveSlots(section, hero.def);\n const changes =\n hero.state === \"locked\"\n ? []\n : bestLoadout(\n hero.id,\n hero.rank,\n slots,\n pieces,\n hero.model?.Equipment,\n items,\n );\n const rarity = catalog.rarityColor(hero.def.Classification?.RarityID);\n const heroName = catalog.localize(hero.def.Identity?.DisplayName ?? hero.id);\n const skinCount = Object.keys(hero.def.Skins?.Definitions ?? {}).length;\n\n const rankUp = async () => {\n setBusy(\"rank\");\n const res = await client.character.upgradeCharacterLevel(hero.id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"levelUp\");\n kit.fx.burst(cardRef.current);\n };\n\n const equipBest = async () => {\n setBusy(\"best\");\n const res = await client.character.equipItems(hero.id, changes);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(cardRef.current);\n };\n\n // Always enabled (Unity EquipmentPanel.OnUnequipAllClicked): it is the way out of a local/server\n // mismatch, and a mismatch looks exactly like \"nothing is worn here\". The server empties only what\n // it has; the hero and the gear are re-read afterwards either way — the player pressed it because\n // what they see is not what they expect.\n const unequipAll = async () => {\n setBusy(\"off\");\n const slotIDs = unequipAllSlots(\n hero.id,\n slots,\n hero.model?.Equipment,\n pieces,\n );\n const res =\n slotIDs.length > 0\n ? await client.character.unequipItems(hero.id, slotIDs)\n : null;\n await client.cache.ensureState([\"Character\", \"InventoryV2\"], {\n force: true,\n });\n setBusy(null);\n if (res && !res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"click\");\n };\n\n const card = (\n <div\n ref={cardRef}\n className=\"idos-shine\"\n style={{\n ...panel,\n padding: 16,\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n background: `radial-gradient(circle at 50% 30%, ${rarity}55 0%, ${v.panelDeep} 75%)`,\n border: `2px solid ${rarity}`,\n }}\n >\n <div\n className=\"idos-bounce\"\n style={{\n width: 110,\n height: 110,\n borderRadius: 28,\n display: \"grid\",\n placeItems: \"center\",\n background: v.wellSoft,\n border: `3px solid ${rarity}`,\n }}\n >\n <Icon\n glyph={\n hero.def.Identity?.AssetPaths?.portrait ??\n hero.def.Identity?.AssetPaths?.icon ??\n \"hero\"\n }\n size={80}\n />\n </div>\n <div style={{ ...outlined, fontSize: 22 }}>{heroName}</div>\n {hero.def.Classification?.ClassID ? (\n <div style={{ ...outlined, fontSize: 12, color: rarity }}>\n {hero.def.Classification.ClassID} · {hero.def.Classification.RarityID}\n </div>\n ) : null}\n <div style={{ display: \"flex\", gap: 18 }}>\n <Stat\n label={t(\"rank\")}\n value={`${hero.rank}${ladder?.MaxRank ? ` / ${ladder.MaxRank}` : \"\"}`}\n />\n <Stat label={t(\"power\")} value={formatAmount(hero.power)} />\n </div>\n {hero.state === \"locked\" ? null : nextRank === null ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"maxRank\")}\n </div>\n ) : (\n <Button\n tone=\"gold\"\n attract={canAfford(nextRank, balances)}\n disabled={!canAfford(nextRank, balances)}\n busy={busy === \"rank\"}\n onClick={() => void rankUp()}\n data-tutorial-anchor=\"heroes:rank-up\"\n >\n {t(\"rankUp\")}{\" \"}\n {nextRank.length ? (\n <ResourceList\n lines={nextRank}\n size={18}\n dimIf={() => !canAfford(nextRank, balances)}\n />\n ) : (\n t(\"free\")\n )}\n </Button>\n )}\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n <span style={{ position: \"relative\" }}>\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup({ kind: \"heroes\" })}\n >\n <Icon glyph=\"friends\" size={18} /> {t(\"heroes\")}\n </Button>\n <Badge\n count={\n list.filter(\n (h) =>\n h.state === \"locked\" &&\n h.unlockPrice.length > 0 &&\n canAfford(h.unlockPrice, balances),\n ).length\n }\n dot\n />\n </span>\n {hero.state !== \"locked\" ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup({ kind: \"stats\" })}\n >\n <Icon glyph=\"sword\" size={18} /> {t(\"stats\")}\n </Button>\n ) : null}\n {skinCount > 0 && hero.state !== \"locked\" ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup({ kind: \"skins\" })}\n >\n <Icon glyph=\"user\" size={18} /> {t(\"skins\")}\n </Button>\n ) : null}\n </div>\n </div>\n );\n\n const equipment = (\n <div style={{ display: \"grid\", gap: 14 }}>\n <SectionTitle\n right={\n hero.state === \"locked\" ? null : (\n <span style={{ display: \"flex\", gap: 6 }}>\n <Button\n tone=\"blue\"\n size=\"sm\"\n busy={busy === \"off\"}\n onClick={() => void unequipAll()}\n >\n {t(\"unequipAll\")}\n </Button>\n {changes.length > 0 ? (\n <Button\n tone=\"green\"\n size=\"sm\"\n attract\n busy={busy === \"best\"}\n onClick={() => void equipBest()}\n data-tutorial-anchor=\"heroes:equip-best\"\n >\n {t(\"equipBest\")}\n </Button>\n ) : null}\n </span>\n )\n }\n >\n {t(\"equipment\")}\n </SectionTitle>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(auto-fill, minmax(${layout === \"phone\" ? 88 : 104}px, 1fr))`,\n gap: 12,\n justifyItems: \"center\",\n }}\n >\n {slots.map((slot) => (\n <SlotTile\n key={slot.id}\n slot={slot}\n hero={hero}\n pieces={pieces}\n onClick={() => setPopup({ kind: \"slot\", slot })}\n />\n ))}\n </div>\n <Bag\n pieces={pieces.filter((p) => !p.equippedBy)}\n onPick={(id) => setPopup({ kind: \"piece\", instanceID: id })}\n onShop={\n features.get(\"store\") ? () => features.open(\"store\") : undefined\n }\n />\n </div>\n );\n\n return (\n <div style={{ display: \"grid\", gap: 18 }}>\n {layout === \"desktop\" ? (\n <div\n style={{\n display: \"grid\",\n gap: 20,\n gridTemplateColumns: \"minmax(0, 4fr) minmax(0, 6fr)\",\n alignItems: \"start\",\n }}\n >\n {card}\n {equipment}\n </div>\n ) : (\n <>\n {card}\n {equipment}\n </>\n )}\n {popup?.kind === \"heroes\" ? (\n <HeroesPopup\n list={list}\n current={hero.id}\n balances={balances}\n onSelect={select}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup?.kind === \"stats\" ? (\n <StatsPopup\n hero={hero}\n section={section}\n pieces={pieces}\n items={items}\n balances={balances}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup?.kind === \"skins\" ? (\n <SkinsPopup hero={hero} onClose={() => setPopup(null)} />\n ) : null}\n {popup?.kind === \"slot\" ? (\n <SlotPopup\n slot={popup.slot}\n hero={hero}\n pieces={pieces}\n items={items}\n onPick={(id) => setPopup({ kind: \"piece\", instanceID: id })}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup?.kind === \"piece\" ? (\n <PiecePopup\n instanceID={popup.instanceID}\n hero={hero}\n slots={slots}\n pieces={pieces}\n items={items}\n balances={balances}\n onClose={() => setPopup(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction Stat({ label, value }: { label: string; value: string }): ReactNode {\n return (\n <div style={{ display: \"grid\", justifyItems: \"center\" }}>\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {label}\n </span>\n <span style={{ ...outlined, fontSize: 20, color: v.gold }}>{value}</span>\n </div>\n );\n}\n\nfunction SlotTile({\n slot,\n hero,\n pieces,\n onClick,\n}: {\n slot: SlotView;\n hero: RosterEntry;\n pieces: GearPiece[];\n onClick: () => void;\n}): ReactNode {\n const worn = hero.model?.Equipment?.[slot.id];\n const piece = worn?.ItemInstanceID\n ? pieces.find((p) => p.instanceID === worn.ItemInstanceID)\n : undefined;\n const locked = hero.rank < slot.minRank;\n const { play } = useUiKit();\n return (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n {worn?.ItemID ? (\n <ItemTile\n itemID={worn.ItemID}\n level={piece?.level}\n onClick={onClick}\n size={80}\n />\n ) : (\n <button\n type=\"button\"\n className=\"idos-press\"\n onClick={() => {\n play(\"click\");\n onClick();\n }}\n style={{\n width: 80,\n height: 80,\n borderRadius: 16,\n border: `3px dashed ${v.panelEdge}`,\n background: v.wellSoft,\n display: \"grid\",\n placeItems: \"center\",\n cursor: \"pointer\",\n opacity: locked ? 0.5 : 1,\n }}\n >\n <Icon glyph={locked ? \"lock\" : \"plus\"} size={30} />\n </button>\n )}\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {locked ? `${t(\"slotLocked\")} ${slot.minRank}` : slot.id}\n </span>\n </div>\n );\n}\n\nfunction Bag({\n pieces,\n onPick,\n onShop,\n}: {\n pieces: GearPiece[];\n onPick: (instanceID: string) => void;\n onShop?: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const stagger = useStagger();\n const classes = [\n ...new Set(\n pieces.map((p) => catalog.items.get(p.itemID)?.ItemClass || \"other\"),\n ),\n ];\n const [tab, setTab] = useState(\"all\");\n const shown =\n tab === \"all\"\n ? pieces\n : pieces.filter(\n (p) => (catalog.items.get(p.itemID)?.ItemClass || \"other\") === tab,\n );\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n <SectionTitle>{t(\"bag\")}</SectionTitle>\n {pieces.length === 0 ? (\n <EmptyState\n glyph=\"sword\"\n text={t(\"noGear\")}\n action={\n onShop ? (\n <Button onClick={onShop}>{catalog.localize(\"Shop\")}</Button>\n ) : undefined\n }\n />\n ) : (\n <>\n <Tabs\n tabs={[\n { id: \"all\", label: \"★\" },\n ...classes.map((c) => ({ id: c, label: c })),\n ]}\n value={tab}\n onChange={setTab}\n />\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(84px, 1fr))\",\n gap: 10,\n justifyItems: \"center\",\n }}\n >\n {shown.map((p, i) => (\n <div key={p.instanceID} style={stagger(i)}>\n <ItemTile\n itemID={p.itemID}\n level={p.level}\n onClick={() => onPick(p.instanceID)}\n />\n </div>\n ))}\n </div>\n </>\n )}\n </div>\n );\n}\n\n// ── Popups ───────────────────────────────────────────────────────────────────────────────────\n\nfunction HeroesPopup({\n list,\n current,\n balances,\n onSelect,\n onClose,\n}: {\n list: RosterEntry[];\n current: string;\n balances: Record<string, { Amount?: number }> | undefined;\n onSelect: (id: string) => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n\n const unlock = async (e: RosterEntry) => {\n setBusy(e.id);\n const res = await client.character.unlockCharacter(e.id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"levelUp\");\n onSelect(e.id);\n };\n\n return (\n <Popup title={t(\"heroes\")} onClose={onClose} width={560}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(150px, 1fr))\",\n gap: 10,\n }}\n >\n {list.map((e, i) => {\n const color = catalog.rarityColor(e.def.Classification?.RarityID);\n const affordable = canAfford(e.unlockPrice, balances);\n return (\n <Card\n key={e.id}\n highlight={e.id === current}\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 6,\n opacity: e.state === \"locked\" ? 0.85 : 1,\n border: `2px solid ${e.id === current ? v.gold : color}`,\n ...stagger(i),\n }}\n >\n <div style={{ position: \"relative\" }}>\n <Icon\n glyph={e.def.Identity?.AssetPaths?.icon ?? \"hero\"}\n size={56}\n />\n {e.state === \"locked\" ? (\n <span style={{ position: \"absolute\", right: -8, bottom: -4 }}>\n <Icon glyph=\"lock\" size={22} />\n </span>\n ) : null}\n </div>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(e.def.Identity?.DisplayName ?? e.id)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {e.state === \"locked\"\n ? t(\"locked\")\n : `${t(\"rank\")} ${e.rank} · ${t(\"power\")} ${formatAmount(e.power)}`}\n </div>\n {e.state === \"locked\" ? (\n e.unlockPrice.length > 0 ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract={affordable}\n disabled={!affordable}\n busy={busy === e.id}\n onClick={() => void unlock(e)}\n style={{ width: \"100%\" }}\n >\n {t(\"unlock\")}{\" \"}\n <ResourceList lines={e.unlockPrice} size={16} />\n </Button>\n ) : (\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"notForSale\")}\n </span>\n )\n ) : e.id === current ? (\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"selected\")}\n </span>\n ) : (\n <SelectButton onSelect={() => onSelect(e.id)} />\n )}\n </Card>\n );\n })}\n </div>\n </Popup>\n );\n}\n\nfunction SelectButton({ onSelect }: { onSelect: () => void }): ReactNode {\n const close = usePopupClose();\n return (\n <Button\n size=\"sm\"\n onClick={() => {\n onSelect();\n close();\n }}\n style={{ width: \"100%\" }}\n >\n {t(\"select\")}\n </Button>\n );\n}\n\nfunction StatsPopup({\n hero,\n section,\n pieces,\n items,\n balances,\n onClose,\n}: {\n hero: RosterEntry;\n section: CharacterSection | undefined;\n pieces: GearPiece[];\n items: Map<string, ItemDefWithGear>;\n balances: Record<string, { Amount?: number }> | undefined;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const play = useSound();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n const [detail, setDetail] = useState<StatView | null>(null);\n const stats = resolveStats(section, hero.def);\n const levels = hero.model?.StatLevels ?? {};\n const worn = Object.values(hero.model?.Equipment ?? {})\n .filter((e) => e?.ItemInstanceID)\n .map((e) => {\n const p = pieces.find((x) => x.instanceID === e!.ItemInstanceID);\n return { def: items.get(e!.ItemID ?? \"\"), level: p?.level ?? 1 };\n });\n\n const upgrade = async (stat: StatView) => {\n setBusy(stat.id);\n const res = await client.character.upgradeStatLevel(hero.id, stat.id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n play(\"levelUp\");\n };\n\n // \"Upgrade all\" (Unity CharacterStatPopup): as many levels as the balances cover, spread over the\n // stats cheapest-first, in ONE atomic batch.\n const plan = upgradeAllPlan(stats, levels, hero.rank, hero.def, balances);\n const upgradeAll = async () => {\n setBusy(\"all\");\n const res = await client.character.upgradeStatLevelsBatch(\n plan.map((p) => ({ CharacterID: hero.id, ...p })),\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n play(\"levelUp\");\n };\n\n return (\n <Popup\n title={`${t(\"stats\")} · ${catalog.localize(hero.def.Identity?.DisplayName ?? hero.id)}`}\n onClose={onClose}\n width={480}\n >\n <div style={{ display: \"grid\", gap: 10 }}>\n <Button\n tone=\"gold\"\n attract={plan.length > 0}\n disabled={plan.length === 0}\n busy={busy === \"all\"}\n onClick={() => void upgradeAll()}\n >\n {t(\"upgradeAll\")}\n {plan.length > 0 ? ` +${plan.reduce((n, p) => n + p.Levels, 0)}` : \"\"}\n </Button>\n {stats.map((stat, i) => {\n const level = Number(levels[stat.id] ?? 0);\n const cap = statMaxLevel(stat, hero.rank, hero.def);\n const capped = cap > 0 && level >= cap;\n const cost = statCost(stat, level + 1);\n const missing = unmetRequirements(stat, levels);\n const affordable = canAfford(cost, balances);\n const total = breakdown(stat, level, hero.rank, hero.def, worn).total;\n return (\n <Card\n key={stat.id}\n style={{ display: \"grid\", gap: 8, ...stagger(i) }}\n >\n <button\n type=\"button\"\n onClick={() => setDetail(stat)}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n background: \"none\",\n border: \"none\",\n padding: 0,\n cursor: \"pointer\",\n color: v.text,\n }}\n >\n <span style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(stat.name)} ⓘ\n </span>\n <span style={{ ...outlined, fontSize: 18, color: v.gold }}>\n {statNumber(total)}\n </span>\n </button>\n {cap > 0 ? (\n <ProgressBar\n value={level}\n max={cap}\n label={`${t(\"level\")} ${level} / ${cap}`}\n height={16}\n />\n ) : null}\n {missing.length > 0 ? (\n <div style={{ ...outlined, fontSize: 12, color: v.red }}>\n {t(\"needs\")}:{\" \"}\n {missing.map((m) => `${m.statID} ${m.level}`).join(\", \")}\n </div>\n ) : capped ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"statCapped\")}\n </div>\n ) : (\n <Button\n size=\"sm\"\n tone=\"green\"\n disabled={!affordable}\n busy={busy === stat.id}\n onClick={() => void upgrade(stat)}\n >\n {t(\"upgrade\")} →{\" \"}\n {statNumber(\n breakdown(stat, level + 1, hero.rank, hero.def, worn).total,\n )}{\" \"}\n <ResourceList\n lines={cost}\n size={16}\n dimIf={() => !affordable}\n />\n </Button>\n )}\n </Card>\n );\n })}\n </div>\n {detail ? (\n <BreakdownPopup\n stat={detail}\n level={Number(levels[detail.id] ?? 0)}\n hero={hero}\n worn={worn}\n onClose={() => setDetail(null)}\n />\n ) : null}\n </Popup>\n );\n}\n\nfunction BreakdownPopup({\n stat,\n level,\n hero,\n worn,\n onClose,\n}: {\n stat: StatView;\n level: number;\n hero: RosterEntry;\n worn: Array<{ def: ItemDefWithGear | undefined; level: number }>;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const b = breakdown(stat, level, hero.rank, hero.def, worn);\n const rows: Array<[string, number, string]> = [\n [t(\"base\"), b.base, v.text],\n [t(\"fromLevel\"), b.fromLevel, v.green],\n [t(\"fromRank\"), b.fromRank, v.blue],\n [t(\"fromGear\"), b.fromGear, v.gold],\n ];\n const round = statNumber;\n return (\n <Popup\n title={`${catalog.localize(stat.name)} — ${t(\"where\")}`}\n onClose={onClose}\n width={380}\n variant=\"center\"\n >\n {stat.description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {catalog.localize(stat.description)}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 6 }}>\n {rows.map(([label, value, color]) => (\n <div\n key={label}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 15,\n }}\n >\n <span style={{ color: v.textDim }}>{label}</span>\n <span style={{ color }}>\n {value === 0 && label !== t(\"base\")\n ? \"—\"\n : `${label === t(\"base\") ? \"\" : \"+\"}${round(value)}`}\n </span>\n </div>\n ))}\n {b.percent ? (\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 15,\n }}\n >\n <span style={{ color: v.textDim }}>{t(\"percent\")}</span>\n <span style={{ color: v.gold }}>+{round(b.percent)}%</span>\n </div>\n ) : null}\n <div style={{ height: 2, background: v.panelEdge, margin: \"4px 0\" }} />\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 18,\n }}\n >\n <span>{t(\"total\")}</span>\n <span style={{ color: v.gold }}>{round(b.total)}</span>\n </div>\n </div>\n </Popup>\n );\n}\n\nfunction SlotPopup({\n slot,\n hero,\n pieces,\n items,\n onPick,\n onClose,\n}: {\n slot: SlotView;\n hero: RosterEntry;\n pieces: GearPiece[];\n items: Map<string, ItemDefWithGear>;\n onPick: (instanceID: string) => void;\n onClose: () => void;\n}): ReactNode {\n const worn = hero.model?.Equipment?.[slot.id]?.ItemInstanceID ?? null;\n const fitting = pieces.filter(\n (p) =>\n p.instanceID !== worn &&\n (!p.equippedBy || p.equippedBy.characterID === hero.id) &&\n fitProblem(p, slot, hero.id, hero.rank, items) === null,\n );\n const stagger = useStagger();\n return (\n <Popup title={slot.id} onClose={onClose} width={440}>\n {worn ? (\n <PickRow\n instanceID={worn}\n pieces={pieces}\n label={t(\"worn\")}\n onPick={onPick}\n />\n ) : null}\n {fitting.length === 0 ? (\n <EmptyState\n glyph=\"sword\"\n text={\n hero.rank < slot.minRank\n ? `${t(\"slotLocked\")} ${slot.minRank}`\n : t(\"noGear\")\n }\n />\n ) : (\n <div style={{ display: \"grid\", gap: 8 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"fits\")}\n </div>\n {fitting.map((p, i) => (\n <div key={p.instanceID} style={stagger(i)}>\n <PickRow\n instanceID={p.instanceID}\n pieces={pieces}\n onPick={onPick}\n />\n </div>\n ))}\n </div>\n )}\n </Popup>\n );\n}\n\nfunction PickRow({\n instanceID,\n pieces,\n label,\n onPick,\n}: {\n instanceID: string;\n pieces: GearPiece[];\n label?: string;\n onPick: (id: string) => void;\n}): ReactNode {\n const catalog = useCatalog();\n const piece = pieces.find((p) => p.instanceID === instanceID);\n if (!piece) return null;\n return (\n <Card\n onClick={() => onPick(instanceID)}\n style={{ display: \"flex\", alignItems: \"center\", gap: 12, padding: 10 }}\n >\n <ItemTile itemID={piece.itemID} level={piece.level} size={56} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.itemName(piece.itemID)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {label ? `${label} · ` : \"\"}\n {t(\"level\")} {piece.level}\n </div>\n </div>\n </Card>\n );\n}\n\nfunction PiecePopup({\n instanceID,\n hero,\n slots,\n pieces,\n items,\n balances,\n onClose,\n}: {\n instanceID: string;\n hero: RosterEntry;\n slots: SlotView[];\n pieces: GearPiece[];\n items: Map<string, ItemDefWithGear>;\n balances: Record<string, { Amount?: number }> | undefined;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const iconRef = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const piece = pieces.find((p) => p.instanceID === instanceID);\n if (!piece) return null;\n const def = items.get(piece.itemID);\n const color = catalog.rarityColor(def?.Metadata?.RarityID);\n const bonuses = pieceBonuses(def, piece.level);\n const fitSlot = slots.find(\n (s) => fitProblem(piece, s, hero.id, hero.rank, items) === null,\n );\n const onThisHero =\n piece.equippedBy?.characterID === hero.id ? piece.equippedBy : null;\n const levelCost = itemLevelCost(def, piece.level);\n const affordable = levelCost\n ? canAfford(levelCost as ResourceLine[], balances)\n : false;\n\n const act = async (kind: \"equip\" | \"unequip\" | \"level\") => {\n setBusy(kind);\n const res =\n kind === \"equip\" && fitSlot\n ? await client.character.equipItems(hero.id, [\n { SlotID: fitSlot.id, ItemInstanceID: piece.instanceID },\n ])\n : kind === \"unequip\" && onThisHero\n ? await client.character.unequipItems(hero.id, [onThisHero.slotID])\n : await client.item.upgradeLevel(piece.instanceID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(kind === \"level\" ? \"levelUp\" : \"claim\");\n kit.fx.burst(iconRef.current);\n };\n\n return (\n <Popup title={catalog.itemName(piece.itemID)} onClose={onClose}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <div\n ref={iconRef}\n className=\"idos-glow\"\n style={{\n width: 110,\n height: 110,\n borderRadius: 26,\n display: \"grid\",\n placeItems: \"center\",\n border: `4px solid ${color}`,\n background: `radial-gradient(circle at 50% 35%, ${color}66 0%, ${v.panelDeep} 72%)`,\n }}\n >\n <Icon\n glyph={catalog.iconOf({\n kind: \"item\",\n id: piece.itemID,\n amount: 1,\n })}\n size={72}\n />\n </div>\n <div style={{ ...outlined, color, fontSize: 14 }}>\n {def?.Metadata?.RarityID ?? \"Common\"} · {t(\"level\")} {piece.level}\n {def?.Upgrade?.MaxLevel ? ` / ${def.Upgrade.MaxLevel}` : \"\"}\n </div>\n {Object.entries(bonuses).map(([stat, value]) => (\n <div key={stat} style={{ ...outlined, fontSize: 15, color: v.green }}>\n +{statNumber(value)} {stat}\n </div>\n ))}\n {Object.entries(def?.Stats?.PercentBonuses ?? {}).map(\n ([stat, value]) => (\n <div\n key={stat}\n style={{ ...outlined, fontSize: 15, color: v.gold }}\n >\n +{value}% {stat}\n </div>\n ),\n )}\n {piece.equippedBy && !onThisHero ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"wornBy\")} {piece.equippedBy.characterID}\n </div>\n ) : null}\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n {onThisHero ? (\n <Button\n tone=\"grey\"\n busy={busy === \"unequip\"}\n onClick={() => void act(\"unequip\")}\n >\n {t(\"unequip\")}\n </Button>\n ) : fitSlot ? (\n <Button\n tone=\"blue\"\n busy={busy === \"equip\"}\n onClick={() => void act(\"equip\")}\n >\n {t(\"equip\")} → {fitSlot.id}\n </Button>\n ) : (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"cannot\")}\n </div>\n )}\n {levelCost === null ? (\n def?.Upgrade ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"maxLevel\")}\n </div>\n ) : null\n ) : (\n <Button\n tone=\"gold\"\n disabled={!affordable}\n busy={busy === \"level\"}\n onClick={() => void act(\"level\")}\n >\n {t(\"levelUp\")}{\" \"}\n <ResourceList\n lines={levelCost}\n size={18}\n dimIf={() => !affordable}\n />\n </Button>\n )}\n </div>\n </Popup>\n );\n}\n\nfunction SkinsPopup({\n hero,\n onClose,\n}: {\n hero: RosterEntry;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const stagger = useStagger();\n const [view, setView] = useState<{\n WornSkinID?: string | null;\n Skins?: Array<{\n SkinID: string;\n Owned?: boolean | null;\n IsWorn?: boolean | null;\n Purchasable?: boolean | null;\n MeetsRequirements?: boolean | null;\n PriceOptions?: Record<string, { Cost?: unknown }> | null;\n }> | null;\n } | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n\n const load = async () => {\n const res = await client.character.getCharacterSkins(hero.id);\n if (res.ok) setView(res.data.Characters?.[hero.id] ?? null);\n };\n useEffect(() => {\n void load();\n // once per hero\n }, [hero.id]);\n\n const act = async (skinID: string, kind: \"wear\" | \"buy\" | \"base\") => {\n setBusy(skinID);\n const res =\n kind === \"buy\"\n ? await client.character.unlockSkin(hero.id, skinID, {\n autoEquip: true,\n })\n : kind === \"base\"\n ? await client.character.unequipSkin(hero.id)\n : await client.character.equipSkin(hero.id, skinID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(kind === \"buy\" ? \"purchase\" : \"claim\");\n await load();\n };\n\n const defs = hero.def.Skins?.Definitions ?? {};\n const wornID = hero.model?.Equipment?.[SKIN_KEY]?.ItemID ?? null;\n return (\n <Popup title={t(\"skins\")} onClose={onClose} width={520}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(140px, 1fr))\",\n gap: 10,\n }}\n >\n <Card\n highlight={!wornID}\n style={{ display: \"grid\", justifyItems: \"center\", gap: 6 }}\n >\n <Icon glyph=\"hero\" size={52} />\n <div style={{ ...outlined, fontSize: 14 }}>{t(\"baseLook\")}</div>\n {wornID ? (\n <Button\n size=\"sm\"\n busy={busy === \"@base\"}\n onClick={() => void act(\"@base\", \"base\")}\n >\n {t(\"wear\")}\n </Button>\n ) : (\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"worn\")}\n </span>\n )}\n </Card>\n {(view?.Skins ?? []).map((s, i) => {\n const def = defs[s.SkinID];\n const price = Object.values(s.PriceOptions ?? {})[0];\n return (\n <Card\n key={s.SkinID}\n highlight={s.IsWorn === true}\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 6,\n ...stagger(i),\n }}\n >\n <Icon\n glyph={def?.Identity?.AssetPaths?.icon ?? \"user\"}\n size={52}\n />\n <div style={{ ...outlined, fontSize: 14 }}>\n {catalog.localize(def?.Identity?.DisplayName ?? s.SkinID)}\n </div>\n {s.IsWorn ? (\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"worn\")}\n </span>\n ) : s.Owned ? (\n <Button\n size=\"sm\"\n disabled={s.MeetsRequirements === false}\n busy={busy === s.SkinID}\n onClick={() => void act(s.SkinID, \"wear\")}\n >\n {t(\"wear\")}\n </Button>\n ) : s.Purchasable && price ? (\n <Button\n size=\"sm\"\n tone=\"gold\"\n busy={busy === s.SkinID}\n onClick={() => void act(s.SkinID, \"buy\")}\n >\n <ResourceList\n lines={costLines(price.Cost as never)}\n size={16}\n />\n </Button>\n ) : (\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"notForSale\")}\n </span>\n )}\n </Card>\n );\n })}\n </div>\n </Popup>\n );\n}\n"
65
+ "content": "import { useRef, useState, type ReactNode } from \"react\";\nimport {\n type FeatureRegistry,\n type FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport {\n Badge,\n Button,\n EmptyState,\n Icon,\n ResourceList,\n SectionTitle,\n canAfford,\n errorText,\n formatAmount,\n outlined,\n panel,\n useCatalog,\n useGameLayout,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n bestLoadout,\n rankCost,\n resolveLadder,\n resolveSlots,\n roster,\n unequipAllSlots,\n type SlotView,\n} from \"./model\";\nimport { t } from \"./i18n\";\nimport { readSelected, resolveSelected, saveSelected } from \"./selection\";\nimport { HeroesPopup } from \"./components/HeroesPopup\";\nimport { PiecePopup } from \"./components/PiecePopup\";\nimport { SkinsPopup } from \"./components/SkinsPopup\";\nimport { SlotPopup } from \"./components/SlotPopup\";\nimport { StatsPopup } from \"./components/StatsPopup\";\nimport { Bag, SlotTile, Stat } from \"./components/parts\";\nimport { useData } from \"./components/useData\";\n\n// Heroes (Unity: the Hero button → CharacterListPanel, CharacterDetailPanel, EquipmentPanel). The\n// screen is the equipment hub of the selected hero — portrait, rank and Power, the slots, \"Equip\n// best\", and the gear the player owns. Everything with more data opens as a popup: the hero list\n// (select / unlock), the stats (upgrade, and \"where does this number come from\"), a piece of gear\n// (equip / take off / level up), a slot's fitting gear, and skins.\n\n/**\n * `onSelect` — the player picked a hero to play with; the module turns it into the\n * `character:hero-selected@1` event for the game.\n */\nexport function makeCharacterScreen(\n features: FeatureRegistry,\n onSelect: (characterId: string) => void,\n) {\n return function CharacterScreen({ args }: FeatureScreenProps): ReactNode {\n return (\n <Heroes\n features={features}\n onSelect={onSelect}\n openInstance={\n typeof args?.itemInstanceID === \"string\" ? args.itemInstanceID : null\n }\n />\n );\n };\n}\n\ntype PopupState =\n | { kind: \"heroes\" }\n | { kind: \"stats\" }\n | { kind: \"skins\" }\n | { kind: \"slot\"; slot: SlotView }\n | { kind: \"piece\"; instanceID: string }\n | null;\n\nfunction Heroes({\n features,\n onSelect,\n openInstance,\n}: {\n features: FeatureRegistry;\n onSelect: (characterId: string) => void;\n openInstance: string | null;\n}): ReactNode {\n const { client, section, owned, items, pieces, balances } = useData();\n const catalog = useCatalog();\n const layout = useGameLayout();\n const toast = useToast();\n const kit = useUiKit();\n const cardRef = useRef<HTMLDivElement>(null);\n const [popup, setPopup] = useState<PopupState>(\n openInstance ? { kind: \"piece\", instanceID: openInstance } : null,\n );\n const [busy, setBusy] = useState<string | null>(null);\n const [selectedID, setSelectedID] = useState<string | null>(readSelected);\n\n const list = roster(section, owned);\n if (list.length === 0)\n return <EmptyState glyph=\"hero\" text={t(\"noHeroes\")} />;\n const hero = resolveSelected(list, selectedID)!;\n const select = (id: string) => {\n setSelectedID(id);\n saveSelected(id);\n onSelect(id);\n };\n\n const ladder = resolveLadder(section, hero.def);\n const nextRank = rankCost(ladder, hero.rank);\n const slots = resolveSlots(section, hero.def);\n const changes =\n hero.state === \"locked\"\n ? []\n : bestLoadout(\n hero.id,\n hero.rank,\n slots,\n pieces,\n hero.model?.Equipment,\n items,\n );\n const rarity = catalog.rarityColor(hero.def.Classification?.RarityID);\n const heroName = catalog.localize(hero.def.Identity?.DisplayName ?? hero.id);\n const skinCount = Object.keys(hero.def.Skins?.Definitions ?? {}).length;\n\n const rankUp = async () => {\n setBusy(\"rank\");\n const res = await client.character.upgradeCharacterLevel(hero.id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"levelUp\");\n kit.fx.burst(cardRef.current);\n };\n\n const equipBest = async () => {\n setBusy(\"best\");\n const res = await client.character.equipItems(hero.id, changes);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(cardRef.current);\n };\n\n // Always enabled (Unity EquipmentPanel.OnUnequipAllClicked): it is the way out of a local/server\n // mismatch, and a mismatch looks exactly like \"nothing is worn here\". The server empties only what\n // it has; the hero and the gear are re-read afterwards either way — the player pressed it because\n // what they see is not what they expect.\n const unequipAll = async () => {\n setBusy(\"off\");\n const slotIDs = unequipAllSlots(\n hero.id,\n slots,\n hero.model?.Equipment,\n pieces,\n );\n const res =\n slotIDs.length > 0\n ? await client.character.unequipItems(hero.id, slotIDs)\n : null;\n await client.cache.ensureState([\"Character\", \"InventoryV2\"], {\n force: true,\n });\n setBusy(null);\n if (res && !res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"click\");\n };\n\n const card = (\n <div\n ref={cardRef}\n className=\"idos-shine\"\n style={{\n ...panel,\n padding: 16,\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n background: `radial-gradient(circle at 50% 30%, ${rarity}55 0%, ${v.panelDeep} 75%)`,\n border: `2px solid ${rarity}`,\n }}\n >\n <div\n className=\"idos-bounce\"\n style={{\n width: 110,\n height: 110,\n borderRadius: 28,\n display: \"grid\",\n placeItems: \"center\",\n background: v.wellSoft,\n border: `3px solid ${rarity}`,\n }}\n >\n <Icon\n glyph={\n hero.def.Identity?.AssetPaths?.portrait ??\n hero.def.Identity?.AssetPaths?.icon ??\n \"hero\"\n }\n size={80}\n />\n </div>\n <div style={{ ...outlined, fontSize: 22 }}>{heroName}</div>\n {hero.def.Classification?.ClassID ? (\n <div style={{ ...outlined, fontSize: 12, color: rarity }}>\n {hero.def.Classification.ClassID} · {hero.def.Classification.RarityID}\n </div>\n ) : null}\n <div style={{ display: \"flex\", gap: 18 }}>\n <Stat\n label={t(\"rank\")}\n value={`${hero.rank}${ladder?.MaxRank ? ` / ${ladder.MaxRank}` : \"\"}`}\n />\n <Stat label={t(\"power\")} value={formatAmount(hero.power)} />\n </div>\n {hero.state === \"locked\" ? null : nextRank === null ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"maxRank\")}\n </div>\n ) : (\n <Button\n tone=\"gold\"\n attract={canAfford(nextRank, balances)}\n disabled={!canAfford(nextRank, balances)}\n busy={busy === \"rank\"}\n onClick={() => void rankUp()}\n data-tutorial-anchor=\"heroes:rank-up\"\n >\n {t(\"rankUp\")}{\" \"}\n {nextRank.length ? (\n <ResourceList\n lines={nextRank}\n size={18}\n dimIf={() => !canAfford(nextRank, balances)}\n />\n ) : (\n t(\"free\")\n )}\n </Button>\n )}\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n <span style={{ position: \"relative\" }}>\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup({ kind: \"heroes\" })}\n >\n <Icon glyph=\"friends\" size={18} /> {t(\"heroes\")}\n </Button>\n <Badge\n count={\n list.filter(\n (h) =>\n h.state === \"locked\" &&\n h.unlockPrice.length > 0 &&\n canAfford(h.unlockPrice, balances),\n ).length\n }\n dot\n />\n </span>\n {hero.state !== \"locked\" ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup({ kind: \"stats\" })}\n >\n <Icon glyph=\"sword\" size={18} /> {t(\"stats\")}\n </Button>\n ) : null}\n {skinCount > 0 && hero.state !== \"locked\" ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup({ kind: \"skins\" })}\n >\n <Icon glyph=\"user\" size={18} /> {t(\"skins\")}\n </Button>\n ) : null}\n </div>\n </div>\n );\n\n const equipment = (\n <div style={{ display: \"grid\", gap: 14 }}>\n <SectionTitle\n right={\n hero.state === \"locked\" ? null : (\n <span style={{ display: \"flex\", gap: 6 }}>\n <Button\n tone=\"blue\"\n size=\"sm\"\n busy={busy === \"off\"}\n onClick={() => void unequipAll()}\n >\n {t(\"unequipAll\")}\n </Button>\n {changes.length > 0 ? (\n <Button\n tone=\"green\"\n size=\"sm\"\n attract\n busy={busy === \"best\"}\n onClick={() => void equipBest()}\n data-tutorial-anchor=\"heroes:equip-best\"\n >\n {t(\"equipBest\")}\n </Button>\n ) : null}\n </span>\n )\n }\n >\n {t(\"equipment\")}\n </SectionTitle>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(auto-fill, minmax(${layout === \"phone\" ? 88 : 104}px, 1fr))`,\n gap: 12,\n justifyItems: \"center\",\n }}\n >\n {slots.map((slot) => (\n <SlotTile\n key={slot.id}\n slot={slot}\n hero={hero}\n pieces={pieces}\n onClick={() => setPopup({ kind: \"slot\", slot })}\n />\n ))}\n </div>\n <Bag\n pieces={pieces.filter((p) => !p.equippedBy)}\n onPick={(id) => setPopup({ kind: \"piece\", instanceID: id })}\n onShop={\n features.get(\"store\") ? () => features.open(\"store\") : undefined\n }\n />\n </div>\n );\n\n return (\n <div style={{ display: \"grid\", gap: 18 }}>\n {layout === \"desktop\" ? (\n <div\n style={{\n display: \"grid\",\n gap: 20,\n gridTemplateColumns: \"minmax(0, 4fr) minmax(0, 6fr)\",\n alignItems: \"start\",\n }}\n >\n {card}\n {equipment}\n </div>\n ) : (\n <>\n {card}\n {equipment}\n </>\n )}\n {popup?.kind === \"heroes\" ? (\n <HeroesPopup\n list={list}\n current={hero.id}\n balances={balances}\n onSelect={select}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup?.kind === \"stats\" ? (\n <StatsPopup\n hero={hero}\n section={section}\n pieces={pieces}\n items={items}\n balances={balances}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup?.kind === \"skins\" ? (\n <SkinsPopup hero={hero} onClose={() => setPopup(null)} />\n ) : null}\n {popup?.kind === \"slot\" ? (\n <SlotPopup\n slot={popup.slot}\n hero={hero}\n pieces={pieces}\n items={items}\n onPick={(id) => setPopup({ kind: \"piece\", instanceID: id })}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup?.kind === \"piece\" ? (\n <PiecePopup\n instanceID={popup.instanceID}\n hero={hero}\n slots={slots}\n pieces={pieces}\n items={items}\n balances={balances}\n onClose={() => setPopup(null)}\n />\n ) : null}\n </div>\n );\n}\n"
66
+ },
67
+ {
68
+ "path": "components/HeroesPopup.tsx",
69
+ "content": "import { useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Icon,\n Popup,\n ResourceList,\n canAfford,\n errorText,\n formatAmount,\n outlined,\n useCatalog,\n usePopupClose,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { type RosterEntry } from \"../model\";\nimport { t } from \"../i18n\";\n\nexport function HeroesPopup({\n list,\n current,\n balances,\n onSelect,\n onClose,\n}: {\n list: RosterEntry[];\n current: string;\n balances: Record<string, { Amount?: number }> | undefined;\n onSelect: (id: string) => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n\n const unlock = async (e: RosterEntry) => {\n setBusy(e.id);\n const res = await client.character.unlockCharacter(e.id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"levelUp\");\n onSelect(e.id);\n };\n\n return (\n <Popup title={t(\"heroes\")} onClose={onClose} width={560}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(150px, 1fr))\",\n gap: 10,\n }}\n >\n {list.map((e, i) => {\n const color = catalog.rarityColor(e.def.Classification?.RarityID);\n const affordable = canAfford(e.unlockPrice, balances);\n return (\n <Card\n key={e.id}\n highlight={e.id === current}\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 6,\n opacity: e.state === \"locked\" ? 0.85 : 1,\n border: `2px solid ${e.id === current ? v.gold : color}`,\n ...stagger(i),\n }}\n >\n <div style={{ position: \"relative\" }}>\n <Icon\n glyph={e.def.Identity?.AssetPaths?.icon ?? \"hero\"}\n size={56}\n />\n {e.state === \"locked\" ? (\n <span style={{ position: \"absolute\", right: -8, bottom: -4 }}>\n <Icon glyph=\"lock\" size={22} />\n </span>\n ) : null}\n </div>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(e.def.Identity?.DisplayName ?? e.id)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {e.state === \"locked\"\n ? t(\"locked\")\n : `${t(\"rank\")} ${e.rank} · ${t(\"power\")} ${formatAmount(e.power)}`}\n </div>\n {e.state === \"locked\" ? (\n e.unlockPrice.length > 0 ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract={affordable}\n disabled={!affordable}\n busy={busy === e.id}\n onClick={() => void unlock(e)}\n style={{ width: \"100%\" }}\n >\n {t(\"unlock\")}{\" \"}\n <ResourceList lines={e.unlockPrice} size={16} />\n </Button>\n ) : (\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"notForSale\")}\n </span>\n )\n ) : e.id === current ? (\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"selected\")}\n </span>\n ) : (\n <SelectButton onSelect={() => onSelect(e.id)} />\n )}\n </Card>\n );\n })}\n </div>\n </Popup>\n );\n}\n\nfunction SelectButton({ onSelect }: { onSelect: () => void }): ReactNode {\n const close = usePopupClose();\n return (\n <Button\n size=\"sm\"\n onClick={() => {\n onSelect();\n close();\n }}\n style={{ width: \"100%\" }}\n >\n {t(\"select\")}\n </Button>\n );\n}\n"
70
+ },
71
+ {
72
+ "path": "components/parts.tsx",
73
+ "content": "import { useState, type ReactNode } from \"react\";\nimport {\n Button,\n EmptyState,\n Icon,\n ItemTile,\n SectionTitle,\n Tabs,\n outlined,\n useCatalog,\n useStagger,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { type GearPiece, type RosterEntry, type SlotView } from \"../model\";\nimport { t } from \"../i18n\";\n\nexport function Stat({\n label,\n value,\n}: {\n label: string;\n value: string;\n}): ReactNode {\n return (\n <div style={{ display: \"grid\", justifyItems: \"center\" }}>\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {label}\n </span>\n <span style={{ ...outlined, fontSize: 20, color: v.gold }}>{value}</span>\n </div>\n );\n}\n\nexport function SlotTile({\n slot,\n hero,\n pieces,\n onClick,\n}: {\n slot: SlotView;\n hero: RosterEntry;\n pieces: GearPiece[];\n onClick: () => void;\n}): ReactNode {\n const worn = hero.model?.Equipment?.[slot.id];\n const piece = worn?.ItemInstanceID\n ? pieces.find((p) => p.instanceID === worn.ItemInstanceID)\n : undefined;\n const locked = hero.rank < slot.minRank;\n const { play } = useUiKit();\n return (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n {worn?.ItemID ? (\n <ItemTile\n itemID={worn.ItemID}\n level={piece?.level}\n onClick={onClick}\n size={80}\n />\n ) : (\n <button\n type=\"button\"\n className=\"idos-press\"\n onClick={() => {\n play(\"click\");\n onClick();\n }}\n style={{\n width: 80,\n height: 80,\n borderRadius: 16,\n border: `3px dashed ${v.panelEdge}`,\n background: v.wellSoft,\n display: \"grid\",\n placeItems: \"center\",\n cursor: \"pointer\",\n opacity: locked ? 0.5 : 1,\n }}\n >\n <Icon glyph={locked ? \"lock\" : \"plus\"} size={30} />\n </button>\n )}\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {locked ? `${t(\"slotLocked\")} ${slot.minRank}` : slot.id}\n </span>\n </div>\n );\n}\n\nexport function Bag({\n pieces,\n onPick,\n onShop,\n}: {\n pieces: GearPiece[];\n onPick: (instanceID: string) => void;\n onShop?: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const stagger = useStagger();\n const classes = [\n ...new Set(\n pieces.map((p) => catalog.items.get(p.itemID)?.ItemClass || \"other\"),\n ),\n ];\n const [tab, setTab] = useState(\"all\");\n const shown =\n tab === \"all\"\n ? pieces\n : pieces.filter(\n (p) => (catalog.items.get(p.itemID)?.ItemClass || \"other\") === tab,\n );\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n <SectionTitle>{t(\"bag\")}</SectionTitle>\n {pieces.length === 0 ? (\n <EmptyState\n glyph=\"sword\"\n text={t(\"noGear\")}\n action={\n onShop ? (\n <Button onClick={onShop}>{catalog.localize(\"Shop\")}</Button>\n ) : undefined\n }\n />\n ) : (\n <>\n <Tabs\n tabs={[\n { id: \"all\", label: \"★\" },\n ...classes.map((c) => ({ id: c, label: c })),\n ]}\n value={tab}\n onChange={setTab}\n />\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(84px, 1fr))\",\n gap: 10,\n justifyItems: \"center\",\n }}\n >\n {shown.map((p, i) => (\n <div key={p.instanceID} style={stagger(i)}>\n <ItemTile\n itemID={p.itemID}\n level={p.level}\n onClick={() => onPick(p.instanceID)}\n />\n </div>\n ))}\n </div>\n </>\n )}\n </div>\n );\n}\n\n// ── Popups ───────────────────────────────────────────────────────────────────────────────────\n"
74
+ },
75
+ {
76
+ "path": "components/PiecePopup.tsx",
77
+ "content": "import { useRef, useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Icon,\n Popup,\n ResourceList,\n canAfford,\n errorText,\n outlined,\n useCatalog,\n useToast,\n useUiKit,\n v,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\nimport {\n fitProblem,\n itemLevelCost,\n pieceBonuses,\n statNumber,\n type GearPiece,\n type ItemDefWithGear,\n type RosterEntry,\n type SlotView,\n} from \"../model\";\nimport { t } from \"../i18n\";\n\nexport function PiecePopup({\n instanceID,\n hero,\n slots,\n pieces,\n items,\n balances,\n onClose,\n}: {\n instanceID: string;\n hero: RosterEntry;\n slots: SlotView[];\n pieces: GearPiece[];\n items: Map<string, ItemDefWithGear>;\n balances: Record<string, { Amount?: number }> | undefined;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const iconRef = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const piece = pieces.find((p) => p.instanceID === instanceID);\n if (!piece) return null;\n const def = items.get(piece.itemID);\n const color = catalog.rarityColor(def?.Metadata?.RarityID);\n const bonuses = pieceBonuses(def, piece.level);\n const fitSlot = slots.find(\n (s) => fitProblem(piece, s, hero.id, hero.rank, items) === null,\n );\n const onThisHero =\n piece.equippedBy?.characterID === hero.id ? piece.equippedBy : null;\n const levelCost = itemLevelCost(def, piece.level);\n const affordable = levelCost\n ? canAfford(levelCost as ResourceLine[], balances)\n : false;\n\n const act = async (kind: \"equip\" | \"unequip\" | \"level\") => {\n setBusy(kind);\n const res =\n kind === \"equip\" && fitSlot\n ? await client.character.equipItems(hero.id, [\n { SlotID: fitSlot.id, ItemInstanceID: piece.instanceID },\n ])\n : kind === \"unequip\" && onThisHero\n ? await client.character.unequipItems(hero.id, [onThisHero.slotID])\n : await client.item.upgradeLevel(piece.instanceID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(kind === \"level\" ? \"levelUp\" : \"claim\");\n kit.fx.burst(iconRef.current);\n };\n\n return (\n <Popup title={catalog.itemName(piece.itemID)} onClose={onClose}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <div\n ref={iconRef}\n className=\"idos-glow\"\n style={{\n width: 110,\n height: 110,\n borderRadius: 26,\n display: \"grid\",\n placeItems: \"center\",\n border: `4px solid ${color}`,\n background: `radial-gradient(circle at 50% 35%, ${color}66 0%, ${v.panelDeep} 72%)`,\n }}\n >\n <Icon\n glyph={catalog.iconOf({\n kind: \"item\",\n id: piece.itemID,\n amount: 1,\n })}\n size={72}\n />\n </div>\n <div style={{ ...outlined, color, fontSize: 14 }}>\n {def?.Metadata?.RarityID ?? \"Common\"} · {t(\"level\")} {piece.level}\n {def?.Upgrade?.MaxLevel ? ` / ${def.Upgrade.MaxLevel}` : \"\"}\n </div>\n {Object.entries(bonuses).map(([stat, value]) => (\n <div key={stat} style={{ ...outlined, fontSize: 15, color: v.green }}>\n +{statNumber(value)} {stat}\n </div>\n ))}\n {Object.entries(def?.Stats?.PercentBonuses ?? {}).map(\n ([stat, value]) => (\n <div\n key={stat}\n style={{ ...outlined, fontSize: 15, color: v.gold }}\n >\n +{value}% {stat}\n </div>\n ),\n )}\n {piece.equippedBy && !onThisHero ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"wornBy\")} {piece.equippedBy.characterID}\n </div>\n ) : null}\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n {onThisHero ? (\n <Button\n tone=\"grey\"\n busy={busy === \"unequip\"}\n onClick={() => void act(\"unequip\")}\n >\n {t(\"unequip\")}\n </Button>\n ) : fitSlot ? (\n <Button\n tone=\"blue\"\n busy={busy === \"equip\"}\n onClick={() => void act(\"equip\")}\n >\n {t(\"equip\")} → {fitSlot.id}\n </Button>\n ) : (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"cannot\")}\n </div>\n )}\n {levelCost === null ? (\n def?.Upgrade ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"maxLevel\")}\n </div>\n ) : null\n ) : (\n <Button\n tone=\"gold\"\n disabled={!affordable}\n busy={busy === \"level\"}\n onClick={() => void act(\"level\")}\n >\n {t(\"levelUp\")}{\" \"}\n <ResourceList\n lines={levelCost}\n size={18}\n dimIf={() => !affordable}\n />\n </Button>\n )}\n </div>\n </Popup>\n );\n}\n"
78
+ },
79
+ {
80
+ "path": "components/SkinsPopup.tsx",
81
+ "content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Icon,\n Popup,\n ResourceList,\n costLines,\n errorText,\n outlined,\n useCatalog,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { SKIN_KEY, type RosterEntry } from \"../model\";\nimport { t } from \"../i18n\";\n\nexport function SkinsPopup({\n hero,\n onClose,\n}: {\n hero: RosterEntry;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const stagger = useStagger();\n const [view, setView] = useState<{\n WornSkinID?: string | null;\n Skins?: Array<{\n SkinID: string;\n Owned?: boolean | null;\n IsWorn?: boolean | null;\n Purchasable?: boolean | null;\n MeetsRequirements?: boolean | null;\n PriceOptions?: Record<string, { Cost?: unknown }> | null;\n }> | null;\n } | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n\n const load = async () => {\n const res = await client.character.getCharacterSkins(hero.id);\n if (res.ok) setView(res.data.Characters?.[hero.id] ?? null);\n };\n useEffect(() => {\n void load();\n // once per hero\n }, [hero.id]);\n\n const act = async (skinID: string, kind: \"wear\" | \"buy\" | \"base\") => {\n setBusy(skinID);\n const res =\n kind === \"buy\"\n ? await client.character.unlockSkin(hero.id, skinID, {\n autoEquip: true,\n })\n : kind === \"base\"\n ? await client.character.unequipSkin(hero.id)\n : await client.character.equipSkin(hero.id, skinID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(kind === \"buy\" ? \"purchase\" : \"claim\");\n await load();\n };\n\n const defs = hero.def.Skins?.Definitions ?? {};\n const wornID = hero.model?.Equipment?.[SKIN_KEY]?.ItemID ?? null;\n return (\n <Popup title={t(\"skins\")} onClose={onClose} width={520}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(140px, 1fr))\",\n gap: 10,\n }}\n >\n <Card\n highlight={!wornID}\n style={{ display: \"grid\", justifyItems: \"center\", gap: 6 }}\n >\n <Icon glyph=\"hero\" size={52} />\n <div style={{ ...outlined, fontSize: 14 }}>{t(\"baseLook\")}</div>\n {wornID ? (\n <Button\n size=\"sm\"\n busy={busy === \"@base\"}\n onClick={() => void act(\"@base\", \"base\")}\n >\n {t(\"wear\")}\n </Button>\n ) : (\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"worn\")}\n </span>\n )}\n </Card>\n {(view?.Skins ?? []).map((s, i) => {\n const def = defs[s.SkinID];\n const price = Object.values(s.PriceOptions ?? {})[0];\n return (\n <Card\n key={s.SkinID}\n highlight={s.IsWorn === true}\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 6,\n ...stagger(i),\n }}\n >\n <Icon\n glyph={def?.Identity?.AssetPaths?.icon ?? \"user\"}\n size={52}\n />\n <div style={{ ...outlined, fontSize: 14 }}>\n {catalog.localize(def?.Identity?.DisplayName ?? s.SkinID)}\n </div>\n {s.IsWorn ? (\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"worn\")}\n </span>\n ) : s.Owned ? (\n <Button\n size=\"sm\"\n disabled={s.MeetsRequirements === false}\n busy={busy === s.SkinID}\n onClick={() => void act(s.SkinID, \"wear\")}\n >\n {t(\"wear\")}\n </Button>\n ) : s.Purchasable && price ? (\n <Button\n size=\"sm\"\n tone=\"gold\"\n busy={busy === s.SkinID}\n onClick={() => void act(s.SkinID, \"buy\")}\n >\n <ResourceList\n lines={costLines(price.Cost as never)}\n size={16}\n />\n </Button>\n ) : (\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"notForSale\")}\n </span>\n )}\n </Card>\n );\n })}\n </div>\n </Popup>\n );\n}\n"
82
+ },
83
+ {
84
+ "path": "components/SlotPopup.tsx",
85
+ "content": "import { type ReactNode } from \"react\";\nimport {\n Card,\n EmptyState,\n ItemTile,\n Popup,\n outlined,\n useCatalog,\n useStagger,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n fitProblem,\n type GearPiece,\n type ItemDefWithGear,\n type RosterEntry,\n type SlotView,\n} from \"../model\";\nimport { t } from \"../i18n\";\n\nexport function SlotPopup({\n slot,\n hero,\n pieces,\n items,\n onPick,\n onClose,\n}: {\n slot: SlotView;\n hero: RosterEntry;\n pieces: GearPiece[];\n items: Map<string, ItemDefWithGear>;\n onPick: (instanceID: string) => void;\n onClose: () => void;\n}): ReactNode {\n const worn = hero.model?.Equipment?.[slot.id]?.ItemInstanceID ?? null;\n const fitting = pieces.filter(\n (p) =>\n p.instanceID !== worn &&\n (!p.equippedBy || p.equippedBy.characterID === hero.id) &&\n fitProblem(p, slot, hero.id, hero.rank, items) === null,\n );\n const stagger = useStagger();\n return (\n <Popup title={slot.id} onClose={onClose} width={440}>\n {worn ? (\n <PickRow\n instanceID={worn}\n pieces={pieces}\n label={t(\"worn\")}\n onPick={onPick}\n />\n ) : null}\n {fitting.length === 0 ? (\n <EmptyState\n glyph=\"sword\"\n text={\n hero.rank < slot.minRank\n ? `${t(\"slotLocked\")} ${slot.minRank}`\n : t(\"noGear\")\n }\n />\n ) : (\n <div style={{ display: \"grid\", gap: 8 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"fits\")}\n </div>\n {fitting.map((p, i) => (\n <div key={p.instanceID} style={stagger(i)}>\n <PickRow\n instanceID={p.instanceID}\n pieces={pieces}\n onPick={onPick}\n />\n </div>\n ))}\n </div>\n )}\n </Popup>\n );\n}\n\nfunction PickRow({\n instanceID,\n pieces,\n label,\n onPick,\n}: {\n instanceID: string;\n pieces: GearPiece[];\n label?: string;\n onPick: (id: string) => void;\n}): ReactNode {\n const catalog = useCatalog();\n const piece = pieces.find((p) => p.instanceID === instanceID);\n if (!piece) return null;\n return (\n <Card\n onClick={() => onPick(instanceID)}\n style={{ display: \"flex\", alignItems: \"center\", gap: 12, padding: 10 }}\n >\n <ItemTile itemID={piece.itemID} level={piece.level} size={56} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.itemName(piece.itemID)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {label ? `${label} · ` : \"\"}\n {t(\"level\")} {piece.level}\n </div>\n </div>\n </Card>\n );\n}\n"
86
+ },
87
+ {
88
+ "path": "components/StatsPopup.tsx",
89
+ "content": "import { useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Popup,\n ProgressBar,\n ResourceList,\n canAfford,\n errorText,\n outlined,\n useCatalog,\n useSound,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n breakdown,\n resolveStats,\n statCost,\n statMaxLevel,\n statNumber,\n unmetRequirements,\n upgradeAllPlan,\n type CharacterSection,\n type GearPiece,\n type ItemDefWithGear,\n type RosterEntry,\n type StatView,\n} from \"../model\";\nimport { t } from \"../i18n\";\n\nexport function StatsPopup({\n hero,\n section,\n pieces,\n items,\n balances,\n onClose,\n}: {\n hero: RosterEntry;\n section: CharacterSection | undefined;\n pieces: GearPiece[];\n items: Map<string, ItemDefWithGear>;\n balances: Record<string, { Amount?: number }> | undefined;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const play = useSound();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n const [detail, setDetail] = useState<StatView | null>(null);\n const stats = resolveStats(section, hero.def);\n const levels = hero.model?.StatLevels ?? {};\n const worn = Object.values(hero.model?.Equipment ?? {})\n .filter((e) => e?.ItemInstanceID)\n .map((e) => {\n const p = pieces.find((x) => x.instanceID === e!.ItemInstanceID);\n return { def: items.get(e!.ItemID ?? \"\"), level: p?.level ?? 1 };\n });\n\n const upgrade = async (stat: StatView) => {\n setBusy(stat.id);\n const res = await client.character.upgradeStatLevel(hero.id, stat.id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n play(\"levelUp\");\n };\n\n // \"Upgrade all\" (Unity CharacterStatPopup): as many levels as the balances cover, spread over the\n // stats cheapest-first, in ONE atomic batch.\n const plan = upgradeAllPlan(stats, levels, hero.rank, hero.def, balances);\n const upgradeAll = async () => {\n setBusy(\"all\");\n const res = await client.character.upgradeStatLevelsBatch(\n plan.map((p) => ({ CharacterID: hero.id, ...p })),\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n play(\"levelUp\");\n };\n\n return (\n <Popup\n title={`${t(\"stats\")} · ${catalog.localize(hero.def.Identity?.DisplayName ?? hero.id)}`}\n onClose={onClose}\n width={480}\n >\n <div style={{ display: \"grid\", gap: 10 }}>\n <Button\n tone=\"gold\"\n attract={plan.length > 0}\n disabled={plan.length === 0}\n busy={busy === \"all\"}\n onClick={() => void upgradeAll()}\n >\n {t(\"upgradeAll\")}\n {plan.length > 0 ? ` +${plan.reduce((n, p) => n + p.Levels, 0)}` : \"\"}\n </Button>\n {stats.map((stat, i) => {\n const level = Number(levels[stat.id] ?? 0);\n const cap = statMaxLevel(stat, hero.rank, hero.def);\n const capped = cap > 0 && level >= cap;\n const cost = statCost(stat, level + 1);\n const missing = unmetRequirements(stat, levels);\n const affordable = canAfford(cost, balances);\n const total = breakdown(stat, level, hero.rank, hero.def, worn).total;\n return (\n <Card\n key={stat.id}\n style={{ display: \"grid\", gap: 8, ...stagger(i) }}\n >\n <button\n type=\"button\"\n onClick={() => setDetail(stat)}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n background: \"none\",\n border: \"none\",\n padding: 0,\n cursor: \"pointer\",\n color: v.text,\n }}\n >\n <span style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(stat.name)} ⓘ\n </span>\n <span style={{ ...outlined, fontSize: 18, color: v.gold }}>\n {statNumber(total)}\n </span>\n </button>\n {cap > 0 ? (\n <ProgressBar\n value={level}\n max={cap}\n label={`${t(\"level\")} ${level} / ${cap}`}\n height={16}\n />\n ) : null}\n {missing.length > 0 ? (\n <div style={{ ...outlined, fontSize: 12, color: v.red }}>\n {t(\"needs\")}:{\" \"}\n {missing.map((m) => `${m.statID} ${m.level}`).join(\", \")}\n </div>\n ) : capped ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"statCapped\")}\n </div>\n ) : (\n <Button\n size=\"sm\"\n tone=\"green\"\n disabled={!affordable}\n busy={busy === stat.id}\n onClick={() => void upgrade(stat)}\n >\n {t(\"upgrade\")} →{\" \"}\n {statNumber(\n breakdown(stat, level + 1, hero.rank, hero.def, worn).total,\n )}{\" \"}\n <ResourceList\n lines={cost}\n size={16}\n dimIf={() => !affordable}\n />\n </Button>\n )}\n </Card>\n );\n })}\n </div>\n {detail ? (\n <BreakdownPopup\n stat={detail}\n level={Number(levels[detail.id] ?? 0)}\n hero={hero}\n worn={worn}\n onClose={() => setDetail(null)}\n />\n ) : null}\n </Popup>\n );\n}\n\nfunction BreakdownPopup({\n stat,\n level,\n hero,\n worn,\n onClose,\n}: {\n stat: StatView;\n level: number;\n hero: RosterEntry;\n worn: Array<{ def: ItemDefWithGear | undefined; level: number }>;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const b = breakdown(stat, level, hero.rank, hero.def, worn);\n const rows: Array<[string, number, string]> = [\n [t(\"base\"), b.base, v.text],\n [t(\"fromLevel\"), b.fromLevel, v.green],\n [t(\"fromRank\"), b.fromRank, v.blue],\n [t(\"fromGear\"), b.fromGear, v.gold],\n ];\n const round = statNumber;\n return (\n <Popup\n title={`${catalog.localize(stat.name)} — ${t(\"where\")}`}\n onClose={onClose}\n width={380}\n variant=\"center\"\n >\n {stat.description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {catalog.localize(stat.description)}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 6 }}>\n {rows.map(([label, value, color]) => (\n <div\n key={label}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 15,\n }}\n >\n <span style={{ color: v.textDim }}>{label}</span>\n <span style={{ color }}>\n {value === 0 && label !== t(\"base\")\n ? \"—\"\n : `${label === t(\"base\") ? \"\" : \"+\"}${round(value)}`}\n </span>\n </div>\n ))}\n {b.percent ? (\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 15,\n }}\n >\n <span style={{ color: v.textDim }}>{t(\"percent\")}</span>\n <span style={{ color: v.gold }}>+{round(b.percent)}%</span>\n </div>\n ) : null}\n <div style={{ height: 2, background: v.panelEdge, margin: \"4px 0\" }} />\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 18,\n }}\n >\n <span>{t(\"total\")}</span>\n <span style={{ color: v.gold }}>{round(b.total)}</span>\n </div>\n </div>\n </Popup>\n );\n}\n"
90
+ },
91
+ {
92
+ "path": "components/useData.ts",
93
+ "content": "import { useEffect, useState } from \"react\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport { configSection, useCatalog } from \"@idosgames/react/ui\";\nimport {\n gearPieces,\n type CharacterModelView,\n type CharacterSection,\n type InstanceView,\n type ItemDefWithGear,\n} from \"../model\";\n\nexport function useData() {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n // Definitions arrive with the title config; a title whose config did not carry them fetches them\n // once — and the screen redraws when they land (the section cache emits no user-state event).\n const [, loaded] = useState(0);\n const section = configSection<CharacterSection>(client, \"Character\");\n useEffect(() => {\n if (!section)\n void client.character\n .getCharacterDefinitions()\n .then(() => loaded((n) => n + 1));\n // Heroes and gear (with their instances) are in the login state and every reply keeps them\n // fresh. Opening the screen re-checks them against changes made elsewhere (another device) —\n // through the central cache: nothing at all while they were confirmed a minute ago.\n void client.cache.ensureState([\"Character\", \"InventoryV2\"], {\n maxAgeMs: 60_000,\n });\n }, [client, section]);\n const owned = (state?.Character?.Characters ?? {}) as Record<\n string,\n CharacterModelView\n >;\n const inventory = state?.InventoryV2 as\n | {\n UnstackableItems?: Record<string, InstanceView | null>;\n VirtualCurrencies?: Record<string, { Amount?: number }>;\n }\n | undefined;\n const items = catalog.items as Map<string, ItemDefWithGear>;\n // Not memoized: the SDK cache updates UnstackableItems IN PLACE (an equip flips EquippedSlot on\n // the same object), so a memo keyed by the object would keep showing the old state.\n const pieces = gearPieces(inventory?.UnstackableItems, items);\n return {\n client,\n section,\n owned,\n inventory,\n items,\n pieces,\n balances: inventory?.VirtualCurrencies,\n };\n}\n"
66
94
  },
67
95
  {
68
96
  "path": "events.ts",
@@ -82,7 +110,7 @@
82
110
  },
83
111
  {
84
112
  "path": "module.ts",
85
- "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 {\n canAfford,\n configSection,\n itemDefinitions,\n type ItemSection,\n} from \"@idosgames/react/ui\";\nimport { makeCharacterScreen } from \"./CharacterScreen\";\nimport {\n bestLoadout,\n gearPieces,\n hasTitleData,\n heroesBadge,\n resolveLadder,\n resolveSlots,\n roster,\n type CharacterModelView,\n type CharacterSection,\n type InstanceView,\n type ItemDefWithGear,\n} from \"./model\";\nimport { t } from \"./i18n\";\nimport { heroSelected } from \"./events\";\nimport { readSelected, resolveSelected } from \"./selection\";\n\n// Heroes as a feature of the game (the Hero button of the Unity project). The badge counts heroes\n// that can be unlocked or ranked up right now, plus \"better gear is available\". The hero the player\n// plays with goes out as `character:hero-selected@1` — once the roster is known, and on every pick.\nexport const characterModule: Module = defineModule({\n id: \"character\",\n meta: { name: \"Heroes\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n let announced: string | null = null;\n const announce = (characterId: string | undefined) => {\n if (!characterId || characterId === announced) return;\n announced = characterId;\n ctx.events.emit(heroSelected, { characterId });\n };\n\n ctx.features.register({\n id: \"character\",\n label: t(\"title\"),\n icon: \"hero\",\n group: \"economy\",\n order: 20,\n primary: true,\n available: false,\n Screen: makeCharacterScreen(ctx.features, announce),\n });\n ctx.features.setAvailable(\n \"character\",\n hasTitleData(configSection<CharacterSection>(ctx.client, \"Character\")),\n \"no heroes on the title (Character definitions are empty)\",\n );\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeBadgeWatcher(ctx.features, announce),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"character\", selected: announced }),\n describeActions: {},\n });\n },\n});\n\nfunction makeBadgeWatcher(\n features: FeatureRegistry,\n announce: (characterId: string | undefined) => void,\n) {\n return function HeroesBadge() {\n const client = useIDosGamesClient();\n useEffect(() => {\n const update = () => {\n const section = configSection<CharacterSection>(client, \"Character\");\n if (!section) return features.setBadge(\"character\", 0);\n const state = client.data.user.state;\n const owned = (state?.Character?.Characters ?? {}) as Record<\n string,\n CharacterModelView\n >;\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const items = itemDefinitions(\n configSection<ItemSection>(client, \"Item\"),\n ) as Map<string, ItemDefWithGear>;\n const pieces = gearPieces(\n (\n state?.InventoryV2 as\n | { UnstackableItems?: Record<string, InstanceView | null> }\n | undefined\n )?.UnstackableItems,\n items,\n );\n const list = roster(section, owned);\n // The saved hero (or the first playable one) as soon as the roster is known — the game\n // fields it from the first frame. Re-resolved on every update: a hero that just became\n // playable may now be the answer.\n announce(resolveSelected(list, readSelected())?.id);\n const better = list\n .filter((h) => h.state === \"owned\")\n .reduce(\n (n, h) =>\n n +\n bestLoadout(\n h.id,\n h.rank,\n resolveSlots(section, h.def),\n pieces,\n h.model?.Equipment,\n items,\n ).length,\n 0,\n );\n features.setBadge(\n \"character\",\n heroesBadge(\n list,\n (lines) => canAfford(lines, balances),\n (e) => resolveLadder(section, e.def),\n better,\n ),\n );\n };\n // Heroes and gear come with the login state; the central cache asks the server only for what\n // is not there yet (a hero given by default).\n update();\n void client.cache.ensureState([\"Character\"]).then(update);\n return client.on(\"user:anyUpdated\", update);\n }, [client]);\n return createElement(\"span\", { hidden: true });\n };\n}\n"
113
+ "content": "import {\n defineModule,\n type ModuleContext,\n type Module,\n} from \"@idosgames/module-sdk\";\nimport {\n canAfford,\n configSection,\n itemDefinitions,\n type ItemSection,\n} from \"@idosgames/react/ui\";\nimport { makeCharacterScreen } from \"./CharacterScreen\";\nimport {\n bestLoadout,\n gearPieces,\n hasTitleData,\n heroesBadge,\n resolveLadder,\n resolveSlots,\n roster,\n type CharacterModelView,\n type CharacterSection,\n type InstanceView,\n type ItemDefWithGear,\n} from \"./model\";\nimport { t } from \"./i18n\";\nimport { heroSelected } from \"./events\";\nimport { readSelected, resolveSelected } from \"./selection\";\n\n// Heroes as a feature of the game (the Hero button of the Unity project). The badge counts heroes\n// that can be unlocked or ranked up right now, plus \"better gear is available\". The hero the player\n// plays with goes out as `character:hero-selected@1` — once the roster is known, and on every pick.\nexport const characterModule: Module = defineModule({\n id: \"character\",\n meta: { name: \"Heroes\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n let announced: string | null = null;\n const announce = (characterId: string | undefined) => {\n if (!characterId || characterId === announced) return;\n announced = characterId;\n ctx.events.emit(heroSelected, { characterId });\n };\n\n ctx.features.register({\n id: \"character\",\n label: t(\"title\"),\n icon: \"hero\",\n group: \"economy\",\n order: 20,\n primary: true,\n available: false,\n Screen: makeCharacterScreen(ctx.features, announce),\n });\n ctx.features.setAvailable(\n \"character\",\n hasTitleData(configSection<CharacterSection>(ctx.client, \"Character\")),\n \"no heroes on the title (Character definitions are empty)\",\n );\n watchBadge(ctx, announce);\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"character\", selected: announced }),\n describeActions: {},\n });\n },\n});\n\nfunction watchBadge(\n ctx: ModuleContext,\n announce: (characterId: string | undefined) => void,\n): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n const update = () => {\n const section = configSection<CharacterSection>(client, \"Character\");\n if (!section) return features.setBadge(\"character\", 0);\n const state = client.data.user.state;\n const owned = (state?.Character?.Characters ?? {}) as Record<\n string,\n CharacterModelView\n >;\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const items = itemDefinitions(\n configSection<ItemSection>(client, \"Item\"),\n ) as Map<string, ItemDefWithGear>;\n const pieces = gearPieces(\n (\n state?.InventoryV2 as\n | { UnstackableItems?: Record<string, InstanceView | null> }\n | undefined\n )?.UnstackableItems,\n items,\n );\n const list = roster(section, owned);\n // The saved hero (or the first playable one) as soon as the roster is known — the game\n // fields it from the first frame. Re-resolved on every update: a hero that just became\n // playable may now be the answer.\n announce(resolveSelected(list, readSelected())?.id);\n const better = list\n .filter((h) => h.state === \"owned\")\n .reduce(\n (n, h) =>\n n +\n bestLoadout(\n h.id,\n h.rank,\n resolveSlots(section, h.def),\n pieces,\n h.model?.Equipment,\n items,\n ).length,\n 0,\n );\n features.setBadge(\n \"character\",\n heroesBadge(\n list,\n (lines) => canAfford(lines, balances),\n (e) => resolveLadder(section, e.def),\n better,\n ),\n );\n };\n // Heroes and gear come with the login state; the central cache asks the server only for what\n // is not there yet (a hero given by default).\n update();\n void client.cache.ensureState([\"Character\"]).then(update);\n return client.on(\"user:anyUpdated\", update);\n });\n}\n"
86
114
  },
87
115
  {
88
116
  "path": "selection.ts",