@idosgames/mcp 0.1.19 → 0.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +2 -2
- package/package.json +1 -1
- package/registry/host.json +7 -7
- package/registry/index.json +437 -110
- package/registry/modules/ai.json +65 -0
- package/registry/modules/board-game.json +3 -3
- package/registry/modules/boosts.json +66 -0
- package/registry/modules/character.json +4 -5
- package/registry/modules/chat.json +4 -5
- package/registry/modules/collections.json +69 -0
- package/registry/modules/coop-events.json +67 -0
- package/registry/modules/craft.json +4 -5
- package/registry/modules/deals.json +4 -5
- package/registry/modules/events.json +8 -13
- package/registry/modules/friends.json +5 -5
- package/registry/modules/idle-rpg.json +3 -3
- package/registry/modules/inventory.json +15 -18
- package/registry/modules/invite.json +7 -7
- package/registry/modules/leaderboard.json +4 -5
- package/registry/modules/lootboxes.json +67 -0
- package/registry/modules/mailbox.json +71 -0
- package/registry/modules/marketplace.json +4 -5
- package/registry/modules/mini-game.json +2 -2
- package/registry/modules/multiplayer.json +74 -0
- package/registry/modules/premium.json +67 -0
- package/registry/modules/profile.json +9 -10
- package/registry/modules/pvp.json +4 -5
- package/registry/modules/quests.json +69 -0
- package/registry/modules/rewards.json +15 -21
- package/registry/modules/season.json +8 -13
- package/registry/modules/store.json +4 -5
- package/registry/modules/tutorials.json +4 -5
- package/registry/modules/voxelcraft.json +15 -11
- package/registry/modules/workshop.json +4 -5
- package/registry/skills/blockchain-system.json +1 -1
- package/registry/skills/idosgames-compose-modules.json +2 -2
- package/registry/skills/idosgames-getting-started.json +1 -1
- package/registry/skills/idosgames-module-contract.json +2 -2
- package/registry/skills/idosgames-ui-kit.json +2 -2
- package/registry/skills/mailbox-system.json +6 -0
- package/registry/skills/multiplayer-realtime.json +2 -2
- package/registry/skills/workshop-system.json +1 -1
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "ai",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "AI studio",
|
|
5
|
+
"type": "app",
|
|
6
|
+
"engine": "dom"
|
|
7
|
+
},
|
|
8
|
+
"catalog": {
|
|
9
|
+
"type": "feature",
|
|
10
|
+
"summary": "AI studio for any game: the title's AI features for players — text, pictures, voice, music, video and 3D — with the price and the history.",
|
|
11
|
+
"description": "A ready AI studio (engine AI, InApp AI) for ANY game, built on the @idosgames/react/ui kit and client.ai. Every AI feature the publisher opened to players (section AI, sanitized by the server: no prompts, no models), grouped by kind — text, picture, voice, music, video, 3D. The player describes what they want, pays the feature's price in in-game resources (or nothing), and the result comes back from a job the server runs (waitForGeneration): text, pictures, a player for sound and video, a download link for 3D. 'My creations' lists the player's generations. A feature tagged 'hidden' is for the game's own code (NPC lines and the like) and is not shown. Registers the 'ai' feature. The money side (publisher credits) is the engine's; see the in-app AI rules.",
|
|
12
|
+
"provides": [
|
|
13
|
+
"AI studio screen",
|
|
14
|
+
"text, image, voice, music, video and 3D generation for players",
|
|
15
|
+
"feature price in in-game resources",
|
|
16
|
+
"history of the player's generations"
|
|
17
|
+
],
|
|
18
|
+
"tags": [
|
|
19
|
+
"ai",
|
|
20
|
+
"generation",
|
|
21
|
+
"ugc",
|
|
22
|
+
"creative",
|
|
23
|
+
"ui",
|
|
24
|
+
"screen"
|
|
25
|
+
],
|
|
26
|
+
"author": {
|
|
27
|
+
"name": "iDos Games",
|
|
28
|
+
"url": "https://idosgames.com"
|
|
29
|
+
},
|
|
30
|
+
"version": "0.1.0"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@idosgames/core": "0.19.1",
|
|
34
|
+
"@idosgames/module-sdk": "0.7.0",
|
|
35
|
+
"@idosgames/react": "0.3.0",
|
|
36
|
+
"react": "19.2.7"
|
|
37
|
+
},
|
|
38
|
+
"contentHash": "1ac6c1fb5fc8cbe94891f258339e8f27abe7bded84dccc8f4f6bbb6465e2dc35",
|
|
39
|
+
"files": [
|
|
40
|
+
{
|
|
41
|
+
"path": "AiScreen.tsx",
|
|
42
|
+
"content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type { AIGenerationView, IDosGamesClient } from \"@idosgames/core\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n ResourceList,\n Spinner,\n Tabs,\n canAfford,\n configSection,\n errorText,\n outlined,\n panel,\n useCatalog,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n featureRows,\n resultKind,\n type AISection,\n type FeatureRow,\n type Modality,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// AI studio (engine AI): the AI features the publisher opened to players — text, pictures, speech,\n// music, video, 3D. The player writes what they want, pays the feature's price (or nothing), and the\n// result comes back from a job the server runs (polled with waitForGeneration). A feature tagged\n// \"hidden\" is for the game's own code (NPC lines and the like) and does not show here.\n\ntype Tab = \"create\" | \"history\";\n\nexport function AiScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const [tab, setTab] = useState<Tab>(\"create\");\n const [picked, setPicked] = useState<string | null>(null);\n const features = featureRows(configSection<AISection>(client, \"AI\"));\n if (features.length === 0)\n return <EmptyState glyph=\"bulb\" text={t(\"noFeatures\")} />;\n const current = features.find((f) => f.key === picked) ?? features[0]!;\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <Tabs\n tabs={[\n { id: \"create\", label: t(\"create\") },\n { id: \"history\", label: t(\"history\") },\n ]}\n value={tab}\n onChange={setTab}\n />\n {tab === \"create\" ? (\n <>\n {features.length > 1 ? (\n <div style={{ display: \"flex\", gap: 6, flexWrap: \"wrap\" }}>\n {features.map((f) => (\n <Button\n key={f.key}\n size=\"sm\"\n tone={f.key === current.key ? \"gold\" : \"grey\"}\n onClick={() => setPicked(f.key)}\n >\n {catalog.localize(f.key)} · {t(`m_${f.modality}`)}\n </Button>\n ))}\n </div>\n ) : null}\n <Create key={current.key} feature={current} />\n </>\n ) : (\n <History />\n )}\n </div>\n );\n}\n\n/** Starts a job for a feature by its modality and waits for the result. */\nfunction run(\n client: IDosGamesClient,\n feature: FeatureRow,\n prompt: string,\n optionID: string | undefined,\n): Promise<\n { ok: true; data: AIGenerationView } | { ok: false; error: string }\n> {\n const options = {\n selectedOptionID: optionID,\n relatedEntityID: crypto.randomUUID(),\n };\n const byModality: Record<\n Modality,\n () => ReturnType<IDosGamesClient[\"ai\"][\"generateText\"]>\n > = {\n Text: () =>\n client.ai.generateText(feature.key, { Prompt: prompt }, options),\n Image: () =>\n client.ai.generateImage(feature.key, { Prompt: prompt }, options),\n Audio: () =>\n client.ai.generateAudio(feature.key, { Text: prompt }, options),\n Music: () =>\n client.ai.generateMusic(feature.key, { Prompt: prompt }, options),\n Video: () =>\n client.ai.generateVideo(feature.key, { Prompt: prompt }, options),\n ThreeD: () =>\n client.ai.generateThreeD(feature.key, { Prompt: prompt }, options),\n };\n return byModality[feature.modality]().then((started) =>\n started.ok\n ? client.ai.waitForGeneration(started.data, { timeoutMs: 10 * 60_000 })\n : started,\n );\n}\n\nfunction Create({ feature }: { feature: FeatureRow }): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const [prompt, setPrompt] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [result, setResult] = useState<AIGenerationView | null>(null);\n const alive = useRef(true);\n useEffect(\n () => () => {\n alive.current = false;\n },\n [],\n );\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const option =\n feature.options.find((o) => canAfford(o.price, balances)) ??\n feature.options[0];\n const affordable = !option || canAfford(option.price, balances);\n\n const go = async () => {\n setBusy(true);\n setResult(null);\n const res = await run(client, feature, prompt.trim(), option?.id);\n if (!alive.current) return;\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (res.data.Status !== \"completed\")\n return toast(res.data.Error?.Message || t(\"failed\"), \"error\");\n setResult(res.data);\n };\n\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n <textarea\n value={prompt}\n maxLength={feature.maxChars > 0 ? feature.maxChars : undefined}\n placeholder={\n feature.modality === \"Audio\" ? t(\"sayWhat\") : t(\"describe\")\n }\n onChange={(e) => setPrompt(e.target.value)}\n rows={4}\n style={{\n ...panel,\n padding: 12,\n color: \"#fff\",\n fontSize: 15,\n resize: \"vertical\",\n width: \"100%\",\n boxSizing: \"border-box\",\n }}\n />\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract={!!prompt.trim() && affordable}\n disabled={!prompt.trim() || !affordable}\n busy={busy}\n onClick={() => void go()}\n data-tutorial-anchor={`ai:${feature.key}`}\n >\n {busy ? t(\"working\") : t(\"generate\")}{\" \"}\n {option ? <ResourceList lines={option.price} size={18} /> : null}\n </Button>\n {busy ? (\n <Center>\n <Spinner />\n </Center>\n ) : null}\n {result ? <Result generation={result} /> : null}\n </div>\n );\n}\n\nfunction Result({ generation }: { generation: AIGenerationView }): ReactNode {\n return (\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 10 }}>\n {generation.OutputText ? (\n <div style={{ color: \"#fff\", fontSize: 15, whiteSpace: \"pre-wrap\" }}>\n {generation.OutputText}\n </div>\n ) : null}\n {(generation.Assets ?? []).map((a, i) => {\n const kind = resultKind(a.ContentType);\n const url = a.Url ?? \"\";\n return kind === \"image\" ? (\n <img\n key={i}\n src={url}\n alt=\"\"\n style={{ width: \"100%\", borderRadius: 12 }}\n />\n ) : kind === \"audio\" ? (\n <audio key={i} src={url} controls style={{ width: \"100%\" }} />\n ) : kind === \"video\" ? (\n <video\n key={i}\n src={url}\n controls\n style={{ width: \"100%\", borderRadius: 12 }}\n />\n ) : (\n <a\n key={i}\n href={url}\n target=\"_blank\"\n rel=\"noreferrer\"\n style={{ ...outlined, color: v.gold, fontSize: 14 }}\n >\n {t(\"download\")}\n </a>\n );\n })}\n </div>\n );\n}\n\nfunction History(): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const [items, setItems] = useState<AIGenerationView[] | null>(null);\n const [open, setOpen] = useState<string | null>(null);\n useEffect(() => {\n void client.ai\n .listGenerations({ Limit: 20 })\n .then((r) => setItems(r.ok ? (r.data.Data ?? []) : []));\n }, [client]);\n if (!items)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (items.length === 0)\n return <EmptyState glyph=\"bulb\" text={t(\"noHistory\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {items.map((g, i) => (\n <Card\n key={g.GenerationID}\n onClick={() =>\n setOpen(open === g.GenerationID ? null : g.GenerationID)\n }\n style={{ display: \"grid\", gap: 8, ...stagger(i) }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n <Icon glyph=\"bulb\" size={26} />\n <span style={{ ...outlined, fontSize: 14, flex: 1 }}>\n {catalog.localize(g.FeatureKey ?? \"\")}\n </span>\n <span\n style={{\n ...outlined,\n fontSize: 12,\n color:\n g.Status === \"completed\"\n ? v.green\n : g.Status === \"failed\"\n ? v.textDim\n : v.gold,\n }}\n >\n {t(\n `s_${g.Status === \"completed\" || g.Status === \"failed\" ? g.Status : \"running\"}`,\n )}\n </span>\n </div>\n {open === g.GenerationID && g.Status === \"completed\" ? (\n <Result generation={g} />\n ) : null}\n </Card>\n ))}\n </div>\n );\n}\n"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"path": "i18n.ts",
|
|
46
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"AI studio\",\n create: \"Create\",\n history: \"My creations\",\n describe: \"Describe what you want…\",\n sayWhat: \"Text to say…\",\n generate: \"Create\",\n working: \"Creating…\",\n failed: \"Could not create it. Try again.\",\n download: \"Download the file\",\n noFeatures: \"No AI in this game yet. Add it in LiveOps → AI.\",\n noHistory: \"Nothing created yet.\",\n m_Text: \"text\",\n m_Image: \"picture\",\n m_Audio: \"voice\",\n m_Music: \"music\",\n m_Video: \"video\",\n m_ThreeD: \"3D\",\n s_completed: \"ready\",\n s_failed: \"failed\",\n s_running: \"in progress\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"ИИ-студия\",\n create: \"Создать\",\n history: \"Мои работы\",\n describe: \"Опишите, что хотите получить…\",\n sayWhat: \"Текст, который нужно озвучить…\",\n generate: \"Создать\",\n working: \"Создаём…\",\n failed: \"Не получилось. Попробуйте ещё раз.\",\n download: \"Скачать файл\",\n noFeatures: \"ИИ в этой игре пока нет. Добавьте его в LiveOps → AI.\",\n noHistory: \"Вы ещё ничего не создали.\",\n m_Text: \"текст\",\n m_Image: \"картинка\",\n m_Audio: \"голос\",\n m_Music: \"музыка\",\n m_Video: \"видео\",\n m_ThreeD: \"3D\",\n s_completed: \"готово\",\n s_failed: \"не вышло\",\n s_running: \"в работе\",\n});\n"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"path": "index.ts",
|
|
50
|
+
"content": "// @idosgames/mod-ai — AI studio for any game: the AI features of the title - text, images, speech, music, video and 3D made by the player, with the price and the history.\n\nexport { aiModule } from \"./module\";\n"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"path": "model.ts",
|
|
54
|
+
"content": "// Pure helpers of the AI studio screen — covered by model.test.ts. Prompts, models and limits live on\n// the server; the client only sees what it may send and what it costs.\n\nimport { costLines, type ResourceLine } from \"@idosgames/react/ui\";\n\nexport type Modality =\n \"Text\" | \"Image\" | \"Video\" | \"Audio\" | \"Music\" | \"ThreeD\";\n\nexport interface AIFeatureView {\n Modality?: string | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Cost?: unknown } | null\n > | null;\n Tags?: string[] | null;\n MaxPromptChars?: number | null;\n}\n\n/** The title's AI section (sanitized by the server), as far as the studio cares. */\nexport interface AISection {\n Enabled?: boolean | null;\n Features?: Record<string, AIFeatureView | null> | null;\n}\n\nexport interface FeatureRow {\n key: string;\n modality: Modality;\n /** Ways to pay; empty — free for the player. */\n options: Array<{ id: string; price: ResourceLine[] }>;\n maxChars: number;\n}\n\nconst MODALITIES: readonly Modality[] = [\n \"Text\",\n \"Image\",\n \"Audio\",\n \"Music\",\n \"Video\",\n \"ThreeD\",\n];\n\n/** A feature the publisher keeps for the game's own code only (e.g. NPC lines) carries this tag. */\nexport const HIDDEN_TAG = \"hidden\";\n\n/** The features the player can use in the studio, by modality then key. */\nexport function featureRows(\n section: AISection | null | undefined,\n): FeatureRow[] {\n if (section?.Enabled === false) return [];\n return Object.entries(section?.Features ?? {})\n .filter(([, f]) => f && !(f.Tags ?? []).includes(HIDDEN_TAG))\n .map(([key, f]) => ({\n key,\n modality: (MODALITIES as readonly string[]).includes(f!.Modality ?? \"\")\n ? (f!.Modality as Modality)\n : \"Text\",\n options: Object.entries(f!.PriceOptions ?? {})\n .filter(([, o]) => o)\n .map(([oid, o]) => ({\n id: o?.OptionID || oid,\n price: costLines(o?.Cost as never).filter((l) => l.kind !== \"other\"),\n }))\n .filter((o) => o.price.length > 0),\n maxChars: Math.max(0, Number(f!.MaxPromptChars ?? 0)),\n }))\n .sort(\n (a, b) =>\n MODALITIES.indexOf(a.modality) - MODALITIES.indexOf(b.modality) ||\n a.key.localeCompare(b.key),\n );\n}\n\n/** What a finished generation shows: its text, pictures, sound or video, or a file to download. */\nexport type ResultKind = \"text\" | \"image\" | \"audio\" | \"video\" | \"file\";\nexport function resultKind(contentType: string | null | undefined): ResultKind {\n const ct = (contentType ?? \"\").toLowerCase();\n if (ct.startsWith(\"image/\")) return \"image\";\n if (ct.startsWith(\"audio/\")) return \"audio\";\n if (ct.startsWith(\"video/\")) return \"video\";\n return \"file\";\n}\n\n/** Whether the title has AI features for the player — until it does, the lobby hides the tab. */\nexport function hasTitleData(section: AISection | undefined): boolean {\n return featureRows(section).length > 0;\n}\n"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"path": "module.ts",
|
|
58
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { AiScreen } from \"./AiScreen\";\nimport { hasTitleData, type AISection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The AI studio as a feature of the game: the AI features the publisher opened to players. No badge\n// and no startup request — the features come with the title config (section \"AI\").\nexport const aiModule: Module = defineModule({\n id: \"ai\",\n meta: { name: \"AI studio\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"ai\",\n label: t(\"title\"),\n icon: \"bulb\",\n group: \"social\",\n order: 80,\n available: false,\n Screen: AiScreen,\n });\n ctx.features.setAvailable(\n \"ai\",\n hasTitleData(configSection<AISection>(ctx.client, \"AI\")),\n \"no AI features for players on the title (AI.Features, none untagged 'hidden')\",\n );\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"ai\" }),\n describeActions: {},\n });\n },\n});\n"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"path": "module.meta.json",
|
|
62
|
+
"content": "{\n \"id\": \"ai\",\n \"type\": \"feature\",\n \"summary\": \"AI studio for any game: the title's AI features for players — text, pictures, voice, music, video and 3D — with the price and the history.\",\n \"description\": \"A ready AI studio (engine AI, InApp AI) for ANY game, built on the @idosgames/react/ui kit and client.ai. Every AI feature the publisher opened to players (section AI, sanitized by the server: no prompts, no models), grouped by kind — text, picture, voice, music, video, 3D. The player describes what they want, pays the feature's price in in-game resources (or nothing), and the result comes back from a job the server runs (waitForGeneration): text, pictures, a player for sound and video, a download link for 3D. 'My creations' lists the player's generations. A feature tagged 'hidden' is for the game's own code (NPC lines and the like) and is not shown. Registers the 'ai' feature. The money side (publisher credits) is the engine's; see the in-app AI rules.\",\n \"provides\": [\n \"AI studio screen\",\n \"text, image, voice, music, video and 3D generation for players\",\n \"feature price in in-game resources\",\n \"history of the player's generations\"\n ],\n \"tags\": [\"ai\", \"generation\", \"ugc\", \"creative\", \"ui\", \"screen\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|
|
@@ -45,10 +45,10 @@
|
|
|
45
45
|
}
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@idosgames/core": "0.19.
|
|
49
|
-
"@idosgames/module-sdk": "0.
|
|
48
|
+
"@idosgames/core": "0.19.1",
|
|
49
|
+
"@idosgames/module-sdk": "0.7.0",
|
|
50
50
|
"@idosgames/react": "0.3.0",
|
|
51
|
-
"@idosgames/wallet": "0.
|
|
51
|
+
"@idosgames/wallet": "0.6.0",
|
|
52
52
|
"@tanstack/react-query": "5.101.2",
|
|
53
53
|
"react": "19.2.7",
|
|
54
54
|
"three": "0.185.1",
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "boosts",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "Boosts",
|
|
5
|
+
"type": "app",
|
|
6
|
+
"engine": "dom"
|
|
7
|
+
},
|
|
8
|
+
"catalog": {
|
|
9
|
+
"type": "feature",
|
|
10
|
+
"summary": "Boosts for any game: timed boosts to activate (×2 rewards and the like), what runs now with timers, and the title's global happy hours.",
|
|
11
|
+
"description": "A ready boosts screen for ANY game, built on the @idosgames/react/ui kit and client.timedBoost. 'Active now': the player's own boosts with their timers or uses left, and the title's global windows (a scheduled happy hour, a phase of a boost chain) marked 'for everyone'. 'Boosts': every boost of the title with its effect as the server applies it (×2, +50%, +10 — to all rewards, quest rewards, board rewards or costs), its duration and uses, activated for free or for currencies/items. The server applies every boost; the screen only shows. Registers the 'boosts' feature. Ships title-seed.json: a free welcome boost and a paid double-rewards boost.",
|
|
12
|
+
"provides": [
|
|
13
|
+
"boosts screen",
|
|
14
|
+
"activate a boost for free or for currency",
|
|
15
|
+
"running boosts with timers",
|
|
16
|
+
"global happy hours"
|
|
17
|
+
],
|
|
18
|
+
"tags": [
|
|
19
|
+
"boosts",
|
|
20
|
+
"multiplier",
|
|
21
|
+
"happy-hour",
|
|
22
|
+
"monetization",
|
|
23
|
+
"ui",
|
|
24
|
+
"screen",
|
|
25
|
+
"economy"
|
|
26
|
+
],
|
|
27
|
+
"author": {
|
|
28
|
+
"name": "iDos Games",
|
|
29
|
+
"url": "https://idosgames.com"
|
|
30
|
+
},
|
|
31
|
+
"version": "0.1.0"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@idosgames/core": "0.19.1",
|
|
35
|
+
"@idosgames/module-sdk": "0.7.0",
|
|
36
|
+
"@idosgames/react": "0.3.0",
|
|
37
|
+
"react": "19.2.7"
|
|
38
|
+
},
|
|
39
|
+
"contentHash": "1c1a852a29a89982e2ed997b1a2356bc4722d00eb4db834ab7472f66f9a8eee3",
|
|
40
|
+
"files": [
|
|
41
|
+
{
|
|
42
|
+
"path": "BoostsScreen.tsx",
|
|
43
|
+
"content": "import {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ActiveBoostWindowInfo } from \"@idosgames/core\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n ResourceList,\n SectionTitle,\n Timer,\n canAfford,\n configSection,\n errorText,\n outlined,\n useCatalog,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n boostRows,\n durationParts,\n effectText,\n runningBoosts,\n type ActiveBoostView,\n type BoostEffectView,\n type BoostRow,\n type BoostSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Boosts (engine TimedBoost): what runs now — the player's own boosts with their timers or charges,\n// and the title's global windows (a happy hour, a phase of a boost chain) — then the boosts the\n// player can activate, free or for currency. The server applies every boost; the screen only shows.\n\nexport function BoostsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const now = useNow();\n const stagger = useStagger();\n const [windows, setWindows] = useState<ActiveBoostWindowInfo[]>([]);\n const section = configSection<BoostSection>(client, \"TimedBoost\");\n const rows = boostRows(section);\n const running = runningBoosts(\n (state?.TimedBoost as { Active?: Record<string, ActiveBoostView> } | null)\n ?.Active,\n now,\n );\n const liveWindows = windows.filter(\n (w) => !w.EndUtc || new Date(w.EndUtc).getTime() > now,\n );\n\n // Fresh on open: expired boosts drop out, global windows are resolved by the server.\n useEffect(() => {\n void client.timedBoost.getActive();\n void client.timedBoost\n .getActiveWindows()\n .then((r) => r.ok && setWindows(r.data.Windows ?? []));\n }, [client]);\n\n if (rows.length === 0 && running.length === 0 && liveWindows.length === 0)\n return <EmptyState glyph=\"energy\" text={t(\"noBoosts\")} />;\n\n const nameOf = (boostID: string | undefined) =>\n catalog.localize(\n rows.find((r) => r.boostID === boostID)?.name ?? boostID ?? \"\",\n );\n\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n {running.length + liveWindows.length > 0 ? (\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle>{t(\"running\")}</SectionTitle>\n {liveWindows.map((w, i) => (\n <RunningCard\n key={`w:${w.SourceID}:${w.PhaseID ?? \"\"}`}\n name={catalog.localize(w.DisplayName ?? w.SourceID ?? \"\")}\n effects={w.Effects ?? []}\n until={w.EndUtc ?? null}\n charges={null}\n global\n style={stagger(i)}\n />\n ))}\n {running.map((b, i) => (\n <RunningCard\n key={b.InstanceID}\n name={nameOf(b.BoostID)}\n effects={b.EffectSnapshot ? [b.EffectSnapshot] : []}\n until={b.ExpiresAtUtc ?? null}\n charges={b.RemainingCharges ?? null}\n style={stagger(liveWindows.length + i)}\n />\n ))}\n </div>\n ) : null}\n {rows.length > 0 ? (\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle>{t(\"boosts\")}</SectionTitle>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(220px, 1fr))\",\n gap: 10,\n }}\n >\n {rows.map((row, i) => (\n <BoostCard key={row.boostID} row={row} style={stagger(i)} />\n ))}\n </div>\n </div>\n ) : null}\n </div>\n );\n}\n\nfunction EffectLine({ effect }: { effect: BoostEffectView }): ReactNode {\n const e = effectText(effect);\n return (\n <span style={{ ...outlined, fontSize: 14, color: v.gold }}>\n {e.size} {e.target === \"custom\" ? \"\" : t(`target_${e.target}`)}\n </span>\n );\n}\n\nfunction RunningCard({\n name,\n effects,\n until,\n charges,\n global,\n style,\n}: {\n name: string;\n effects: BoostEffectView[];\n until: string | null;\n charges: number | null;\n global?: boolean;\n style: CSSProperties;\n}): ReactNode {\n const now = useNow();\n return (\n <Card\n highlight\n style={{\n display: \"grid\",\n gridTemplateColumns: \"auto 1fr\",\n alignItems: \"center\",\n gap: 12,\n ...style,\n }}\n >\n <div className=\"idos-bounce\">\n <Icon glyph={global ? \"clock\" : \"energy\"} size={40} />\n </div>\n <div style={{ display: \"grid\", gap: 4, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {name}\n {global ? (\n <span style={{ color: v.textDim, fontSize: 12 }}>\n {\" \"}\n · {t(\"forEveryone\")}\n </span>\n ) : null}\n </div>\n <div style={{ display: \"flex\", gap: 10, flexWrap: \"wrap\" }}>\n {effects.map((e, i) => (\n <EffectLine key={i} effect={e} />\n ))}\n </div>\n {until ? <Timer at={until} now={now} label={t(\"endsIn\")} /> : null}\n {charges != null ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"charges\")}: {charges}\n </div>\n ) : null}\n </div>\n </Card>\n );\n}\n\nfunction BoostCard({\n row,\n style,\n}: {\n row: BoostRow;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const { h, m } = durationParts(row.durationSeconds);\n\n const activate = async (optionID: string | undefined) => {\n setBusy(optionID ?? \"free\");\n const res = await client.timedBoost.activate(\n row.boostID,\n optionID ? { selectedOptionID: optionID } : undefined,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n toast(t(\"activated\"), \"success\");\n };\n\n return (\n <div ref={ref} style={style}>\n <Card style={{ display: \"grid\", gap: 8, justifyItems: \"center\" }}>\n <Icon glyph={row.icon} size={52} />\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {catalog.localize(row.name)}\n </div>\n {row.effect ? <EffectLine effect={row.effect} /> : null}\n {row.description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n fontSize: 12,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {catalog.localize(row.description)}\n </div>\n ) : null}\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {row.durationSeconds > 0\n ? `${h > 0 ? `${h} ${t(\"hours\")} ` : \"\"}${m > 0 ? `${m} ${t(\"minutes\")}` : \"\"}`\n : null}\n {row.charges > 0 ? ` · ${t(\"charges\")}: ${row.charges}` : null}\n </div>\n {row.free ? (\n <Button\n tone=\"green\"\n attract\n busy={busy === \"free\"}\n onClick={() => void activate(undefined)}\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`boost:${row.boostID}`}\n >\n {t(\"activate\")}\n </Button>\n ) : (\n row.options.map((o) => {\n const affordable = canAfford(o.price, balances);\n return (\n <Button\n key={o.id}\n tone=\"gold\"\n disabled={!affordable}\n busy={busy === o.id}\n onClick={() => void activate(o.id)}\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`boost:${row.boostID}`}\n >\n <ResourceList\n lines={o.price}\n size={18}\n dimIf={() => !affordable}\n />\n </Button>\n );\n })\n )}\n </Card>\n </div>\n );\n}\n"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"path": "i18n.ts",
|
|
47
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Boosts\",\n running: \"Active now\",\n boosts: \"Boosts\",\n forEveryone: \"for everyone\",\n endsIn: \"Ends in\",\n charges: \"Uses left\",\n hours: \"h\",\n minutes: \"min\",\n activate: \"Activate\",\n activated: \"Boost activated!\",\n target_all: \"to all rewards\",\n target_quest: \"to quest rewards\",\n target_board: \"to board rewards\",\n target_cost: \"to costs\",\n noBoosts: \"No boosts in this game yet. Add them in LiveOps → Timed Boosts.\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Бустеры\",\n running: \"Действуют сейчас\",\n boosts: \"Бустеры\",\n forEveryone: \"для всех\",\n endsIn: \"До конца\",\n charges: \"Осталось использований\",\n hours: \"ч\",\n minutes: \"мин\",\n activate: \"Включить\",\n activated: \"Бустер включён!\",\n target_all: \"ко всем наградам\",\n target_quest: \"к наградам заданий\",\n target_board: \"к наградам на поле\",\n target_cost: \"к ценам\",\n noBoosts:\n \"Бустеров в этой игре пока нет. Добавьте их в LiveOps → Timed Boosts.\",\n});\n"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"path": "index.ts",
|
|
51
|
+
"content": "// @idosgames/mod-boosts — Boosts for any game: timed boosts to activate (x2 rewards and the like), the ones running with their timers and the global happy hours of the title.\n\nexport { boostsModule } from \"./module\";\n"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"path": "model.ts",
|
|
55
|
+
"content": "// Pure helpers of the boosts screen — covered by model.test.ts. The server applies boosts; these\n// only decide what the screen draws.\n\nimport { costLines, type ResourceLine } from \"@idosgames/react/ui\";\n\nexport interface BoostEffectView {\n Target?: string | null;\n Operation?: string | null;\n Value?: number | null;\n}\n\nexport interface BoostDefinitionView {\n BoostID?: string | null;\n DisplayName?: string | null;\n Description?: string | null;\n AssetPaths?: Record<string, string> | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Cost?: unknown } | null\n > | null;\n Effect?: BoostEffectView | null;\n DurationSeconds?: number | null;\n Charges?: number | null;\n}\n\n/** The title's TimedBoost section, as far as the boosts screen cares. */\nexport interface BoostSection {\n Definitions?: Record<string, BoostDefinitionView | null> | null;\n ScheduledBoosts?: Record<string, unknown> | null;\n BoostChains?: Record<string, unknown> | null;\n}\n\n/** A boost running for the player (UserState.TimedBoost.Active). */\nexport interface ActiveBoostView {\n InstanceID?: string;\n BoostID?: string;\n ExpiresAtUtc?: string | null;\n RemainingCharges?: number | null;\n EffectSnapshot?: BoostEffectView | null;\n}\n\n/** What an effect touches, as the screen names it (engine EventModifierTarget, grouped). */\nexport type EffectTarget = \"all\" | \"quest\" | \"board\" | \"cost\" | \"custom\";\n\n/** What the effect reads as: the size (\"×2\", \"+50%\", \"+10\") and what it touches. */\nexport function effectText(effect: BoostEffectView | null | undefined): {\n size: string;\n target: EffectTarget;\n} {\n const value = Number(effect?.Value ?? 0);\n const op = effect?.Operation ?? \"Multiply\";\n const size =\n op === \"AddPercent\"\n ? `+${value}%`\n : op === \"AddFlat\"\n ? `${value >= 0 ? \"+\" : \"\"}${value}`\n : `×${value}`;\n const t = effect?.Target ?? \"\";\n const target: EffectTarget =\n t === \"AllReward\"\n ? \"all\"\n : t === \"QuestReward\"\n ? \"quest\"\n : t.endsWith(\"Cost\")\n ? \"cost\"\n : t.startsWith(\"Board\")\n ? \"board\"\n : \"custom\";\n return { size, target };\n}\n\nexport interface BoostRow {\n boostID: string;\n name: string;\n description: string;\n icon: string;\n effect: BoostEffectView | null;\n durationSeconds: number;\n charges: number;\n /** Ways to activate with currencies/items (a price paid in a store is not sold here). */\n options: Array<{ id: string; price: ResourceLine[] }>;\n /** No price at all — activates for nothing. */\n free: boolean;\n}\n\n/** The boosts the player can activate, by id. */\nexport function boostRows(\n section: BoostSection | null | undefined,\n): BoostRow[] {\n const rows: BoostRow[] = [];\n for (const [id, def] of Object.entries(section?.Definitions ?? {})) {\n if (!def) continue;\n const priced = Object.entries(def.PriceOptions ?? {}).filter(([, o]) => o);\n const options = priced\n .map(([oid, o]) => ({\n id: o?.OptionID || oid,\n price: costLines(o?.Cost as never).filter((l) => l.kind !== \"other\"),\n }))\n .filter((o) => o.price.length > 0);\n rows.push({\n boostID: def.BoostID || id,\n name: def.DisplayName || def.BoostID || id,\n description: def.Description || \"\",\n icon: def.AssetPaths?.icon ?? \"energy\",\n effect: def.Effect ?? null,\n durationSeconds: Math.max(0, Number(def.DurationSeconds ?? 0)),\n charges: Math.max(0, Number(def.Charges ?? 0)),\n options,\n free: priced.length === 0,\n });\n }\n return rows.sort((a, b) => a.boostID.localeCompare(b.boostID));\n}\n\n/** Boosts running now: not expired and with charges left (the server filters the same on read). */\nexport function runningBoosts(\n active: Record<string, ActiveBoostView | null | undefined> | null | undefined,\n nowMs: number,\n): ActiveBoostView[] {\n return Object.values(active ?? {})\n .filter((b): b is ActiveBoostView => !!b)\n .filter(\n (b) =>\n (!b.ExpiresAtUtc || new Date(b.ExpiresAtUtc).getTime() > nowMs) &&\n (b.RemainingCharges == null || b.RemainingCharges > 0),\n )\n .sort(\n (a, b) =>\n new Date(a.ExpiresAtUtc ?? 0).getTime() -\n new Date(b.ExpiresAtUtc ?? 0).getTime(),\n );\n}\n\n/** Hours and minutes of a boost's duration (seconds). */\nexport function durationParts(seconds: number): { h: number; m: number } {\n const total = Math.round(seconds / 60);\n return { h: Math.floor(total / 60), m: total % 60 };\n}\n\n/** Whether the title has boosts — personal ones or global windows. */\nexport function hasTitleData(section: BoostSection | undefined): boolean {\n return (\n Object.values(section?.Definitions ?? {}).some(Boolean) ||\n Object.keys(section?.ScheduledBoosts ?? {}).length > 0 ||\n Object.keys(section?.BoostChains ?? {}).length > 0\n );\n}\n"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"path": "module.ts",
|
|
59
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { BoostsScreen } from \"./BoostsScreen\";\nimport { hasTitleData, type BoostSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Boosts as a feature of the game: what runs now (the player's own boosts and the title's global\n// windows) and what can be activated. No badge: a running boost asks nothing of the player.\nexport const boostsModule: Module = defineModule({\n id: \"boosts\",\n meta: { name: \"Boosts\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"boosts\",\n label: t(\"title\"),\n icon: \"energy\",\n group: \"economy\",\n order: 58,\n available: false,\n Screen: BoostsScreen,\n });\n ctx.features.setAvailable(\n \"boosts\",\n hasTitleData(configSection<BoostSection>(ctx.client, \"TimedBoost\")),\n \"no boosts on the title (TimedBoost)\",\n );\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"boosts\" }),\n describeActions: {},\n });\n },\n});\n"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"path": "module.meta.json",
|
|
63
|
+
"content": "{\n \"id\": \"boosts\",\n \"type\": \"feature\",\n \"summary\": \"Boosts for any game: timed boosts to activate (×2 rewards and the like), what runs now with timers, and the title's global happy hours.\",\n \"description\": \"A ready boosts screen for ANY game, built on the @idosgames/react/ui kit and client.timedBoost. 'Active now': the player's own boosts with their timers or uses left, and the title's global windows (a scheduled happy hour, a phase of a boost chain) marked 'for everyone'. 'Boosts': every boost of the title with its effect as the server applies it (×2, +50%, +10 — to all rewards, quest rewards, board rewards or costs), its duration and uses, activated for free or for currencies/items. The server applies every boost; the screen only shows. Registers the 'boosts' feature. Ships title-seed.json: a free welcome boost and a paid double-rewards boost.\",\n \"provides\": [\n \"boosts screen\",\n \"activate a boost for free or for currency\",\n \"running boosts with timers\",\n \"global happy hours\"\n ],\n \"tags\": [\n \"boosts\",\n \"multiplier\",\n \"happy-hour\",\n \"monetization\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
}
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
},
|
|
8
8
|
"catalog": {
|
|
9
9
|
"type": "feature",
|
|
10
|
-
"inBase": true,
|
|
11
10
|
"summary": "Heroes screen for any game: roster, ranks and stats with their costs, equipment slots with 'Equip best', item levels and skins.",
|
|
12
11
|
"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.",
|
|
13
12
|
"provides": [
|
|
@@ -40,12 +39,12 @@
|
|
|
40
39
|
"version": "0.1.0"
|
|
41
40
|
},
|
|
42
41
|
"dependencies": {
|
|
43
|
-
"@idosgames/core": "0.19.
|
|
44
|
-
"@idosgames/module-sdk": "0.
|
|
42
|
+
"@idosgames/core": "0.19.1",
|
|
43
|
+
"@idosgames/module-sdk": "0.7.0",
|
|
45
44
|
"@idosgames/react": "0.3.0",
|
|
46
45
|
"react": "19.2.7"
|
|
47
46
|
},
|
|
48
|
-
"contentHash": "
|
|
47
|
+
"contentHash": "aff7a5b3c7b4970cd421c4e01932eea8123f1a91d3cfddd058e92660c0083377",
|
|
49
48
|
"files": [
|
|
50
49
|
{
|
|
51
50
|
"path": "CharacterScreen.tsx",
|
|
@@ -69,7 +68,7 @@
|
|
|
69
68
|
},
|
|
70
69
|
{
|
|
71
70
|
"path": "module.meta.json",
|
|
72
|
-
"content": "{\n \"id\": \"character\",\n \"type\": \"feature\",\n \"
|
|
71
|
+
"content": "{\n \"id\": \"character\",\n \"type\": \"feature\",\n \"summary\": \"Heroes screen for any game: roster, ranks and stats with their costs, equipment slots with 'Equip best', item levels and skins.\",\n \"description\": \"A ready heroes screen (the Hero button of the Unity SDK) for ANY game, built on the @idosgames/react/ui kit and client.character. The screen is the selected hero's equipment hub: portrait, class and rarity, rank and server-computed Power, 'Rank up' with its price, gear slots (locked ones show the rank they open at), 'Equip best' when better gear fits, and the gear the player owns by class. Everything detailed is a popup: the hero list (select, unlock for currency), the stats (level/cap bars, upgrade prices, requirements, and 'where this number comes from' — base, stat levels, rank, gear flat and percent bonuses), a slot's fitting gear, a piece of gear (equip / take off / level up) and skins (wear, buy). Prices and caps follow the title's curves (rank ladder, stat cost curve, StatMaxLevelCurve, item Upgrade); the server re-checks everything. Registers the 'character' feature; the inventory hands equipment to it with ctx.features.open('character', { itemInstanceID }). Its badge counts heroes to unlock or rank up and better gear. Ships title-seed.json: three heroes (Knight free, Archer and Mage for gems) with stats, ranks and slots.\",\n \"provides\": [\n \"hero roster with unlock for currency\",\n \"rank up with price by the rank ladder\",\n \"stat upgrades with costs, caps and requirements\",\n \"stat breakdown popup by source\",\n \"equipment slots and equip / take off\",\n \"equip best gear automatically\",\n \"item level upgrades\",\n \"character skins\",\n \"demo heroes\"\n ],\n \"tags\": [\n \"heroes\",\n \"characters\",\n \"rpg\",\n \"stats\",\n \"equipment\",\n \"gear\",\n \"skins\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
|
|
73
72
|
}
|
|
74
73
|
]
|
|
75
74
|
}
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
},
|
|
8
8
|
"catalog": {
|
|
9
9
|
"type": "feature",
|
|
10
|
-
"inBase": true,
|
|
11
10
|
"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.",
|
|
12
11
|
"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.",
|
|
13
12
|
"provides": [
|
|
@@ -36,12 +35,12 @@
|
|
|
36
35
|
"version": "0.1.0"
|
|
37
36
|
},
|
|
38
37
|
"dependencies": {
|
|
39
|
-
"@idosgames/core": "0.19.
|
|
40
|
-
"@idosgames/module-sdk": "0.
|
|
38
|
+
"@idosgames/core": "0.19.1",
|
|
39
|
+
"@idosgames/module-sdk": "0.7.0",
|
|
41
40
|
"@idosgames/react": "0.3.0",
|
|
42
41
|
"react": "19.2.7"
|
|
43
42
|
},
|
|
44
|
-
"contentHash": "
|
|
43
|
+
"contentHash": "10eb1b49d043a7002a4c51b8d9aefac3112679599b97881a8102992cb7e103a1",
|
|
45
44
|
"files": [
|
|
46
45
|
{
|
|
47
46
|
"path": "ChatScreen.tsx",
|
|
@@ -65,7 +64,7 @@
|
|
|
65
64
|
},
|
|
66
65
|
{
|
|
67
66
|
"path": "module.meta.json",
|
|
68
|
-
"content": "{\n \"id\": \"chat\",\n \"type\": \"feature\",\n \"
|
|
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.0\"\n}\n"
|
|
69
68
|
}
|
|
70
69
|
]
|
|
71
70
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "collections",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "Collections",
|
|
5
|
+
"type": "app",
|
|
6
|
+
"engine": "dom"
|
|
7
|
+
},
|
|
8
|
+
"catalog": {
|
|
9
|
+
"type": "feature",
|
|
10
|
+
"summary": "Collections for any game: an album of card sets with set rewards and a grand prize, packs and chests, trading spare cards with friends.",
|
|
11
|
+
"description": "A ready collections screen (engine Collection) for ANY game, built on the @idosgames/react/ui kit and client.collection. Album: the player's collection with every set in order — the cards held (with how many) and the gaps, a reward to claim per completed set and a grand prize for the whole album. Packs: every pack type with every way to pay, opened once or ten at a time (the server rolls them — new cards and duplicates are shown), and collection chests bought with the points duplicates turn into. Trades: offers from other players (accept / decline), your own offers (cancel), and giving a spare card to a friend. Registers the 'collections' feature; its badge counts set rewards and the grand prize waiting. Ships title-seed.json: a small album with sets, a pack and a chest.",
|
|
12
|
+
"provides": [
|
|
13
|
+
"collection album with sets",
|
|
14
|
+
"set rewards and grand prize",
|
|
15
|
+
"card packs, once or ten at a time",
|
|
16
|
+
"collection chests for points",
|
|
17
|
+
"trading cards with friends",
|
|
18
|
+
"badge for rewards waiting"
|
|
19
|
+
],
|
|
20
|
+
"tags": [
|
|
21
|
+
"collection",
|
|
22
|
+
"album",
|
|
23
|
+
"cards",
|
|
24
|
+
"stickers",
|
|
25
|
+
"trading",
|
|
26
|
+
"ui",
|
|
27
|
+
"screen",
|
|
28
|
+
"events"
|
|
29
|
+
],
|
|
30
|
+
"author": {
|
|
31
|
+
"name": "iDos Games",
|
|
32
|
+
"url": "https://idosgames.com"
|
|
33
|
+
},
|
|
34
|
+
"version": "0.1.0"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@idosgames/core": "0.19.1",
|
|
38
|
+
"@idosgames/module-sdk": "0.7.0",
|
|
39
|
+
"@idosgames/react": "0.3.0",
|
|
40
|
+
"react": "19.2.7"
|
|
41
|
+
},
|
|
42
|
+
"contentHash": "dc0cf44c410258a1aa38f31d38eba7fb895cde14c7bb4ee67b1c7ee4546b25d0",
|
|
43
|
+
"files": [
|
|
44
|
+
{
|
|
45
|
+
"path": "CollectionsScreen.tsx",
|
|
46
|
+
"content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport type {\n CollectionTradeOfferDocument,\n FriendPublicProfile,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Spinner,\n Tabs,\n canAfford,\n configSection,\n errorText,\n grantLines,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n usePopupClose,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n albumSets,\n chestRows,\n collectionsBadge,\n currentCollection,\n grandPrizeState,\n packOptions,\n type CardRow,\n type ChestRow,\n type CollectionSection,\n type CollectionStateView,\n type PackOption,\n type SetRow,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Collections (engine Collection): an album of sets — every card the player holds or still misses,\n// a reward per completed set and a grand prize for the whole album; packs of cards (currencies) and\n// collection chests (the currency duplicates turn into); and trading a spare card with a friend.\n\ntype Tab = \"album\" | \"packs\" | \"trades\";\n\nexport function CollectionsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [tab, setTab] = useState<Tab>(\"album\");\n const section = configSection<CollectionSection>(client, \"Collection\");\n const mine = (state?.Collection ?? null) as CollectionStateView | null;\n const collection = currentCollection(section, mine);\n\n const reload = useCallback(async () => {\n await client.collection.getUserState();\n }, [client]);\n useEffect(() => {\n void reload();\n }, [reload]);\n\n if (!collection) return <EmptyState glyph=\"star\" text={t(\"noCollection\")} />;\n const sets = albumSets(collection, mine);\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n <Tabs\n tabs={[\n {\n id: \"album\",\n label: t(\"album\"),\n badge: collectionsBadge(collection, sets, mine),\n },\n { id: \"packs\", label: t(\"packs\") },\n { id: \"trades\", label: t(\"trades\") },\n ]}\n value={tab}\n onChange={setTab}\n />\n {tab === \"album\" ? (\n <Album\n collectionID={collection.CollectionID}\n name={collection.DisplayName ?? collection.CollectionID}\n grandPrize={grantLines(collection.GrandPrize)}\n prize={grandPrizeState(collection, sets, mine)}\n sets={sets}\n onChanged={reload}\n />\n ) : tab === \"packs\" ? (\n <Packs\n collectionID={collection.CollectionID}\n section={section}\n balance={Number(mine?.CollectionCurrencyBalance ?? 0)}\n onChanged={reload}\n />\n ) : (\n <Trades collectionID={collection.CollectionID} sets={sets} />\n )}\n </div>\n );\n}\n\n// ── Album ────────────────────────────────────────────────────────────────────────────────────\n\nfunction Album({\n collectionID,\n name,\n grandPrize,\n prize,\n sets,\n onChanged,\n}: {\n collectionID: string;\n name: string;\n grandPrize: ReturnType<typeof grantLines>;\n prize: ReturnType<typeof grandPrizeState>;\n sets: SetRow[];\n onChanged: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n const owned = sets.reduce((s, x) => s + x.owned, 0);\n const total = sets.reduce((s, x) => s + x.total, 0);\n\n const claimSet = async (setID: string) => {\n setBusy(setID);\n const res = await client.collection.claimSetReward(collectionID, setID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n await onChanged();\n };\n const claimPrize = async () => {\n setBusy(\"grand\");\n const res = await client.collection.claimGrandPrize(collectionID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data), { sound: \"levelUp\" });\n await onChanged();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 8 }}>\n <div style={{ ...outlined, fontSize: 16 }}>\n {catalog.localize(name)}\n </div>\n <ProgressBar value={owned} max={Math.max(1, total)} tone=\"gold\" />\n {grandPrize.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n flexWrap: \"wrap\",\n }}\n >\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"grandPrize\")}\n </span>\n <ResourceList lines={grandPrize} size={20} />\n {prize === \"ready\" ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract\n busy={busy === \"grand\"}\n onClick={() => void claimPrize()}\n >\n {t(\"claim\")}\n </Button>\n ) : prize === \"claimed\" ? (\n <Icon glyph=\"check\" size={22} />\n ) : null}\n </div>\n ) : null}\n </div>\n {sets.map((s, i) => (\n <Card key={s.setID} highlight={s.state === \"ready\"} style={stagger(i)}>\n <div style={{ display: \"grid\", gap: 10 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(s.name)}\n </span>\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {s.owned} / {s.total}\n </span>\n </div>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(64px, 1fr))\",\n gap: 8,\n }}\n >\n {s.cards.map((c) => (\n <CardTile key={c.id} card={c} />\n ))}\n </div>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n flexWrap: \"wrap\",\n }}\n >\n <ResourceList lines={s.reward} size={18} />\n {s.state === \"ready\" ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract\n busy={busy === s.setID}\n onClick={() => void claimSet(s.setID)}\n data-tutorial-anchor={`collection:set:${s.setID}`}\n >\n {t(\"claim\")}\n </Button>\n ) : s.state === \"claimed\" ? (\n <Icon glyph=\"check\" size={20} />\n ) : null}\n </div>\n </div>\n </Card>\n ))}\n </div>\n );\n}\n\nfunction CardTile({ card }: { card: CardRow }): ReactNode {\n const catalog = useCatalog();\n const has = card.count > 0;\n return (\n <div\n title={catalog.localize(card.name)}\n style={{\n position: \"relative\",\n aspectRatio: \"3 / 4\",\n borderRadius: 10,\n display: \"grid\",\n placeItems: \"center\",\n background: has ? v.panelDeep : \"rgba(0,0,0,.35)\",\n border: `2px solid ${has ? catalog.rarityColor(String(card.rarity)) : \"rgba(255,255,255,.08)\"}`,\n opacity: has ? 1 : 0.45,\n }}\n >\n <Icon glyph={has ? card.icon : \"lock\"} size={30} />\n {card.count > 1 ? (\n <span\n style={{\n position: \"absolute\",\n right: 4,\n bottom: 2,\n ...outlined,\n fontSize: 12,\n }}\n >\n ×{card.count}\n </span>\n ) : null}\n </div>\n );\n}\n\n// ── Packs and chests ─────────────────────────────────────────────────────────────────────────\n\nfunction Packs({\n collectionID,\n section,\n balance,\n onChanged,\n}: {\n collectionID: string;\n section: CollectionSection | undefined;\n balance: number;\n onChanged: () => Promise<void>;\n}): ReactNode {\n const state = useUserState();\n const stagger = useStagger();\n const [opening, setOpening] = useState<\n | { kind: \"pack\"; pack: PackOption; count: number }\n | { kind: \"chest\"; chest: ChestRow }\n | null\n >(null);\n const packs = packOptions(section);\n const chests = chestRows(section);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n if (packs.length === 0 && chests.length === 0)\n return <EmptyState glyph=\"ticket\" text={t(\"noPacks\")} />;\n const bulkOf = (p: PackOption) =>\n p.maxOpen >= 10 &&\n canAfford(\n p.price.map((l) => ({ ...l, amount: l.amount * 10 })),\n balances,\n );\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {packs.length > 0 ? (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 10,\n }}\n >\n {packs.map((p, i) => {\n const affordable = canAfford(p.price, balances);\n return (\n <Card\n key={`${p.packTypeID}:${p.optionID ?? \"\"}`}\n style={{\n display: \"grid\",\n gap: 8,\n justifyItems: \"center\",\n ...stagger(i),\n }}\n >\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {p.name}\n </div>\n <div className=\"idos-bounce\">\n <Icon glyph={p.icon} size={56} />\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {p.cards} {t(\"cards\")}\n </div>\n <Button\n tone={p.price.length === 0 ? \"green\" : \"gold\"}\n disabled={!affordable}\n onClick={() =>\n setOpening({ kind: \"pack\", pack: p, count: 1 })\n }\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`collection:pack:${p.packTypeID}`}\n >\n {p.price.length === 0 ? (\n t(\"open\")\n ) : (\n <ResourceList lines={p.price} size={18} />\n )}\n </Button>\n {bulkOf(p) ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() =>\n setOpening({ kind: \"pack\", pack: p, count: 10 })\n }\n style={{ width: \"100%\" }}\n >\n {t(\"open\")} ×10\n </Button>\n ) : null}\n </Card>\n );\n })}\n </div>\n ) : null}\n {chests.length > 0 ? (\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle\n right={\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"points\")}: {balance}\n </span>\n }\n >\n {t(\"chests\")}\n </SectionTitle>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 10,\n }}\n >\n {chests.map((c) => (\n <Card\n key={c.chestID}\n style={{ display: \"grid\", gap: 8, justifyItems: \"center\" }}\n >\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {c.name}\n </div>\n <Icon glyph={c.icon} size={56} />\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {c.cards} {t(\"cards\")}\n </div>\n <Button\n tone=\"gold\"\n disabled={balance < c.cost}\n onClick={() => setOpening({ kind: \"chest\", chest: c })}\n style={{ width: \"100%\" }}\n >\n {c.cost} {t(\"points\")}\n </Button>\n </Card>\n ))}\n </div>\n </div>\n ) : null}\n {opening ? (\n <OpenPopup\n collectionID={collectionID}\n opening={opening}\n onClose={() => setOpening(null)}\n onOpened={onChanged}\n />\n ) : null}\n </div>\n );\n}\n\nfunction OpenPopup({\n collectionID,\n opening,\n onClose,\n onOpened,\n}: {\n collectionID: string;\n opening:\n | { kind: \"pack\"; pack: PackOption; count: number }\n | { kind: \"chest\"; chest: ChestRow };\n onClose: () => void;\n onOpened: () => Promise<void>;\n}): ReactNode {\n const name = opening.kind === \"pack\" ? opening.pack.name : opening.chest.name;\n const count = opening.kind === \"pack\" ? opening.count : 1;\n return (\n <Popup\n title={count > 1 ? `${name} ×${count}` : name}\n onClose={onClose}\n variant=\"center\"\n >\n <OpenBody\n collectionID={collectionID}\n opening={opening}\n onOpened={onOpened}\n />\n </Popup>\n );\n}\n\nfunction OpenBody({\n collectionID,\n opening,\n onOpened,\n}: {\n collectionID: string;\n opening:\n | { kind: \"pack\"; pack: PackOption; count: number }\n | { kind: \"chest\"; chest: ChestRow };\n onOpened: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const close = usePopupClose();\n const boxRef = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const [got, setGot] = useState<Array<{ id: string; dup: boolean }> | null>(\n null,\n );\n\n const open = async () => {\n setBusy(true);\n const started = Date.now();\n const res =\n opening.kind === \"pack\"\n ? await client.collection.openPack(\n collectionID,\n opening.pack.packTypeID,\n opening.count,\n opening.pack.optionID\n ? { selectedOptionID: opening.pack.optionID }\n : undefined,\n )\n : await client.collection.openCollectionChest(\n collectionID,\n opening.chest.chestID,\n );\n await new Promise((r) =>\n setTimeout(\n r,\n Math.max(0, (kit.motion.on ? 700 : 0) - (Date.now() - started)),\n ),\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"lootbox\");\n kit.fx.burst(boxRef.current);\n setGot([\n ...(res.data.GrantedCollectibles ?? []).map((c) => ({\n id: c.CollectibleID,\n dup: false,\n })),\n ...(res.data.DuplicateCollectibles ?? []).map((c) => ({\n id: c.CollectibleID,\n dup: true,\n })),\n ]);\n const bonus = grantedBy(res.data);\n if (bonus.length > 0) celebrate(bonus);\n await onOpened();\n };\n\n if (got)\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(72px, 1fr))\",\n gap: 8,\n }}\n >\n {got.map((c, i) => (\n <div\n key={`${c.id}:${i}`}\n style={{\n ...panel,\n padding: 6,\n display: \"grid\",\n justifyItems: \"center\",\n gap: 4,\n opacity: c.dup ? 0.6 : 1,\n }}\n >\n <Icon glyph=\"star\" size={28} />\n <span style={{ ...outlined, fontSize: 11, textAlign: \"center\" }}>\n {catalog.localize(c.id)}\n </span>\n <span\n style={{\n ...outlined,\n fontSize: 10,\n color: c.dup ? v.textDim : v.green,\n }}\n >\n {c.dup ? t(\"duplicate\") : t(\"new\")}\n </span>\n </div>\n ))}\n </div>\n <Button tone=\"blue\" onClick={close}>\n {t(\"great\")}\n </Button>\n </div>\n );\n return (\n <div style={{ display: \"grid\", gap: 12, justifyItems: \"center\" }}>\n <div\n ref={boxRef}\n style={{\n animation: busy\n ? \"idos-shake .35s ease-in-out infinite\"\n : \"idos-bounce 1.2s ease-in-out infinite\",\n }}\n >\n <Icon\n glyph={\n opening.kind === \"pack\" ? opening.pack.icon : opening.chest.icon\n }\n size={110}\n />\n </div>\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n onClick={() => void open()}\n >\n {busy ? t(\"opening\") : t(\"open\")}\n </Button>\n </div>\n );\n}\n\n// ── Trades ───────────────────────────────────────────────────────────────────────────────────\n\nfunction Trades({\n collectionID,\n sets,\n}: {\n collectionID: string;\n sets: SetRow[];\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const [incoming, setIncoming] = useState<\n CollectionTradeOfferDocument[] | null\n >(null);\n const [mine, setMine] = useState<CollectionTradeOfferDocument[]>([]);\n const [giving, setGiving] = useState<CardRow | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const spare = sets.flatMap((s) => s.cards).filter((c) => c.spare > 0);\n\n const load = useCallback(async () => {\n const [inc, my] = await Promise.all([\n client.collection.getIncomingTradeOffers(collectionID),\n client.collection.getMyTradeOffers(collectionID),\n ]);\n setIncoming(inc.ok ? (inc.data.Offers ?? []) : []);\n setMine(my.ok ? (my.data.Offers ?? []) : []);\n }, [client, collectionID]);\n useEffect(() => {\n void load();\n }, [load]);\n\n const act = async (\n offerID: string,\n action: \"accept\" | \"decline\" | \"cancel\",\n ) => {\n setBusy(offerID);\n const res =\n action === \"accept\"\n ? await client.collection.acceptTradeOffer(offerID)\n : action === \"decline\"\n ? await client.collection.declineTradeOffer(offerID)\n : await client.collection.cancelTradeOffer(offerID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (action === \"accept\") {\n toast(t(\"traded\"), \"success\");\n void client.collection.getUserState();\n }\n await load();\n };\n\n if (!incoming)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const pending = (o: CollectionTradeOfferDocument) =>\n !o.Status || o.Status === \"Pending\";\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle>{t(\"incoming\")}</SectionTitle>\n {incoming.filter(pending).length === 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"noIncoming\")}\n </div>\n ) : (\n incoming.filter(pending).map((o) => (\n <Card key={o.OfferID} style={{ display: \"grid\", gap: 8 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {o.SenderPublicData?.Username ?? t(\"player\")} ·{\" \"}\n {catalog.localize(o.OfferedCollectibleID ?? \"\")}\n {o.RequestedCollectibleID\n ? ` ⇄ ${catalog.localize(o.RequestedCollectibleID)}`\n : \"\"}\n </div>\n <div style={{ display: \"flex\", gap: 8 }}>\n <Button\n tone=\"green\"\n size=\"sm\"\n busy={busy === o.OfferID}\n onClick={() => void act(o.OfferID, \"accept\")}\n >\n {t(\"accept\")}\n </Button>\n <Button\n tone=\"grey\"\n size=\"sm\"\n onClick={() => void act(o.OfferID, \"decline\")}\n >\n {t(\"decline\")}\n </Button>\n </div>\n </Card>\n ))\n )}\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle>{t(\"sent\")}</SectionTitle>\n {mine.filter(pending).map((o) => (\n <Card\n key={o.OfferID}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 14 }}>\n {catalog.localize(o.OfferedCollectibleID ?? \"\")}\n </span>\n <Button\n tone=\"grey\"\n size=\"sm\"\n busy={busy === o.OfferID}\n onClick={() => void act(o.OfferID, \"cancel\")}\n >\n {t(\"cancel\")}\n </Button>\n </Card>\n ))}\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle>{t(\"giveSpare\")}</SectionTitle>\n {spare.length === 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"noSpare\")}\n </div>\n ) : (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(64px, 1fr))\",\n gap: 8,\n }}\n >\n {spare.map((c) => (\n <button\n key={c.id}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => setGiving(c)}\n style={{ border: \"none\", background: \"none\", padding: 0 }}\n >\n <CardTile card={c} />\n </button>\n ))}\n </div>\n )}\n </div>\n {giving ? (\n <GivePopup\n collectionID={collectionID}\n card={giving}\n onClose={() => setGiving(null)}\n onSent={load}\n />\n ) : null}\n </div>\n );\n}\n\nfunction GivePopup({\n collectionID,\n card,\n onClose,\n onSent,\n}: {\n collectionID: string;\n card: CardRow;\n onClose: () => void;\n onSent: () => Promise<void>;\n}): ReactNode {\n const catalog = useCatalog();\n return (\n <Popup\n title={`${t(\"offerTo\")}: ${catalog.localize(card.name)}`}\n onClose={onClose}\n width={420}\n >\n <FriendPicker collectionID={collectionID} card={card} onSent={onSent} />\n </Popup>\n );\n}\n\nfunction FriendPicker({\n collectionID,\n card,\n onSent,\n}: {\n collectionID: string;\n card: CardRow;\n onSent: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const close = usePopupClose();\n const [friends, setFriends] = useState<FriendPublicProfile[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n useEffect(() => {\n void client.social\n .getFriendsList()\n .then((r) => setFriends(r.ok ? (r.data.Friends ?? []) : []));\n }, [client]);\n if (!friends)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (friends.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noFriends\")} />;\n const send = async (userID: string) => {\n setBusy(userID);\n const res = await client.collection.sendTradeOffer(\n collectionID,\n card.id,\n false,\n userID,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"offerSent\"), \"success\");\n close();\n await onSent();\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {friends.map((f) => (\n <Card\n key={f.UserID}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 14 }}>\n {f.PublicData?.Username ?? t(\"player\")}\n </span>\n <Button\n tone=\"green\"\n size=\"sm\"\n busy={busy === f.UserID}\n onClick={() => void send(f.UserID)}\n >\n {t(\"give\")}\n </Button>\n </Card>\n ))}\n </div>\n );\n}\n\n// ── Always-on: the badge ─────────────────────────────────────────────────────────────────────\n\n/** Badge: set rewards and the grand prize waiting — only on a title with a collection. */\nexport function makeCollectionsWatcher(features: FeatureRegistry) {\n return function CollectionsBadge(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n useEffect(() => {\n void client.collection.getUserState();\n }, [client]);\n useEffect(() => {\n const mine = (state?.Collection ?? null) as CollectionStateView | null;\n const collection = currentCollection(\n configSection<CollectionSection>(client, \"Collection\"),\n mine,\n );\n features.setBadge(\n \"collections\",\n collectionsBadge(collection, albumSets(collection, mine), mine),\n );\n }, [client, state?.Collection]);\n return null;\n };\n}\n"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"path": "i18n.ts",
|
|
50
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Collections\",\n album: \"Album\",\n packs: \"Packs\",\n trades: \"Trades\",\n grandPrize: \"Grand prize\",\n claim: \"Claim\",\n cards: \"cards\",\n open: \"Open\",\n opening: \"Opening…\",\n chests: \"Chests for points\",\n points: \"Points\",\n new: \"New!\",\n duplicate: \"Duplicate\",\n great: \"Great!\",\n incoming: \"Offers to you\",\n noIncoming: \"No offers yet.\",\n sent: \"Your offers\",\n giveSpare: \"Give a spare card\",\n noSpare: \"No spare cards yet — duplicates show up here.\",\n offerTo: \"Give\",\n give: \"Give\",\n offerSent: \"Offer sent!\",\n accept: \"Accept\",\n decline: \"Decline\",\n cancel: \"Cancel\",\n traded: \"Card received!\",\n player: \"Player\",\n noFriends: \"Add friends to trade with them.\",\n noPacks: \"No packs yet. Add them in LiveOps → Collections.\",\n noCollection:\n \"No collection in this game yet. Add one in LiveOps → Collections.\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Коллекции\",\n album: \"Альбом\",\n packs: \"Паки\",\n trades: \"Обмен\",\n grandPrize: \"Главный приз\",\n claim: \"Забрать\",\n cards: \"карт.\",\n open: \"Открыть\",\n opening: \"Открываем…\",\n chests: \"Сундуки за очки\",\n points: \"Очки\",\n new: \"Новая!\",\n duplicate: \"Повтор\",\n great: \"Отлично!\",\n incoming: \"Предложения вам\",\n noIncoming: \"Предложений пока нет.\",\n sent: \"Ваши предложения\",\n giveSpare: \"Отдать лишнюю карту\",\n noSpare: \"Лишних карт пока нет — здесь появятся повторы.\",\n offerTo: \"Отдать\",\n give: \"Отдать\",\n offerSent: \"Предложение отправлено!\",\n accept: \"Принять\",\n decline: \"Отклонить\",\n cancel: \"Отменить\",\n traded: \"Карта получена!\",\n player: \"Игрок\",\n noFriends: \"Добавьте друзей, чтобы меняться с ними.\",\n noPacks: \"Паков пока нет. Добавьте их в LiveOps → Collections.\",\n noCollection:\n \"Коллекций в этой игре пока нет. Добавьте её в LiveOps → Collections.\",\n});\n"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"path": "index.ts",
|
|
54
|
+
"content": "// @idosgames/mod-collections — Collections for any game: an album of sets to complete, packs and chests of collectibles, set rewards and a grand prize, trading cards with friends.\n\nexport { collectionsModule } from \"./module\";\n"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"path": "model.ts",
|
|
58
|
+
"content": "// Pure helpers of the collections screen — covered by model.test.ts. The server rolls packs and\n// decides what is complete; these only decide what the screen draws.\n\nimport type { ResourceGrant } from \"@idosgames/core\";\nimport { costLines, grantLines, type ResourceLine } from \"@idosgames/react/ui\";\n\n// ── Configuration (the fields this screen reads) ─────────────────────────────────────────────\n\nexport interface CollectibleView {\n CollectibleID?: string | null;\n DisplayName?: string | null;\n AssetPaths?: Record<string, string> | null;\n Rarity?: number | null;\n SortOrder?: number | null;\n}\n\nexport interface SetView {\n SetID?: string | null;\n DisplayName?: string | null;\n SortOrder?: number | null;\n Collectibles?: CollectibleView[] | null;\n SetCompletionReward?: ResourceGrant | null;\n}\n\nexport interface CollectionView {\n CollectionID?: string | null;\n DisplayName?: string | null;\n Description?: string | null;\n Sets?: SetView[] | null;\n GrandPrize?: ResourceGrant | null;\n}\n\nexport interface PackTypeView {\n PackTypeID?: string | null;\n DisplayName?: string | null;\n AssetPaths?: Record<string, string> | null;\n CollectibleCount?: number | null;\n MaxOpenCount?: number | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Cost?: unknown } | null\n > | null;\n}\n\nexport interface CollectionChestView {\n CollectionChestID?: string | null;\n DisplayName?: string | null;\n AssetPaths?: Record<string, string> | null;\n CollectionCurrencyCost?: number | null;\n MinCollectibleCount?: number | null;\n MaxCollectibleCount?: number | null;\n}\n\n/** The title's Collection section, as far as the screen cares. */\nexport interface CollectionSection {\n Collections?: Record<string, CollectionView | null> | null;\n PackTypes?: Record<string, PackTypeView | null> | null;\n CollectionChests?: Array<CollectionChestView | null> | null;\n Settings?: { MaxPackOpenCount?: number | null } | null;\n}\n\n/** The player's collection state (UserState.Collection). */\nexport interface CollectionStateView {\n CollectionID?: string;\n CollectionCurrencyBalance?: number;\n OwnedCollectibles?: Record<string, number>;\n OwnedSpecialCollectibles?: Record<string, number>;\n ClaimedSetRewards?: string[];\n IsCollectionCompleted?: boolean;\n GrandPrizeClaimed?: boolean;\n}\n\n// ── Album ────────────────────────────────────────────────────────────────────────────────────\n\n/** The collection the player collects: the one in their state, else the title's first. */\nexport function currentCollection(\n section: CollectionSection | null | undefined,\n state: CollectionStateView | null | undefined,\n): (CollectionView & { CollectionID: string }) | null {\n const all = Object.entries(section?.Collections ?? {})\n .filter(([, c]) => c)\n .map(([id, c]) => ({ ...c!, CollectionID: c!.CollectionID || id }))\n .sort((a, b) => a.CollectionID.localeCompare(b.CollectionID));\n return (\n all.find((c) => c.CollectionID === state?.CollectionID) ?? all[0] ?? null\n );\n}\n\nexport interface CardRow {\n id: string;\n name: string;\n icon: string;\n rarity: number;\n /** How many the player holds (0 — a gap in the album). */\n count: number;\n /** Duplicates the player can give away in a trade. */\n spare: number;\n}\n\nexport interface SetRow {\n setID: string;\n name: string;\n cards: CardRow[];\n owned: number;\n total: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"collecting\";\n}\n\n/** The album: sets in order, their cards with what the player holds, and each set's reward state. */\nexport function albumSets(\n collection: CollectionView | null | undefined,\n state: CollectionStateView | null | undefined,\n): SetRow[] {\n const owned = state?.OwnedCollectibles ?? {};\n const claimed = state?.ClaimedSetRewards ?? [];\n return [...(collection?.Sets ?? [])]\n .filter((s): s is SetView => !!s)\n .sort(\n (a, b) =>\n Number(a.SortOrder ?? 0) - Number(b.SortOrder ?? 0) ||\n (a.SetID ?? \"\").localeCompare(b.SetID ?? \"\"),\n )\n .map((s) => {\n const setID = s.SetID ?? \"\";\n const cards = [...(s.Collectibles ?? [])]\n .filter((c): c is CollectibleView => !!c?.CollectibleID)\n .sort(\n (a, b) =>\n Number(a.SortOrder ?? 0) - Number(b.SortOrder ?? 0) ||\n a.CollectibleID!.localeCompare(b.CollectibleID!),\n )\n .map((c) => {\n const count = Number(owned[c.CollectibleID!] ?? 0);\n return {\n id: c.CollectibleID!,\n name: c.DisplayName || c.CollectibleID!,\n icon: c.AssetPaths?.icon ?? \"star\",\n rarity: Number(c.Rarity ?? 1),\n count,\n spare: Math.max(0, count - 1),\n };\n });\n const have = cards.filter((c) => c.count > 0).length;\n return {\n setID,\n name: s.DisplayName || setID,\n cards,\n owned: have,\n total: cards.length,\n reward: grantLines(s.SetCompletionReward),\n state: claimed.includes(setID)\n ? \"claimed\"\n : cards.length > 0 && have === cards.length\n ? \"ready\"\n : \"collecting\",\n };\n });\n}\n\n/** The grand prize: claimable once every set is complete (\"none\" — the collection has no prize). */\nexport function grandPrizeState(\n collection: CollectionView | null | undefined,\n sets: SetRow[],\n state: CollectionStateView | null | undefined,\n): \"none\" | \"claimed\" | \"ready\" | \"locked\" {\n if (grantLines(collection?.GrandPrize).length === 0) return \"none\";\n if (state?.GrandPrizeClaimed) return \"claimed\";\n return sets.length > 0 && sets.every((s) => s.state !== \"collecting\")\n ? \"ready\"\n : \"locked\";\n}\n\n// ── Packs and chests ─────────────────────────────────────────────────────────────────────────\n\nexport interface PackOption {\n packTypeID: string;\n optionID: string | null;\n name: string;\n icon: string;\n cards: number;\n price: ResourceLine[];\n maxOpen: number;\n}\n\n/** Every way to open every pack type (one entry per price option; free packs have one entry). */\nexport function packOptions(\n section: CollectionSection | null | undefined,\n): PackOption[] {\n const out: PackOption[] = [];\n for (const [id, p] of Object.entries(section?.PackTypes ?? {})) {\n if (!p) continue;\n const maxOpen = Number(\n p.MaxOpenCount ?? section?.Settings?.MaxPackOpenCount ?? 100,\n );\n if (maxOpen <= 0) continue;\n const base = {\n packTypeID: p.PackTypeID || id,\n name: p.DisplayName || p.PackTypeID || id,\n icon: p.AssetPaths?.icon ?? \"ticket\",\n cards: Number(p.CollectibleCount ?? 1),\n maxOpen,\n };\n const options = Object.entries(p.PriceOptions ?? {}).filter(([, o]) => o);\n if (options.length === 0) out.push({ ...base, optionID: null, price: [] });\n for (const [oid, o] of options)\n out.push({\n ...base,\n optionID: o?.OptionID || oid,\n price: costLines(o?.Cost as never).filter((l) => l.kind !== \"other\"),\n });\n }\n return out.sort(\n (a, b) =>\n a.packTypeID.localeCompare(b.packTypeID) ||\n (a.optionID ?? \"\").localeCompare(b.optionID ?? \"\"),\n );\n}\n\nexport interface ChestRow {\n chestID: string;\n name: string;\n icon: string;\n cost: number;\n cards: string;\n}\n\n/** Collection chests: bought with the collection's own currency (what duplicates turn into). */\nexport function chestRows(\n section: CollectionSection | null | undefined,\n): ChestRow[] {\n return (section?.CollectionChests ?? [])\n .filter((c): c is CollectionChestView => !!c?.CollectionChestID)\n .map((c) => {\n const min = Number(c.MinCollectibleCount ?? 1);\n const max = Number(c.MaxCollectibleCount ?? min);\n return {\n chestID: c.CollectionChestID!,\n name: c.DisplayName || c.CollectionChestID!,\n icon: c.AssetPaths?.icon ?? \"chest\",\n cost: Number(c.CollectionCurrencyCost ?? 0),\n cards: max > min ? `${min}–${max}` : `${min}`,\n };\n });\n}\n\n/** Badge: set rewards and the grand prize waiting. */\nexport function collectionsBadge(\n collection: CollectionView | null | undefined,\n sets: SetRow[],\n state: CollectionStateView | null | undefined,\n): number {\n return (\n sets.filter((s) => s.state === \"ready\").length +\n (grandPrizeState(collection, sets, state) === \"ready\" ? 1 : 0)\n );\n}\n\n/** Whether the title has a collection — until it does, the lobby hides the tab. */\nexport function hasTitleData(section: CollectionSection | undefined): boolean {\n return Object.values(section?.Collections ?? {}).some(Boolean);\n}\n"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"path": "module.ts",
|
|
62
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { CollectionsScreen, makeCollectionsWatcher } from \"./CollectionsScreen\";\nimport { hasTitleData, type CollectionSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Collections as a feature of the game: the album, packs and chests, trades with friends. The\n// watcher keeps the badge (set rewards and the grand prize waiting) — only on a title with a\n// collection.\nexport const collectionsModule: Module = defineModule({\n id: \"collections\",\n meta: { name: \"Collections\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"collections\",\n label: t(\"title\"),\n icon: \"star\",\n group: \"events\",\n order: 45,\n available: false,\n Screen: CollectionsScreen,\n });\n const enabled = hasTitleData(\n configSection<CollectionSection>(ctx.client, \"Collection\"),\n );\n ctx.features.setAvailable(\n \"collections\",\n enabled,\n \"no collection on the title (Collection)\",\n );\n if (enabled)\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeCollectionsWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"collections\" }),\n describeActions: {},\n });\n },\n});\n"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"path": "module.meta.json",
|
|
66
|
+
"content": "{\n \"id\": \"collections\",\n \"type\": \"feature\",\n \"summary\": \"Collections for any game: an album of card sets with set rewards and a grand prize, packs and chests, trading spare cards with friends.\",\n \"description\": \"A ready collections screen (engine Collection) for ANY game, built on the @idosgames/react/ui kit and client.collection. Album: the player's collection with every set in order — the cards held (with how many) and the gaps, a reward to claim per completed set and a grand prize for the whole album. Packs: every pack type with every way to pay, opened once or ten at a time (the server rolls them — new cards and duplicates are shown), and collection chests bought with the points duplicates turn into. Trades: offers from other players (accept / decline), your own offers (cancel), and giving a spare card to a friend. Registers the 'collections' feature; its badge counts set rewards and the grand prize waiting. Ships title-seed.json: a small album with sets, a pack and a chest.\",\n \"provides\": [\n \"collection album with sets\",\n \"set rewards and grand prize\",\n \"card packs, once or ten at a time\",\n \"collection chests for points\",\n \"trading cards with friends\",\n \"badge for rewards waiting\"\n ],\n \"tags\": [\n \"collection\",\n \"album\",\n \"cards\",\n \"stickers\",\n \"trading\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
|
|
67
|
+
}
|
|
68
|
+
]
|
|
69
|
+
}
|