@idosgames/mcp 0.1.20 → 0.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/registry/host.json +10 -10
- package/registry/index.json +141 -141
- package/registry/modules/ai.json +7 -7
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/boosts.json +3 -3
- package/registry/modules/character.json +7 -7
- package/registry/modules/chat.json +7 -7
- package/registry/modules/collections.json +7 -7
- package/registry/modules/coop-events.json +7 -7
- package/registry/modules/craft.json +7 -7
- package/registry/modules/deals.json +7 -7
- package/registry/modules/events.json +7 -7
- package/registry/modules/friends.json +7 -7
- package/registry/modules/idle-rpg.json +4 -4
- package/registry/modules/inventory.json +3 -3
- package/registry/modules/invite.json +7 -7
- package/registry/modules/leaderboard.json +7 -7
- package/registry/modules/lootboxes.json +3 -3
- package/registry/modules/mailbox.json +3 -3
- package/registry/modules/marketplace.json +7 -7
- package/registry/modules/mini-game.json +7 -7
- package/registry/modules/multiplayer.json +7 -7
- package/registry/modules/premium.json +7 -7
- package/registry/modules/profile.json +7 -7
- package/registry/modules/pvp.json +3 -3
- package/registry/modules/quests.json +3 -3
- package/registry/modules/rewards.json +7 -7
- package/registry/modules/season.json +7 -7
- package/registry/modules/store.json +7 -7
- package/registry/modules/tutorials.json +7 -7
- package/registry/modules/voxelcraft.json +6 -6
- package/registry/modules/workshop.json +7 -7
- package/registry/skills/authentication.json +1 -1
- package/registry/skills/idosgames-module-contract.json +1 -1
- package/registry/skills/idosgames-project-structure.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +1 -1
package/registry/modules/ai.json
CHANGED
|
@@ -27,19 +27,19 @@
|
|
|
27
27
|
"name": "iDos Games",
|
|
28
28
|
"url": "https://idosgames.com"
|
|
29
29
|
},
|
|
30
|
-
"version": "0.1.
|
|
30
|
+
"version": "0.1.1"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@idosgames/core": "0.
|
|
34
|
-
"@idosgames/module-sdk": "0.
|
|
35
|
-
"@idosgames/react": "0.
|
|
33
|
+
"@idosgames/core": "0.20.0",
|
|
34
|
+
"@idosgames/module-sdk": "0.8.1",
|
|
35
|
+
"@idosgames/react": "0.4.2",
|
|
36
36
|
"react": "19.2.7"
|
|
37
37
|
},
|
|
38
|
-
"contentHash": "
|
|
38
|
+
"contentHash": "09df6751a0e4009a1b8675178a88f9ae49bc33edf36e352f22dbf01dd4482d1e",
|
|
39
39
|
"files": [
|
|
40
40
|
{
|
|
41
41
|
"path": "AiScreen.tsx",
|
|
42
|
-
"content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type { AIGenerationView, IDosGamesClient } from \"@idosgames/core\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n ResourceList,\n Spinner,\n Tabs,\n canAfford,\n configSection,\n errorText,\n outlined,\n panel,\n useCatalog,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n featureRows,\n resultKind,\n type AISection,\n type FeatureRow,\n type Modality,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// AI studio (engine AI): the AI features the publisher opened to players — text, pictures, speech,\n// music, video, 3D. The player writes what they want, pays the feature's price (or nothing), and the\n// result comes back from a job the server runs (polled with waitForGeneration). A feature tagged\n// \"hidden\" is for the game's own code (NPC lines and the like) and does not show here.\n\ntype Tab = \"create\" | \"history\";\n\nexport function AiScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const [tab, setTab] = useState<Tab>(\"create\");\n const [picked, setPicked] = useState<string | null>(null);\n const features = featureRows(configSection<AISection>(client, \"AI\"));\n if (features.length === 0)\n return <EmptyState glyph=\"bulb\" text={t(\"noFeatures\")} />;\n const current = features.find((f) => f.key === picked) ?? features[0]!;\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <Tabs\n tabs={[\n { id: \"create\", label: t(\"create\") },\n { id: \"history\", label: t(\"history\") },\n ]}\n value={tab}\n onChange={setTab}\n />\n {tab === \"create\" ? (\n <>\n {features.length > 1 ? (\n <div style={{ display: \"flex\", gap: 6, flexWrap: \"wrap\" }}>\n {features.map((f) => (\n <Button\n key={f.key}\n size=\"sm\"\n tone={f.key === current.key ? \"gold\" : \"grey\"}\n onClick={() => setPicked(f.key)}\n >\n {catalog.localize(f.key)} · {t(`m_${f.modality}`)}\n </Button>\n ))}\n </div>\n ) : null}\n <Create key={current.key} feature={current} />\n </>\n ) : (\n <History />\n )}\n </div>\n );\n}\n\n/** Starts a job for a feature by its modality and waits for the result. */\nfunction run(\n client: IDosGamesClient,\n feature: FeatureRow,\n prompt: string,\n optionID: string | undefined,\n): Promise<\n { ok: true; data: AIGenerationView } | { ok: false; error: string }\n> {\n const options = {\n selectedOptionID: optionID,\n relatedEntityID: crypto.randomUUID(),\n };\n const byModality: Record<\n Modality,\n () => ReturnType<IDosGamesClient[\"ai\"][\"generateText\"]>\n > = {\n Text: () =>\n client.ai.generateText(feature.key, { Prompt: prompt }, options),\n Image: () =>\n client.ai.generateImage(feature.key, { Prompt: prompt }, options),\n Audio: () =>\n client.ai.generateAudio(feature.key, { Text: prompt }, options),\n Music: () =>\n client.ai.generateMusic(feature.key, { Prompt: prompt }, options),\n Video: () =>\n client.ai.generateVideo(feature.key, { Prompt: prompt }, options),\n ThreeD: () =>\n client.ai.generateThreeD(feature.key, { Prompt: prompt }, options),\n };\n return byModality[feature.modality]().then((started) =>\n started.ok\n ? client.ai.waitForGeneration(started.data, { timeoutMs: 10 * 60_000 })\n : started,\n );\n}\n\nfunction Create({ feature }: { feature: FeatureRow }): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const [prompt, setPrompt] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [result, setResult] = useState<AIGenerationView | null>(null);\n const alive = useRef(true);\n useEffect(\n () => () => {\n alive.current = false;\n },\n [],\n );\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const option =\n feature.options.find((o) => canAfford(o.price, balances)) ??\n feature.options[0];\n const affordable = !option || canAfford(option.price, balances);\n\n const go = async () => {\n setBusy(true);\n setResult(null);\n const res = await run(client, feature, prompt.trim(), option?.id);\n if (!alive.current) return;\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (res.data.Status !== \"completed\")\n return toast(res.data.Error?.Message || t(\"failed\"), \"error\");\n setResult(res.data);\n };\n\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n <textarea\n value={prompt}\n maxLength={feature.maxChars > 0 ? feature.maxChars : undefined}\n placeholder={\n feature.modality === \"Audio\" ? t(\"sayWhat\") : t(\"describe\")\n }\n onChange={(e) => setPrompt(e.target.value)}\n rows={4}\n style={{\n ...panel,\n padding: 12,\n color:
|
|
42
|
+
"content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type { AIGenerationView, IDosGamesClient } from \"@idosgames/core\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n ResourceList,\n Spinner,\n Tabs,\n canAfford,\n configSection,\n errorText,\n outlined,\n panel,\n useCatalog,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n featureRows,\n resultKind,\n type AISection,\n type FeatureRow,\n type Modality,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// AI studio (engine AI): the AI features the publisher opened to players — text, pictures, speech,\n// music, video, 3D. The player writes what they want, pays the feature's price (or nothing), and the\n// result comes back from a job the server runs (polled with waitForGeneration). A feature tagged\n// \"hidden\" is for the game's own code (NPC lines and the like) and does not show here.\n\ntype Tab = \"create\" | \"history\";\n\nexport function AiScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const [tab, setTab] = useState<Tab>(\"create\");\n const [picked, setPicked] = useState<string | null>(null);\n const features = featureRows(configSection<AISection>(client, \"AI\"));\n if (features.length === 0)\n return <EmptyState glyph=\"bulb\" text={t(\"noFeatures\")} />;\n const current = features.find((f) => f.key === picked) ?? features[0]!;\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <Tabs\n tabs={[\n { id: \"create\", label: t(\"create\") },\n { id: \"history\", label: t(\"history\") },\n ]}\n value={tab}\n onChange={setTab}\n />\n {tab === \"create\" ? (\n <>\n {features.length > 1 ? (\n <div style={{ display: \"flex\", gap: 6, flexWrap: \"wrap\" }}>\n {features.map((f) => (\n <Button\n key={f.key}\n size=\"sm\"\n tone={f.key === current.key ? \"gold\" : \"grey\"}\n onClick={() => setPicked(f.key)}\n >\n {catalog.localize(f.key)} · {t(`m_${f.modality}`)}\n </Button>\n ))}\n </div>\n ) : null}\n <Create key={current.key} feature={current} />\n </>\n ) : (\n <History />\n )}\n </div>\n );\n}\n\n/** Starts a job for a feature by its modality and waits for the result. */\nfunction run(\n client: IDosGamesClient,\n feature: FeatureRow,\n prompt: string,\n optionID: string | undefined,\n): Promise<\n { ok: true; data: AIGenerationView } | { ok: false; error: string }\n> {\n const options = {\n selectedOptionID: optionID,\n relatedEntityID: crypto.randomUUID(),\n };\n const byModality: Record<\n Modality,\n () => ReturnType<IDosGamesClient[\"ai\"][\"generateText\"]>\n > = {\n Text: () =>\n client.ai.generateText(feature.key, { Prompt: prompt }, options),\n Image: () =>\n client.ai.generateImage(feature.key, { Prompt: prompt }, options),\n Audio: () =>\n client.ai.generateAudio(feature.key, { Text: prompt }, options),\n Music: () =>\n client.ai.generateMusic(feature.key, { Prompt: prompt }, options),\n Video: () =>\n client.ai.generateVideo(feature.key, { Prompt: prompt }, options),\n ThreeD: () =>\n client.ai.generateThreeD(feature.key, { Prompt: prompt }, options),\n };\n return byModality[feature.modality]().then((started) =>\n started.ok\n ? client.ai.waitForGeneration(started.data, { timeoutMs: 10 * 60_000 })\n : started,\n );\n}\n\nfunction Create({ feature }: { feature: FeatureRow }): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const [prompt, setPrompt] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [result, setResult] = useState<AIGenerationView | null>(null);\n const alive = useRef(true);\n useEffect(\n () => () => {\n alive.current = false;\n },\n [],\n );\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const option =\n feature.options.find((o) => canAfford(o.price, balances)) ??\n feature.options[0];\n const affordable = !option || canAfford(option.price, balances);\n\n const go = async () => {\n setBusy(true);\n setResult(null);\n const res = await run(client, feature, prompt.trim(), option?.id);\n if (!alive.current) return;\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (res.data.Status !== \"completed\")\n return toast(res.data.Error?.Message || t(\"failed\"), \"error\");\n setResult(res.data);\n };\n\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n <textarea\n value={prompt}\n maxLength={feature.maxChars > 0 ? feature.maxChars : undefined}\n placeholder={\n feature.modality === \"Audio\" ? t(\"sayWhat\") : t(\"describe\")\n }\n onChange={(e) => setPrompt(e.target.value)}\n rows={4}\n style={{\n ...panel,\n padding: 12,\n color: v.text,\n fontSize: 15,\n resize: \"vertical\",\n width: \"100%\",\n boxSizing: \"border-box\",\n }}\n />\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract={!!prompt.trim() && affordable}\n disabled={!prompt.trim() || !affordable}\n busy={busy}\n onClick={() => void go()}\n data-tutorial-anchor={`ai:${feature.key}`}\n >\n {busy ? t(\"working\") : t(\"generate\")}{\" \"}\n {option ? <ResourceList lines={option.price} size={18} /> : null}\n </Button>\n {busy ? (\n <Center>\n <Spinner />\n </Center>\n ) : null}\n {result ? <Result generation={result} /> : null}\n </div>\n );\n}\n\nfunction Result({ generation }: { generation: AIGenerationView }): ReactNode {\n return (\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 10 }}>\n {generation.OutputText ? (\n <div style={{ color: v.text, fontSize: 15, whiteSpace: \"pre-wrap\" }}>\n {generation.OutputText}\n </div>\n ) : null}\n {(generation.Assets ?? []).map((a, i) => {\n const kind = resultKind(a.ContentType);\n const url = a.Url ?? \"\";\n return kind === \"image\" ? (\n <img\n key={i}\n src={url}\n alt=\"\"\n style={{ width: \"100%\", borderRadius: 12 }}\n />\n ) : kind === \"audio\" ? (\n <audio key={i} src={url} controls style={{ width: \"100%\" }} />\n ) : kind === \"video\" ? (\n <video\n key={i}\n src={url}\n controls\n style={{ width: \"100%\", borderRadius: 12 }}\n />\n ) : (\n <a\n key={i}\n href={url}\n target=\"_blank\"\n rel=\"noreferrer\"\n style={{ ...outlined, color: v.gold, fontSize: 14 }}\n >\n {t(\"download\")}\n </a>\n );\n })}\n </div>\n );\n}\n\nfunction History(): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const [items, setItems] = useState<AIGenerationView[] | null>(null);\n const [open, setOpen] = useState<string | null>(null);\n useEffect(() => {\n void client.ai\n .listGenerations({ Limit: 20 })\n .then((r) => setItems(r.ok ? (r.data.Data ?? []) : []));\n }, [client]);\n if (!items)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (items.length === 0)\n return <EmptyState glyph=\"bulb\" text={t(\"noHistory\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {items.map((g, i) => (\n <Card\n key={g.GenerationID}\n onClick={() =>\n setOpen(open === g.GenerationID ? null : g.GenerationID)\n }\n style={{ display: \"grid\", gap: 8, ...stagger(i) }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n <Icon glyph=\"bulb\" size={26} />\n <span style={{ ...outlined, fontSize: 14, flex: 1 }}>\n {catalog.localize(g.FeatureKey ?? \"\")}\n </span>\n <span\n style={{\n ...outlined,\n fontSize: 12,\n color:\n g.Status === \"completed\"\n ? v.green\n : g.Status === \"failed\"\n ? v.textDim\n : v.gold,\n }}\n >\n {t(\n `s_${g.Status === \"completed\" || g.Status === \"failed\" ? g.Status : \"running\"}`,\n )}\n </span>\n </div>\n {open === g.GenerationID && g.Status === \"completed\" ? (\n <Result generation={g} />\n ) : null}\n </Card>\n ))}\n </div>\n );\n}\n"
|
|
43
43
|
},
|
|
44
44
|
{
|
|
45
45
|
"path": "i18n.ts",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
},
|
|
60
60
|
{
|
|
61
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.
|
|
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.1\"\n}\n"
|
|
63
63
|
}
|
|
64
64
|
]
|
|
65
65
|
}
|
|
@@ -45,10 +45,10 @@
|
|
|
45
45
|
}
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@idosgames/core": "0.
|
|
49
|
-
"@idosgames/module-sdk": "0.
|
|
50
|
-
"@idosgames/react": "0.
|
|
51
|
-
"@idosgames/wallet": "0.6.
|
|
48
|
+
"@idosgames/core": "0.20.0",
|
|
49
|
+
"@idosgames/module-sdk": "0.8.1",
|
|
50
|
+
"@idosgames/react": "0.4.2",
|
|
51
|
+
"@idosgames/wallet": "0.6.1",
|
|
52
52
|
"@tanstack/react-query": "5.101.2",
|
|
53
53
|
"react": "19.2.7",
|
|
54
54
|
"three": "0.185.1",
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"version": "0.1.0"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@idosgames/core": "0.
|
|
35
|
-
"@idosgames/module-sdk": "0.
|
|
36
|
-
"@idosgames/react": "0.
|
|
34
|
+
"@idosgames/core": "0.20.0",
|
|
35
|
+
"@idosgames/module-sdk": "0.8.1",
|
|
36
|
+
"@idosgames/react": "0.4.2",
|
|
37
37
|
"react": "19.2.7"
|
|
38
38
|
},
|
|
39
39
|
"contentHash": "1c1a852a29a89982e2ed997b1a2356bc4722d00eb4db834ab7472f66f9a8eee3",
|
|
@@ -36,19 +36,19 @@
|
|
|
36
36
|
"name": "iDos Games",
|
|
37
37
|
"url": "https://idosgames.com"
|
|
38
38
|
},
|
|
39
|
-
"version": "0.1.
|
|
39
|
+
"version": "0.1.1"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@idosgames/core": "0.
|
|
43
|
-
"@idosgames/module-sdk": "0.
|
|
44
|
-
"@idosgames/react": "0.
|
|
42
|
+
"@idosgames/core": "0.20.0",
|
|
43
|
+
"@idosgames/module-sdk": "0.8.1",
|
|
44
|
+
"@idosgames/react": "0.4.2",
|
|
45
45
|
"react": "19.2.7"
|
|
46
46
|
},
|
|
47
|
-
"contentHash": "
|
|
47
|
+
"contentHash": "6f756470b453ff891d612204dbe04e6826000abe0f7a7ba67df240516eb707bb",
|
|
48
48
|
"files": [
|
|
49
49
|
{
|
|
50
50
|
"path": "CharacterScreen.tsx",
|
|
51
|
-
"content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Badge,\n Button,\n Card,\n EmptyState,\n Icon,\n ItemTile,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Tabs,\n canAfford,\n configSection,\n costLines,\n errorText,\n formatAmount,\n outlined,\n panel,\n useCatalog,\n useGameLayout,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\nimport {\n SKIN_KEY,\n bestLoadout,\n breakdown,\n fitProblem,\n gearPieces,\n itemLevelCost,\n pieceBonuses,\n rankCost,\n resolveLadder,\n resolveSlots,\n resolveStats,\n roster,\n statCost,\n statMaxLevel,\n unmetRequirements,\n type CharacterModelView,\n type CharacterSection,\n type GearPiece,\n type InstanceView,\n type ItemDefWithGear,\n type RosterEntry,\n type SlotView,\n type StatView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Heroes (Unity: the Hero button → CharacterListPanel, CharacterDetailPanel, EquipmentPanel). The\n// screen is the equipment hub of the selected hero — portrait, rank and Power, the slots, \"Equip\n// best\", and the gear the player owns. Everything with more data opens as a popup: the hero list\n// (select / unlock), the stats (upgrade, and \"where does this number come from\"), a piece of gear\n// (equip / take off / level up), a slot's fitting gear, and skins.\n\nexport function makeCharacterScreen(features: FeatureRegistry) {\n return function CharacterScreen({ args }: FeatureScreenProps): ReactNode {\n return (\n <Heroes\n features={features}\n openInstance={\n typeof args?.itemInstanceID === \"string\" ? args.itemInstanceID : null\n }\n />\n );\n };\n}\n\ntype PopupState =\n | { kind: \"heroes\" }\n | { kind: \"stats\" }\n | { kind: \"skins\" }\n | { kind: \"slot\"; slot: SlotView }\n | { kind: \"piece\"; instanceID: string }\n | null;\n\nconst SELECTED_KEY = \"idos-heroes-selected\";\n\nfunction useData() {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n // Definitions arrive with the title config; a title whose config did not carry them fetches them\n // once — and the screen redraws when they land (the section cache emits no user-state event).\n const [, loaded] = useState(0);\n const section = configSection<CharacterSection>(client, \"Character\");\n useEffect(() => {\n if (!section)\n void client.character\n .getCharacterDefinitions()\n .then(() => loaded((n) => n + 1));\n void client.character.getUserCharacters();\n // Gear instances (levels, where they are worn) live outside the player document on the server\n // and come with the full inventory read, not with the login state.\n void client.user.getUserInventory();\n }, [client, section]);\n const owned = (state?.Character?.Characters ?? {}) as Record<\n string,\n CharacterModelView\n >;\n const inventory = state?.InventoryV2 as\n | {\n UnstackableItems?: Record<string, InstanceView | null>;\n VirtualCurrencies?: Record<string, { Amount?: number }>;\n }\n | undefined;\n const items = catalog.items as Map<string, ItemDefWithGear>;\n // Not memoized: the SDK cache updates UnstackableItems IN PLACE (an equip flips EquippedSlot on\n // the same object), so a memo keyed by the object would keep showing the old state.\n const pieces = gearPieces(inventory?.UnstackableItems, items);\n return {\n client,\n section,\n owned,\n inventory,\n items,\n pieces,\n balances: inventory?.VirtualCurrencies,\n };\n}\n\nfunction Heroes({\n features,\n openInstance,\n}: {\n features: FeatureRegistry;\n openInstance: string | null;\n}): ReactNode {\n const { client, section, owned, items, pieces, balances } = useData();\n const catalog = useCatalog();\n const layout = useGameLayout();\n const toast = useToast();\n const kit = useUiKit();\n const cardRef = useRef<HTMLDivElement>(null);\n const [popup, setPopup] = useState<PopupState>(\n openInstance ? { kind: \"piece\", instanceID: openInstance } : null,\n );\n const [busy, setBusy] = useState<string | null>(null);\n const [selectedID, setSelectedID] = useState<string | null>(() => {\n try {\n return localStorage.getItem(SELECTED_KEY);\n } catch {\n return null;\n }\n });\n\n const list = roster(section, owned);\n if (list.length === 0)\n return <EmptyState glyph=\"hero\" text={t(\"noHeroes\")} />;\n const hero =\n list.find((h) => h.id === selectedID && h.state !== \"locked\") ??\n list.find((h) => h.state !== \"locked\") ??\n list[0]!;\n const select = (id: string) => {\n setSelectedID(id);\n try {\n localStorage.setItem(SELECTED_KEY, id);\n } catch {\n // storage blocked: the choice lives for this session\n }\n };\n\n const ladder = resolveLadder(section, hero.def);\n const nextRank = rankCost(ladder, hero.rank);\n const slots = resolveSlots(section, hero.def);\n const changes =\n hero.state === \"locked\"\n ? []\n : bestLoadout(\n hero.id,\n hero.rank,\n slots,\n pieces,\n hero.model?.Equipment,\n items,\n );\n const rarity = catalog.rarityColor(hero.def.Classification?.RarityID);\n const heroName = catalog.localize(hero.def.Identity?.DisplayName ?? hero.id);\n const skinCount = Object.keys(hero.def.Skins?.Definitions ?? {}).length;\n\n const rankUp = async () => {\n setBusy(\"rank\");\n const res = await client.character.upgradeCharacterLevel(hero.id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"levelUp\");\n kit.fx.burst(cardRef.current);\n };\n\n const equipBest = async () => {\n setBusy(\"best\");\n const res = await client.character.equipItems(hero.id, changes);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(cardRef.current);\n };\n\n const card = (\n <div\n ref={cardRef}\n className=\"idos-shine\"\n style={{\n ...panel,\n padding: 16,\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n background: `radial-gradient(circle at 50% 30%, ${rarity}55 0%, ${v.panelDeep} 75%)`,\n border: `2px solid ${rarity}`,\n }}\n >\n <div\n className=\"idos-bounce\"\n style={{\n width: 110,\n height: 110,\n borderRadius: 28,\n display: \"grid\",\n placeItems: \"center\",\n background: \"rgba(0,0,0,.25)\",\n border: `3px solid ${rarity}`,\n }}\n >\n <Icon\n glyph={\n hero.def.Identity?.AssetPaths?.portrait ??\n hero.def.Identity?.AssetPaths?.icon ??\n \"hero\"\n }\n size={80}\n />\n </div>\n <div style={{ ...outlined, fontSize: 22 }}>{heroName}</div>\n {hero.def.Classification?.ClassID ? (\n <div style={{ ...outlined, fontSize: 12, color: rarity }}>\n {hero.def.Classification.ClassID} · {hero.def.Classification.RarityID}\n </div>\n ) : null}\n <div style={{ display: \"flex\", gap: 18 }}>\n <Stat\n label={t(\"rank\")}\n value={`${hero.rank}${ladder?.MaxRank ? ` / ${ladder.MaxRank}` : \"\"}`}\n />\n <Stat label={t(\"power\")} value={formatAmount(hero.power)} />\n </div>\n {hero.state === \"locked\" ? null : nextRank === null ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"maxRank\")}\n </div>\n ) : (\n <Button\n tone=\"gold\"\n attract={canAfford(nextRank, balances)}\n disabled={!canAfford(nextRank, balances)}\n busy={busy === \"rank\"}\n onClick={() => void rankUp()}\n data-tutorial-anchor=\"heroes:rank-up\"\n >\n {t(\"rankUp\")}{\" \"}\n {nextRank.length ? (\n <ResourceList\n lines={nextRank}\n size={18}\n dimIf={() => !canAfford(nextRank, balances)}\n />\n ) : (\n t(\"free\")\n )}\n </Button>\n )}\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n <span style={{ position: \"relative\" }}>\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup({ kind: \"heroes\" })}\n >\n <Icon glyph=\"friends\" size={18} /> {t(\"heroes\")}\n </Button>\n <Badge\n count={\n list.filter(\n (h) =>\n h.state === \"locked\" &&\n h.unlockPrice.length > 0 &&\n canAfford(h.unlockPrice, balances),\n ).length\n }\n dot\n />\n </span>\n {hero.state !== \"locked\" ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup({ kind: \"stats\" })}\n >\n <Icon glyph=\"sword\" size={18} /> {t(\"stats\")}\n </Button>\n ) : null}\n {skinCount > 0 && hero.state !== \"locked\" ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup({ kind: \"skins\" })}\n >\n <Icon glyph=\"user\" size={18} /> {t(\"skins\")}\n </Button>\n ) : null}\n </div>\n </div>\n );\n\n const equipment = (\n <div style={{ display: \"grid\", gap: 14 }}>\n <SectionTitle\n right={\n changes.length > 0 ? (\n <Button\n tone=\"green\"\n size=\"sm\"\n attract\n busy={busy === \"best\"}\n onClick={() => void equipBest()}\n data-tutorial-anchor=\"heroes:equip-best\"\n >\n {t(\"equipBest\")}\n </Button>\n ) : null\n }\n >\n {t(\"equipment\")}\n </SectionTitle>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(auto-fill, minmax(${layout === \"phone\" ? 88 : 104}px, 1fr))`,\n gap: 12,\n justifyItems: \"center\",\n }}\n >\n {slots.map((slot) => (\n <SlotTile\n key={slot.id}\n slot={slot}\n hero={hero}\n pieces={pieces}\n onClick={() => setPopup({ kind: \"slot\", slot })}\n />\n ))}\n </div>\n <Bag\n pieces={pieces.filter((p) => !p.equippedBy)}\n onPick={(id) => setPopup({ kind: \"piece\", instanceID: id })}\n onShop={\n features.get(\"store\") ? () => features.open(\"store\") : undefined\n }\n />\n </div>\n );\n\n return (\n <div style={{ display: \"grid\", gap: 18 }}>\n {layout === \"desktop\" ? (\n <div\n style={{\n display: \"grid\",\n gap: 20,\n gridTemplateColumns: \"minmax(0, 4fr) minmax(0, 6fr)\",\n alignItems: \"start\",\n }}\n >\n {card}\n {equipment}\n </div>\n ) : (\n <>\n {card}\n {equipment}\n </>\n )}\n {popup?.kind === \"heroes\" ? (\n <HeroesPopup\n list={list}\n current={hero.id}\n balances={balances}\n onSelect={select}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup?.kind === \"stats\" ? (\n <StatsPopup\n hero={hero}\n section={section}\n pieces={pieces}\n items={items}\n balances={balances}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup?.kind === \"skins\" ? (\n <SkinsPopup hero={hero} onClose={() => setPopup(null)} />\n ) : null}\n {popup?.kind === \"slot\" ? (\n <SlotPopup\n slot={popup.slot}\n hero={hero}\n pieces={pieces}\n items={items}\n onPick={(id) => setPopup({ kind: \"piece\", instanceID: id })}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup?.kind === \"piece\" ? (\n <PiecePopup\n instanceID={popup.instanceID}\n hero={hero}\n slots={slots}\n pieces={pieces}\n items={items}\n balances={balances}\n onClose={() => setPopup(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction Stat({ label, value }: { label: string; value: string }): ReactNode {\n return (\n <div style={{ display: \"grid\", justifyItems: \"center\" }}>\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {label}\n </span>\n <span style={{ ...outlined, fontSize: 20, color: v.gold }}>{value}</span>\n </div>\n );\n}\n\nfunction SlotTile({\n slot,\n hero,\n pieces,\n onClick,\n}: {\n slot: SlotView;\n hero: RosterEntry;\n pieces: GearPiece[];\n onClick: () => void;\n}): ReactNode {\n const worn = hero.model?.Equipment?.[slot.id];\n const piece = worn?.ItemInstanceID\n ? pieces.find((p) => p.instanceID === worn.ItemInstanceID)\n : undefined;\n const locked = hero.rank < slot.minRank;\n const { play } = useUiKit();\n return (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n {worn?.ItemID ? (\n <ItemTile\n itemID={worn.ItemID}\n level={piece?.level}\n onClick={onClick}\n size={80}\n />\n ) : (\n <button\n type=\"button\"\n className=\"idos-press\"\n onClick={() => {\n play(\"click\");\n onClick();\n }}\n style={{\n width: 80,\n height: 80,\n borderRadius: 16,\n border: `3px dashed ${v.panelEdge}`,\n background: \"rgba(0,0,0,.25)\",\n display: \"grid\",\n placeItems: \"center\",\n cursor: \"pointer\",\n opacity: locked ? 0.5 : 1,\n }}\n >\n <Icon glyph={locked ? \"lock\" : \"plus\"} size={30} />\n </button>\n )}\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {locked ? `${t(\"slotLocked\")} ${slot.minRank}` : slot.id}\n </span>\n </div>\n );\n}\n\nfunction Bag({\n pieces,\n onPick,\n onShop,\n}: {\n pieces: GearPiece[];\n onPick: (instanceID: string) => void;\n onShop?: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const stagger = useStagger();\n const classes = [\n ...new Set(\n pieces.map((p) => catalog.items.get(p.itemID)?.ItemClass || \"other\"),\n ),\n ];\n const [tab, setTab] = useState(\"all\");\n const shown =\n tab === \"all\"\n ? pieces\n : pieces.filter(\n (p) => (catalog.items.get(p.itemID)?.ItemClass || \"other\") === tab,\n );\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n <SectionTitle>{t(\"bag\")}</SectionTitle>\n {pieces.length === 0 ? (\n <EmptyState\n glyph=\"sword\"\n text={t(\"noGear\")}\n action={\n onShop ? (\n <Button onClick={onShop}>{catalog.localize(\"Shop\")}</Button>\n ) : undefined\n }\n />\n ) : (\n <>\n <Tabs\n tabs={[\n { id: \"all\", label: \"★\" },\n ...classes.map((c) => ({ id: c, label: c })),\n ]}\n value={tab}\n onChange={setTab}\n />\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(84px, 1fr))\",\n gap: 10,\n justifyItems: \"center\",\n }}\n >\n {shown.map((p, i) => (\n <div key={p.instanceID} style={stagger(i)}>\n <ItemTile\n itemID={p.itemID}\n level={p.level}\n onClick={() => onPick(p.instanceID)}\n />\n </div>\n ))}\n </div>\n </>\n )}\n </div>\n );\n}\n\n// ── Popups ───────────────────────────────────────────────────────────────────────────────────\n\nfunction HeroesPopup({\n list,\n current,\n balances,\n onSelect,\n onClose,\n}: {\n list: RosterEntry[];\n current: string;\n balances: Record<string, { Amount?: number }> | undefined;\n onSelect: (id: string) => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n\n const unlock = async (e: RosterEntry) => {\n setBusy(e.id);\n const res = await client.character.unlockCharacter(e.id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"levelUp\");\n onSelect(e.id);\n };\n\n return (\n <Popup title={t(\"heroes\")} onClose={onClose} width={560}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(150px, 1fr))\",\n gap: 10,\n }}\n >\n {list.map((e, i) => {\n const color = catalog.rarityColor(e.def.Classification?.RarityID);\n const affordable = canAfford(e.unlockPrice, balances);\n return (\n <Card\n key={e.id}\n highlight={e.id === current}\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 6,\n opacity: e.state === \"locked\" ? 0.85 : 1,\n border: `2px solid ${e.id === current ? v.gold : color}`,\n ...stagger(i),\n }}\n >\n <div style={{ position: \"relative\" }}>\n <Icon\n glyph={e.def.Identity?.AssetPaths?.icon ?? \"hero\"}\n size={56}\n />\n {e.state === \"locked\" ? (\n <span style={{ position: \"absolute\", right: -8, bottom: -4 }}>\n <Icon glyph=\"lock\" size={22} />\n </span>\n ) : null}\n </div>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(e.def.Identity?.DisplayName ?? e.id)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {e.state === \"locked\"\n ? t(\"locked\")\n : `${t(\"rank\")} ${e.rank} · ${t(\"power\")} ${formatAmount(e.power)}`}\n </div>\n {e.state === \"locked\" ? (\n e.unlockPrice.length > 0 ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract={affordable}\n disabled={!affordable}\n busy={busy === e.id}\n onClick={() => void unlock(e)}\n style={{ width: \"100%\" }}\n >\n {t(\"unlock\")}{\" \"}\n <ResourceList lines={e.unlockPrice} size={16} />\n </Button>\n ) : (\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"notForSale\")}\n </span>\n )\n ) : e.id === current ? (\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"selected\")}\n </span>\n ) : (\n <SelectButton onSelect={() => onSelect(e.id)} />\n )}\n </Card>\n );\n })}\n </div>\n </Popup>\n );\n}\n\nfunction SelectButton({ onSelect }: { onSelect: () => void }): ReactNode {\n const close = usePopupClose();\n return (\n <Button\n size=\"sm\"\n onClick={() => {\n onSelect();\n close();\n }}\n style={{ width: \"100%\" }}\n >\n {t(\"select\")}\n </Button>\n );\n}\n\nfunction StatsPopup({\n hero,\n section,\n pieces,\n items,\n balances,\n onClose,\n}: {\n hero: RosterEntry;\n section: CharacterSection | undefined;\n pieces: GearPiece[];\n items: Map<string, ItemDefWithGear>;\n balances: Record<string, { Amount?: number }> | undefined;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const play = useSound();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n const [detail, setDetail] = useState<StatView | null>(null);\n const stats = resolveStats(section, hero.def);\n const levels = hero.model?.StatLevels ?? {};\n const worn = Object.values(hero.model?.Equipment ?? {})\n .filter((e) => e?.ItemInstanceID)\n .map((e) => {\n const p = pieces.find((x) => x.instanceID === e!.ItemInstanceID);\n return { def: items.get(e!.ItemID ?? \"\"), level: p?.level ?? 1 };\n });\n\n const upgrade = async (stat: StatView) => {\n setBusy(stat.id);\n const res = await client.character.upgradeStatLevel(hero.id, stat.id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n play(\"levelUp\");\n };\n\n return (\n <Popup\n title={`${t(\"stats\")} · ${catalog.localize(hero.def.Identity?.DisplayName ?? hero.id)}`}\n onClose={onClose}\n width={480}\n >\n <div style={{ display: \"grid\", gap: 10 }}>\n {stats.map((stat, i) => {\n const level = Number(levels[stat.id] ?? 0);\n const cap = statMaxLevel(stat, hero.rank, hero.def);\n const capped = cap > 0 && level >= cap;\n const cost = statCost(stat, level + 1);\n const missing = unmetRequirements(stat, levels);\n const affordable = canAfford(cost, balances);\n const total = breakdown(stat, level, hero.rank, hero.def, worn).total;\n return (\n <Card\n key={stat.id}\n style={{ display: \"grid\", gap: 8, ...stagger(i) }}\n >\n <button\n type=\"button\"\n onClick={() => setDetail(stat)}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n background: \"none\",\n border: \"none\",\n padding: 0,\n cursor: \"pointer\",\n color: v.text,\n }}\n >\n <span style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(stat.name)} ⓘ\n </span>\n <span style={{ ...outlined, fontSize: 18, color: v.gold }}>\n {formatAmount(Math.round(total * 10) / 10)}\n </span>\n </button>\n {cap > 0 ? (\n <ProgressBar\n value={level}\n max={cap}\n label={`${t(\"level\")} ${level} / ${cap}`}\n height={16}\n />\n ) : null}\n {missing.length > 0 ? (\n <div style={{ ...outlined, fontSize: 12, color: v.red }}>\n {t(\"needs\")}:{\" \"}\n {missing.map((m) => `${m.statID} ${m.level}`).join(\", \")}\n </div>\n ) : capped ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"statCapped\")}\n </div>\n ) : (\n <Button\n size=\"sm\"\n tone=\"green\"\n disabled={!affordable}\n busy={busy === stat.id}\n onClick={() => void upgrade(stat)}\n >\n {t(\"upgrade\")} →{\" \"}\n {formatAmount(\n Math.round(\n breakdown(stat, level + 1, hero.rank, hero.def, worn)\n .total * 10,\n ) / 10,\n )}{\" \"}\n <ResourceList\n lines={cost}\n size={16}\n dimIf={() => !affordable}\n />\n </Button>\n )}\n </Card>\n );\n })}\n </div>\n {detail ? (\n <BreakdownPopup\n stat={detail}\n level={Number(levels[detail.id] ?? 0)}\n hero={hero}\n worn={worn}\n onClose={() => setDetail(null)}\n />\n ) : null}\n </Popup>\n );\n}\n\nfunction BreakdownPopup({\n stat,\n level,\n hero,\n worn,\n onClose,\n}: {\n stat: StatView;\n level: number;\n hero: RosterEntry;\n worn: Array<{ def: ItemDefWithGear | undefined; level: number }>;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const b = breakdown(stat, level, hero.rank, hero.def, worn);\n const rows: Array<[string, number, string]> = [\n [t(\"base\"), b.base, v.text],\n [t(\"fromLevel\"), b.fromLevel, v.green],\n [t(\"fromRank\"), b.fromRank, v.blue],\n [t(\"fromGear\"), b.fromGear, v.gold],\n ];\n const round = (n: number) => formatAmount(Math.round(n * 10) / 10);\n return (\n <Popup\n title={`${catalog.localize(stat.name)} — ${t(\"where\")}`}\n onClose={onClose}\n width={380}\n variant=\"center\"\n >\n {stat.description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {catalog.localize(stat.description)}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 6 }}>\n {rows.map(([label, value, color]) => (\n <div\n key={label}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 15,\n }}\n >\n <span style={{ color: v.textDim }}>{label}</span>\n <span style={{ color }}>\n {value === 0 && label !== t(\"base\")\n ? \"—\"\n : `${label === t(\"base\") ? \"\" : \"+\"}${round(value)}`}\n </span>\n </div>\n ))}\n {b.percent ? (\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 15,\n }}\n >\n <span style={{ color: v.textDim }}>{t(\"percent\")}</span>\n <span style={{ color: v.gold }}>+{round(b.percent)}%</span>\n </div>\n ) : null}\n <div style={{ height: 2, background: v.panelEdge, margin: \"4px 0\" }} />\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 18,\n }}\n >\n <span>{t(\"total\")}</span>\n <span style={{ color: v.gold }}>{round(b.total)}</span>\n </div>\n </div>\n </Popup>\n );\n}\n\nfunction SlotPopup({\n slot,\n hero,\n pieces,\n items,\n onPick,\n onClose,\n}: {\n slot: SlotView;\n hero: RosterEntry;\n pieces: GearPiece[];\n items: Map<string, ItemDefWithGear>;\n onPick: (instanceID: string) => void;\n onClose: () => void;\n}): ReactNode {\n const worn = hero.model?.Equipment?.[slot.id]?.ItemInstanceID ?? null;\n const fitting = pieces.filter(\n (p) =>\n p.instanceID !== worn &&\n (!p.equippedBy || p.equippedBy.characterID === hero.id) &&\n fitProblem(p, slot, hero.id, hero.rank, items) === null,\n );\n const stagger = useStagger();\n return (\n <Popup title={slot.id} onClose={onClose} width={440}>\n {worn ? (\n <PickRow\n instanceID={worn}\n pieces={pieces}\n label={t(\"worn\")}\n onPick={onPick}\n />\n ) : null}\n {fitting.length === 0 ? (\n <EmptyState\n glyph=\"sword\"\n text={\n hero.rank < slot.minRank\n ? `${t(\"slotLocked\")} ${slot.minRank}`\n : t(\"noGear\")\n }\n />\n ) : (\n <div style={{ display: \"grid\", gap: 8 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"fits\")}\n </div>\n {fitting.map((p, i) => (\n <div key={p.instanceID} style={stagger(i)}>\n <PickRow\n instanceID={p.instanceID}\n pieces={pieces}\n onPick={onPick}\n />\n </div>\n ))}\n </div>\n )}\n </Popup>\n );\n}\n\nfunction PickRow({\n instanceID,\n pieces,\n label,\n onPick,\n}: {\n instanceID: string;\n pieces: GearPiece[];\n label?: string;\n onPick: (id: string) => void;\n}): ReactNode {\n const catalog = useCatalog();\n const piece = pieces.find((p) => p.instanceID === instanceID);\n if (!piece) return null;\n return (\n <Card\n onClick={() => onPick(instanceID)}\n style={{ display: \"flex\", alignItems: \"center\", gap: 12, padding: 10 }}\n >\n <ItemTile itemID={piece.itemID} level={piece.level} size={56} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.itemName(piece.itemID)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {label ? `${label} · ` : \"\"}\n {t(\"level\")} {piece.level}\n </div>\n </div>\n </Card>\n );\n}\n\nfunction PiecePopup({\n instanceID,\n hero,\n slots,\n pieces,\n items,\n balances,\n onClose,\n}: {\n instanceID: string;\n hero: RosterEntry;\n slots: SlotView[];\n pieces: GearPiece[];\n items: Map<string, ItemDefWithGear>;\n balances: Record<string, { Amount?: number }> | undefined;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const iconRef = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const piece = pieces.find((p) => p.instanceID === instanceID);\n if (!piece) return null;\n const def = items.get(piece.itemID);\n const color = catalog.rarityColor(def?.Metadata?.RarityID);\n const bonuses = pieceBonuses(def, piece.level);\n const fitSlot = slots.find(\n (s) => fitProblem(piece, s, hero.id, hero.rank, items) === null,\n );\n const onThisHero =\n piece.equippedBy?.characterID === hero.id ? piece.equippedBy : null;\n const levelCost = itemLevelCost(def, piece.level);\n const affordable = levelCost\n ? canAfford(levelCost as ResourceLine[], balances)\n : false;\n\n const act = async (kind: \"equip\" | \"unequip\" | \"level\") => {\n setBusy(kind);\n const res =\n kind === \"equip\" && fitSlot\n ? await client.character.equipItems(hero.id, [\n { SlotID: fitSlot.id, ItemInstanceID: piece.instanceID },\n ])\n : kind === \"unequip\" && onThisHero\n ? await client.character.unequipItems(hero.id, [onThisHero.slotID])\n : await client.item.upgradeLevel(piece.instanceID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(kind === \"level\" ? \"levelUp\" : \"claim\");\n kit.fx.burst(iconRef.current);\n };\n\n return (\n <Popup title={catalog.itemName(piece.itemID)} onClose={onClose}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <div\n ref={iconRef}\n className=\"idos-glow\"\n style={{\n width: 110,\n height: 110,\n borderRadius: 26,\n display: \"grid\",\n placeItems: \"center\",\n border: `4px solid ${color}`,\n background: `radial-gradient(circle at 50% 35%, ${color}66 0%, ${v.panelDeep} 72%)`,\n }}\n >\n <Icon\n glyph={catalog.iconOf({\n kind: \"item\",\n id: piece.itemID,\n amount: 1,\n })}\n size={72}\n />\n </div>\n <div style={{ ...outlined, color, fontSize: 14 }}>\n {def?.Metadata?.RarityID ?? \"Common\"} · {t(\"level\")} {piece.level}\n {def?.Upgrade?.MaxLevel ? ` / ${def.Upgrade.MaxLevel}` : \"\"}\n </div>\n {Object.entries(bonuses).map(([stat, value]) => (\n <div key={stat} style={{ ...outlined, fontSize: 15, color: v.green }}>\n +{formatAmount(Math.round(value * 10) / 10)} {stat}\n </div>\n ))}\n {Object.entries(def?.Stats?.PercentBonuses ?? {}).map(\n ([stat, value]) => (\n <div\n key={stat}\n style={{ ...outlined, fontSize: 15, color: v.gold }}\n >\n +{value}% {stat}\n </div>\n ),\n )}\n {piece.equippedBy && !onThisHero ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"wornBy\")} {piece.equippedBy.characterID}\n </div>\n ) : null}\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n {onThisHero ? (\n <Button\n tone=\"grey\"\n busy={busy === \"unequip\"}\n onClick={() => void act(\"unequip\")}\n >\n {t(\"unequip\")}\n </Button>\n ) : fitSlot ? (\n <Button\n tone=\"blue\"\n busy={busy === \"equip\"}\n onClick={() => void act(\"equip\")}\n >\n {t(\"equip\")} → {fitSlot.id}\n </Button>\n ) : (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"cannot\")}\n </div>\n )}\n {levelCost === null ? (\n def?.Upgrade ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"maxLevel\")}\n </div>\n ) : null\n ) : (\n <Button\n tone=\"gold\"\n disabled={!affordable}\n busy={busy === \"level\"}\n onClick={() => void act(\"level\")}\n >\n {t(\"levelUp\")}{\" \"}\n <ResourceList\n lines={levelCost}\n size={18}\n dimIf={() => !affordable}\n />\n </Button>\n )}\n </div>\n </Popup>\n );\n}\n\nfunction SkinsPopup({\n hero,\n onClose,\n}: {\n hero: RosterEntry;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const stagger = useStagger();\n const [view, setView] = useState<{\n WornSkinID?: string | null;\n Skins?: Array<{\n SkinID: string;\n Owned?: boolean | null;\n IsWorn?: boolean | null;\n Purchasable?: boolean | null;\n MeetsRequirements?: boolean | null;\n PriceOptions?: Record<string, { Cost?: unknown }> | null;\n }> | null;\n } | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n\n const load = async () => {\n const res = await client.character.getCharacterSkins(hero.id);\n if (res.ok) setView(res.data.Characters?.[hero.id] ?? null);\n };\n useEffect(() => {\n void load();\n // once per hero\n }, [hero.id]);\n\n const act = async (skinID: string, kind: \"wear\" | \"buy\" | \"base\") => {\n setBusy(skinID);\n const res =\n kind === \"buy\"\n ? await client.character.unlockSkin(hero.id, skinID, {\n autoEquip: true,\n })\n : kind === \"base\"\n ? await client.character.unequipSkin(hero.id)\n : await client.character.equipSkin(hero.id, skinID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(kind === \"buy\" ? \"purchase\" : \"claim\");\n await load();\n };\n\n const defs = hero.def.Skins?.Definitions ?? {};\n const wornID = hero.model?.Equipment?.[SKIN_KEY]?.ItemID ?? null;\n return (\n <Popup title={t(\"skins\")} onClose={onClose} width={520}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(140px, 1fr))\",\n gap: 10,\n }}\n >\n <Card\n highlight={!wornID}\n style={{ display: \"grid\", justifyItems: \"center\", gap: 6 }}\n >\n <Icon glyph=\"hero\" size={52} />\n <div style={{ ...outlined, fontSize: 14 }}>{t(\"baseLook\")}</div>\n {wornID ? (\n <Button\n size=\"sm\"\n busy={busy === \"@base\"}\n onClick={() => void act(\"@base\", \"base\")}\n >\n {t(\"wear\")}\n </Button>\n ) : (\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"worn\")}\n </span>\n )}\n </Card>\n {(view?.Skins ?? []).map((s, i) => {\n const def = defs[s.SkinID];\n const price = Object.values(s.PriceOptions ?? {})[0];\n return (\n <Card\n key={s.SkinID}\n highlight={s.IsWorn === true}\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 6,\n ...stagger(i),\n }}\n >\n <Icon\n glyph={def?.Identity?.AssetPaths?.icon ?? \"user\"}\n size={52}\n />\n <div style={{ ...outlined, fontSize: 14 }}>\n {catalog.localize(def?.Identity?.DisplayName ?? s.SkinID)}\n </div>\n {s.IsWorn ? (\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"worn\")}\n </span>\n ) : s.Owned ? (\n <Button\n size=\"sm\"\n disabled={s.MeetsRequirements === false}\n busy={busy === s.SkinID}\n onClick={() => void act(s.SkinID, \"wear\")}\n >\n {t(\"wear\")}\n </Button>\n ) : s.Purchasable && price ? (\n <Button\n size=\"sm\"\n tone=\"gold\"\n busy={busy === s.SkinID}\n onClick={() => void act(s.SkinID, \"buy\")}\n >\n <ResourceList\n lines={costLines(price.Cost as never)}\n size={16}\n />\n </Button>\n ) : (\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"notForSale\")}\n </span>\n )}\n </Card>\n );\n })}\n </div>\n </Popup>\n );\n}\n"
|
|
51
|
+
"content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Badge,\n Button,\n Card,\n EmptyState,\n Icon,\n ItemTile,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Tabs,\n canAfford,\n configSection,\n costLines,\n errorText,\n formatAmount,\n outlined,\n panel,\n useCatalog,\n useGameLayout,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\nimport {\n SKIN_KEY,\n bestLoadout,\n breakdown,\n fitProblem,\n gearPieces,\n itemLevelCost,\n pieceBonuses,\n rankCost,\n resolveLadder,\n resolveSlots,\n resolveStats,\n roster,\n statCost,\n statMaxLevel,\n unmetRequirements,\n type CharacterModelView,\n type CharacterSection,\n type GearPiece,\n type InstanceView,\n type ItemDefWithGear,\n type RosterEntry,\n type SlotView,\n type StatView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Heroes (Unity: the Hero button → CharacterListPanel, CharacterDetailPanel, EquipmentPanel). The\n// screen is the equipment hub of the selected hero — portrait, rank and Power, the slots, \"Equip\n// best\", and the gear the player owns. Everything with more data opens as a popup: the hero list\n// (select / unlock), the stats (upgrade, and \"where does this number come from\"), a piece of gear\n// (equip / take off / level up), a slot's fitting gear, and skins.\n\nexport function makeCharacterScreen(features: FeatureRegistry) {\n return function CharacterScreen({ args }: FeatureScreenProps): ReactNode {\n return (\n <Heroes\n features={features}\n openInstance={\n typeof args?.itemInstanceID === \"string\" ? args.itemInstanceID : null\n }\n />\n );\n };\n}\n\ntype PopupState =\n | { kind: \"heroes\" }\n | { kind: \"stats\" }\n | { kind: \"skins\" }\n | { kind: \"slot\"; slot: SlotView }\n | { kind: \"piece\"; instanceID: string }\n | null;\n\nconst SELECTED_KEY = \"idos-heroes-selected\";\n\nfunction useData() {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n // Definitions arrive with the title config; a title whose config did not carry them fetches them\n // once — and the screen redraws when they land (the section cache emits no user-state event).\n const [, loaded] = useState(0);\n const section = configSection<CharacterSection>(client, \"Character\");\n useEffect(() => {\n if (!section)\n void client.character\n .getCharacterDefinitions()\n .then(() => loaded((n) => n + 1));\n void client.character.getUserCharacters();\n // Gear instances (levels, where they are worn) live outside the player document on the server\n // and come with the full inventory read, not with the login state.\n void client.user.getUserInventory();\n }, [client, section]);\n const owned = (state?.Character?.Characters ?? {}) as Record<\n string,\n CharacterModelView\n >;\n const inventory = state?.InventoryV2 as\n | {\n UnstackableItems?: Record<string, InstanceView | null>;\n VirtualCurrencies?: Record<string, { Amount?: number }>;\n }\n | undefined;\n const items = catalog.items as Map<string, ItemDefWithGear>;\n // Not memoized: the SDK cache updates UnstackableItems IN PLACE (an equip flips EquippedSlot on\n // the same object), so a memo keyed by the object would keep showing the old state.\n const pieces = gearPieces(inventory?.UnstackableItems, items);\n return {\n client,\n section,\n owned,\n inventory,\n items,\n pieces,\n balances: inventory?.VirtualCurrencies,\n };\n}\n\nfunction Heroes({\n features,\n openInstance,\n}: {\n features: FeatureRegistry;\n openInstance: string | null;\n}): ReactNode {\n const { client, section, owned, items, pieces, balances } = useData();\n const catalog = useCatalog();\n const layout = useGameLayout();\n const toast = useToast();\n const kit = useUiKit();\n const cardRef = useRef<HTMLDivElement>(null);\n const [popup, setPopup] = useState<PopupState>(\n openInstance ? { kind: \"piece\", instanceID: openInstance } : null,\n );\n const [busy, setBusy] = useState<string | null>(null);\n const [selectedID, setSelectedID] = useState<string | null>(() => {\n try {\n return localStorage.getItem(SELECTED_KEY);\n } catch {\n return null;\n }\n });\n\n const list = roster(section, owned);\n if (list.length === 0)\n return <EmptyState glyph=\"hero\" text={t(\"noHeroes\")} />;\n const hero =\n list.find((h) => h.id === selectedID && h.state !== \"locked\") ??\n list.find((h) => h.state !== \"locked\") ??\n list[0]!;\n const select = (id: string) => {\n setSelectedID(id);\n try {\n localStorage.setItem(SELECTED_KEY, id);\n } catch {\n // storage blocked: the choice lives for this session\n }\n };\n\n const ladder = resolveLadder(section, hero.def);\n const nextRank = rankCost(ladder, hero.rank);\n const slots = resolveSlots(section, hero.def);\n const changes =\n hero.state === \"locked\"\n ? []\n : bestLoadout(\n hero.id,\n hero.rank,\n slots,\n pieces,\n hero.model?.Equipment,\n items,\n );\n const rarity = catalog.rarityColor(hero.def.Classification?.RarityID);\n const heroName = catalog.localize(hero.def.Identity?.DisplayName ?? hero.id);\n const skinCount = Object.keys(hero.def.Skins?.Definitions ?? {}).length;\n\n const rankUp = async () => {\n setBusy(\"rank\");\n const res = await client.character.upgradeCharacterLevel(hero.id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"levelUp\");\n kit.fx.burst(cardRef.current);\n };\n\n const equipBest = async () => {\n setBusy(\"best\");\n const res = await client.character.equipItems(hero.id, changes);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(cardRef.current);\n };\n\n const card = (\n <div\n ref={cardRef}\n className=\"idos-shine\"\n style={{\n ...panel,\n padding: 16,\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n background: `radial-gradient(circle at 50% 30%, ${rarity}55 0%, ${v.panelDeep} 75%)`,\n border: `2px solid ${rarity}`,\n }}\n >\n <div\n className=\"idos-bounce\"\n style={{\n width: 110,\n height: 110,\n borderRadius: 28,\n display: \"grid\",\n placeItems: \"center\",\n background: v.wellSoft,\n border: `3px solid ${rarity}`,\n }}\n >\n <Icon\n glyph={\n hero.def.Identity?.AssetPaths?.portrait ??\n hero.def.Identity?.AssetPaths?.icon ??\n \"hero\"\n }\n size={80}\n />\n </div>\n <div style={{ ...outlined, fontSize: 22 }}>{heroName}</div>\n {hero.def.Classification?.ClassID ? (\n <div style={{ ...outlined, fontSize: 12, color: rarity }}>\n {hero.def.Classification.ClassID} · {hero.def.Classification.RarityID}\n </div>\n ) : null}\n <div style={{ display: \"flex\", gap: 18 }}>\n <Stat\n label={t(\"rank\")}\n value={`${hero.rank}${ladder?.MaxRank ? ` / ${ladder.MaxRank}` : \"\"}`}\n />\n <Stat label={t(\"power\")} value={formatAmount(hero.power)} />\n </div>\n {hero.state === \"locked\" ? null : nextRank === null ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"maxRank\")}\n </div>\n ) : (\n <Button\n tone=\"gold\"\n attract={canAfford(nextRank, balances)}\n disabled={!canAfford(nextRank, balances)}\n busy={busy === \"rank\"}\n onClick={() => void rankUp()}\n data-tutorial-anchor=\"heroes:rank-up\"\n >\n {t(\"rankUp\")}{\" \"}\n {nextRank.length ? (\n <ResourceList\n lines={nextRank}\n size={18}\n dimIf={() => !canAfford(nextRank, balances)}\n />\n ) : (\n t(\"free\")\n )}\n </Button>\n )}\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n <span style={{ position: \"relative\" }}>\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup({ kind: \"heroes\" })}\n >\n <Icon glyph=\"friends\" size={18} /> {t(\"heroes\")}\n </Button>\n <Badge\n count={\n list.filter(\n (h) =>\n h.state === \"locked\" &&\n h.unlockPrice.length > 0 &&\n canAfford(h.unlockPrice, balances),\n ).length\n }\n dot\n />\n </span>\n {hero.state !== \"locked\" ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup({ kind: \"stats\" })}\n >\n <Icon glyph=\"sword\" size={18} /> {t(\"stats\")}\n </Button>\n ) : null}\n {skinCount > 0 && hero.state !== \"locked\" ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup({ kind: \"skins\" })}\n >\n <Icon glyph=\"user\" size={18} /> {t(\"skins\")}\n </Button>\n ) : null}\n </div>\n </div>\n );\n\n const equipment = (\n <div style={{ display: \"grid\", gap: 14 }}>\n <SectionTitle\n right={\n changes.length > 0 ? (\n <Button\n tone=\"green\"\n size=\"sm\"\n attract\n busy={busy === \"best\"}\n onClick={() => void equipBest()}\n data-tutorial-anchor=\"heroes:equip-best\"\n >\n {t(\"equipBest\")}\n </Button>\n ) : null\n }\n >\n {t(\"equipment\")}\n </SectionTitle>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(auto-fill, minmax(${layout === \"phone\" ? 88 : 104}px, 1fr))`,\n gap: 12,\n justifyItems: \"center\",\n }}\n >\n {slots.map((slot) => (\n <SlotTile\n key={slot.id}\n slot={slot}\n hero={hero}\n pieces={pieces}\n onClick={() => setPopup({ kind: \"slot\", slot })}\n />\n ))}\n </div>\n <Bag\n pieces={pieces.filter((p) => !p.equippedBy)}\n onPick={(id) => setPopup({ kind: \"piece\", instanceID: id })}\n onShop={\n features.get(\"store\") ? () => features.open(\"store\") : undefined\n }\n />\n </div>\n );\n\n return (\n <div style={{ display: \"grid\", gap: 18 }}>\n {layout === \"desktop\" ? (\n <div\n style={{\n display: \"grid\",\n gap: 20,\n gridTemplateColumns: \"minmax(0, 4fr) minmax(0, 6fr)\",\n alignItems: \"start\",\n }}\n >\n {card}\n {equipment}\n </div>\n ) : (\n <>\n {card}\n {equipment}\n </>\n )}\n {popup?.kind === \"heroes\" ? (\n <HeroesPopup\n list={list}\n current={hero.id}\n balances={balances}\n onSelect={select}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup?.kind === \"stats\" ? (\n <StatsPopup\n hero={hero}\n section={section}\n pieces={pieces}\n items={items}\n balances={balances}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup?.kind === \"skins\" ? (\n <SkinsPopup hero={hero} onClose={() => setPopup(null)} />\n ) : null}\n {popup?.kind === \"slot\" ? (\n <SlotPopup\n slot={popup.slot}\n hero={hero}\n pieces={pieces}\n items={items}\n onPick={(id) => setPopup({ kind: \"piece\", instanceID: id })}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup?.kind === \"piece\" ? (\n <PiecePopup\n instanceID={popup.instanceID}\n hero={hero}\n slots={slots}\n pieces={pieces}\n items={items}\n balances={balances}\n onClose={() => setPopup(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction Stat({ label, value }: { label: string; value: string }): ReactNode {\n return (\n <div style={{ display: \"grid\", justifyItems: \"center\" }}>\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {label}\n </span>\n <span style={{ ...outlined, fontSize: 20, color: v.gold }}>{value}</span>\n </div>\n );\n}\n\nfunction SlotTile({\n slot,\n hero,\n pieces,\n onClick,\n}: {\n slot: SlotView;\n hero: RosterEntry;\n pieces: GearPiece[];\n onClick: () => void;\n}): ReactNode {\n const worn = hero.model?.Equipment?.[slot.id];\n const piece = worn?.ItemInstanceID\n ? pieces.find((p) => p.instanceID === worn.ItemInstanceID)\n : undefined;\n const locked = hero.rank < slot.minRank;\n const { play } = useUiKit();\n return (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n {worn?.ItemID ? (\n <ItemTile\n itemID={worn.ItemID}\n level={piece?.level}\n onClick={onClick}\n size={80}\n />\n ) : (\n <button\n type=\"button\"\n className=\"idos-press\"\n onClick={() => {\n play(\"click\");\n onClick();\n }}\n style={{\n width: 80,\n height: 80,\n borderRadius: 16,\n border: `3px dashed ${v.panelEdge}`,\n background: v.wellSoft,\n display: \"grid\",\n placeItems: \"center\",\n cursor: \"pointer\",\n opacity: locked ? 0.5 : 1,\n }}\n >\n <Icon glyph={locked ? \"lock\" : \"plus\"} size={30} />\n </button>\n )}\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {locked ? `${t(\"slotLocked\")} ${slot.minRank}` : slot.id}\n </span>\n </div>\n );\n}\n\nfunction Bag({\n pieces,\n onPick,\n onShop,\n}: {\n pieces: GearPiece[];\n onPick: (instanceID: string) => void;\n onShop?: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const stagger = useStagger();\n const classes = [\n ...new Set(\n pieces.map((p) => catalog.items.get(p.itemID)?.ItemClass || \"other\"),\n ),\n ];\n const [tab, setTab] = useState(\"all\");\n const shown =\n tab === \"all\"\n ? pieces\n : pieces.filter(\n (p) => (catalog.items.get(p.itemID)?.ItemClass || \"other\") === tab,\n );\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n <SectionTitle>{t(\"bag\")}</SectionTitle>\n {pieces.length === 0 ? (\n <EmptyState\n glyph=\"sword\"\n text={t(\"noGear\")}\n action={\n onShop ? (\n <Button onClick={onShop}>{catalog.localize(\"Shop\")}</Button>\n ) : undefined\n }\n />\n ) : (\n <>\n <Tabs\n tabs={[\n { id: \"all\", label: \"★\" },\n ...classes.map((c) => ({ id: c, label: c })),\n ]}\n value={tab}\n onChange={setTab}\n />\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(84px, 1fr))\",\n gap: 10,\n justifyItems: \"center\",\n }}\n >\n {shown.map((p, i) => (\n <div key={p.instanceID} style={stagger(i)}>\n <ItemTile\n itemID={p.itemID}\n level={p.level}\n onClick={() => onPick(p.instanceID)}\n />\n </div>\n ))}\n </div>\n </>\n )}\n </div>\n );\n}\n\n// ── Popups ───────────────────────────────────────────────────────────────────────────────────\n\nfunction HeroesPopup({\n list,\n current,\n balances,\n onSelect,\n onClose,\n}: {\n list: RosterEntry[];\n current: string;\n balances: Record<string, { Amount?: number }> | undefined;\n onSelect: (id: string) => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n\n const unlock = async (e: RosterEntry) => {\n setBusy(e.id);\n const res = await client.character.unlockCharacter(e.id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"levelUp\");\n onSelect(e.id);\n };\n\n return (\n <Popup title={t(\"heroes\")} onClose={onClose} width={560}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(150px, 1fr))\",\n gap: 10,\n }}\n >\n {list.map((e, i) => {\n const color = catalog.rarityColor(e.def.Classification?.RarityID);\n const affordable = canAfford(e.unlockPrice, balances);\n return (\n <Card\n key={e.id}\n highlight={e.id === current}\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 6,\n opacity: e.state === \"locked\" ? 0.85 : 1,\n border: `2px solid ${e.id === current ? v.gold : color}`,\n ...stagger(i),\n }}\n >\n <div style={{ position: \"relative\" }}>\n <Icon\n glyph={e.def.Identity?.AssetPaths?.icon ?? \"hero\"}\n size={56}\n />\n {e.state === \"locked\" ? (\n <span style={{ position: \"absolute\", right: -8, bottom: -4 }}>\n <Icon glyph=\"lock\" size={22} />\n </span>\n ) : null}\n </div>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(e.def.Identity?.DisplayName ?? e.id)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {e.state === \"locked\"\n ? t(\"locked\")\n : `${t(\"rank\")} ${e.rank} · ${t(\"power\")} ${formatAmount(e.power)}`}\n </div>\n {e.state === \"locked\" ? (\n e.unlockPrice.length > 0 ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract={affordable}\n disabled={!affordable}\n busy={busy === e.id}\n onClick={() => void unlock(e)}\n style={{ width: \"100%\" }}\n >\n {t(\"unlock\")}{\" \"}\n <ResourceList lines={e.unlockPrice} size={16} />\n </Button>\n ) : (\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"notForSale\")}\n </span>\n )\n ) : e.id === current ? (\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"selected\")}\n </span>\n ) : (\n <SelectButton onSelect={() => onSelect(e.id)} />\n )}\n </Card>\n );\n })}\n </div>\n </Popup>\n );\n}\n\nfunction SelectButton({ onSelect }: { onSelect: () => void }): ReactNode {\n const close = usePopupClose();\n return (\n <Button\n size=\"sm\"\n onClick={() => {\n onSelect();\n close();\n }}\n style={{ width: \"100%\" }}\n >\n {t(\"select\")}\n </Button>\n );\n}\n\nfunction StatsPopup({\n hero,\n section,\n pieces,\n items,\n balances,\n onClose,\n}: {\n hero: RosterEntry;\n section: CharacterSection | undefined;\n pieces: GearPiece[];\n items: Map<string, ItemDefWithGear>;\n balances: Record<string, { Amount?: number }> | undefined;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const play = useSound();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n const [detail, setDetail] = useState<StatView | null>(null);\n const stats = resolveStats(section, hero.def);\n const levels = hero.model?.StatLevels ?? {};\n const worn = Object.values(hero.model?.Equipment ?? {})\n .filter((e) => e?.ItemInstanceID)\n .map((e) => {\n const p = pieces.find((x) => x.instanceID === e!.ItemInstanceID);\n return { def: items.get(e!.ItemID ?? \"\"), level: p?.level ?? 1 };\n });\n\n const upgrade = async (stat: StatView) => {\n setBusy(stat.id);\n const res = await client.character.upgradeStatLevel(hero.id, stat.id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n play(\"levelUp\");\n };\n\n return (\n <Popup\n title={`${t(\"stats\")} · ${catalog.localize(hero.def.Identity?.DisplayName ?? hero.id)}`}\n onClose={onClose}\n width={480}\n >\n <div style={{ display: \"grid\", gap: 10 }}>\n {stats.map((stat, i) => {\n const level = Number(levels[stat.id] ?? 0);\n const cap = statMaxLevel(stat, hero.rank, hero.def);\n const capped = cap > 0 && level >= cap;\n const cost = statCost(stat, level + 1);\n const missing = unmetRequirements(stat, levels);\n const affordable = canAfford(cost, balances);\n const total = breakdown(stat, level, hero.rank, hero.def, worn).total;\n return (\n <Card\n key={stat.id}\n style={{ display: \"grid\", gap: 8, ...stagger(i) }}\n >\n <button\n type=\"button\"\n onClick={() => setDetail(stat)}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n background: \"none\",\n border: \"none\",\n padding: 0,\n cursor: \"pointer\",\n color: v.text,\n }}\n >\n <span style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(stat.name)} ⓘ\n </span>\n <span style={{ ...outlined, fontSize: 18, color: v.gold }}>\n {formatAmount(Math.round(total * 10) / 10)}\n </span>\n </button>\n {cap > 0 ? (\n <ProgressBar\n value={level}\n max={cap}\n label={`${t(\"level\")} ${level} / ${cap}`}\n height={16}\n />\n ) : null}\n {missing.length > 0 ? (\n <div style={{ ...outlined, fontSize: 12, color: v.red }}>\n {t(\"needs\")}:{\" \"}\n {missing.map((m) => `${m.statID} ${m.level}`).join(\", \")}\n </div>\n ) : capped ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"statCapped\")}\n </div>\n ) : (\n <Button\n size=\"sm\"\n tone=\"green\"\n disabled={!affordable}\n busy={busy === stat.id}\n onClick={() => void upgrade(stat)}\n >\n {t(\"upgrade\")} →{\" \"}\n {formatAmount(\n Math.round(\n breakdown(stat, level + 1, hero.rank, hero.def, worn)\n .total * 10,\n ) / 10,\n )}{\" \"}\n <ResourceList\n lines={cost}\n size={16}\n dimIf={() => !affordable}\n />\n </Button>\n )}\n </Card>\n );\n })}\n </div>\n {detail ? (\n <BreakdownPopup\n stat={detail}\n level={Number(levels[detail.id] ?? 0)}\n hero={hero}\n worn={worn}\n onClose={() => setDetail(null)}\n />\n ) : null}\n </Popup>\n );\n}\n\nfunction BreakdownPopup({\n stat,\n level,\n hero,\n worn,\n onClose,\n}: {\n stat: StatView;\n level: number;\n hero: RosterEntry;\n worn: Array<{ def: ItemDefWithGear | undefined; level: number }>;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const b = breakdown(stat, level, hero.rank, hero.def, worn);\n const rows: Array<[string, number, string]> = [\n [t(\"base\"), b.base, v.text],\n [t(\"fromLevel\"), b.fromLevel, v.green],\n [t(\"fromRank\"), b.fromRank, v.blue],\n [t(\"fromGear\"), b.fromGear, v.gold],\n ];\n const round = (n: number) => formatAmount(Math.round(n * 10) / 10);\n return (\n <Popup\n title={`${catalog.localize(stat.name)} — ${t(\"where\")}`}\n onClose={onClose}\n width={380}\n variant=\"center\"\n >\n {stat.description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {catalog.localize(stat.description)}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 6 }}>\n {rows.map(([label, value, color]) => (\n <div\n key={label}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 15,\n }}\n >\n <span style={{ color: v.textDim }}>{label}</span>\n <span style={{ color }}>\n {value === 0 && label !== t(\"base\")\n ? \"—\"\n : `${label === t(\"base\") ? \"\" : \"+\"}${round(value)}`}\n </span>\n </div>\n ))}\n {b.percent ? (\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 15,\n }}\n >\n <span style={{ color: v.textDim }}>{t(\"percent\")}</span>\n <span style={{ color: v.gold }}>+{round(b.percent)}%</span>\n </div>\n ) : null}\n <div style={{ height: 2, background: v.panelEdge, margin: \"4px 0\" }} />\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 18,\n }}\n >\n <span>{t(\"total\")}</span>\n <span style={{ color: v.gold }}>{round(b.total)}</span>\n </div>\n </div>\n </Popup>\n );\n}\n\nfunction SlotPopup({\n slot,\n hero,\n pieces,\n items,\n onPick,\n onClose,\n}: {\n slot: SlotView;\n hero: RosterEntry;\n pieces: GearPiece[];\n items: Map<string, ItemDefWithGear>;\n onPick: (instanceID: string) => void;\n onClose: () => void;\n}): ReactNode {\n const worn = hero.model?.Equipment?.[slot.id]?.ItemInstanceID ?? null;\n const fitting = pieces.filter(\n (p) =>\n p.instanceID !== worn &&\n (!p.equippedBy || p.equippedBy.characterID === hero.id) &&\n fitProblem(p, slot, hero.id, hero.rank, items) === null,\n );\n const stagger = useStagger();\n return (\n <Popup title={slot.id} onClose={onClose} width={440}>\n {worn ? (\n <PickRow\n instanceID={worn}\n pieces={pieces}\n label={t(\"worn\")}\n onPick={onPick}\n />\n ) : null}\n {fitting.length === 0 ? (\n <EmptyState\n glyph=\"sword\"\n text={\n hero.rank < slot.minRank\n ? `${t(\"slotLocked\")} ${slot.minRank}`\n : t(\"noGear\")\n }\n />\n ) : (\n <div style={{ display: \"grid\", gap: 8 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"fits\")}\n </div>\n {fitting.map((p, i) => (\n <div key={p.instanceID} style={stagger(i)}>\n <PickRow\n instanceID={p.instanceID}\n pieces={pieces}\n onPick={onPick}\n />\n </div>\n ))}\n </div>\n )}\n </Popup>\n );\n}\n\nfunction PickRow({\n instanceID,\n pieces,\n label,\n onPick,\n}: {\n instanceID: string;\n pieces: GearPiece[];\n label?: string;\n onPick: (id: string) => void;\n}): ReactNode {\n const catalog = useCatalog();\n const piece = pieces.find((p) => p.instanceID === instanceID);\n if (!piece) return null;\n return (\n <Card\n onClick={() => onPick(instanceID)}\n style={{ display: \"flex\", alignItems: \"center\", gap: 12, padding: 10 }}\n >\n <ItemTile itemID={piece.itemID} level={piece.level} size={56} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.itemName(piece.itemID)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {label ? `${label} · ` : \"\"}\n {t(\"level\")} {piece.level}\n </div>\n </div>\n </Card>\n );\n}\n\nfunction PiecePopup({\n instanceID,\n hero,\n slots,\n pieces,\n items,\n balances,\n onClose,\n}: {\n instanceID: string;\n hero: RosterEntry;\n slots: SlotView[];\n pieces: GearPiece[];\n items: Map<string, ItemDefWithGear>;\n balances: Record<string, { Amount?: number }> | undefined;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const iconRef = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const piece = pieces.find((p) => p.instanceID === instanceID);\n if (!piece) return null;\n const def = items.get(piece.itemID);\n const color = catalog.rarityColor(def?.Metadata?.RarityID);\n const bonuses = pieceBonuses(def, piece.level);\n const fitSlot = slots.find(\n (s) => fitProblem(piece, s, hero.id, hero.rank, items) === null,\n );\n const onThisHero =\n piece.equippedBy?.characterID === hero.id ? piece.equippedBy : null;\n const levelCost = itemLevelCost(def, piece.level);\n const affordable = levelCost\n ? canAfford(levelCost as ResourceLine[], balances)\n : false;\n\n const act = async (kind: \"equip\" | \"unequip\" | \"level\") => {\n setBusy(kind);\n const res =\n kind === \"equip\" && fitSlot\n ? await client.character.equipItems(hero.id, [\n { SlotID: fitSlot.id, ItemInstanceID: piece.instanceID },\n ])\n : kind === \"unequip\" && onThisHero\n ? await client.character.unequipItems(hero.id, [onThisHero.slotID])\n : await client.item.upgradeLevel(piece.instanceID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(kind === \"level\" ? \"levelUp\" : \"claim\");\n kit.fx.burst(iconRef.current);\n };\n\n return (\n <Popup title={catalog.itemName(piece.itemID)} onClose={onClose}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <div\n ref={iconRef}\n className=\"idos-glow\"\n style={{\n width: 110,\n height: 110,\n borderRadius: 26,\n display: \"grid\",\n placeItems: \"center\",\n border: `4px solid ${color}`,\n background: `radial-gradient(circle at 50% 35%, ${color}66 0%, ${v.panelDeep} 72%)`,\n }}\n >\n <Icon\n glyph={catalog.iconOf({\n kind: \"item\",\n id: piece.itemID,\n amount: 1,\n })}\n size={72}\n />\n </div>\n <div style={{ ...outlined, color, fontSize: 14 }}>\n {def?.Metadata?.RarityID ?? \"Common\"} · {t(\"level\")} {piece.level}\n {def?.Upgrade?.MaxLevel ? ` / ${def.Upgrade.MaxLevel}` : \"\"}\n </div>\n {Object.entries(bonuses).map(([stat, value]) => (\n <div key={stat} style={{ ...outlined, fontSize: 15, color: v.green }}>\n +{formatAmount(Math.round(value * 10) / 10)} {stat}\n </div>\n ))}\n {Object.entries(def?.Stats?.PercentBonuses ?? {}).map(\n ([stat, value]) => (\n <div\n key={stat}\n style={{ ...outlined, fontSize: 15, color: v.gold }}\n >\n +{value}% {stat}\n </div>\n ),\n )}\n {piece.equippedBy && !onThisHero ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"wornBy\")} {piece.equippedBy.characterID}\n </div>\n ) : null}\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n {onThisHero ? (\n <Button\n tone=\"grey\"\n busy={busy === \"unequip\"}\n onClick={() => void act(\"unequip\")}\n >\n {t(\"unequip\")}\n </Button>\n ) : fitSlot ? (\n <Button\n tone=\"blue\"\n busy={busy === \"equip\"}\n onClick={() => void act(\"equip\")}\n >\n {t(\"equip\")} → {fitSlot.id}\n </Button>\n ) : (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"cannot\")}\n </div>\n )}\n {levelCost === null ? (\n def?.Upgrade ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"maxLevel\")}\n </div>\n ) : null\n ) : (\n <Button\n tone=\"gold\"\n disabled={!affordable}\n busy={busy === \"level\"}\n onClick={() => void act(\"level\")}\n >\n {t(\"levelUp\")}{\" \"}\n <ResourceList\n lines={levelCost}\n size={18}\n dimIf={() => !affordable}\n />\n </Button>\n )}\n </div>\n </Popup>\n );\n}\n\nfunction SkinsPopup({\n hero,\n onClose,\n}: {\n hero: RosterEntry;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const stagger = useStagger();\n const [view, setView] = useState<{\n WornSkinID?: string | null;\n Skins?: Array<{\n SkinID: string;\n Owned?: boolean | null;\n IsWorn?: boolean | null;\n Purchasable?: boolean | null;\n MeetsRequirements?: boolean | null;\n PriceOptions?: Record<string, { Cost?: unknown }> | null;\n }> | null;\n } | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n\n const load = async () => {\n const res = await client.character.getCharacterSkins(hero.id);\n if (res.ok) setView(res.data.Characters?.[hero.id] ?? null);\n };\n useEffect(() => {\n void load();\n // once per hero\n }, [hero.id]);\n\n const act = async (skinID: string, kind: \"wear\" | \"buy\" | \"base\") => {\n setBusy(skinID);\n const res =\n kind === \"buy\"\n ? await client.character.unlockSkin(hero.id, skinID, {\n autoEquip: true,\n })\n : kind === \"base\"\n ? await client.character.unequipSkin(hero.id)\n : await client.character.equipSkin(hero.id, skinID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(kind === \"buy\" ? \"purchase\" : \"claim\");\n await load();\n };\n\n const defs = hero.def.Skins?.Definitions ?? {};\n const wornID = hero.model?.Equipment?.[SKIN_KEY]?.ItemID ?? null;\n return (\n <Popup title={t(\"skins\")} onClose={onClose} width={520}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(140px, 1fr))\",\n gap: 10,\n }}\n >\n <Card\n highlight={!wornID}\n style={{ display: \"grid\", justifyItems: \"center\", gap: 6 }}\n >\n <Icon glyph=\"hero\" size={52} />\n <div style={{ ...outlined, fontSize: 14 }}>{t(\"baseLook\")}</div>\n {wornID ? (\n <Button\n size=\"sm\"\n busy={busy === \"@base\"}\n onClick={() => void act(\"@base\", \"base\")}\n >\n {t(\"wear\")}\n </Button>\n ) : (\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"worn\")}\n </span>\n )}\n </Card>\n {(view?.Skins ?? []).map((s, i) => {\n const def = defs[s.SkinID];\n const price = Object.values(s.PriceOptions ?? {})[0];\n return (\n <Card\n key={s.SkinID}\n highlight={s.IsWorn === true}\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 6,\n ...stagger(i),\n }}\n >\n <Icon\n glyph={def?.Identity?.AssetPaths?.icon ?? \"user\"}\n size={52}\n />\n <div style={{ ...outlined, fontSize: 14 }}>\n {catalog.localize(def?.Identity?.DisplayName ?? s.SkinID)}\n </div>\n {s.IsWorn ? (\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"worn\")}\n </span>\n ) : s.Owned ? (\n <Button\n size=\"sm\"\n disabled={s.MeetsRequirements === false}\n busy={busy === s.SkinID}\n onClick={() => void act(s.SkinID, \"wear\")}\n >\n {t(\"wear\")}\n </Button>\n ) : s.Purchasable && price ? (\n <Button\n size=\"sm\"\n tone=\"gold\"\n busy={busy === s.SkinID}\n onClick={() => void act(s.SkinID, \"buy\")}\n >\n <ResourceList\n lines={costLines(price.Cost as never)}\n size={16}\n />\n </Button>\n ) : (\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"notForSale\")}\n </span>\n )}\n </Card>\n );\n })}\n </div>\n </Popup>\n );\n}\n"
|
|
52
52
|
},
|
|
53
53
|
{
|
|
54
54
|
"path": "i18n.ts",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
},
|
|
69
69
|
{
|
|
70
70
|
"path": "module.meta.json",
|
|
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.
|
|
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.1\"\n}\n"
|
|
72
72
|
}
|
|
73
73
|
]
|
|
74
74
|
}
|
|
@@ -32,19 +32,19 @@
|
|
|
32
32
|
"name": "iDos Games",
|
|
33
33
|
"url": "https://idosgames.com"
|
|
34
34
|
},
|
|
35
|
-
"version": "0.1.
|
|
35
|
+
"version": "0.1.1"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@idosgames/core": "0.
|
|
39
|
-
"@idosgames/module-sdk": "0.
|
|
40
|
-
"@idosgames/react": "0.
|
|
38
|
+
"@idosgames/core": "0.20.0",
|
|
39
|
+
"@idosgames/module-sdk": "0.8.1",
|
|
40
|
+
"@idosgames/react": "0.4.2",
|
|
41
41
|
"react": "19.2.7"
|
|
42
42
|
},
|
|
43
|
-
"contentHash": "
|
|
43
|
+
"contentHash": "1bd9bec3b0ee8548e800e51bf045b0215da4320231fbf8565350554558f846e4",
|
|
44
44
|
"files": [
|
|
45
45
|
{
|
|
46
46
|
"path": "ChatScreen.tsx",
|
|
47
|
-
"content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport type {\n ChatCursor,\n ChatReportReason,\n IDosGamesClient,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n EmptyState,\n Icon,\n IconButton,\n Popup,\n Spinner,\n Tabs,\n errorText,\n outlined,\n panel,\n useCatalog,\n useNow,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n authorName,\n channelChoices,\n chatBadge,\n isSystem,\n lastCursor,\n mergeMessages,\n orderedChannels,\n sendable,\n slowModeLeft,\n unreadLabel,\n type ChannelView,\n type MessageView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Chat (Unity V2/Runtime/UI/Chat): the title's channels as tabs plus \"Private\" (1-on-1 chats with\n// friends). One poll covers every room and runs ONLY while this screen is open, at the interval the\n// server sets; rooms are marked read when the player leaves them. Message actions (report, ignore,\n// write privately) and joining / leaving channels are popups. Another module opens a private chat\n// with features.open(\"chat\", { userID }).\n\ntype Room =\n | { kind: \"channel\"; channel: ChannelView }\n | { kind: \"direct\"; roomID: string; peerName: string };\ntype Conversation = {\n RoomID?: string | null;\n PeerUserID?: string | null;\n PeerProfile?: { Username?: string | null } | null;\n UnreadCount?: number | null;\n LastMessage?: MessageView | null;\n};\nconst DIRECT = \"__direct\";\ntype ChatDefs = {\n Direct?: { IsEnabled?: boolean | null } | null;\n Channels?: Parameters<typeof channelChoices>[0];\n};\n\n/** Badge once per session (a closed chat costs nothing: no background polling). */\nexport function makeChatWatcher(features: FeatureRegistry) {\n return function ChatWatcher(): ReactNode {\n const client = useIDosGamesClient();\n useEffect(() => {\n // The same read gives the badge and whether the title has a chat at all: switched off, or\n // no channels — the lobby hides the tab.\n void client.chat.getChannels().then((r) => {\n const channels = r.ok ? (r.data.Channels ?? []) : [];\n features.setAvailable(\n \"chat\",\n channels.length > 0,\n r.ok\n ? \"the title's chat has no channels (Chat section)\"\n : `the chat is not available on the title (${r.error})`,\n );\n features.setBadge(\"chat\", chatBadge(channels));\n });\n }, [client]);\n return null;\n };\n}\n\nexport function makeChatScreen(features: FeatureRegistry) {\n return function ChatScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const [channels, setChannels] = useState<ChannelView[] | null>(null);\n const [interval, setIntervalSec] = useState(5);\n const [mutedUntil, setMutedUntil] = useState<string | null>(null);\n const [tab, setTab] = useState<string | null>(null);\n const [room, setRoom] = useState<Room | null>(null);\n const [feeds, setFeeds] = useState<Record<string, MessageView[]>>({});\n const [unread, setUnread] = useState<Record<string, number | null>>({});\n const [managing, setManaging] = useState(false);\n // The Chat section is not part of the client config — the definitions come from their own call.\n const [defs, setDefs] = useState<ChatDefs | null>(null);\n const roomRef = useRef<Room | null>(null);\n roomRef.current = room;\n\n const loadChannels = useCallback(async () => {\n const res = await client.chat.getChannels();\n if (!res.ok) return setChannels([]);\n const list = orderedChannels(res.data.Channels ?? []);\n setChannels(list);\n setIntervalSec(Math.max(2, Number(res.data.PollIntervalSeconds ?? 5)));\n setMutedUntil(res.data.MutedUntil ?? null);\n setUnread(\n Object.fromEntries(\n list.map((c) => [c.RoomID ?? \"\", c.UnreadCount ?? null]),\n ),\n );\n features.setBadge(\"chat\", 0);\n return list;\n }, [client]);\n\n useEffect(() => {\n void client.chat\n .getChatDefinitions()\n .then((r) => setDefs(r.ok ? (r.data as ChatDefs) : {}));\n }, [client]);\n\n useEffect(() => {\n void loadChannels().then((list) => {\n const target = (args as { userID?: string } | undefined)?.userID;\n if (target)\n return void openDirect(client, target, toast, (r) => {\n setTab(DIRECT);\n setRoom(r);\n });\n const first = list?.[0];\n if (first) {\n setTab(first.ChannelID ?? null);\n setRoom({ kind: \"channel\", channel: first });\n }\n });\n }, [loadChannels]);\n\n // One poll for every room, only while the screen is open.\n useEffect(() => {\n let stopped = false;\n let timer: ReturnType<typeof setTimeout> | undefined;\n const tick = async () => {\n const res = await client.chat.poll();\n if (stopped) return;\n if (res.ok) {\n const current = roomID(roomRef.current);\n for (const r of res.data.Rooms ?? []) {\n if (!r.RoomID) continue;\n const msgs = r.Messages ?? [];\n if (msgs.length > 0)\n setFeeds((f) => ({\n ...f,\n [r.RoomID!]: mergeMessages(f[r.RoomID!] ?? [], msgs),\n }));\n if (r.RoomID !== current)\n setUnread((u) => ({\n ...u,\n [r.RoomID!]: r.UnreadCount ?? u[r.RoomID!] ?? null,\n }));\n }\n if (res.data.PollIntervalSeconds)\n setIntervalSec(Math.max(2, res.data.PollIntervalSeconds));\n }\n timer = setTimeout(() => void tick(), interval * 1000);\n };\n timer = setTimeout(() => void tick(), interval * 1000);\n return () => {\n stopped = true;\n if (timer) clearTimeout(timer);\n };\n }, [client, interval]);\n\n if (!channels || !defs)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const directEnabled = !!defs.Direct?.IsEnabled;\n if (channels.length === 0 && !directEnabled)\n return <EmptyState glyph=\"chat\" text={t(\"disabled\")} />;\n\n const pick = (id: string) => {\n setTab(id);\n if (id === DIRECT) return setRoom(null);\n const ch = channels.find((c) => c.ChannelID === id);\n if (ch) setRoom({ kind: \"channel\", channel: ch });\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 10,\n }}\n >\n <div style={{ display: \"flex\", gap: 8, alignItems: \"center\" }}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <Tabs\n tabs={[\n ...channels.map((c) => ({\n id: c.ChannelID!,\n label: catalog.localize(c.DisplayNameKey ?? c.ChannelID),\n badge:\n Number(\n unreadLabel(unread[c.RoomID ?? \"\"]).replace(\"+\", \"\"),\n ) || undefined,\n })),\n ...(directEnabled ? [{ id: DIRECT, label: t(\"direct\") }] : []),\n ]}\n value={tab ?? \"\"}\n onChange={pick}\n />\n </div>\n <IconButton\n glyph=\"gear\"\n label={t(\"channels\")}\n onClick={() => setManaging(true)}\n size={36}\n tone=\"blue\"\n />\n </div>\n {mutedUntil && new Date(mutedUntil).getTime() > Date.now() ? (\n <div\n style={{\n ...panel,\n padding: 8,\n ...outlined,\n fontSize: 13,\n color: v.red,\n textAlign: \"center\",\n }}\n >\n {t(\"muted\")} {new Date(mutedUntil).toLocaleString()}\n </div>\n ) : null}\n {tab === DIRECT && !room ? (\n <Conversations onOpen={(r) => setRoom(r)} />\n ) : room ? (\n <RoomView\n key={roomID(room)}\n room={room}\n feed={feeds[roomID(room)] ?? []}\n setFeed={(fn) =>\n setFeeds((f) => ({\n ...f,\n [roomID(room)]: fn(f[roomID(room)] ?? []),\n }))\n }\n onBack={room.kind === \"direct\" ? () => setRoom(null) : undefined}\n onOpenDirect={(r) => {\n setTab(DIRECT);\n setRoom(r);\n }}\n directEnabled={directEnabled}\n />\n ) : null}\n {managing ? (\n <ChannelsPopup\n defs={defs}\n joined={channels.map((c) => c.ChannelID ?? \"\")}\n onClose={() => setManaging(false)}\n onChanged={() => void loadChannels()}\n />\n ) : null}\n </div>\n );\n };\n}\n\nfunction roomID(room: Room | null): string {\n if (!room) return \"\";\n return room.kind === \"channel\" ? (room.channel.RoomID ?? \"\") : room.roomID;\n}\n\nasync function openDirect(\n client: IDosGamesClient,\n userID: string,\n toast: (text: string, kind?: \"error\") => void,\n open: (r: Room) => void,\n): Promise<void> {\n const res = await client.chat.openDirectChannel(userID);\n if (!res.ok || !res.data.RoomID)\n return toast(errorText(res.ok ? \"\" : res.error), \"error\");\n open({\n kind: \"direct\",\n roomID: res.data.RoomID,\n peerName: res.data.PeerProfile?.Username ?? userID,\n });\n}\n\nfunction RoomView({\n room,\n feed,\n setFeed,\n onBack,\n onOpenDirect,\n directEnabled,\n}: {\n room: Room;\n feed: MessageView[];\n setFeed: (fn: (prev: MessageView[]) => MessageView[]) => void;\n onBack?: () => void;\n onOpenDirect: (r: Room) => void;\n directEnabled: boolean;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const now = useNow();\n const id = roomID(room);\n const [older, setOlder] = useState<ChatCursor | null | undefined>(undefined);\n const [loading, setLoading] = useState(true);\n const [text, setText] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [lastSent, setLastSent] = useState<number | null>(null);\n const [picked, setPicked] = useState<MessageView | null>(null);\n const feedRef = useRef<HTMLDivElement>(null);\n const feedNow = useRef(feed);\n feedNow.current = feed;\n const me = client.auth.context?.userID;\n const channel = room.kind === \"channel\" ? room.channel : null;\n const maxLength = channel?.MaxLength ?? 500;\n\n // History first, then mark read up to what was drawn; on leaving, read up to the last message.\n useEffect(() => {\n let alive = true;\n void client.chat.getHistory(id).then((res) => {\n if (!alive) return;\n setLoading(false);\n if (!res.ok) return;\n const merged = mergeMessages(feedNow.current, res.data.Messages ?? []);\n setFeed(() => merged);\n setOlder(res.data.NextBefore ?? null);\n const cursor = lastCursor(id, merged);\n if (cursor) void client.chat.markRead(id, cursor);\n });\n return () => {\n alive = false;\n const cursor = lastCursor(id, feedNow.current);\n if (cursor) void client.chat.markRead(id, cursor);\n };\n }, [client, id]);\n\n // Keep the newest message in view.\n useEffect(() => {\n const el = feedRef.current;\n if (el) el.scrollTop = el.scrollHeight;\n }, [feed.length]);\n\n const loadOlder = async () => {\n if (!older) return;\n const res = await client.chat.getHistory(id, older);\n if (!res.ok) return;\n setFeed((prev) => mergeMessages(prev, res.data.Messages ?? []));\n setOlder(res.data.NextBefore ?? null);\n };\n\n const wait = slowModeLeft(lastSent, channel?.SlowModeSeconds, now);\n const message = sendable(text, maxLength);\n const canWrite = channel ? channel.CanWrite !== false : true;\n\n const send = async () => {\n if (!message || wait > 0) return;\n setBusy(true);\n const res = channel\n ? await client.chat.sendMessage(channel.ChannelID!, message)\n : await client.chat.sendDirect(id, message);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"tab\");\n setText(\"\");\n setLastSent(Date.now());\n // Render the SERVER's copy: moderation may have masked a word.\n if (res.data.Message)\n setFeed((prev) => mergeMessages(prev, [res.data.Message!]));\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateRows: \"auto minmax(0, 1fr) auto\",\n gap: 8,\n height: \"min(70vh, 640px)\",\n minHeight: 320,\n }}\n >\n {onBack ? (\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n <Button size=\"sm\" tone=\"grey\" onClick={onBack}>\n ← {t(\"back\")}\n </Button>\n <span style={{ ...outlined, fontSize: 15 }}>\n {room.kind === \"direct\" ? room.peerName : \"\"}\n </span>\n </div>\n ) : (\n <div />\n )}\n <div\n ref={feedRef}\n className=\"idos-scroll\"\n style={{\n ...panel,\n padding: 10,\n overflowY: \"auto\",\n display: \"grid\",\n alignContent: \"start\",\n gap: 6,\n }}\n >\n {older ? (\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() => void loadOlder()}\n style={{ justifySelf: \"center\" }}\n >\n {t(\"older\")}\n </Button>\n ) : null}\n {loading ? (\n <Center minHeight={120}>\n <Spinner />\n </Center>\n ) : feed.length === 0 ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n padding: 20,\n }}\n >\n {t(\"noMessages\")}\n </div>\n ) : null}\n {feed.map((m) => {\n const mine = m.SenderUserID === me;\n const system = isSystem(m);\n return (\n <div\n key={m.MessageID}\n style={{\n display: \"flex\",\n justifyContent: system\n ? \"center\"\n : mine\n ? \"flex-end\"\n : \"flex-start\",\n }}\n >\n <div\n onClick={!mine && !system ? () => setPicked(m) : undefined}\n style={{\n maxWidth: \"80%\",\n padding: \"6px 10px\",\n borderRadius: 12,\n background: system\n ? \"rgba(255,200,60,.18)\"\n : mine\n ? `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`\n : \"rgba(255,255,255,.1)\",\n cursor: !mine && !system ? \"pointer\" : undefined,\n overflowWrap: \"anywhere\",\n }}\n >\n {!mine ? (\n <div\n style={{\n ...outlined,\n fontSize: 11,\n color: system ? v.gold : v.textDim,\n }}\n >\n {system ? t(\"system\") : authorName(m)}\n </div>\n ) : null}\n <div style={{ fontSize: 14, fontWeight: 600 }}>{m.Text}</div>\n <div\n style={{\n fontSize: 10,\n color: \"rgba(255,255,255,.6)\",\n textAlign: \"right\",\n }}\n >\n {m.SentAt\n ? new Date(m.SentAt).toLocaleTimeString([], {\n hour: \"2-digit\",\n minute: \"2-digit\",\n })\n : \"\"}\n </div>\n </div>\n </div>\n );\n })}\n </div>\n {canWrite ? (\n <div style={{ display: \"flex\", gap: 8 }}>\n <input\n value={text}\n maxLength={maxLength}\n onChange={(e) => setText(e.target.value)}\n onKeyDown={(e) => e.key === \"Enter\" && void send()}\n placeholder={t(\"placeholder\")}\n style={{\n ...panel,\n flex: 1,\n minWidth: 0,\n padding: \"10px 12px\",\n fontSize: 15,\n color: v.text,\n outline: \"none\",\n }}\n data-tutorial-anchor=\"chat:input\"\n />\n <Button\n tone=\"green\"\n disabled={!message || wait > 0}\n busy={busy}\n onClick={() => void send()}\n >\n {wait > 0 ? `${wait}${t(\"sec\")}` : <Icon glyph=\"chat\" size={20} />}\n </Button>\n </div>\n ) : (\n <div\n style={{\n ...panel,\n padding: 10,\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {channel?.WriteBlockedReason ?? \"—\"}\n </div>\n )}\n {picked ? (\n <MessagePopup\n message={picked}\n roomID={id}\n directEnabled={directEnabled}\n onOpenDirect={onOpenDirect}\n onClose={() => setPicked(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction Conversations({ onOpen }: { onOpen: (r: Room) => void }): ReactNode {\n const client = useIDosGamesClient();\n const [list, setList] = useState<Conversation[] | null>(null);\n useEffect(() => {\n void client.chat\n .getConversations()\n .then((r) =>\n setList(r.ok ? ((r.data.Conversations ?? []) as Conversation[]) : []),\n );\n }, [client]);\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"chat\" text={t(\"noConversations\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((c) => {\n const name = c.PeerProfile?.Username ?? c.PeerUserID ?? \"?\";\n return (\n <div\n key={c.RoomID}\n onClick={() =>\n c.RoomID &&\n onOpen({ kind: \"direct\", roomID: c.RoomID, peerName: name })\n }\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: \"pointer\",\n }}\n >\n <Icon glyph=\"user\" size={28} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>{name}</div>\n <div\n style={{\n fontSize: 12,\n color: v.textDim,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {c.LastMessage?.Text ?? \"\"}\n </div>\n </div>\n {unreadLabel(c.UnreadCount) ? (\n <span\n style={{\n ...outlined,\n fontSize: 12,\n background: v.red,\n borderRadius: 10,\n padding: \"2px 7px\",\n }}\n >\n {unreadLabel(c.UnreadCount)}\n </span>\n ) : null}\n </div>\n );\n })}\n </div>\n );\n}\n\nconst REASONS: Array<{ id: ChatReportReason; label: Parameters<typeof t>[0] }> =\n [\n { id: \"Spam\", label: \"rSpam\" },\n { id: \"Abuse\", label: \"rAbuse\" },\n { id: \"Scam\", label: \"rScam\" },\n { id: \"Inappropriate\", label: \"rInappropriate\" },\n { id: \"Other\", label: \"rOther\" },\n ];\n\nfunction MessagePopup({\n message,\n roomID,\n directEnabled,\n onOpenDirect,\n onClose,\n}: {\n message: MessageView;\n roomID: string;\n directEnabled: boolean;\n onOpenDirect: (r: Room) => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [reason, setReason] = useState<ChatReportReason>(\"Spam\");\n const [busy, setBusy] = useState<string | null>(null);\n const sender = message.SenderUserID!;\n const run = async (what: string, fn: () => Promise<void>) => {\n setBusy(what);\n await fn();\n setBusy(null);\n };\n return (\n <Popup title={authorName(message)} onClose={onClose} width={420}>\n <div\n style={{\n ...panel,\n padding: 10,\n fontSize: 14,\n fontWeight: 600,\n overflowWrap: \"anywhere\",\n }}\n >\n {message.Text}\n </div>\n {directEnabled ? (\n <Button\n tone=\"blue\"\n busy={busy === \"dm\"}\n onClick={() =>\n void run(\"dm\", async () => {\n await openDirect(client, sender, toast, (r) => {\n onOpenDirect(r);\n onClose();\n });\n })\n }\n >\n <Icon glyph=\"chat\" size={18} /> {t(\"writePrivately\")}\n </Button>\n ) : null}\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"reason\")}\n </div>\n <Tabs\n tabs={REASONS.map((r) => ({ id: r.id, label: t(r.label) }))}\n value={reason}\n onChange={setReason}\n />\n <Button\n tone=\"red\"\n busy={busy === \"report\"}\n onClick={() =>\n void run(\"report\", async () => {\n const res = await client.chat.reportMessage(\n roomID,\n message.MessageID!,\n reason,\n );\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(\n (res.data as { AlreadyReported?: boolean | null })\n .AlreadyReported\n ? t(\"alreadyReported\")\n : t(\"reported\"),\n );\n onClose();\n })\n }\n >\n {t(\"report\")}\n </Button>\n </div>\n <Button\n tone=\"grey\"\n busy={busy === \"ignore\"}\n onClick={() =>\n void run(\"ignore\", async () => {\n const res = await client.chat.ignoreUser(sender);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"ignored\"));\n onClose();\n })\n }\n >\n {t(\"ignore\")}\n </Button>\n </Popup>\n );\n}\n\nfunction ChannelsPopup({\n defs,\n joined,\n onClose,\n onChanged,\n}: {\n defs: ChatDefs;\n joined: string[];\n onClose: () => void;\n onChanged: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const [busy, setBusy] = useState<string | null>(null);\n const [ids, setIds] = useState(joined);\n const choices = channelChoices(defs.Channels, ids);\n const toggle = async (id: string, join: boolean) => {\n setBusy(id);\n const res = join\n ? await client.chat.joinChannel(id)\n : await client.chat.leaveChannel(id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n setIds((prev) => (join ? [...prev, id] : prev.filter((x) => x !== id)));\n onChanged();\n };\n return (\n <Popup title={t(\"channels\")} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {choices.map((c) => (\n <div\n key={c.id}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon glyph=\"chat\" size={24} />\n <div style={{ flex: 1, ...outlined, fontSize: 15 }}>\n {catalog.localize(c.name)}\n </div>\n <Button\n size=\"sm\"\n tone={c.joined ? \"grey\" : \"green\"}\n busy={busy === c.id}\n onClick={() => void toggle(c.id, !c.joined)}\n >\n {c.joined ? t(\"leave\") : t(\"join\")}\n </Button>\n </div>\n ))}\n </div>\n </Popup>\n );\n}\n"
|
|
47
|
+
"content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport type {\n ChatCursor,\n ChatReportReason,\n IDosGamesClient,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n EmptyState,\n errorText,\n Icon,\n IconButton,\n outlined,\n panel,\n Popup,\n Spinner,\n Tabs,\n toneSurface,\n useCatalog,\n useNow,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n authorName,\n channelChoices,\n chatBadge,\n isSystem,\n lastCursor,\n mergeMessages,\n orderedChannels,\n sendable,\n slowModeLeft,\n unreadLabel,\n type ChannelView,\n type MessageView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Chat (Unity V2/Runtime/UI/Chat): the title's channels as tabs plus \"Private\" (1-on-1 chats with\n// friends). One poll covers every room and runs ONLY while this screen is open, at the interval the\n// server sets; rooms are marked read when the player leaves them. Message actions (report, ignore,\n// write privately) and joining / leaving channels are popups. Another module opens a private chat\n// with features.open(\"chat\", { userID }).\n\ntype Room =\n | { kind: \"channel\"; channel: ChannelView }\n | { kind: \"direct\"; roomID: string; peerName: string };\ntype Conversation = {\n RoomID?: string | null;\n PeerUserID?: string | null;\n PeerProfile?: { Username?: string | null } | null;\n UnreadCount?: number | null;\n LastMessage?: MessageView | null;\n};\nconst DIRECT = \"__direct\";\ntype ChatDefs = {\n Direct?: { IsEnabled?: boolean | null } | null;\n Channels?: Parameters<typeof channelChoices>[0];\n};\n\n/** Badge once per session (a closed chat costs nothing: no background polling). */\nexport function makeChatWatcher(features: FeatureRegistry) {\n return function ChatWatcher(): ReactNode {\n const client = useIDosGamesClient();\n useEffect(() => {\n // The same read gives the badge and whether the title has a chat at all: switched off, or\n // no channels — the lobby hides the tab.\n void client.chat.getChannels().then((r) => {\n const channels = r.ok ? (r.data.Channels ?? []) : [];\n features.setAvailable(\n \"chat\",\n channels.length > 0,\n r.ok\n ? \"the title's chat has no channels (Chat section)\"\n : `the chat is not available on the title (${r.error})`,\n );\n features.setBadge(\"chat\", chatBadge(channels));\n });\n }, [client]);\n return null;\n };\n}\n\nexport function makeChatScreen(features: FeatureRegistry) {\n return function ChatScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const [channels, setChannels] = useState<ChannelView[] | null>(null);\n const [interval, setIntervalSec] = useState(5);\n const [mutedUntil, setMutedUntil] = useState<string | null>(null);\n const [tab, setTab] = useState<string | null>(null);\n const [room, setRoom] = useState<Room | null>(null);\n const [feeds, setFeeds] = useState<Record<string, MessageView[]>>({});\n const [unread, setUnread] = useState<Record<string, number | null>>({});\n const [managing, setManaging] = useState(false);\n // The Chat section is not part of the client config — the definitions come from their own call.\n const [defs, setDefs] = useState<ChatDefs | null>(null);\n const roomRef = useRef<Room | null>(null);\n roomRef.current = room;\n\n const loadChannels = useCallback(async () => {\n const res = await client.chat.getChannels();\n if (!res.ok) return setChannels([]);\n const list = orderedChannels(res.data.Channels ?? []);\n setChannels(list);\n setIntervalSec(Math.max(2, Number(res.data.PollIntervalSeconds ?? 5)));\n setMutedUntil(res.data.MutedUntil ?? null);\n setUnread(\n Object.fromEntries(\n list.map((c) => [c.RoomID ?? \"\", c.UnreadCount ?? null]),\n ),\n );\n features.setBadge(\"chat\", 0);\n return list;\n }, [client]);\n\n useEffect(() => {\n void client.chat\n .getChatDefinitions()\n .then((r) => setDefs(r.ok ? (r.data as ChatDefs) : {}));\n }, [client]);\n\n useEffect(() => {\n void loadChannels().then((list) => {\n const target = (args as { userID?: string } | undefined)?.userID;\n if (target)\n return void openDirect(client, target, toast, (r) => {\n setTab(DIRECT);\n setRoom(r);\n });\n const first = list?.[0];\n if (first) {\n setTab(first.ChannelID ?? null);\n setRoom({ kind: \"channel\", channel: first });\n }\n });\n }, [loadChannels]);\n\n // One poll for every room, only while the screen is open.\n useEffect(() => {\n let stopped = false;\n let timer: ReturnType<typeof setTimeout> | undefined;\n const tick = async () => {\n const res = await client.chat.poll();\n if (stopped) return;\n if (res.ok) {\n const current = roomID(roomRef.current);\n for (const r of res.data.Rooms ?? []) {\n if (!r.RoomID) continue;\n const msgs = r.Messages ?? [];\n if (msgs.length > 0)\n setFeeds((f) => ({\n ...f,\n [r.RoomID!]: mergeMessages(f[r.RoomID!] ?? [], msgs),\n }));\n if (r.RoomID !== current)\n setUnread((u) => ({\n ...u,\n [r.RoomID!]: r.UnreadCount ?? u[r.RoomID!] ?? null,\n }));\n }\n if (res.data.PollIntervalSeconds)\n setIntervalSec(Math.max(2, res.data.PollIntervalSeconds));\n }\n timer = setTimeout(() => void tick(), interval * 1000);\n };\n timer = setTimeout(() => void tick(), interval * 1000);\n return () => {\n stopped = true;\n if (timer) clearTimeout(timer);\n };\n }, [client, interval]);\n\n if (!channels || !defs)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const directEnabled = !!defs.Direct?.IsEnabled;\n if (channels.length === 0 && !directEnabled)\n return <EmptyState glyph=\"chat\" text={t(\"disabled\")} />;\n\n const pick = (id: string) => {\n setTab(id);\n if (id === DIRECT) return setRoom(null);\n const ch = channels.find((c) => c.ChannelID === id);\n if (ch) setRoom({ kind: \"channel\", channel: ch });\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 10,\n }}\n >\n <div style={{ display: \"flex\", gap: 8, alignItems: \"center\" }}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <Tabs\n tabs={[\n ...channels.map((c) => ({\n id: c.ChannelID!,\n label: catalog.localize(c.DisplayNameKey ?? c.ChannelID),\n badge:\n Number(\n unreadLabel(unread[c.RoomID ?? \"\"]).replace(\"+\", \"\"),\n ) || undefined,\n })),\n ...(directEnabled ? [{ id: DIRECT, label: t(\"direct\") }] : []),\n ]}\n value={tab ?? \"\"}\n onChange={pick}\n />\n </div>\n <IconButton\n glyph=\"gear\"\n label={t(\"channels\")}\n onClick={() => setManaging(true)}\n size={36}\n tone=\"blue\"\n />\n </div>\n {mutedUntil && new Date(mutedUntil).getTime() > Date.now() ? (\n <div\n style={{\n ...panel,\n padding: 8,\n ...outlined,\n fontSize: 13,\n color: v.red,\n textAlign: \"center\",\n }}\n >\n {t(\"muted\")} {new Date(mutedUntil).toLocaleString()}\n </div>\n ) : null}\n {tab === DIRECT && !room ? (\n <Conversations onOpen={(r) => setRoom(r)} />\n ) : room ? (\n <RoomView\n key={roomID(room)}\n room={room}\n feed={feeds[roomID(room)] ?? []}\n setFeed={(fn) =>\n setFeeds((f) => ({\n ...f,\n [roomID(room)]: fn(f[roomID(room)] ?? []),\n }))\n }\n onBack={room.kind === \"direct\" ? () => setRoom(null) : undefined}\n onOpenDirect={(r) => {\n setTab(DIRECT);\n setRoom(r);\n }}\n directEnabled={directEnabled}\n />\n ) : null}\n {managing ? (\n <ChannelsPopup\n defs={defs}\n joined={channels.map((c) => c.ChannelID ?? \"\")}\n onClose={() => setManaging(false)}\n onChanged={() => void loadChannels()}\n />\n ) : null}\n </div>\n );\n };\n}\n\nfunction roomID(room: Room | null): string {\n if (!room) return \"\";\n return room.kind === \"channel\" ? (room.channel.RoomID ?? \"\") : room.roomID;\n}\n\nasync function openDirect(\n client: IDosGamesClient,\n userID: string,\n toast: (text: string, kind?: \"error\") => void,\n open: (r: Room) => void,\n): Promise<void> {\n const res = await client.chat.openDirectChannel(userID);\n if (!res.ok || !res.data.RoomID)\n return toast(errorText(res.ok ? \"\" : res.error), \"error\");\n open({\n kind: \"direct\",\n roomID: res.data.RoomID,\n peerName: res.data.PeerProfile?.Username ?? userID,\n });\n}\n\nfunction RoomView({\n room,\n feed,\n setFeed,\n onBack,\n onOpenDirect,\n directEnabled,\n}: {\n room: Room;\n feed: MessageView[];\n setFeed: (fn: (prev: MessageView[]) => MessageView[]) => void;\n onBack?: () => void;\n onOpenDirect: (r: Room) => void;\n directEnabled: boolean;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const now = useNow();\n const id = roomID(room);\n const [older, setOlder] = useState<ChatCursor | null | undefined>(undefined);\n const [loading, setLoading] = useState(true);\n const [text, setText] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [lastSent, setLastSent] = useState<number | null>(null);\n const [picked, setPicked] = useState<MessageView | null>(null);\n const feedRef = useRef<HTMLDivElement>(null);\n const feedNow = useRef(feed);\n feedNow.current = feed;\n const me = client.auth.context?.userID;\n const channel = room.kind === \"channel\" ? room.channel : null;\n const maxLength = channel?.MaxLength ?? 500;\n\n // History first, then mark read up to what was drawn; on leaving, read up to the last message.\n useEffect(() => {\n let alive = true;\n void client.chat.getHistory(id).then((res) => {\n if (!alive) return;\n setLoading(false);\n if (!res.ok) return;\n const merged = mergeMessages(feedNow.current, res.data.Messages ?? []);\n setFeed(() => merged);\n setOlder(res.data.NextBefore ?? null);\n const cursor = lastCursor(id, merged);\n if (cursor) void client.chat.markRead(id, cursor);\n });\n return () => {\n alive = false;\n const cursor = lastCursor(id, feedNow.current);\n if (cursor) void client.chat.markRead(id, cursor);\n };\n }, [client, id]);\n\n // Keep the newest message in view.\n useEffect(() => {\n const el = feedRef.current;\n if (el) el.scrollTop = el.scrollHeight;\n }, [feed.length]);\n\n const loadOlder = async () => {\n if (!older) return;\n const res = await client.chat.getHistory(id, older);\n if (!res.ok) return;\n setFeed((prev) => mergeMessages(prev, res.data.Messages ?? []));\n setOlder(res.data.NextBefore ?? null);\n };\n\n const wait = slowModeLeft(lastSent, channel?.SlowModeSeconds, now);\n const message = sendable(text, maxLength);\n const canWrite = channel ? channel.CanWrite !== false : true;\n\n const send = async () => {\n if (!message || wait > 0) return;\n setBusy(true);\n const res = channel\n ? await client.chat.sendMessage(channel.ChannelID!, message)\n : await client.chat.sendDirect(id, message);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"tab\");\n setText(\"\");\n setLastSent(Date.now());\n // Render the SERVER's copy: moderation may have masked a word.\n if (res.data.Message)\n setFeed((prev) => mergeMessages(prev, [res.data.Message!]));\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateRows: \"auto minmax(0, 1fr) auto\",\n gap: 8,\n height: \"min(70vh, 640px)\",\n minHeight: 320,\n }}\n >\n {onBack ? (\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n <Button size=\"sm\" tone=\"grey\" onClick={onBack}>\n ← {t(\"back\")}\n </Button>\n <span style={{ ...outlined, fontSize: 15 }}>\n {room.kind === \"direct\" ? room.peerName : \"\"}\n </span>\n </div>\n ) : (\n <div />\n )}\n <div\n ref={feedRef}\n className=\"idos-scroll\"\n style={{\n ...panel,\n padding: 10,\n overflowY: \"auto\",\n display: \"grid\",\n alignContent: \"start\",\n gap: 6,\n }}\n >\n {older ? (\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() => void loadOlder()}\n style={{ justifySelf: \"center\" }}\n >\n {t(\"older\")}\n </Button>\n ) : null}\n {loading ? (\n <Center minHeight={120}>\n <Spinner />\n </Center>\n ) : feed.length === 0 ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n padding: 20,\n }}\n >\n {t(\"noMessages\")}\n </div>\n ) : null}\n {feed.map((m) => {\n const mine = m.SenderUserID === me;\n const system = isSystem(m);\n return (\n <div\n key={m.MessageID}\n style={{\n display: \"flex\",\n justifyContent: system\n ? \"center\"\n : mine\n ? \"flex-end\"\n : \"flex-start\",\n }}\n >\n <div\n onClick={!mine && !system ? () => setPicked(m) : undefined}\n style={{\n maxWidth: \"80%\",\n padding: \"6px 10px\",\n borderRadius: 12,\n ...(system\n ? {\n background: `color-mix(in srgb, ${v.gold} 18%, transparent)`,\n }\n : mine\n ? toneSurface(\"blue\")\n : {\n background: `color-mix(in srgb, ${v.text} 10%, transparent)`,\n }),\n cursor: !mine && !system ? \"pointer\" : undefined,\n overflowWrap: \"anywhere\",\n }}\n >\n {!mine ? (\n <div\n style={{\n ...outlined,\n fontSize: 11,\n color: system ? v.gold : v.textDim,\n }}\n >\n {system ? t(\"system\") : authorName(m)}\n </div>\n ) : null}\n <div style={{ fontSize: 14, fontWeight: 600 }}>{m.Text}</div>\n <div\n style={{\n fontSize: 10,\n color: v.textDim,\n textAlign: \"right\",\n }}\n >\n {m.SentAt\n ? new Date(m.SentAt).toLocaleTimeString([], {\n hour: \"2-digit\",\n minute: \"2-digit\",\n })\n : \"\"}\n </div>\n </div>\n </div>\n );\n })}\n </div>\n {canWrite ? (\n <div style={{ display: \"flex\", gap: 8 }}>\n <input\n value={text}\n maxLength={maxLength}\n onChange={(e) => setText(e.target.value)}\n onKeyDown={(e) => e.key === \"Enter\" && void send()}\n placeholder={t(\"placeholder\")}\n style={{\n ...panel,\n flex: 1,\n minWidth: 0,\n padding: \"10px 12px\",\n fontSize: 15,\n color: v.text,\n outline: \"none\",\n }}\n data-tutorial-anchor=\"chat:input\"\n />\n <Button\n tone=\"green\"\n disabled={!message || wait > 0}\n busy={busy}\n onClick={() => void send()}\n >\n {wait > 0 ? `${wait}${t(\"sec\")}` : <Icon glyph=\"chat\" size={20} />}\n </Button>\n </div>\n ) : (\n <div\n style={{\n ...panel,\n padding: 10,\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {channel?.WriteBlockedReason ?? \"—\"}\n </div>\n )}\n {picked ? (\n <MessagePopup\n message={picked}\n roomID={id}\n directEnabled={directEnabled}\n onOpenDirect={onOpenDirect}\n onClose={() => setPicked(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction Conversations({ onOpen }: { onOpen: (r: Room) => void }): ReactNode {\n const client = useIDosGamesClient();\n const [list, setList] = useState<Conversation[] | null>(null);\n useEffect(() => {\n void client.chat\n .getConversations()\n .then((r) =>\n setList(r.ok ? ((r.data.Conversations ?? []) as Conversation[]) : []),\n );\n }, [client]);\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"chat\" text={t(\"noConversations\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((c) => {\n const name = c.PeerProfile?.Username ?? c.PeerUserID ?? \"?\";\n return (\n <div\n key={c.RoomID}\n onClick={() =>\n c.RoomID &&\n onOpen({ kind: \"direct\", roomID: c.RoomID, peerName: name })\n }\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: \"pointer\",\n }}\n >\n <Icon glyph=\"user\" size={28} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>{name}</div>\n <div\n style={{\n fontSize: 12,\n color: v.textDim,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {c.LastMessage?.Text ?? \"\"}\n </div>\n </div>\n {unreadLabel(c.UnreadCount) ? (\n <span\n style={{\n ...outlined,\n fontSize: 12,\n background: v.red,\n borderRadius: 10,\n padding: \"2px 7px\",\n }}\n >\n {unreadLabel(c.UnreadCount)}\n </span>\n ) : null}\n </div>\n );\n })}\n </div>\n );\n}\n\nconst REASONS: Array<{ id: ChatReportReason; label: Parameters<typeof t>[0] }> =\n [\n { id: \"Spam\", label: \"rSpam\" },\n { id: \"Abuse\", label: \"rAbuse\" },\n { id: \"Scam\", label: \"rScam\" },\n { id: \"Inappropriate\", label: \"rInappropriate\" },\n { id: \"Other\", label: \"rOther\" },\n ];\n\nfunction MessagePopup({\n message,\n roomID,\n directEnabled,\n onOpenDirect,\n onClose,\n}: {\n message: MessageView;\n roomID: string;\n directEnabled: boolean;\n onOpenDirect: (r: Room) => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [reason, setReason] = useState<ChatReportReason>(\"Spam\");\n const [busy, setBusy] = useState<string | null>(null);\n const sender = message.SenderUserID!;\n const run = async (what: string, fn: () => Promise<void>) => {\n setBusy(what);\n await fn();\n setBusy(null);\n };\n return (\n <Popup title={authorName(message)} onClose={onClose} width={420}>\n <div\n style={{\n ...panel,\n padding: 10,\n fontSize: 14,\n fontWeight: 600,\n overflowWrap: \"anywhere\",\n }}\n >\n {message.Text}\n </div>\n {directEnabled ? (\n <Button\n tone=\"blue\"\n busy={busy === \"dm\"}\n onClick={() =>\n void run(\"dm\", async () => {\n await openDirect(client, sender, toast, (r) => {\n onOpenDirect(r);\n onClose();\n });\n })\n }\n >\n <Icon glyph=\"chat\" size={18} /> {t(\"writePrivately\")}\n </Button>\n ) : null}\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"reason\")}\n </div>\n <Tabs\n tabs={REASONS.map((r) => ({ id: r.id, label: t(r.label) }))}\n value={reason}\n onChange={setReason}\n />\n <Button\n tone=\"red\"\n busy={busy === \"report\"}\n onClick={() =>\n void run(\"report\", async () => {\n const res = await client.chat.reportMessage(\n roomID,\n message.MessageID!,\n reason,\n );\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(\n (res.data as { AlreadyReported?: boolean | null })\n .AlreadyReported\n ? t(\"alreadyReported\")\n : t(\"reported\"),\n );\n onClose();\n })\n }\n >\n {t(\"report\")}\n </Button>\n </div>\n <Button\n tone=\"grey\"\n busy={busy === \"ignore\"}\n onClick={() =>\n void run(\"ignore\", async () => {\n const res = await client.chat.ignoreUser(sender);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"ignored\"));\n onClose();\n })\n }\n >\n {t(\"ignore\")}\n </Button>\n </Popup>\n );\n}\n\nfunction ChannelsPopup({\n defs,\n joined,\n onClose,\n onChanged,\n}: {\n defs: ChatDefs;\n joined: string[];\n onClose: () => void;\n onChanged: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const [busy, setBusy] = useState<string | null>(null);\n const [ids, setIds] = useState(joined);\n const choices = channelChoices(defs.Channels, ids);\n const toggle = async (id: string, join: boolean) => {\n setBusy(id);\n const res = join\n ? await client.chat.joinChannel(id)\n : await client.chat.leaveChannel(id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n setIds((prev) => (join ? [...prev, id] : prev.filter((x) => x !== id)));\n onChanged();\n };\n return (\n <Popup title={t(\"channels\")} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {choices.map((c) => (\n <div\n key={c.id}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon glyph=\"chat\" size={24} />\n <div style={{ flex: 1, ...outlined, fontSize: 15 }}>\n {catalog.localize(c.name)}\n </div>\n <Button\n size=\"sm\"\n tone={c.joined ? \"grey\" : \"green\"}\n busy={busy === c.id}\n onClick={() => void toggle(c.id, !c.joined)}\n >\n {c.joined ? t(\"leave\") : t(\"join\")}\n </Button>\n </div>\n ))}\n </div>\n </Popup>\n );\n}\n"
|
|
48
48
|
},
|
|
49
49
|
{
|
|
50
50
|
"path": "i18n.ts",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
},
|
|
65
65
|
{
|
|
66
66
|
"path": "module.meta.json",
|
|
67
|
-
"content": "{\n \"id\": \"chat\",\n \"type\": \"feature\",\n \"summary\": \"Chat for any game: channels as tabs, private chats, a feed with earlier messages, slow mode, and report / ignore / write privately in a popup.\",\n \"description\": \"A ready player chat (Unity V2/Runtime/UI/Chat) for ANY game, built on the @idosgames/react/ui kit and client.chat. The title's channels are tabs (names are localization keys) plus 'Private' — 1-on-1 chats (with friends when the title says so). The feed loads its history, pages back to earlier messages, shows the server's copy of what was sent (moderation may mask a word) and counts down the slow mode; a channel the player may not write to shows the server's reason. ONE poll covers every room and runs only while the screen is open, at the interval the server sets; rooms are marked read when the player leaves them. Popups: a message's actions (write privately, report with a reason, ignore the player) and the channel list to join or leave opt-in channels. Registers the 'chat' feature; features.open('chat', { userID }) opens a private chat (the friends card uses it). Its badge is the unread count, read once per session — a closed chat costs nothing. Ships title-seed.json: a world channel, an opt-in trade channel, private chats between friends.\",\n \"provides\": [\n \"chat channels as tabs\",\n \"private chats\",\n \"message feed with earlier messages\",\n \"slow mode countdown\",\n \"report, ignore and write privately\",\n \"join and leave channels\",\n \"chat badge\",\n \"demo channels\"\n ],\n \"tags\": [\"chat\", \"social\", \"messages\", \"dm\", \"moderation\", \"ui\", \"screen\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.
|
|
67
|
+
"content": "{\n \"id\": \"chat\",\n \"type\": \"feature\",\n \"summary\": \"Chat for any game: channels as tabs, private chats, a feed with earlier messages, slow mode, and report / ignore / write privately in a popup.\",\n \"description\": \"A ready player chat (Unity V2/Runtime/UI/Chat) for ANY game, built on the @idosgames/react/ui kit and client.chat. The title's channels are tabs (names are localization keys) plus 'Private' — 1-on-1 chats (with friends when the title says so). The feed loads its history, pages back to earlier messages, shows the server's copy of what was sent (moderation may mask a word) and counts down the slow mode; a channel the player may not write to shows the server's reason. ONE poll covers every room and runs only while the screen is open, at the interval the server sets; rooms are marked read when the player leaves them. Popups: a message's actions (write privately, report with a reason, ignore the player) and the channel list to join or leave opt-in channels. Registers the 'chat' feature; features.open('chat', { userID }) opens a private chat (the friends card uses it). Its badge is the unread count, read once per session — a closed chat costs nothing. Ships title-seed.json: a world channel, an opt-in trade channel, private chats between friends.\",\n \"provides\": [\n \"chat channels as tabs\",\n \"private chats\",\n \"message feed with earlier messages\",\n \"slow mode countdown\",\n \"report, ignore and write privately\",\n \"join and leave channels\",\n \"chat badge\",\n \"demo channels\"\n ],\n \"tags\": [\"chat\", \"social\", \"messages\", \"dm\", \"moderation\", \"ui\", \"screen\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.1\"\n}\n"
|
|
68
68
|
}
|
|
69
69
|
]
|
|
70
70
|
}
|