@idosgames/mcp 0.1.18 → 0.1.20

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 (55) hide show
  1. package/dist/cli.js +2 -2
  2. package/package.json +1 -1
  3. package/registry/host.json +69 -25
  4. package/registry/index.json +895 -620
  5. package/registry/modules/ai.json +65 -0
  6. package/registry/modules/board-game.json +8 -8
  7. package/registry/modules/boosts.json +66 -0
  8. package/registry/modules/{character-ui.json → character.json} +8 -12
  9. package/registry/modules/chat.json +70 -0
  10. package/registry/modules/collections.json +69 -0
  11. package/registry/modules/coop-events.json +67 -0
  12. package/registry/modules/{craft-ui.json → craft.json} +8 -12
  13. package/registry/modules/{deal-offer-ui.json → deals.json} +7 -11
  14. package/registry/modules/events.json +72 -0
  15. package/registry/modules/{friends-ui.json → friends.json} +11 -14
  16. package/registry/modules/idle-rpg.json +7 -7
  17. package/registry/modules/inventory.json +67 -0
  18. package/registry/modules/invite.json +70 -0
  19. package/registry/modules/{leaderboard-ui.json → leaderboard.json} +8 -12
  20. package/registry/modules/lootboxes.json +67 -0
  21. package/registry/modules/mailbox.json +71 -0
  22. package/registry/modules/marketplace.json +71 -0
  23. package/registry/modules/mini-game.json +69 -0
  24. package/registry/modules/multiplayer.json +74 -0
  25. package/registry/modules/premium.json +67 -0
  26. package/registry/modules/profile.json +70 -0
  27. package/registry/modules/{pvp-ui.json → pvp.json} +9 -13
  28. package/registry/modules/quests.json +69 -0
  29. package/registry/modules/rewards.json +66 -0
  30. package/registry/modules/season.json +71 -0
  31. package/registry/modules/{store-ui.json → store.json} +9 -13
  32. package/registry/modules/{tutorial-ui.json → tutorials.json} +8 -12
  33. package/registry/modules/voxelcraft.json +15 -455
  34. package/registry/modules/workshop.json +10 -6
  35. package/registry/skills/blockchain-system.json +1 -1
  36. package/registry/skills/dev-test-loop.json +1 -1
  37. package/registry/skills/idosgames-compose-modules.json +2 -2
  38. package/registry/skills/idosgames-getting-started.json +2 -2
  39. package/registry/skills/idosgames-module-contract.json +2 -2
  40. package/registry/skills/idosgames-project-structure.json +1 -1
  41. package/registry/skills/idosgames-ui-kit.json +2 -2
  42. package/registry/skills/mailbox-system.json +6 -0
  43. package/registry/skills/multiplayer-realtime.json +2 -2
  44. package/registry/skills/push-notifications.json +1 -1
  45. package/registry/skills/workshop-system.json +1 -1
  46. package/registry/modules/chat-ui.json +0 -74
  47. package/registry/modules/events-ui.json +0 -80
  48. package/registry/modules/game-hud.json +0 -104
  49. package/registry/modules/inventory-ui.json +0 -73
  50. package/registry/modules/marketplace-ui.json +0 -75
  51. package/registry/modules/profile-ui.json +0 -74
  52. package/registry/modules/referral-ui.json +0 -73
  53. package/registry/modules/rewards-ui.json +0 -75
  54. package/registry/modules/season-ui.json +0 -79
  55. package/registry/modules/showcase.json +0 -92
@@ -0,0 +1,65 @@
1
+ {
2
+ "id": "ai",
3
+ "meta": {
4
+ "name": "AI studio",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "catalog": {
9
+ "type": "feature",
10
+ "summary": "AI studio for any game: the title's AI features for players — text, pictures, voice, music, video and 3D — with the price and the history.",
11
+ "description": "A ready AI studio (engine AI, InApp AI) for ANY game, built on the @idosgames/react/ui kit and client.ai. Every AI feature the publisher opened to players (section AI, sanitized by the server: no prompts, no models), grouped by kind — text, picture, voice, music, video, 3D. The player describes what they want, pays the feature's price in in-game resources (or nothing), and the result comes back from a job the server runs (waitForGeneration): text, pictures, a player for sound and video, a download link for 3D. 'My creations' lists the player's generations. A feature tagged 'hidden' is for the game's own code (NPC lines and the like) and is not shown. Registers the 'ai' feature. The money side (publisher credits) is the engine's; see the in-app AI rules.",
12
+ "provides": [
13
+ "AI studio screen",
14
+ "text, image, voice, music, video and 3D generation for players",
15
+ "feature price in in-game resources",
16
+ "history of the player's generations"
17
+ ],
18
+ "tags": [
19
+ "ai",
20
+ "generation",
21
+ "ugc",
22
+ "creative",
23
+ "ui",
24
+ "screen"
25
+ ],
26
+ "author": {
27
+ "name": "iDos Games",
28
+ "url": "https://idosgames.com"
29
+ },
30
+ "version": "0.1.0"
31
+ },
32
+ "dependencies": {
33
+ "@idosgames/core": "0.19.1",
34
+ "@idosgames/module-sdk": "0.7.0",
35
+ "@idosgames/react": "0.3.0",
36
+ "react": "19.2.7"
37
+ },
38
+ "contentHash": "1ac6c1fb5fc8cbe94891f258339e8f27abe7bded84dccc8f4f6bbb6465e2dc35",
39
+ "files": [
40
+ {
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: \"#fff\",\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: \"#fff\", 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
+ },
44
+ {
45
+ "path": "i18n.ts",
46
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"AI studio\",\n create: \"Create\",\n history: \"My creations\",\n describe: \"Describe what you want…\",\n sayWhat: \"Text to say…\",\n generate: \"Create\",\n working: \"Creating…\",\n failed: \"Could not create it. Try again.\",\n download: \"Download the file\",\n noFeatures: \"No AI in this game yet. Add it in LiveOps → AI.\",\n noHistory: \"Nothing created yet.\",\n m_Text: \"text\",\n m_Image: \"picture\",\n m_Audio: \"voice\",\n m_Music: \"music\",\n m_Video: \"video\",\n m_ThreeD: \"3D\",\n s_completed: \"ready\",\n s_failed: \"failed\",\n s_running: \"in progress\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"ИИ-студия\",\n create: \"Создать\",\n history: \"Мои работы\",\n describe: \"Опишите, что хотите получить…\",\n sayWhat: \"Текст, который нужно озвучить…\",\n generate: \"Создать\",\n working: \"Создаём…\",\n failed: \"Не получилось. Попробуйте ещё раз.\",\n download: \"Скачать файл\",\n noFeatures: \"ИИ в этой игре пока нет. Добавьте его в LiveOps → AI.\",\n noHistory: \"Вы ещё ничего не создали.\",\n m_Text: \"текст\",\n m_Image: \"картинка\",\n m_Audio: \"голос\",\n m_Music: \"музыка\",\n m_Video: \"видео\",\n m_ThreeD: \"3D\",\n s_completed: \"готово\",\n s_failed: \"не вышло\",\n s_running: \"в работе\",\n});\n"
47
+ },
48
+ {
49
+ "path": "index.ts",
50
+ "content": "// @idosgames/mod-ai — AI studio for any game: the AI features of the title - text, images, speech, music, video and 3D made by the player, with the price and the history.\n\nexport { aiModule } from \"./module\";\n"
51
+ },
52
+ {
53
+ "path": "model.ts",
54
+ "content": "// Pure helpers of the AI studio screen — covered by model.test.ts. Prompts, models and limits live on\n// the server; the client only sees what it may send and what it costs.\n\nimport { costLines, type ResourceLine } from \"@idosgames/react/ui\";\n\nexport type Modality =\n \"Text\" | \"Image\" | \"Video\" | \"Audio\" | \"Music\" | \"ThreeD\";\n\nexport interface AIFeatureView {\n Modality?: string | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Cost?: unknown } | null\n > | null;\n Tags?: string[] | null;\n MaxPromptChars?: number | null;\n}\n\n/** The title's AI section (sanitized by the server), as far as the studio cares. */\nexport interface AISection {\n Enabled?: boolean | null;\n Features?: Record<string, AIFeatureView | null> | null;\n}\n\nexport interface FeatureRow {\n key: string;\n modality: Modality;\n /** Ways to pay; empty — free for the player. */\n options: Array<{ id: string; price: ResourceLine[] }>;\n maxChars: number;\n}\n\nconst MODALITIES: readonly Modality[] = [\n \"Text\",\n \"Image\",\n \"Audio\",\n \"Music\",\n \"Video\",\n \"ThreeD\",\n];\n\n/** A feature the publisher keeps for the game's own code only (e.g. NPC lines) carries this tag. */\nexport const HIDDEN_TAG = \"hidden\";\n\n/** The features the player can use in the studio, by modality then key. */\nexport function featureRows(\n section: AISection | null | undefined,\n): FeatureRow[] {\n if (section?.Enabled === false) return [];\n return Object.entries(section?.Features ?? {})\n .filter(([, f]) => f && !(f.Tags ?? []).includes(HIDDEN_TAG))\n .map(([key, f]) => ({\n key,\n modality: (MODALITIES as readonly string[]).includes(f!.Modality ?? \"\")\n ? (f!.Modality as Modality)\n : \"Text\",\n options: Object.entries(f!.PriceOptions ?? {})\n .filter(([, o]) => o)\n .map(([oid, o]) => ({\n id: o?.OptionID || oid,\n price: costLines(o?.Cost as never).filter((l) => l.kind !== \"other\"),\n }))\n .filter((o) => o.price.length > 0),\n maxChars: Math.max(0, Number(f!.MaxPromptChars ?? 0)),\n }))\n .sort(\n (a, b) =>\n MODALITIES.indexOf(a.modality) - MODALITIES.indexOf(b.modality) ||\n a.key.localeCompare(b.key),\n );\n}\n\n/** What a finished generation shows: its text, pictures, sound or video, or a file to download. */\nexport type ResultKind = \"text\" | \"image\" | \"audio\" | \"video\" | \"file\";\nexport function resultKind(contentType: string | null | undefined): ResultKind {\n const ct = (contentType ?? \"\").toLowerCase();\n if (ct.startsWith(\"image/\")) return \"image\";\n if (ct.startsWith(\"audio/\")) return \"audio\";\n if (ct.startsWith(\"video/\")) return \"video\";\n return \"file\";\n}\n\n/** Whether the title has AI features for the player — until it does, the lobby hides the tab. */\nexport function hasTitleData(section: AISection | undefined): boolean {\n return featureRows(section).length > 0;\n}\n"
55
+ },
56
+ {
57
+ "path": "module.ts",
58
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { AiScreen } from \"./AiScreen\";\nimport { hasTitleData, type AISection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The AI studio as a feature of the game: the AI features the publisher opened to players. No badge\n// and no startup request — the features come with the title config (section \"AI\").\nexport const aiModule: Module = defineModule({\n id: \"ai\",\n meta: { name: \"AI studio\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"ai\",\n label: t(\"title\"),\n icon: \"bulb\",\n group: \"social\",\n order: 80,\n available: false,\n Screen: AiScreen,\n });\n ctx.features.setAvailable(\n \"ai\",\n hasTitleData(configSection<AISection>(ctx.client, \"AI\")),\n \"no AI features for players on the title (AI.Features, none untagged 'hidden')\",\n );\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"ai\" }),\n describeActions: {},\n });\n },\n});\n"
59
+ },
60
+ {
61
+ "path": "module.meta.json",
62
+ "content": "{\n \"id\": \"ai\",\n \"type\": \"feature\",\n \"summary\": \"AI studio for any game: the title's AI features for players — text, pictures, voice, music, video and 3D — with the price and the history.\",\n \"description\": \"A ready AI studio (engine AI, InApp AI) for ANY game, built on the @idosgames/react/ui kit and client.ai. Every AI feature the publisher opened to players (section AI, sanitized by the server: no prompts, no models), grouped by kind — text, picture, voice, music, video, 3D. The player describes what they want, pays the feature's price in in-game resources (or nothing), and the result comes back from a job the server runs (waitForGeneration): text, pictures, a player for sound and video, a download link for 3D. 'My creations' lists the player's generations. A feature tagged 'hidden' is for the game's own code (NPC lines and the like) and is not shown. Registers the 'ai' feature. The money side (publisher credits) is the engine's; see the in-app AI rules.\",\n \"provides\": [\n \"AI studio screen\",\n \"text, image, voice, music, video and 3D generation for players\",\n \"feature price in in-game resources\",\n \"history of the player's generations\"\n ],\n \"tags\": [\"ai\", \"generation\", \"ugc\", \"creative\", \"ui\", \"screen\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
63
+ }
64
+ ]
65
+ }
@@ -45,21 +45,21 @@
45
45
  }
46
46
  },
47
47
  "dependencies": {
48
- "@idosgames/core": "0.19.0",
49
- "@idosgames/module-sdk": "0.5.0",
48
+ "@idosgames/core": "0.19.1",
49
+ "@idosgames/module-sdk": "0.7.0",
50
50
  "@idosgames/react": "0.3.0",
51
- "@idosgames/wallet": "0.5.1",
51
+ "@idosgames/wallet": "0.6.0",
52
52
  "@tanstack/react-query": "5.101.2",
53
53
  "react": "19.2.7",
54
54
  "three": "0.185.1",
55
55
  "viem": "2.55.2",
56
56
  "wagmi": "3.7.2"
57
57
  },
58
- "contentHash": "a7a360f0ecb5106bb5c5565d06b13845a8f1838bdb475d626f33ca89c73cec29",
58
+ "contentHash": "d7dea73b22c207167521b0867e4137eee7dbf761c5de9a45ca154beb562e5ea5",
59
59
  "files": [
60
60
  {
61
61
  "path": "chrome.ts",
62
- "content": "// Which shared chrome the board draws itself, and a tiny store for news from other modules.\n//\n// In a mix with a shared-UI provider (e.g. game-hud) the board hides its wallet button, currency\n// chips and status line — `setup()` asks `ctx.sharedUi.shouldDraw(role)` once and closes over the\n// answer. Stage, tile and cycle chips are the board's own genre UI and always stay. Installed alone,\n// nobody takes a role and the board is the complete game it always was.\n\nexport interface BoardChrome {\n /** The \"💰 Wallet\" button and its dropdown (role \"wallet\"). */\n wallet: boolean;\n /** Currency chips in the board HUD (role \"currency-bar\"). */\n balances: boolean;\n /** The status line under the action panel (role \"status\"). */\n status: boolean;\n}\n\n/** Everything drawn — the standalone board. */\nexport const FULL_CHROME: BoardChrome = {\n wallet: true,\n balances: true,\n status: true,\n};\n\n/**\n * The latest news line from another module (e.g. \"Idle RPG hero reached Lv 5\"). A store, not React\n * state: the event arrives through `ctx.events` in `setup()` — possibly while the board's panel is\n * unmounted because another mode is active — and the panel reads it whenever it shows.\n */\nexport interface NewsStore {\n subscribe(listener: () => void): () => void;\n get(): string | null;\n push(text: string): void;\n clear(): void;\n}\n\nexport function createNewsStore(): NewsStore {\n let current: string | null = null;\n const listeners = new Set<() => void>();\n const set = (next: string | null): void => {\n current = next;\n for (const listener of [...listeners]) listener();\n };\n return {\n subscribe(listener) {\n listeners.add(listener);\n return () => listeners.delete(listener);\n },\n get: () => current,\n push: (text) => set(text),\n clear: () => set(null),\n };\n}\n"
62
+ "content": "// Which shared chrome the board draws itself, and a tiny store for news from other modules.\n//\n// Next to the base (its lobby draws the balances and the wallet) the board hides its wallet button and\n// currency chips — `setup()` asks `ctx.sharedUi.shouldDraw(role)` once and closes over the\n// answer. Stage, tile and cycle chips are the board's own genre UI and always stay. Installed without\n// a base, nobody takes a role and the board is the complete game it always was.\n\nexport interface BoardChrome {\n /** The \"💰 Wallet\" button and its dropdown (role \"wallet\"). */\n wallet: boolean;\n /** Currency chips in the board HUD (role \"currency-bar\"). */\n balances: boolean;\n /** The status line under the action panel (role \"status\"). */\n status: boolean;\n}\n\n/** Everything drawn — the standalone board. */\nexport const FULL_CHROME: BoardChrome = {\n wallet: true,\n balances: true,\n status: true,\n};\n\n/**\n * The latest news line from another module (e.g. \"Idle RPG hero reached Lv 5\"). A store, not React\n * state: the event arrives through `ctx.events` in `setup()` — possibly while the board's panel is\n * unmounted because another mode is active — and the panel reads it whenever it shows.\n */\nexport interface NewsStore {\n subscribe(listener: () => void): () => void;\n get(): string | null;\n push(text: string): void;\n clear(): void;\n}\n\nexport function createNewsStore(): NewsStore {\n let current: string | null = null;\n const listeners = new Set<() => void>();\n const set = (next: string | null): void => {\n current = next;\n for (const listener of [...listeners]) listener();\n };\n return {\n subscribe(listener) {\n listeners.add(listener);\n return () => listeners.delete(listener);\n },\n get: () => current,\n push: (text) => set(text),\n clear: () => set(null),\n };\n}\n"
63
63
  },
64
64
  {
65
65
  "path": "components/AttackPanel.tsx",
@@ -71,7 +71,7 @@
71
71
  },
72
72
  {
73
73
  "path": "components/BoardRoot.tsx",
74
- "content": "import { useEffect, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { useIDosGamesClient, useBoardController } from \"../react/context\";\nimport { FULL_CHROME, type BoardChrome, type NewsStore } from \"../chrome\";\nimport {\n readBoardConfig,\n resolveBuildingCount,\n resolveTiles,\n} from \"../data/boardConfig\";\nimport { BoardHud } from \"./BoardHud\";\nimport { InteractionPanel } from \"./InteractionPanel\";\nimport { StatusBar } from \"./StatusBar\";\nimport { WalletPanel } from \"./WalletPanel\";\n\nconst overlay: CSSProperties = {\n position: \"absolute\",\n inset: 0,\n display: \"flex\",\n flexDirection: \"column\",\n justifyContent: \"space-between\",\n boxSizing: \"border-box\",\n padding: 16,\n fontFamily: \"system-ui, sans-serif\",\n color: \"#fff\",\n pointerEvents: \"none\",\n};\nconst footer: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 10,\n alignItems: \"center\",\n};\n\nconst walletLauncher: CSSProperties = {\n position: \"absolute\",\n top: 16,\n right: 16,\n pointerEvents: \"auto\",\n background: \"#0d66fe\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 10,\n padding: \"8px 14px\",\n fontWeight: 700,\n cursor: \"pointer\",\n};\nconst walletDock: CSSProperties = {\n position: \"absolute\",\n top: 60,\n right: 16,\n pointerEvents: \"none\",\n};\n\nexport function BoardRoot({\n chrome = FULL_CHROME,\n news,\n}: {\n /** Which shared chrome to draw here (the rest is drawn by a shared-UI provider such as game-hud). */\n chrome?: BoardChrome;\n /** News from other modules, shown as a chip in the board HUD. */\n news?: NewsStore;\n} = {}): ReactNode {\n const client = useIDosGamesClient();\n const controller = useBoardController();\n const [ready, setReady] = useState(false);\n const [walletOpen, setWalletOpen] = useState(false);\n\n useEffect(() => {\n let active = true;\n void (async () => {\n await client.gameLoop.getBoardDefinition();\n await client.gameLoop.getUserBoardState();\n if (!active) return;\n const cfg = readBoardConfig(client);\n const board = client.data.user.state?.GameLoop?.Board;\n const stage = board?.StageLevel ?? 1;\n controller.loadBoard(\n resolveTiles(cfg, stage),\n resolveBuildingCount(cfg, stage),\n );\n controller.setPosition(board?.Position ?? 0);\n controller.setBuildingLevels(\n (board?.BuildingStates ?? []).map((b) => b.Level ?? 0),\n );\n setReady(true);\n })();\n return () => {\n active = false;\n };\n }, [client, controller]);\n\n // Keep city pad heights in sync with building levels as the cache updates.\n useEffect(() => {\n const off = client.on(\"user:anyUpdated\", () => {\n const board = client.data.user.state?.GameLoop?.Board;\n controller.setBuildingLevels(\n (board?.BuildingStates ?? []).map((b) => b.Level ?? 0),\n );\n });\n return off;\n }, [client, controller]);\n\n // No StatusProvider of our own: the host wraps every module in ONE root provider, and a nested one\n // would swallow the board's messages — the shared status line (game-hud) would never see them.\n return (\n <div style={overlay}>\n {chrome.wallet && (\n <button\n type=\"button\"\n style={walletLauncher}\n onClick={() => setWalletOpen((v) => !v)}\n >\n 💰 Wallet\n </button>\n )}\n {chrome.wallet && walletOpen && (\n <div style={walletDock}>\n <WalletPanel onClose={() => setWalletOpen(false)} />\n </div>\n )}\n <BoardHud showBalances={chrome.balances} news={news} />\n <div style={footer}>\n {ready ? (\n <InteractionPanel />\n ) : (\n <div style={{ opacity: 0.6, pointerEvents: \"auto\" }}>\n Loading board…\n </div>\n )}\n {chrome.status && <StatusBar />}\n </div>\n </div>\n );\n}\n"
74
+ "content": "import { useEffect, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { useIDosGamesClient, useBoardController } from \"../react/context\";\nimport { FULL_CHROME, type BoardChrome, type NewsStore } from \"../chrome\";\nimport {\n readBoardConfig,\n resolveBuildingCount,\n resolveTiles,\n} from \"../data/boardConfig\";\nimport { BoardHud } from \"./BoardHud\";\nimport { InteractionPanel } from \"./InteractionPanel\";\nimport { StatusBar } from \"./StatusBar\";\nimport { WalletPanel } from \"./WalletPanel\";\n\nconst overlay: CSSProperties = {\n position: \"absolute\",\n inset: 0,\n display: \"flex\",\n flexDirection: \"column\",\n justifyContent: \"space-between\",\n boxSizing: \"border-box\",\n padding: 16,\n fontFamily: \"system-ui, sans-serif\",\n color: \"#fff\",\n pointerEvents: \"none\",\n};\nconst footer: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 10,\n alignItems: \"center\",\n};\n\nconst walletLauncher: CSSProperties = {\n position: \"absolute\",\n top: 16,\n right: 16,\n pointerEvents: \"auto\",\n background: \"#0d66fe\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 10,\n padding: \"8px 14px\",\n fontWeight: 700,\n cursor: \"pointer\",\n};\nconst walletDock: CSSProperties = {\n position: \"absolute\",\n top: 60,\n right: 16,\n pointerEvents: \"none\",\n};\n\nexport function BoardRoot({\n chrome = FULL_CHROME,\n news,\n}: {\n /** Which shared chrome to draw here (the rest is drawn by a shared-UI provider such as the base). */\n chrome?: BoardChrome;\n /** News from other modules, shown as a chip in the board HUD. */\n news?: NewsStore;\n} = {}): ReactNode {\n const client = useIDosGamesClient();\n const controller = useBoardController();\n const [ready, setReady] = useState(false);\n const [walletOpen, setWalletOpen] = useState(false);\n\n useEffect(() => {\n let active = true;\n void (async () => {\n await client.gameLoop.getBoardDefinition();\n await client.gameLoop.getUserBoardState();\n if (!active) return;\n const cfg = readBoardConfig(client);\n const board = client.data.user.state?.GameLoop?.Board;\n const stage = board?.StageLevel ?? 1;\n controller.loadBoard(\n resolveTiles(cfg, stage),\n resolveBuildingCount(cfg, stage),\n );\n controller.setPosition(board?.Position ?? 0);\n controller.setBuildingLevels(\n (board?.BuildingStates ?? []).map((b) => b.Level ?? 0),\n );\n setReady(true);\n })();\n return () => {\n active = false;\n };\n }, [client, controller]);\n\n // Keep city pad heights in sync with building levels as the cache updates.\n useEffect(() => {\n const off = client.on(\"user:anyUpdated\", () => {\n const board = client.data.user.state?.GameLoop?.Board;\n controller.setBuildingLevels(\n (board?.BuildingStates ?? []).map((b) => b.Level ?? 0),\n );\n });\n return off;\n }, [client, controller]);\n\n // No StatusProvider of our own: the host wraps every module in ONE root provider, and a nested one\n // would swallow the board's messages — a shared status line would never see them.\n return (\n <div style={overlay}>\n {chrome.wallet && (\n <button\n type=\"button\"\n style={walletLauncher}\n onClick={() => setWalletOpen((v) => !v)}\n >\n 💰 Wallet\n </button>\n )}\n {chrome.wallet && walletOpen && (\n <div style={walletDock}>\n <WalletPanel onClose={() => setWalletOpen(false)} />\n </div>\n )}\n <BoardHud showBalances={chrome.balances} news={news} />\n <div style={footer}>\n {ready ? (\n <InteractionPanel />\n ) : (\n <div style={{ opacity: 0.6, pointerEvents: \"auto\" }}>\n Loading board…\n </div>\n )}\n {chrome.status && <StatusBar />}\n </div>\n </div>\n );\n}\n"
75
75
  },
76
76
  {
77
77
  "path": "components/BuildPanel.tsx",
@@ -119,7 +119,7 @@
119
119
  },
120
120
  {
121
121
  "path": "env.ts",
122
- "content": "// Runtime config the module reads from the page it runs in: the __IDOS_ENV__ global the host's\n// bundler defines (see the host template's vite.config). A module copied into a project runs in\n// that same page, so it is available; when absent (e.g. the classic preview bundler), the default\n// applies.\n//\n// The TITLE is deliberately NOT here. It comes from the host — `client.titleID`, or `ctx.titleId`\n// in a module's setup(). A module that re-derived the title from the URL could disagree with its\n// host, which is how a build ends up reading one title's data and writing another's.\n\ntype IdosEnv = {\n WALLETCONNECT_PROJECT_ID?: string;\n};\n\ndeclare const __IDOS_ENV__: IdosEnv | undefined;\n\nconst env: IdosEnv =\n typeof __IDOS_ENV__ !== \"undefined\" && __IDOS_ENV__ ? __IDOS_ENV__ : {};\n\nexport const ENV_WALLETCONNECT_PROJECT_ID = env.WALLETCONNECT_PROJECT_ID ?? \"\";\n"
122
+ "content": "// Build-time config the module reads the standard Vite way: VITE_* variables of the project's\n// .env.local via `import.meta.env`. Unset (as in the platform's editor), the default applies. Read\n// through a cast: a module is copied into projects that may not carry Vite's client types.\n//\n// The TITLE is deliberately NOT here. It comes from the host — `client.titleID`, or `ctx.titleId`\n// in a module's setup(). A module that re-derived the title from the URL could disagree with its\n// host, which is how a build ends up reading one title's data and writing another's.\n\nconst env: Record<string, string | undefined> =\n (import.meta as unknown as { env?: Record<string, string | undefined> })\n .env ?? {};\n\nexport const ENV_WALLETCONNECT_PROJECT_ID: string =\n env.VITE_WALLETCONNECT_PROJECT_ID ?? \"\";\n"
123
123
  },
124
124
  {
125
125
  "path": "events.ts",
@@ -135,7 +135,7 @@
135
135
  },
136
136
  {
137
137
  "path": "module.ts",
138
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { BoardController } from \"./game/BoardController\";\nimport { createControllerBox } from \"./controller-box\";\nimport { createBoardScene } from \"./scene\";\nimport { makeBoardRootPanel } from \"./RootPanel\";\nimport { createNewsStore, type BoardChrome } from \"./chrome\";\nimport { idleCharacterUpgraded } from \"./events\";\n\n// The Board Game feature module. `setup` wires the Three.js scene and the board's overlay UI to one\n// shared controller and registers a nav entry. No app shell, no client creation, no login.\nexport const boardGameModule: Module = defineModule({\n id: \"board-game\",\n meta: {\n name: \"Board Game\",\n type: \"game\",\n genre: \"board\",\n engine: \"three\",\n },\n setup(ctx) {\n const box = createControllerBox<BoardController>();\n\n // Shared chrome: draw our own copy only while no other module (e.g. game-hud) took the role.\n const chrome: BoardChrome = {\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n balances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n };\n\n // Optional listen: news from Idle RPG, if it is installed. Subscribed here, not in a panel —\n // the board's panel is unmounted while the player is in the Idle RPG mode, exactly when it fires.\n const news = createNewsStore();\n ctx.events.on(idleCharacterUpgraded, ({ level }) =>\n news.push(`⚔️ Idle RPG hero reached Lv ${level}`),\n );\n\n ctx.registerScene(createBoardScene(box));\n ctx.registerPanel({\n id: \"root\",\n slot: \"overlay\",\n component: makeBoardRootPanel(box, chrome, news),\n });\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" });\n\n // Поверхность для агента. Здесь она НАМЕРЕННО тонкая: игровой интерфейс этого модуля —\n // обычный React в DOM, агент его и так читает деревом и нажимает кнопки как человек.\n // Дублировать эти же действия в actions значило бы завести второй способ играть, который\n // разъедется с настоящим. Из canvas'а агенту не видно только состояние сцены — его и отдаём.\n ctx.exposeToAgent({\n state: () => {\n const controller = box.get();\n return controller\n ? {\n mounted: true,\n scene: controller.describeScene(),\n ui: \"dom\",\n chrome,\n news: news.get(),\n }\n : { mounted: false, chrome };\n },\n describeActions: {},\n });\n },\n});\n"
138
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { BoardController } from \"./game/BoardController\";\nimport { createControllerBox } from \"./controller-box\";\nimport { createBoardScene } from \"./scene\";\nimport { makeBoardRootPanel } from \"./RootPanel\";\nimport { createNewsStore, type BoardChrome } from \"./chrome\";\nimport { idleCharacterUpgraded } from \"./events\";\n\n// The Board Game feature module. `setup` wires the Three.js scene and the board's overlay UI to one\n// shared controller and registers a nav entry. No app shell, no client creation, no login.\nexport const boardGameModule: Module = defineModule({\n id: \"board-game\",\n meta: {\n name: \"Board Game\",\n type: \"game\",\n genre: \"board\",\n engine: \"three\",\n },\n setup(ctx) {\n const box = createControllerBox<BoardController>();\n\n // Shared chrome: draw our own copy only while no other module (the base: the lobby draws them) took the role.\n const chrome: BoardChrome = {\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n balances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n };\n\n // Optional listen: news from Idle RPG, if it is installed. Subscribed here, not in a panel —\n // the board's panel is unmounted while the player is in the Idle RPG mode, exactly when it fires.\n const news = createNewsStore();\n ctx.events.on(idleCharacterUpgraded, ({ level }) =>\n news.push(`⚔️ Idle RPG hero reached Lv ${level}`),\n );\n\n ctx.registerScene(createBoardScene(box));\n ctx.registerPanel({\n id: \"root\",\n slot: \"overlay\",\n component: makeBoardRootPanel(box, chrome, news),\n });\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" });\n\n // Поверхность для агента. Здесь она НАМЕРЕННО тонкая: игровой интерфейс этого модуля —\n // обычный React в DOM, агент его и так читает деревом и нажимает кнопки как человек.\n // Дублировать эти же действия в actions значило бы завести второй способ играть, который\n // разъедется с настоящим. Из canvas'а агенту не видно только состояние сцены — его и отдаём.\n ctx.exposeToAgent({\n state: () => {\n const controller = box.get();\n return controller\n ? {\n mounted: true,\n scene: controller.describeScene(),\n ui: \"dom\",\n chrome,\n news: news.get(),\n }\n : { mounted: false, chrome };\n },\n describeActions: {},\n });\n },\n});\n"
139
139
  },
140
140
  {
141
141
  "path": "react/context.tsx",
@@ -0,0 +1,66 @@
1
+ {
2
+ "id": "boosts",
3
+ "meta": {
4
+ "name": "Boosts",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "catalog": {
9
+ "type": "feature",
10
+ "summary": "Boosts for any game: timed boosts to activate (×2 rewards and the like), what runs now with timers, and the title's global happy hours.",
11
+ "description": "A ready boosts screen for ANY game, built on the @idosgames/react/ui kit and client.timedBoost. 'Active now': the player's own boosts with their timers or uses left, and the title's global windows (a scheduled happy hour, a phase of a boost chain) marked 'for everyone'. 'Boosts': every boost of the title with its effect as the server applies it (×2, +50%, +10 — to all rewards, quest rewards, board rewards or costs), its duration and uses, activated for free or for currencies/items. The server applies every boost; the screen only shows. Registers the 'boosts' feature. Ships title-seed.json: a free welcome boost and a paid double-rewards boost.",
12
+ "provides": [
13
+ "boosts screen",
14
+ "activate a boost for free or for currency",
15
+ "running boosts with timers",
16
+ "global happy hours"
17
+ ],
18
+ "tags": [
19
+ "boosts",
20
+ "multiplier",
21
+ "happy-hour",
22
+ "monetization",
23
+ "ui",
24
+ "screen",
25
+ "economy"
26
+ ],
27
+ "author": {
28
+ "name": "iDos Games",
29
+ "url": "https://idosgames.com"
30
+ },
31
+ "version": "0.1.0"
32
+ },
33
+ "dependencies": {
34
+ "@idosgames/core": "0.19.1",
35
+ "@idosgames/module-sdk": "0.7.0",
36
+ "@idosgames/react": "0.3.0",
37
+ "react": "19.2.7"
38
+ },
39
+ "contentHash": "1c1a852a29a89982e2ed997b1a2356bc4722d00eb4db834ab7472f66f9a8eee3",
40
+ "files": [
41
+ {
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 {row.free ? (\n <Button\n tone=\"green\"\n attract\n busy={busy === \"free\"}\n onClick={() => void activate(undefined)}\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`boost:${row.boostID}`}\n >\n {t(\"activate\")}\n </Button>\n ) : (\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={`boost:${row.boostID}`}\n >\n <ResourceList\n lines={o.price}\n size={18}\n dimIf={() => !affordable}\n />\n </Button>\n );\n })\n )}\n </Card>\n </div>\n );\n}\n"
44
+ },
45
+ {
46
+ "path": "i18n.ts",
47
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Boosts\",\n running: \"Active now\",\n boosts: \"Boosts\",\n forEveryone: \"for everyone\",\n endsIn: \"Ends in\",\n charges: \"Uses left\",\n hours: \"h\",\n minutes: \"min\",\n activate: \"Activate\",\n activated: \"Boost activated!\",\n target_all: \"to all rewards\",\n target_quest: \"to quest rewards\",\n target_board: \"to board rewards\",\n target_cost: \"to costs\",\n noBoosts: \"No boosts in this game yet. Add them in LiveOps → Timed Boosts.\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Бустеры\",\n running: \"Действуют сейчас\",\n boosts: \"Бустеры\",\n forEveryone: \"для всех\",\n endsIn: \"До конца\",\n charges: \"Осталось использований\",\n hours: \"ч\",\n minutes: \"мин\",\n activate: \"Включить\",\n activated: \"Бустер включён!\",\n target_all: \"ко всем наградам\",\n target_quest: \"к наградам заданий\",\n target_board: \"к наградам на поле\",\n target_cost: \"к ценам\",\n noBoosts:\n \"Бустеров в этой игре пока нет. Добавьте их в LiveOps → Timed Boosts.\",\n});\n"
48
+ },
49
+ {
50
+ "path": "index.ts",
51
+ "content": "// @idosgames/mod-boosts — Boosts for any game: timed boosts to activate (x2 rewards and the like), the ones running with their timers and the global happy hours of the title.\n\nexport { boostsModule } from \"./module\";\n"
52
+ },
53
+ {
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}\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 options = priced\n .map(([oid, o]) => ({\n id: o?.OptionID || oid,\n price: costLines(o?.Cost as never).filter((l) => l.kind !== \"other\"),\n }))\n .filter((o) => o.price.length > 0);\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,\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"
56
+ },
57
+ {
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 ctx.features.setAvailable(\n \"boosts\",\n hasTitleData(configSection<BoostSection>(ctx.client, \"TimedBoost\")),\n \"no boosts on the title (TimedBoost)\",\n );\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"boosts\" }),\n describeActions: {},\n });\n },\n});\n"
60
+ },
61
+ {
62
+ "path": "module.meta.json",
63
+ "content": "{\n \"id\": \"boosts\",\n \"type\": \"feature\",\n \"summary\": \"Boosts for any game: timed boosts to activate (×2 rewards and the like), what runs now with timers, and the title's global happy hours.\",\n \"description\": \"A ready boosts screen for ANY game, built on the @idosgames/react/ui kit and client.timedBoost. 'Active now': the player's own boosts with their timers or uses left, and the title's global windows (a scheduled happy hour, a phase of a boost chain) marked 'for everyone'. 'Boosts': every boost of the title with its effect as the server applies it (×2, +50%, +10 — to all rewards, quest rewards, board rewards or costs), its duration and uses, activated for free or for currencies/items. The server applies every boost; the screen only shows. Registers the 'boosts' feature. Ships title-seed.json: a free welcome boost and a paid double-rewards boost.\",\n \"provides\": [\n \"boosts screen\",\n \"activate a boost for free or for currency\",\n \"running boosts with timers\",\n \"global happy hours\"\n ],\n \"tags\": [\n \"boosts\",\n \"multiplier\",\n \"happy-hour\",\n \"monetization\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
64
+ }
65
+ ]
66
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
- "id": "character-ui",
2
+ "id": "character",
3
3
  "meta": {
4
4
  "name": "Heroes",
5
5
  "type": "app",
@@ -39,12 +39,12 @@
39
39
  "version": "0.1.0"
40
40
  },
41
41
  "dependencies": {
42
- "@idosgames/core": "0.19.0",
43
- "@idosgames/module-sdk": "0.5.0",
42
+ "@idosgames/core": "0.19.1",
43
+ "@idosgames/module-sdk": "0.7.0",
44
44
  "@idosgames/react": "0.3.0",
45
45
  "react": "19.2.7"
46
46
  },
47
- "contentHash": "25261212eb2e582d0b2cd403ac1fe6406f2b76a5681d7cda1b99708ae136e25f",
47
+ "contentHash": "aff7a5b3c7b4970cd421c4e01932eea8123f1a91d3cfddd058e92660c0083377",
48
48
  "files": [
49
49
  {
50
50
  "path": "CharacterScreen.tsx",
@@ -56,23 +56,19 @@
56
56
  },
57
57
  {
58
58
  "path": "index.ts",
59
- "content": "// @idosgames/mod-character-ui — Heroes screen for any game: the hero roster, ranks and stats with their costs, equipment slots with the best-gear button, item levels and skins.\n\nexport { characterUiModule } from \"./module\";\n"
60
- },
61
- {
62
- "path": "model.test.ts",
63
- "content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport { itemDefinitions } from \"@idosgames/react/ui\";\nimport {\n bestLoadout,\n breakdown,\n fitProblem,\n gearPieces,\n heroesBadge,\n itemLevelCost,\n mergeByKey,\n rankCost,\n resolveLadder,\n resolveSlots,\n resolveStats,\n roster,\n statCost,\n statMaxLevel,\n statValue,\n unmetRequirements,\n type CharacterSection,\n type ItemDefWithGear,\n} from \"./model\";\n\n// The numbers the heroes screen shows before the player taps, checked against the module's own demo\n// heroes (title-seed.json, played through for real by the engine's ShowcaseHeroesTests). The gear\n// lives in the showcase's shared Item section — a small copy of it is inlined here, because a module\n// is copied into a project on its own and cannot read a sibling's seed.\n\nconst section = seed.Character as unknown as CharacterSection;\nconst items = itemDefinitions({\n Catalogs: {\n Item: {\n Items: {\n iron_sword: {\n ItemID: \"iron_sword\",\n ItemClass: \"Weapon\",\n IsStackable: false,\n Metadata: { RarityID: \"Rare\" },\n Tags: [\"weapon\"],\n Equipment: { AllowedSlotIDs: [\"Weapon\"] },\n Stats: { FlatBonuses: { Attack: 8 }, Power: 20 },\n Upgrade: {\n MaxLevel: 5,\n PriceOptions: {\n gold: {\n Cost: {\n Standard: {\n Entries: [\n { Type: \"VirtualCurrency\", CurrencyID: \"CO\", Amount: 60 },\n ],\n },\n },\n },\n },\n CostCurve: { Shape: \"PerStepRate\", PerStepRate: 0.5 },\n FlatBonusCurve: { Shape: \"PerStepRate\", PerStepRate: 0.2 },\n PowerCurve: { Shape: \"PerStepRate\", PerStepRate: 0.2 },\n },\n },\n golden_crown: {\n ItemID: \"golden_crown\",\n ItemClass: \"Helmet\",\n IsStackable: false,\n Metadata: { RarityID: \"Legendary\" },\n Tags: [\"crown\"],\n Equipment: { AllowedSlotIDs: [\"Head\"] },\n Stats: { FlatBonuses: { Health: 40 }, Power: 60 },\n },\n },\n },\n },\n} as never) as Map<string, ItemDefWithGear>;\n\ndescribe(\"roster\", () => {\n it(\"the knight is open, archer and mage are for gems, in roster order\", () => {\n const list = roster(section, {});\n expect(list.map((h) => [h.id, h.state])).toEqual([\n [\"Knight\", \"available\"],\n [\"Archer\", \"locked\"],\n [\"Mage\", \"locked\"],\n ]);\n expect(list[1]?.unlockPrice).toEqual([\n { kind: \"currency\", id: \"GE\", amount: 20 },\n ]);\n expect(roster(section, { Archer: { Level: 1, Power: 5 } })[1]?.state).toBe(\n \"owned\",\n );\n });\n});\n\ndescribe(\"stats and ranks\", () => {\n const knight = section.Definitions!.Knight!;\n const stats = resolveStats(section, knight);\n const attack = stats.find((s) => s.id === \"Attack\")!;\n\n it(\"reads the knight's stats with their prices\", () => {\n expect(stats.map((s) => s.id)).toEqual([\"Attack\", \"Defense\", \"Health\"]);\n expect(statCost(attack, 1)).toEqual([\n { kind: \"currency\", id: \"CO\", amount: 50 },\n ]);\n expect(statCost(attack, 2)[0]?.amount).toBe(75);\n });\n\n it(\"values grow with the stat level and the rank; the cap grows with the rank\", () => {\n expect(statValue(attack, 0, 1, knight)).toBe(12);\n expect(statValue(attack, 1, 1, knight)).toBeGreaterThan(12);\n expect(statValue(attack, 0, 2, knight)).toBeGreaterThan(\n statValue(attack, 0, 1, knight),\n );\n expect(statMaxLevel(attack, 1, knight)).toBe(5);\n expect(statMaxLevel(attack, 2, knight)).toBe(10);\n });\n\n it(\"rank 1 → 2 is the first paid rank, the ceiling is 5\", () => {\n const ladder = resolveLadder(section, knight);\n expect(rankCost(ladder, 1)).toEqual([\n { kind: \"currency\", id: \"CO\", amount: 200 },\n ]);\n expect(rankCost(ladder, 2)?.[0]?.amount).toBe(320);\n expect(rankCost(ladder, 5)).toBeNull();\n expect(rankCost(null, 1)).toBeNull();\n });\n\n it(\"the archer's crit needs attack first\", () => {\n const crit = resolveStats(section, section.Definitions!.Archer!).find(\n (s) => s.id === \"Crit\",\n )!;\n expect(unmetRequirements(crit, {})).toEqual([\n { statID: \"Attack\", level: 1 },\n ]);\n expect(unmetRequirements(crit, { Attack: 1 })).toEqual([]);\n });\n\n it(\"presets merge under the inline block, Remove drops keys\", () => {\n expect(mergeByKey({ a: 1, b: 2 }, { b: 3, c: 4 }, [\"a\"])).toEqual({\n b: 3,\n c: 4,\n });\n expect(mergeByKey(null, { x: 1 }, null)).toEqual({ x: 1 });\n });\n});\n\ndescribe(\"equipment\", () => {\n const knight = section.Definitions!.Knight!;\n const slots = resolveSlots(section, knight);\n const pieces = gearPieces(\n {\n s1: { ItemID: \"iron_sword\", Level: 1 },\n c1: { ItemID: \"golden_crown\", Level: 1 },\n p1: { ItemID: \"potion\" },\n },\n items,\n );\n\n it(\"slots of the knight; the head opens at rank 2\", () => {\n expect(slots.map((s) => [s.id, s.minRank])).toEqual([\n [\"Weapon\", 0],\n [\"Offhand\", 0],\n [\"Head\", 2],\n ]);\n expect(pieces.map((p) => p.itemID)).toEqual([\"iron_sword\", \"golden_crown\"]);\n });\n\n it(\"checks both sides of the equip matrix\", () => {\n const head = slots.find((s) => s.id === \"Head\")!;\n const weapon = slots.find((s) => s.id === \"Weapon\")!;\n const crown = pieces.find((p) => p.itemID === \"golden_crown\")!;\n expect(fitProblem(crown, weapon, \"Knight\", 2, items)).toBe(\"slot\");\n expect(fitProblem(crown, head, \"Knight\", 1, items)).toBe(\"rank\");\n expect(fitProblem(crown, head, \"Knight\", 2, items)).toBeNull();\n });\n\n it(\"'Equip best' fills what fits and skips what is already best\", () => {\n expect(bestLoadout(\"Knight\", 1, slots, pieces, {}, items)).toEqual([\n { SlotID: \"Weapon\", ItemInstanceID: \"s1\" },\n ]);\n expect(bestLoadout(\"Knight\", 2, slots, pieces, {}, items)).toHaveLength(2);\n expect(\n bestLoadout(\n \"Knight\",\n 1,\n slots,\n pieces,\n { Weapon: { ItemInstanceID: \"s1\" } },\n items,\n ),\n ).toEqual([]);\n });\n\n it(\"item level price grows with the target level and stops at the cap\", () => {\n expect(itemLevelCost(items.get(\"iron_sword\"), 1)).toEqual([\n { kind: \"currency\", id: \"CO\", amount: 90 },\n ]);\n expect(itemLevelCost(items.get(\"iron_sword\"), 5)).toBeNull();\n expect(itemLevelCost(items.get(\"golden_crown\"), 1)).toBeNull();\n });\n\n it(\"the parts of a stat sum to its total\", () => {\n const attack = resolveStats(section, knight).find(\n (s) => s.id === \"Attack\",\n )!;\n const b = breakdown(attack, 2, 2, knight, [\n { def: items.get(\"iron_sword\"), level: 1 },\n ]);\n expect(b.fromGear).toBe(8);\n expect(b.base + b.fromLevel + b.fromRank + b.fromGear).toBeCloseTo(b.total);\n });\n\n it(\"badge: unlockable and rankable heroes, plus better gear\", () => {\n const list = roster(section, { Knight: { Level: 1 } });\n const rich = () => true;\n expect(\n heroesBadge(list, rich, (e) => resolveLadder(section, e.def), 1),\n ).toBe(4);\n expect(\n heroesBadge(\n list,\n () => false,\n (e) => resolveLadder(section, e.def),\n 0,\n ),\n ).toBe(0);\n });\n});\n"
59
+ "content": "// @idosgames/mod-character — Heroes screen for any game: the hero roster, ranks and stats with their costs, equipment slots with the best-gear button, item levels and skins.\n\nexport { characterModule } from \"./module\";\n"
64
60
  },
65
61
  {
66
62
  "path": "model.ts",
67
- "content": "// Pure helpers of the heroes screen (Unity IdleRPG/Runtime/Character + Equipment): the roster,\n// merged stats/ladder/slots (preset base + inline override, Remove drops keys — the server's rule),\n// prices of ranks, stats and item levels, which gear fits a slot, the best loadout, and where a\n// stat's number comes from. No React, no client calls — covered by model.test.ts.\n//\n// The server is the authority on every number: these only drive what the screen shows before the\n// player taps, and a rejected action is surfaced as the server's error.\n\nimport {\n curveMultiplier,\n evaluateCurve,\n roundAmount,\n type ScalarCurveSpec,\n} from \"@idosgames/core\";\nimport {\n costLines,\n rarityRank,\n type ItemDefinitionView,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\n\n// ── Config views ─────────────────────────────────────────────────────────────────────────────\n\ntype PriceOptionsView =\n | Record<string, { OptionID?: string | null; Cost?: unknown } | null>\n | null\n | undefined;\n\nexport interface StatDefView {\n StatID?: string;\n DisplayName?: string | null;\n Description?: string | null;\n MaxLevel?: number | null;\n BaseStatValue?: number | null;\n ValueCurve?: ScalarCurveSpec | null;\n RankCurve?: ScalarCurveSpec | null;\n CostCurve?: ScalarCurveSpec | null;\n PriceOptions?: PriceOptionsView;\n Requirements?: Array<{\n RequiredStatID?: string;\n RequiredLevel?: number;\n } | null> | null;\n}\n\nexport interface SlotRuleView {\n SlotID?: string;\n MinCharacterLevel?: number | null;\n AllowedItemTags?: string[] | null;\n AllowedRarityIDs?: string[] | null;\n MinItemLevel?: number | null;\n MaxItemLevel?: number | null;\n}\n\nexport interface RankLadderView {\n MaxRank?: number | null;\n FirstPaidRank?: number | null;\n PriceOptions?: PriceOptionsView;\n CostCurve?: ScalarCurveSpec | null;\n}\n\ninterface Binding {\n PresetID?: string | null;\n Remove?: string[] | null;\n}\n\nexport interface SkinDefView {\n SkinID?: string;\n Identity?: {\n DisplayName?: string | null;\n SortOrder?: number | null;\n AssetPaths?: Record<string, string> | null;\n } | null;\n Item?: { CatalogID?: string | null; ItemID?: string | null } | null;\n}\n\nexport interface CharacterDefView {\n CharacterID?: string;\n Identity?: {\n DisplayName?: string | null;\n Description?: string | null;\n SortOrder?: number | null;\n AssetPaths?: Record<string, string> | null;\n } | null;\n Classification?: { ClassID?: string | null; RarityID?: string | null } | null;\n Unlock?: {\n UnlockedByDefault?: boolean | null;\n PriceOptions?: PriceOptionsView;\n } | null;\n Stats?: Record<string, StatDefView | null> | null;\n RankLadder?: RankLadderView | null;\n RankStatCurve?: ScalarCurveSpec | null;\n StatMaxLevelCurve?: ScalarCurveSpec | null;\n Equipment?: { Slots?: Record<string, SlotRuleView | null> | null } | null;\n Skins?: {\n DefaultSkinID?: string | null;\n Definitions?: Record<string, SkinDefView | null> | null;\n } | null;\n Presets?: {\n Stats?: Binding | null;\n Levels?: Binding | null;\n Equipment?: Binding | null;\n } | null;\n}\n\nexport interface CharacterSection {\n Definitions?: Record<string, CharacterDefView | null> | null;\n Presets?: {\n Stats?: Record<\n string,\n { Stats?: Record<string, StatDefView | null> | null } | null\n > | null;\n Levels?: Record<\n string,\n { RankLadder?: RankLadderView | null } | null\n > | null;\n Equipment?: Record<\n string,\n {\n Equipment?: {\n Slots?: Record<string, SlotRuleView | null> | null;\n } | null;\n } | null\n > | null;\n } | null;\n}\n\n// ── State views ──────────────────────────────────────────────────────────────────────────────\n\nexport interface CharacterModelView {\n CharacterID?: string;\n Level?: number | null;\n Power?: number | null;\n StatLevels?: Record<string, number> | null;\n Equipment?: Record<\n string,\n { ItemID?: string | null; ItemInstanceID?: string | null } | null\n > | null;\n}\n\nexport interface InstanceView {\n ItemID?: string;\n Level?: number | null;\n EquippedSlot?: { CharacterID?: string | null; SlotID?: string | null } | null;\n}\n\nexport interface ItemDefWithGear extends ItemDefinitionView {\n Stats?: {\n FlatBonuses?: Record<string, number> | null;\n PercentBonuses?: Record<string, number> | null;\n Power?: number | null;\n } | null;\n Equipment?: {\n MinCharacterLevel?: number | null;\n AllowedSlotIDs?: string[] | null;\n AllowedCharacterIDs?: string[] | null;\n } | null;\n Upgrade?: {\n MaxLevel?: number | null;\n PriceOptions?: PriceOptionsView;\n CostCurve?: ScalarCurveSpec | null;\n FlatBonusCurve?: ScalarCurveSpec | null;\n PowerCurve?: ScalarCurveSpec | null;\n } | null;\n}\n\n/** The reserved equipment key of the worn skin (SKIN_SLOT in @idosgames/core). */\nexport const SKIN_KEY = \"@skin\";\n\n// ── Presets ──────────────────────────────────────────────────────────────────────────────────\n\n/** Preset base + inline override/add by key, Remove drops keys. No preset → inline as-is. */\nexport function mergeByKey<T>(\n preset: Record<string, T | null> | null | undefined,\n inline: Record<string, T | null> | null | undefined,\n remove: readonly string[] | null | undefined,\n): Record<string, T> {\n const out: Record<string, T> = {};\n for (const [k, v] of Object.entries(preset ?? {})) if (v) out[k] = v;\n for (const [k, v] of Object.entries(inline ?? {})) if (v) out[k] = v;\n for (const k of remove ?? []) delete out[k];\n return out;\n}\n\n/** First way to pay → lines (\"default cost\", like the server's PriceOptionSelector.DefaultCost). */\nexport function firstPrice(options: PriceOptionsView): ResourceLine[] {\n const first = Object.values(options ?? {}).find((o) => o);\n return first ? costLines(first.Cost as never) : [];\n}\n\nfunction scale(lines: ResourceLine[], multiplier: number): ResourceLine[] {\n return lines.map((l) => ({\n ...l,\n amount: roundAmount(l.amount * multiplier),\n }));\n}\n\n// ── Roster ───────────────────────────────────────────────────────────────────────────────────\n\nexport type HeroState = \"owned\" | \"available\" | \"locked\";\n\nexport interface RosterEntry {\n id: string;\n def: CharacterDefView;\n model: CharacterModelView | undefined;\n state: HeroState;\n rank: number;\n power: number;\n unlockPrice: ResourceLine[];\n}\n\n/** Heroes in roster order (SortOrder, then id), each with its state for this player. */\nexport function roster(\n section: CharacterSection | undefined,\n owned:\n Record<string, CharacterModelView | null | undefined> | null | undefined,\n): RosterEntry[] {\n return Object.entries(section?.Definitions ?? {})\n .map(([id, def]) => ({ id: def?.CharacterID ?? id, def }))\n .filter((x): x is { id: string; def: CharacterDefView } => !!x.def)\n .sort(\n (a, b) =>\n Number(a.def.Identity?.SortOrder ?? 0) -\n Number(b.def.Identity?.SortOrder ?? 0) || a.id.localeCompare(b.id),\n )\n .map(({ id, def }) => {\n const model = owned?.[id] ?? undefined;\n const rank = Number(model?.Level ?? 0);\n const state: HeroState =\n rank >= 1\n ? \"owned\"\n : def.Unlock?.UnlockedByDefault\n ? \"available\"\n : \"locked\";\n return {\n id,\n def,\n model,\n state,\n rank: Math.max(rank, state === \"available\" ? 1 : 0),\n power: Number(model?.Power ?? 0),\n unlockPrice: firstPrice(def.Unlock?.PriceOptions),\n };\n });\n}\n\n// ── Stats ────────────────────────────────────────────────────────────────────────────────────\n\nexport interface StatView {\n id: string;\n name: string;\n description: string;\n maxLevel: number;\n base: number;\n valueCurve?: ScalarCurveSpec | null;\n rankCurve?: ScalarCurveSpec | null;\n costCurve?: ScalarCurveSpec | null;\n price: ResourceLine[];\n requirements: Array<{ statID: string; level: number }>;\n}\n\nexport function resolveStats(\n section: CharacterSection | undefined,\n def: CharacterDefView | undefined,\n): StatView[] {\n if (!def) return [];\n const binding = def.Presets?.Stats;\n const preset = binding?.PresetID\n ? section?.Presets?.Stats?.[binding.PresetID]?.Stats\n : undefined;\n return Object.entries(mergeByKey(preset, def.Stats, binding?.Remove)).map(\n ([id, s]) => ({\n id: s.StatID ?? id,\n name: s.DisplayName || id,\n description: s.Description || \"\",\n maxLevel: Number(s.MaxLevel ?? 0),\n base: Number(s.BaseStatValue ?? 0),\n valueCurve: s.ValueCurve,\n rankCurve: s.RankCurve,\n costCurve: s.CostCurve,\n price: firstPrice(s.PriceOptions),\n requirements: (s.Requirements ?? [])\n .filter((r) => r?.RequiredStatID)\n .map((r) => ({\n statID: r!.RequiredStatID!,\n level: Number(r!.RequiredLevel ?? 0),\n })),\n }),\n );\n}\n\n/**\n * Stat value at stat-level `level` for a hero of rank `rank`: the stat's ValueCurve from 0, its\n * RankCurve and the hero's RankStatCurve from 1 — the order the server composes them in.\n */\nexport function statValue(\n stat: StatView,\n level: number,\n rank: number,\n def?: CharacterDefView,\n): number {\n const own = evaluateCurve(\n stat.valueCurve ?? undefined,\n stat.base,\n Math.max(0, level),\n 0,\n );\n const byRank = evaluateCurve(\n stat.rankCurve ?? undefined,\n own,\n Math.max(1, rank),\n 1,\n );\n return evaluateCurve(\n def?.RankStatCurve ?? undefined,\n byRank,\n Math.max(1, rank),\n 1,\n );\n}\n\n/** The effective cap of a stat at a rank: MaxLevel through the hero's StatMaxLevelCurve. */\nexport function statMaxLevel(\n stat: StatView,\n rank: number,\n def?: CharacterDefView,\n): number {\n if (stat.maxLevel <= 0) return 0;\n return Math.ceil(\n evaluateCurve(\n def?.StatMaxLevelCurve ?? undefined,\n stat.maxLevel,\n Math.max(1, rank),\n 1,\n ),\n );\n}\n\n/** What raising a stat TO `nextLevel` costs (base price through CostCurve, rounded up once). */\nexport function statCost(stat: StatView, nextLevel: number): ResourceLine[] {\n return scale(\n stat.price,\n curveMultiplier(stat.costCurve ?? undefined, Math.max(1, nextLevel), 1),\n );\n}\n\n/** Requirements of a stat the hero has not met yet. */\nexport function unmetRequirements(\n stat: StatView,\n levels: Record<string, number> | null | undefined,\n): Array<{ statID: string; level: number }> {\n return stat.requirements.filter(\n (r) => Number(levels?.[r.statID] ?? 0) < r.level,\n );\n}\n\n// ── Ranks ────────────────────────────────────────────────────────────────────────────────────\n\nexport function resolveLadder(\n section: CharacterSection | undefined,\n def: CharacterDefView | undefined,\n): RankLadderView | null {\n if (!def) return null;\n if (def.RankLadder) return def.RankLadder;\n const id = def.Presets?.Levels?.PresetID;\n return id ? (section?.Presets?.Levels?.[id]?.RankLadder ?? null) : null;\n}\n\n/** What the next rank costs; `null` — the hero cannot rank up (no ladder, or at the ceiling). */\nexport function rankCost(\n ladder: RankLadderView | null,\n currentRank: number,\n): ResourceLine[] | null {\n if (!ladder) return null;\n const next = currentRank + 1;\n if (ladder.MaxRank && next > ladder.MaxRank) return null;\n const firstPaid = Math.max(1, Number(ladder.FirstPaidRank ?? 1));\n if (next < firstPaid) return [];\n return scale(\n firstPrice(ladder.PriceOptions),\n curveMultiplier(ladder.CostCurve ?? undefined, next, firstPaid),\n );\n}\n\n// ── Equipment ────────────────────────────────────────────────────────────────────────────────\n\nexport interface SlotView {\n id: string;\n minRank: number;\n rule: SlotRuleView;\n}\n\n/** Gear slots of a hero (the reserved \"@…\" keys — the worn skin — are not slots). */\nexport function resolveSlots(\n section: CharacterSection | undefined,\n def: CharacterDefView | undefined,\n): SlotView[] {\n if (!def) return [];\n const binding = def.Presets?.Equipment;\n const preset = binding?.PresetID\n ? section?.Presets?.Equipment?.[binding.PresetID]?.Equipment?.Slots\n : undefined;\n return Object.entries(\n mergeByKey(preset, def.Equipment?.Slots, binding?.Remove),\n )\n .filter(([id]) => !id.startsWith(\"@\"))\n .map(([id, rule]) => ({\n id: rule.SlotID ?? id,\n minRank: Number(rule.MinCharacterLevel ?? 0),\n rule,\n }));\n}\n\nexport interface GearPiece {\n instanceID: string;\n itemID: string;\n level: number;\n equippedBy: { characterID: string; slotID: string } | null;\n}\n\n/** Every gear instance the player owns (unstackable items that declare equip rules). */\nexport function gearPieces(\n instances: Record<string, InstanceView | null> | null | undefined,\n items: Map<string, ItemDefWithGear>,\n): GearPiece[] {\n return Object.entries(instances ?? {})\n .filter(([, i]) => i?.ItemID && items.get(i.ItemID)?.Equipment)\n .map(([id, i]) => ({\n instanceID: id,\n itemID: i!.ItemID!,\n level: Number(i!.Level ?? 1) || 1,\n equippedBy:\n i!.EquippedSlot?.CharacterID && i!.EquippedSlot.SlotID\n ? {\n characterID: i!.EquippedSlot.CharacterID,\n slotID: i!.EquippedSlot.SlotID,\n }\n : null,\n }));\n}\n\n/** Why a piece cannot go into a slot of this hero, or null when it fits (both sides of the matrix). */\nexport function fitProblem(\n piece: GearPiece,\n slot: SlotView,\n heroID: string,\n rank: number,\n items: Map<string, ItemDefWithGear>,\n): \"slot\" | \"rank\" | \"rarity\" | \"tags\" | \"level\" | \"hero\" | null {\n const def = items.get(piece.itemID);\n const allowed = def?.Equipment?.AllowedSlotIDs ?? [];\n if (allowed.length > 0 && !allowed.includes(slot.id)) return \"slot\";\n if (\n def?.Equipment?.AllowedCharacterIDs?.length &&\n !def.Equipment.AllowedCharacterIDs.includes(heroID)\n )\n return \"hero\";\n if (\n rank < slot.minRank ||\n rank < Number(def?.Equipment?.MinCharacterLevel ?? 0)\n )\n return \"rank\";\n const r = slot.rule;\n if (\n r.AllowedRarityIDs?.length &&\n !r.AllowedRarityIDs.includes(def?.Metadata?.RarityID ?? \"\")\n )\n return \"rarity\";\n if (\n r.AllowedItemTags?.length &&\n !(def?.Tags ?? []).some((t) => r.AllowedItemTags!.includes(t))\n )\n return \"tags\";\n if ((r.MinItemLevel ?? 0) > 0 && piece.level < (r.MinItemLevel ?? 0))\n return \"level\";\n if ((r.MaxItemLevel ?? 0) > 0 && piece.level > (r.MaxItemLevel ?? 0))\n return \"level\";\n return null;\n}\n\n/** A comparable strength of a piece: its Power by level, else the sum of its flat bonuses. */\nexport function gearScore(\n piece: GearPiece,\n items: Map<string, ItemDefWithGear>,\n): number {\n const def = items.get(piece.itemID);\n const power = Number(def?.Stats?.Power ?? 0);\n if (power > 0)\n return (\n power *\n curveMultiplier(def?.Upgrade?.PowerCurve ?? undefined, piece.level, 1)\n );\n const flat = Object.values(def?.Stats?.FlatBonuses ?? {}).reduce(\n (s, n) => s + Number(n || 0),\n 0,\n );\n return (\n flat *\n curveMultiplier(\n def?.Upgrade?.FlatBonusCurve ?? undefined,\n piece.level,\n 1,\n ) +\n (4 - Math.min(4, rarityRank(def?.Metadata?.RarityID))) * 0.01\n );\n}\n\n/**\n * \"Equip best\" (Unity EquipmentAutoAssign): per unlocked slot, the strongest fitting piece not worn\n * by another hero and not taken by an earlier slot. Returns only the slots that would CHANGE.\n */\nexport function bestLoadout(\n heroID: string,\n rank: number,\n slots: SlotView[],\n pieces: GearPiece[],\n current: CharacterModelView[\"Equipment\"],\n items: Map<string, ItemDefWithGear>,\n): Array<{ SlotID: string; ItemInstanceID: string }> {\n const taken = new Set<string>();\n const changes: Array<{ SlotID: string; ItemInstanceID: string }> = [];\n for (const slot of slots) {\n const candidates = pieces\n .filter((p) => !taken.has(p.instanceID))\n .filter((p) => !p.equippedBy || p.equippedBy.characterID === heroID)\n .filter((p) => fitProblem(p, slot, heroID, rank, items) === null)\n .sort(\n (a, b) =>\n gearScore(b, items) - gearScore(a, items) ||\n a.instanceID.localeCompare(b.instanceID),\n );\n const best = candidates[0];\n if (!best) continue;\n taken.add(best.instanceID);\n const worn = current?.[slot.id]?.ItemInstanceID ?? null;\n const wornPiece = worn\n ? pieces.find((p) => p.instanceID === worn)\n : undefined;\n if (worn === best.instanceID) continue;\n if (wornPiece && gearScore(wornPiece, items) >= gearScore(best, items)) {\n taken.add(worn!);\n continue;\n }\n changes.push({ SlotID: slot.id, ItemInstanceID: best.instanceID });\n }\n return changes;\n}\n\n/** What raising an item instance TO `nextLevel` costs; null — at its cap or not upgradable. */\nexport function itemLevelCost(\n def: ItemDefWithGear | undefined,\n currentLevel: number,\n): ResourceLine[] | null {\n const up = def?.Upgrade;\n if (!up) return null;\n const next = currentLevel + 1;\n if ((up.MaxLevel ?? 0) > 0 && next > (up.MaxLevel ?? 0)) return null;\n return scale(\n firstPrice(up.PriceOptions),\n curveMultiplier(up.CostCurve ?? undefined, next, 1),\n );\n}\n\n/** Flat bonuses of a piece at its level (the item's FlatBonusCurve), by stat id. */\nexport function pieceBonuses(\n def: ItemDefWithGear | undefined,\n level: number,\n): Record<string, number> {\n const k = curveMultiplier(\n def?.Upgrade?.FlatBonusCurve ?? undefined,\n Math.max(1, level),\n 1,\n );\n return Object.fromEntries(\n Object.entries(def?.Stats?.FlatBonuses ?? {}).map(([id, n]) => [\n id,\n Number(n || 0) * k,\n ]),\n );\n}\n\n// ── \"Where does this number come from\" (Unity StatBreakdownPopup) ────────────────────────────\n\nexport interface Breakdown {\n base: number;\n fromLevel: number;\n fromRank: number;\n fromGear: number;\n percent: number;\n total: number;\n}\n\n/**\n * The parts of a stat: the base, what its own levels add, what the rank adds, flat bonuses of the\n * worn gear, then their percent bonuses — added in that order, so the parts sum to the total.\n */\nexport function breakdown(\n stat: StatView,\n level: number,\n rank: number,\n def: CharacterDefView | undefined,\n worn: Array<{ def: ItemDefWithGear | undefined; level: number }>,\n): Breakdown {\n const base = stat.base;\n const withLevel = evaluateCurve(\n stat.valueCurve ?? undefined,\n base,\n Math.max(0, level),\n 0,\n );\n const withRank = statValue(stat, level, rank, def);\n const fromGear = worn.reduce(\n (s, w) => s + (pieceBonuses(w.def, w.level)[stat.id] ?? 0),\n 0,\n );\n const percent = worn.reduce(\n (s, w) => s + Number(w.def?.Stats?.PercentBonuses?.[stat.id] ?? 0),\n 0,\n );\n const subtotal = withRank + fromGear;\n const total = subtotal * (1 + percent / 100);\n return {\n base,\n fromLevel: withLevel - base,\n fromRank: withRank - withLevel,\n fromGear,\n percent,\n total,\n };\n}\n\n// ── Badge ────────────────────────────────────────────────────────────────────────────────────\n\n/** Badge of the heroes screen: heroes that can be unlocked or ranked up right now, plus better gear. */\nexport function heroesBadge(\n entries: RosterEntry[],\n canPay: (lines: ResourceLine[]) => boolean,\n ladderOf: (e: RosterEntry) => RankLadderView | null,\n betterGear: number,\n): number {\n let n = 0;\n for (const e of entries) {\n if (\n e.state === \"locked\" &&\n e.unlockPrice.length > 0 &&\n canPay(e.unlockPrice)\n )\n n++;\n if (e.state !== \"locked\") {\n const cost = rankCost(ladderOf(e), e.rank);\n if (cost && cost.length > 0 && canPay(cost)) n++;\n }\n }\n return n + (betterGear > 0 ? 1 : 0);\n}\n"
63
+ "content": "// Pure helpers of the heroes screen (Unity IdleRPG/Runtime/Character + Equipment): the roster,\n// merged stats/ladder/slots (preset base + inline override, Remove drops keys — the server's rule),\n// prices of ranks, stats and item levels, which gear fits a slot, the best loadout, and where a\n// stat's number comes from. No React, no client calls — covered by model.test.ts.\n//\n// The server is the authority on every number: these only drive what the screen shows before the\n// player taps, and a rejected action is surfaced as the server's error.\n\nimport {\n curveMultiplier,\n evaluateCurve,\n roundAmount,\n type ScalarCurveSpec,\n} from \"@idosgames/core\";\nimport {\n costLines,\n rarityRank,\n type ItemDefinitionView,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\n\n// ── Config views ─────────────────────────────────────────────────────────────────────────────\n\ntype PriceOptionsView =\n | Record<string, { OptionID?: string | null; Cost?: unknown } | null>\n | null\n | undefined;\n\nexport interface StatDefView {\n StatID?: string;\n DisplayName?: string | null;\n Description?: string | null;\n MaxLevel?: number | null;\n BaseStatValue?: number | null;\n ValueCurve?: ScalarCurveSpec | null;\n RankCurve?: ScalarCurveSpec | null;\n CostCurve?: ScalarCurveSpec | null;\n PriceOptions?: PriceOptionsView;\n Requirements?: Array<{\n RequiredStatID?: string;\n RequiredLevel?: number;\n } | null> | null;\n}\n\nexport interface SlotRuleView {\n SlotID?: string;\n MinCharacterLevel?: number | null;\n AllowedItemTags?: string[] | null;\n AllowedRarityIDs?: string[] | null;\n MinItemLevel?: number | null;\n MaxItemLevel?: number | null;\n}\n\nexport interface RankLadderView {\n MaxRank?: number | null;\n FirstPaidRank?: number | null;\n PriceOptions?: PriceOptionsView;\n CostCurve?: ScalarCurveSpec | null;\n}\n\ninterface Binding {\n PresetID?: string | null;\n Remove?: string[] | null;\n}\n\nexport interface SkinDefView {\n SkinID?: string;\n Identity?: {\n DisplayName?: string | null;\n SortOrder?: number | null;\n AssetPaths?: Record<string, string> | null;\n } | null;\n Item?: { CatalogID?: string | null; ItemID?: string | null } | null;\n}\n\nexport interface CharacterDefView {\n CharacterID?: string;\n Identity?: {\n DisplayName?: string | null;\n Description?: string | null;\n SortOrder?: number | null;\n AssetPaths?: Record<string, string> | null;\n } | null;\n Classification?: { ClassID?: string | null; RarityID?: string | null } | null;\n Unlock?: {\n UnlockedByDefault?: boolean | null;\n PriceOptions?: PriceOptionsView;\n } | null;\n Stats?: Record<string, StatDefView | null> | null;\n RankLadder?: RankLadderView | null;\n RankStatCurve?: ScalarCurveSpec | null;\n StatMaxLevelCurve?: ScalarCurveSpec | null;\n Equipment?: { Slots?: Record<string, SlotRuleView | null> | null } | null;\n Skins?: {\n DefaultSkinID?: string | null;\n Definitions?: Record<string, SkinDefView | null> | null;\n } | null;\n Presets?: {\n Stats?: Binding | null;\n Levels?: Binding | null;\n Equipment?: Binding | null;\n } | null;\n}\n\nexport interface CharacterSection {\n Definitions?: Record<string, CharacterDefView | null> | null;\n Presets?: {\n Stats?: Record<\n string,\n { Stats?: Record<string, StatDefView | null> | null } | null\n > | null;\n Levels?: Record<\n string,\n { RankLadder?: RankLadderView | null } | null\n > | null;\n Equipment?: Record<\n string,\n {\n Equipment?: {\n Slots?: Record<string, SlotRuleView | null> | null;\n } | null;\n } | null\n > | null;\n } | null;\n}\n\n// ── State views ──────────────────────────────────────────────────────────────────────────────\n\nexport interface CharacterModelView {\n CharacterID?: string;\n Level?: number | null;\n Power?: number | null;\n StatLevels?: Record<string, number> | null;\n Equipment?: Record<\n string,\n { ItemID?: string | null; ItemInstanceID?: string | null } | null\n > | null;\n}\n\nexport interface InstanceView {\n ItemID?: string;\n Level?: number | null;\n EquippedSlot?: { CharacterID?: string | null; SlotID?: string | null } | null;\n}\n\nexport interface ItemDefWithGear extends ItemDefinitionView {\n Stats?: {\n FlatBonuses?: Record<string, number> | null;\n PercentBonuses?: Record<string, number> | null;\n Power?: number | null;\n } | null;\n Equipment?: {\n MinCharacterLevel?: number | null;\n AllowedSlotIDs?: string[] | null;\n AllowedCharacterIDs?: string[] | null;\n } | null;\n Upgrade?: {\n MaxLevel?: number | null;\n PriceOptions?: PriceOptionsView;\n CostCurve?: ScalarCurveSpec | null;\n FlatBonusCurve?: ScalarCurveSpec | null;\n PowerCurve?: ScalarCurveSpec | null;\n } | null;\n}\n\n/** The reserved equipment key of the worn skin (SKIN_SLOT in @idosgames/core). */\nexport const SKIN_KEY = \"@skin\";\n\n// ── Presets ──────────────────────────────────────────────────────────────────────────────────\n\n/** Preset base + inline override/add by key, Remove drops keys. No preset → inline as-is. */\nexport function mergeByKey<T>(\n preset: Record<string, T | null> | null | undefined,\n inline: Record<string, T | null> | null | undefined,\n remove: readonly string[] | null | undefined,\n): Record<string, T> {\n const out: Record<string, T> = {};\n for (const [k, v] of Object.entries(preset ?? {})) if (v) out[k] = v;\n for (const [k, v] of Object.entries(inline ?? {})) if (v) out[k] = v;\n for (const k of remove ?? []) delete out[k];\n return out;\n}\n\n/** First way to pay → lines (\"default cost\", like the server's PriceOptionSelector.DefaultCost). */\nexport function firstPrice(options: PriceOptionsView): ResourceLine[] {\n const first = Object.values(options ?? {}).find((o) => o);\n return first ? costLines(first.Cost as never) : [];\n}\n\nfunction scale(lines: ResourceLine[], multiplier: number): ResourceLine[] {\n return lines.map((l) => ({\n ...l,\n amount: roundAmount(l.amount * multiplier),\n }));\n}\n\n// ── Roster ───────────────────────────────────────────────────────────────────────────────────\n\nexport type HeroState = \"owned\" | \"available\" | \"locked\";\n\nexport interface RosterEntry {\n id: string;\n def: CharacterDefView;\n model: CharacterModelView | undefined;\n state: HeroState;\n rank: number;\n power: number;\n unlockPrice: ResourceLine[];\n}\n\n/** Heroes in roster order (SortOrder, then id), each with its state for this player. */\nexport function roster(\n section: CharacterSection | undefined,\n owned:\n Record<string, CharacterModelView | null | undefined> | null | undefined,\n): RosterEntry[] {\n return Object.entries(section?.Definitions ?? {})\n .map(([id, def]) => ({ id: def?.CharacterID ?? id, def }))\n .filter((x): x is { id: string; def: CharacterDefView } => !!x.def)\n .sort(\n (a, b) =>\n Number(a.def.Identity?.SortOrder ?? 0) -\n Number(b.def.Identity?.SortOrder ?? 0) || a.id.localeCompare(b.id),\n )\n .map(({ id, def }) => {\n const model = owned?.[id] ?? undefined;\n const rank = Number(model?.Level ?? 0);\n const state: HeroState =\n rank >= 1\n ? \"owned\"\n : def.Unlock?.UnlockedByDefault\n ? \"available\"\n : \"locked\";\n return {\n id,\n def,\n model,\n state,\n rank: Math.max(rank, state === \"available\" ? 1 : 0),\n power: Number(model?.Power ?? 0),\n unlockPrice: firstPrice(def.Unlock?.PriceOptions),\n };\n });\n}\n\n// ── Stats ────────────────────────────────────────────────────────────────────────────────────\n\nexport interface StatView {\n id: string;\n name: string;\n description: string;\n maxLevel: number;\n base: number;\n valueCurve?: ScalarCurveSpec | null;\n rankCurve?: ScalarCurveSpec | null;\n costCurve?: ScalarCurveSpec | null;\n price: ResourceLine[];\n requirements: Array<{ statID: string; level: number }>;\n}\n\nexport function resolveStats(\n section: CharacterSection | undefined,\n def: CharacterDefView | undefined,\n): StatView[] {\n if (!def) return [];\n const binding = def.Presets?.Stats;\n const preset = binding?.PresetID\n ? section?.Presets?.Stats?.[binding.PresetID]?.Stats\n : undefined;\n return Object.entries(mergeByKey(preset, def.Stats, binding?.Remove)).map(\n ([id, s]) => ({\n id: s.StatID ?? id,\n name: s.DisplayName || id,\n description: s.Description || \"\",\n maxLevel: Number(s.MaxLevel ?? 0),\n base: Number(s.BaseStatValue ?? 0),\n valueCurve: s.ValueCurve,\n rankCurve: s.RankCurve,\n costCurve: s.CostCurve,\n price: firstPrice(s.PriceOptions),\n requirements: (s.Requirements ?? [])\n .filter((r) => r?.RequiredStatID)\n .map((r) => ({\n statID: r!.RequiredStatID!,\n level: Number(r!.RequiredLevel ?? 0),\n })),\n }),\n );\n}\n\n/**\n * Stat value at stat-level `level` for a hero of rank `rank`: the stat's ValueCurve from 0, its\n * RankCurve and the hero's RankStatCurve from 1 — the order the server composes them in.\n */\nexport function statValue(\n stat: StatView,\n level: number,\n rank: number,\n def?: CharacterDefView,\n): number {\n const own = evaluateCurve(\n stat.valueCurve ?? undefined,\n stat.base,\n Math.max(0, level),\n 0,\n );\n const byRank = evaluateCurve(\n stat.rankCurve ?? undefined,\n own,\n Math.max(1, rank),\n 1,\n );\n return evaluateCurve(\n def?.RankStatCurve ?? undefined,\n byRank,\n Math.max(1, rank),\n 1,\n );\n}\n\n/** The effective cap of a stat at a rank: MaxLevel through the hero's StatMaxLevelCurve. */\nexport function statMaxLevel(\n stat: StatView,\n rank: number,\n def?: CharacterDefView,\n): number {\n if (stat.maxLevel <= 0) return 0;\n return Math.ceil(\n evaluateCurve(\n def?.StatMaxLevelCurve ?? undefined,\n stat.maxLevel,\n Math.max(1, rank),\n 1,\n ),\n );\n}\n\n/** What raising a stat TO `nextLevel` costs (base price through CostCurve, rounded up once). */\nexport function statCost(stat: StatView, nextLevel: number): ResourceLine[] {\n return scale(\n stat.price,\n curveMultiplier(stat.costCurve ?? undefined, Math.max(1, nextLevel), 1),\n );\n}\n\n/** Requirements of a stat the hero has not met yet. */\nexport function unmetRequirements(\n stat: StatView,\n levels: Record<string, number> | null | undefined,\n): Array<{ statID: string; level: number }> {\n return stat.requirements.filter(\n (r) => Number(levels?.[r.statID] ?? 0) < r.level,\n );\n}\n\n// ── Ranks ────────────────────────────────────────────────────────────────────────────────────\n\nexport function resolveLadder(\n section: CharacterSection | undefined,\n def: CharacterDefView | undefined,\n): RankLadderView | null {\n if (!def) return null;\n if (def.RankLadder) return def.RankLadder;\n const id = def.Presets?.Levels?.PresetID;\n return id ? (section?.Presets?.Levels?.[id]?.RankLadder ?? null) : null;\n}\n\n/** What the next rank costs; `null` — the hero cannot rank up (no ladder, or at the ceiling). */\nexport function rankCost(\n ladder: RankLadderView | null,\n currentRank: number,\n): ResourceLine[] | null {\n if (!ladder) return null;\n const next = currentRank + 1;\n if (ladder.MaxRank && next > ladder.MaxRank) return null;\n const firstPaid = Math.max(1, Number(ladder.FirstPaidRank ?? 1));\n if (next < firstPaid) return [];\n return scale(\n firstPrice(ladder.PriceOptions),\n curveMultiplier(ladder.CostCurve ?? undefined, next, firstPaid),\n );\n}\n\n// ── Equipment ────────────────────────────────────────────────────────────────────────────────\n\nexport interface SlotView {\n id: string;\n minRank: number;\n rule: SlotRuleView;\n}\n\n/** Gear slots of a hero (the reserved \"@…\" keys — the worn skin — are not slots). */\nexport function resolveSlots(\n section: CharacterSection | undefined,\n def: CharacterDefView | undefined,\n): SlotView[] {\n if (!def) return [];\n const binding = def.Presets?.Equipment;\n const preset = binding?.PresetID\n ? section?.Presets?.Equipment?.[binding.PresetID]?.Equipment?.Slots\n : undefined;\n return Object.entries(\n mergeByKey(preset, def.Equipment?.Slots, binding?.Remove),\n )\n .filter(([id]) => !id.startsWith(\"@\"))\n .map(([id, rule]) => ({\n id: rule.SlotID ?? id,\n minRank: Number(rule.MinCharacterLevel ?? 0),\n rule,\n }));\n}\n\nexport interface GearPiece {\n instanceID: string;\n itemID: string;\n level: number;\n equippedBy: { characterID: string; slotID: string } | null;\n}\n\n/** Every gear instance the player owns (unstackable items that declare equip rules). */\nexport function gearPieces(\n instances: Record<string, InstanceView | null> | null | undefined,\n items: Map<string, ItemDefWithGear>,\n): GearPiece[] {\n return Object.entries(instances ?? {})\n .filter(([, i]) => i?.ItemID && items.get(i.ItemID)?.Equipment)\n .map(([id, i]) => ({\n instanceID: id,\n itemID: i!.ItemID!,\n level: Number(i!.Level ?? 1) || 1,\n equippedBy:\n i!.EquippedSlot?.CharacterID && i!.EquippedSlot.SlotID\n ? {\n characterID: i!.EquippedSlot.CharacterID,\n slotID: i!.EquippedSlot.SlotID,\n }\n : null,\n }));\n}\n\n/** Why a piece cannot go into a slot of this hero, or null when it fits (both sides of the matrix). */\nexport function fitProblem(\n piece: GearPiece,\n slot: SlotView,\n heroID: string,\n rank: number,\n items: Map<string, ItemDefWithGear>,\n): \"slot\" | \"rank\" | \"rarity\" | \"tags\" | \"level\" | \"hero\" | null {\n const def = items.get(piece.itemID);\n const allowed = def?.Equipment?.AllowedSlotIDs ?? [];\n if (allowed.length > 0 && !allowed.includes(slot.id)) return \"slot\";\n if (\n def?.Equipment?.AllowedCharacterIDs?.length &&\n !def.Equipment.AllowedCharacterIDs.includes(heroID)\n )\n return \"hero\";\n if (\n rank < slot.minRank ||\n rank < Number(def?.Equipment?.MinCharacterLevel ?? 0)\n )\n return \"rank\";\n const r = slot.rule;\n if (\n r.AllowedRarityIDs?.length &&\n !r.AllowedRarityIDs.includes(def?.Metadata?.RarityID ?? \"\")\n )\n return \"rarity\";\n if (\n r.AllowedItemTags?.length &&\n !(def?.Tags ?? []).some((t) => r.AllowedItemTags!.includes(t))\n )\n return \"tags\";\n if ((r.MinItemLevel ?? 0) > 0 && piece.level < (r.MinItemLevel ?? 0))\n return \"level\";\n if ((r.MaxItemLevel ?? 0) > 0 && piece.level > (r.MaxItemLevel ?? 0))\n return \"level\";\n return null;\n}\n\n/** A comparable strength of a piece: its Power by level, else the sum of its flat bonuses. */\nexport function gearScore(\n piece: GearPiece,\n items: Map<string, ItemDefWithGear>,\n): number {\n const def = items.get(piece.itemID);\n const power = Number(def?.Stats?.Power ?? 0);\n if (power > 0)\n return (\n power *\n curveMultiplier(def?.Upgrade?.PowerCurve ?? undefined, piece.level, 1)\n );\n const flat = Object.values(def?.Stats?.FlatBonuses ?? {}).reduce(\n (s, n) => s + Number(n || 0),\n 0,\n );\n return (\n flat *\n curveMultiplier(\n def?.Upgrade?.FlatBonusCurve ?? undefined,\n piece.level,\n 1,\n ) +\n (4 - Math.min(4, rarityRank(def?.Metadata?.RarityID))) * 0.01\n );\n}\n\n/**\n * \"Equip best\" (Unity EquipmentAutoAssign): per unlocked slot, the strongest fitting piece not worn\n * by another hero and not taken by an earlier slot. Returns only the slots that would CHANGE.\n */\nexport function bestLoadout(\n heroID: string,\n rank: number,\n slots: SlotView[],\n pieces: GearPiece[],\n current: CharacterModelView[\"Equipment\"],\n items: Map<string, ItemDefWithGear>,\n): Array<{ SlotID: string; ItemInstanceID: string }> {\n const taken = new Set<string>();\n const changes: Array<{ SlotID: string; ItemInstanceID: string }> = [];\n for (const slot of slots) {\n const candidates = pieces\n .filter((p) => !taken.has(p.instanceID))\n .filter((p) => !p.equippedBy || p.equippedBy.characterID === heroID)\n .filter((p) => fitProblem(p, slot, heroID, rank, items) === null)\n .sort(\n (a, b) =>\n gearScore(b, items) - gearScore(a, items) ||\n a.instanceID.localeCompare(b.instanceID),\n );\n const best = candidates[0];\n if (!best) continue;\n taken.add(best.instanceID);\n const worn = current?.[slot.id]?.ItemInstanceID ?? null;\n const wornPiece = worn\n ? pieces.find((p) => p.instanceID === worn)\n : undefined;\n if (worn === best.instanceID) continue;\n if (wornPiece && gearScore(wornPiece, items) >= gearScore(best, items)) {\n taken.add(worn!);\n continue;\n }\n changes.push({ SlotID: slot.id, ItemInstanceID: best.instanceID });\n }\n return changes;\n}\n\n/** What raising an item instance TO `nextLevel` costs; null — at its cap or not upgradable. */\nexport function itemLevelCost(\n def: ItemDefWithGear | undefined,\n currentLevel: number,\n): ResourceLine[] | null {\n const up = def?.Upgrade;\n if (!up) return null;\n const next = currentLevel + 1;\n if ((up.MaxLevel ?? 0) > 0 && next > (up.MaxLevel ?? 0)) return null;\n return scale(\n firstPrice(up.PriceOptions),\n curveMultiplier(up.CostCurve ?? undefined, next, 1),\n );\n}\n\n/** Flat bonuses of a piece at its level (the item's FlatBonusCurve), by stat id. */\nexport function pieceBonuses(\n def: ItemDefWithGear | undefined,\n level: number,\n): Record<string, number> {\n const k = curveMultiplier(\n def?.Upgrade?.FlatBonusCurve ?? undefined,\n Math.max(1, level),\n 1,\n );\n return Object.fromEntries(\n Object.entries(def?.Stats?.FlatBonuses ?? {}).map(([id, n]) => [\n id,\n Number(n || 0) * k,\n ]),\n );\n}\n\n// ── \"Where does this number come from\" (Unity StatBreakdownPopup) ────────────────────────────\n\nexport interface Breakdown {\n base: number;\n fromLevel: number;\n fromRank: number;\n fromGear: number;\n percent: number;\n total: number;\n}\n\n/**\n * The parts of a stat: the base, what its own levels add, what the rank adds, flat bonuses of the\n * worn gear, then their percent bonuses — added in that order, so the parts sum to the total.\n */\nexport function breakdown(\n stat: StatView,\n level: number,\n rank: number,\n def: CharacterDefView | undefined,\n worn: Array<{ def: ItemDefWithGear | undefined; level: number }>,\n): Breakdown {\n const base = stat.base;\n const withLevel = evaluateCurve(\n stat.valueCurve ?? undefined,\n base,\n Math.max(0, level),\n 0,\n );\n const withRank = statValue(stat, level, rank, def);\n const fromGear = worn.reduce(\n (s, w) => s + (pieceBonuses(w.def, w.level)[stat.id] ?? 0),\n 0,\n );\n const percent = worn.reduce(\n (s, w) => s + Number(w.def?.Stats?.PercentBonuses?.[stat.id] ?? 0),\n 0,\n );\n const subtotal = withRank + fromGear;\n const total = subtotal * (1 + percent / 100);\n return {\n base,\n fromLevel: withLevel - base,\n fromRank: withRank - withLevel,\n fromGear,\n percent,\n total,\n };\n}\n\n// ── Badge ────────────────────────────────────────────────────────────────────────────────────\n\n/** Badge of the heroes screen: heroes that can be unlocked or ranked up right now, plus better gear. */\nexport function heroesBadge(\n entries: RosterEntry[],\n canPay: (lines: ResourceLine[]) => boolean,\n ladderOf: (e: RosterEntry) => RankLadderView | null,\n betterGear: number,\n): number {\n let n = 0;\n for (const e of entries) {\n if (\n e.state === \"locked\" &&\n e.unlockPrice.length > 0 &&\n canPay(e.unlockPrice)\n )\n n++;\n if (e.state !== \"locked\") {\n const cost = rankCost(ladderOf(e), e.rank);\n if (cost && cost.length > 0 && canPay(cost)) n++;\n }\n }\n return n + (betterGear > 0 ? 1 : 0);\n}\n\n/** Whether the title has heroes at all — until it does, the lobby hides the Heroes tab. */\nexport function hasTitleData(section: CharacterSection | undefined): boolean {\n return Object.keys(section?.Definitions ?? {}).length > 0;\n}\n"
68
64
  },
69
65
  {
70
66
  "path": "module.ts",
71
- "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 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\";\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\".\nexport const characterUiModule: Module = defineModule({\n id: \"character-ui\",\n meta: { name: \"Heroes\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"character\",\n label: t(\"title\"),\n icon: \"hero\",\n group: \"economy\",\n order: 20,\n primary: true,\n Screen: makeCharacterScreen(ctx.features),\n });\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeBadgeWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"character\" }),\n describeActions: {},\n });\n },\n});\n\nfunction makeBadgeWatcher(features: FeatureRegistry) {\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 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 update();\n void client.character.getUserCharacters().then(update);\n void client.user.getUserInventory().then(update);\n return client.on(\"user:anyUpdated\", update);\n }, [client]);\n return createElement(\"span\", { hidden: true });\n };\n}\n"
67
+ "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\";\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\".\nexport const characterModule: Module = defineModule({\n id: \"character\",\n meta: { name: \"Heroes\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\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),\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),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"character\" }),\n describeActions: {},\n });\n },\n});\n\nfunction makeBadgeWatcher(features: FeatureRegistry) {\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 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 update();\n void client.character.getUserCharacters().then(update);\n void client.user.getUserInventory().then(update);\n return client.on(\"user:anyUpdated\", update);\n }, [client]);\n return createElement(\"span\", { hidden: true });\n };\n}\n"
72
68
  },
73
69
  {
74
70
  "path": "module.meta.json",
75
- "content": "{\n \"id\": \"character-ui\",\n \"type\": \"feature\",\n \"summary\": \"Heroes screen for any game: roster, ranks and stats with their costs, equipment slots with 'Equip best', item levels and skins.\",\n \"description\": \"A ready heroes screen (the Hero button of the Unity SDK) for ANY game, built on the @idosgames/react/ui kit and client.character. The screen is the selected hero's equipment hub: portrait, class and rarity, rank and server-computed Power, 'Rank up' with its price, gear slots (locked ones show the rank they open at), 'Equip best' when better gear fits, and the gear the player owns by class. Everything detailed is a popup: the hero list (select, unlock for currency), the stats (level/cap bars, upgrade prices, requirements, and 'where this number comes from' — base, stat levels, rank, gear flat and percent bonuses), a slot's fitting gear, a piece of gear (equip / take off / level up) and skins (wear, buy). Prices and caps follow the title's curves (rank ladder, stat cost curve, StatMaxLevelCurve, item Upgrade); the server re-checks everything. Registers the 'character' feature; the inventory hands equipment to it with ctx.features.open('character', { itemInstanceID }). Its badge counts heroes to unlock or rank up and better gear. Ships title-seed.json: three heroes (Knight free, Archer and Mage for gems) with stats, ranks and slots.\",\n \"provides\": [\n \"hero roster with unlock for currency\",\n \"rank up with price by the rank ladder\",\n \"stat upgrades with costs, caps and requirements\",\n \"stat breakdown popup by source\",\n \"equipment slots and equip / take off\",\n \"equip best gear automatically\",\n \"item level upgrades\",\n \"character skins\",\n \"demo heroes\"\n ],\n \"tags\": [\n \"heroes\",\n \"characters\",\n \"rpg\",\n \"stats\",\n \"equipment\",\n \"gear\",\n \"skins\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
71
+ "content": "{\n \"id\": \"character\",\n \"type\": \"feature\",\n \"summary\": \"Heroes screen for any game: roster, ranks and stats with their costs, equipment slots with 'Equip best', item levels and skins.\",\n \"description\": \"A ready heroes screen (the Hero button of the Unity SDK) for ANY game, built on the @idosgames/react/ui kit and client.character. The screen is the selected hero's equipment hub: portrait, class and rarity, rank and server-computed Power, 'Rank up' with its price, gear slots (locked ones show the rank they open at), 'Equip best' when better gear fits, and the gear the player owns by class. Everything detailed is a popup: the hero list (select, unlock for currency), the stats (level/cap bars, upgrade prices, requirements, and 'where this number comes from' — base, stat levels, rank, gear flat and percent bonuses), a slot's fitting gear, a piece of gear (equip / take off / level up) and skins (wear, buy). Prices and caps follow the title's curves (rank ladder, stat cost curve, StatMaxLevelCurve, item Upgrade); the server re-checks everything. Registers the 'character' feature; the inventory hands equipment to it with ctx.features.open('character', { itemInstanceID }). Its badge counts heroes to unlock or rank up and better gear. Ships title-seed.json: three heroes (Knight free, Archer and Mage for gems) with stats, ranks and slots.\",\n \"provides\": [\n \"hero roster with unlock for currency\",\n \"rank up with price by the rank ladder\",\n \"stat upgrades with costs, caps and requirements\",\n \"stat breakdown popup by source\",\n \"equipment slots and equip / take off\",\n \"equip best gear automatically\",\n \"item level upgrades\",\n \"character skins\",\n \"demo heroes\"\n ],\n \"tags\": [\n \"heroes\",\n \"characters\",\n \"rpg\",\n \"stats\",\n \"equipment\",\n \"gear\",\n \"skins\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
76
72
  }
77
73
  ]
78
74
  }