@idosgames/mcp 0.1.16 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/cli.js +1 -1
  2. package/package.json +1 -1
  3. package/registry/host.json +19 -7
  4. package/registry/index.json +731 -33
  5. package/registry/modules/board-game.json +4 -4
  6. package/registry/modules/character-ui.json +78 -0
  7. package/registry/modules/chat-ui.json +74 -0
  8. package/registry/modules/craft-ui.json +74 -0
  9. package/registry/modules/deal-offer-ui.json +73 -0
  10. package/registry/modules/events-ui.json +80 -0
  11. package/registry/modules/friends-ui.json +73 -0
  12. package/registry/modules/game-hud.json +15 -10
  13. package/registry/modules/idle-rpg.json +4 -4
  14. package/registry/modules/inventory-ui.json +73 -0
  15. package/registry/modules/leaderboard-ui.json +75 -0
  16. package/registry/modules/marketplace-ui.json +75 -0
  17. package/registry/modules/profile-ui.json +74 -0
  18. package/registry/modules/pvp-ui.json +72 -0
  19. package/registry/modules/referral-ui.json +73 -0
  20. package/registry/modules/rewards-ui.json +75 -0
  21. package/registry/modules/season-ui.json +79 -0
  22. package/registry/modules/showcase.json +92 -0
  23. package/registry/modules/store-ui.json +75 -0
  24. package/registry/modules/tutorial-ui.json +71 -0
  25. package/registry/modules/voxelcraft.json +98 -41
  26. package/registry/modules/workshop.json +3 -3
  27. package/registry/skills/authentication.json +1 -1
  28. package/registry/skills/blockchain-system.json +1 -1
  29. package/registry/skills/idosgames-module-contract.json +1 -1
  30. package/registry/skills/idosgames-ui-kit.json +6 -0
  31. package/registry/skills/leaderboard-system.json +1 -1
  32. package/registry/skills/multiplayer-realtime.json +3 -3
  33. package/registry/skills/referral-system.json +2 -2
  34. package/registry/skills/timed-event-system.json +1 -1
@@ -0,0 +1,74 @@
1
+ {
2
+ "id": "profile-ui",
3
+ "meta": {
4
+ "name": "Profile",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "catalog": {
9
+ "type": "feature",
10
+ "summary": "Profile and settings for any game: name and ID, sound, volume and motion, language, analytics consent, tutorials, log out and account deletion.",
11
+ "description": "A ready profile screen (Unity Alikhan/Username, UI/HUD/Settings, Runtime/UI/Consent, Localization) for ANY game, built on the @idosgames/react/ui kit. The player's name (rename in a popup with the server's rules: 3–24 characters, trimmed) and ID with copy; settings: sound on/off, volume and 'fewer animations' — the kit's player prefs, kept on this device and applied to every screen at once — the game's language (the title's languages, when there are two or more) and consent to anonymous analytics (client.analytics). Buttons: Tutorials (when tutorial-ui is installed), Log out (confirmed) and Delete account — a popup that unlocks only after the player types the confirmation word, then deletes this game's data for the player. Registers the 'profile' feature; the showcase lobby's profile button opens it. No title settings needed.",
12
+ "provides": [
13
+ "player name and ID",
14
+ "rename popup",
15
+ "sound, volume and fewer animations",
16
+ "language picker",
17
+ "analytics consent",
18
+ "log out",
19
+ "account deletion with confirmation"
20
+ ],
21
+ "tags": [
22
+ "profile",
23
+ "settings",
24
+ "account",
25
+ "sound",
26
+ "language",
27
+ "privacy",
28
+ "ui",
29
+ "screen"
30
+ ],
31
+ "author": {
32
+ "name": "iDos Games",
33
+ "url": "https://idosgames.com"
34
+ },
35
+ "version": "0.1.0"
36
+ },
37
+ "dependencies": {
38
+ "@idosgames/core": "0.19.0",
39
+ "@idosgames/module-sdk": "0.5.0",
40
+ "@idosgames/react": "0.3.0",
41
+ "react": "19.2.7"
42
+ },
43
+ "contentHash": "d5044c75012bd4f39b2156237dd036f26d68e3ec9f25b4d5e840110871ff0fc8",
44
+ "files": [
45
+ {
46
+ "path": "i18n.ts",
47
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Profile\",\n playerID: \"Player ID\",\n copied: \"Copied\",\n rename: \"Change name\",\n newName: \"New name\",\n save: \"Save\",\n nameShort: \"At least 3 characters\",\n nameLong: \"At most 24 characters\",\n renamed: \"Name changed\",\n settings: \"Settings\",\n sound: \"Sound\",\n volume: \"Volume\",\n motion: \"Fewer animations\",\n language: \"Language\",\n analytics: \"Share anonymous usage data\",\n tutorials: \"Tutorials\",\n logout: \"Log out\",\n logoutAsk:\n \"Log out of this device? A guest account is lost unless it is linked.\",\n delete: \"Delete account\",\n deleteAsk:\n \"This deletes your progress in this game for good. Type the word to confirm:\",\n deleteWord: \"DELETE\",\n deleted: \"Account deleted\",\n on: \"On\",\n off: \"Off\",\n guest: \"Guest\",\n wallets: \"Wallets\",\n unlink: \"Unlink\",\n unlinkTitle: \"Unlink the wallet?\",\n unlinkAsk:\n \"The wallet will be taken off this account. For 7 days it cannot be linked to another account; you can link it back here at any time. If the game is played with a wallet, it closes until you link one.\",\n unlinked: \"Wallet unlinked\",\n unlinkLogin: \"You sign in with this wallet — it cannot be unlinked.\",\n unlinkTooMany:\n \"Too many wallets unlinked this week. Try again in a few days.\",\n unlinkMissing: \"This wallet is no longer linked.\",\n loginWallet: \"Sign-in wallet\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Профиль\",\n playerID: \"ID игрока\",\n copied: \"Скопировано\",\n rename: \"Сменить имя\",\n newName: \"Новое имя\",\n save: \"Сохранить\",\n nameShort: \"Минимум 3 символа\",\n nameLong: \"Максимум 24 символа\",\n renamed: \"Имя изменено\",\n settings: \"Настройки\",\n sound: \"Звук\",\n volume: \"Громкость\",\n motion: \"Меньше анимации\",\n language: \"Язык\",\n analytics: \"Делиться анонимной статистикой\",\n tutorials: \"Обучение\",\n logout: \"Выйти\",\n logoutAsk:\n \"Выйти на этом устройстве? Гостевой аккаунт без привязки будет потерян.\",\n delete: \"Удалить аккаунт\",\n deleteAsk:\n \"Весь ваш прогресс в этой игре будет удалён навсегда. Чтобы подтвердить, введите слово:\",\n deleteWord: \"УДАЛИТЬ\",\n deleted: \"Аккаунт удалён\",\n on: \"Вкл\",\n off: \"Выкл\",\n guest: \"Гость\",\n wallets: \"Кошельки\",\n unlink: \"Отвязать\",\n unlinkTitle: \"Отвязать кошелёк?\",\n unlinkAsk:\n \"Кошелёк будет снят с этого аккаунта. 7 дней его нельзя привязать к другому аккаунту, а к этому — можно вернуть в любой момент. Если в игру играют с кошельком, она закроется, пока вы не привяжете другой.\",\n unlinked: \"Кошелёк отвязан\",\n unlinkLogin: \"Вы входите этим кошельком — его нельзя отвязать.\",\n unlinkTooMany:\n \"На этой неделе отвязано слишком много кошельков. Попробуйте через несколько дней.\",\n unlinkMissing: \"Этот кошелёк уже не привязан.\",\n loginWallet: \"Кошелёк входа\",\n});\n"
48
+ },
49
+ {
50
+ "path": "index.ts",
51
+ "content": "// @idosgames/mod-profile-ui — Profile for any game: name, ID, language, sound and motion settings, analytics consent, log out and account deletion.\n\nexport { profileUiModule } from \"./module\";\n"
52
+ },
53
+ {
54
+ "path": "model.test.ts",
55
+ "content": "import { describe, expect, it } from \"vitest\";\nimport {\n deleteUnlocked,\n languageChoices,\n nameProblem,\n shortAddress,\n unlinkRefusal,\n volumePercent,\n} from \"./model\";\n\ndescribe(\"profile\", () => {\n it(\"applies the server's name rules: trimmed, 3–24 characters\", () => {\n expect(nameProblem(\" An \")).toBe(\"short\");\n expect(nameProblem(\" Ann \")).toBeNull();\n expect(nameProblem(\"Anna\")).toBeNull();\n expect(nameProblem(\"x\".repeat(25))).toBe(\"long\");\n });\n\n it(\"shows the volume as a clamped percentage\", () => {\n expect(volumePercent(0.35)).toBe(35);\n expect(volumePercent(2)).toBe(100);\n expect(volumePercent(undefined)).toBe(100);\n });\n\n it(\"deletion unlocks only on the typed word, in any case\", () => {\n expect(deleteUnlocked(\" удалить \", \"УДАЛИТЬ\")).toBe(true);\n expect(deleteUnlocked(\"удали\", \"УДАЛИТЬ\")).toBe(false);\n });\n\n it(\"shortens a wallet address to its ends\", () => {\n expect(shortAddress(\"0x1234567890abcdef1234567890abcdef12345678\")).toBe(\n \"0x1234…5678\",\n );\n expect(shortAddress(\"0xabc\")).toBe(\"0xabc\");\n expect(shortAddress(null)).toBe(\"\");\n });\n\n it(\"names the server's unlink refusals and leaves the rest as they are\", () => {\n expect(unlinkRefusal(\"WALLET_IS_LOGIN_WALLET\")).toBe(\"unlinkLogin\");\n expect(unlinkRefusal(\"WALLET_CHANGES_TOO_FREQUENT\")).toBe(\"unlinkTooMany\");\n expect(unlinkRefusal(\"WALLET_NOT_LINKED\")).toBe(\"unlinkMissing\");\n expect(unlinkRefusal(\"RATE_LIMIT_EXCEEDED\")).toBeNull();\n });\n\n it(\"lists the title's languages in its order\", () => {\n expect(\n languageChoices([\n { Locale: \"ru\", DisplayName: \"Русский\", Order: 1 },\n { Locale: \"en\", DisplayName: \"English\", Order: 0 },\n { Locale: null },\n ]),\n ).toEqual([\n { id: \"en\", name: \"English\" },\n { id: \"ru\", name: \"Русский\" },\n ]);\n });\n});\n"
56
+ },
57
+ {
58
+ "path": "model.ts",
59
+ "content": "// Pure helpers of the profile screen (Unity Alikhan/Username, UI/HUD/Settings): the name rules the\n// server applies, the volume as a percentage, and the typed word that unlocks account deletion.\n// Covered by model.test.ts.\n\nexport const NAME_MIN = 3;\nexport const NAME_MAX = 24;\n\n/** The server trims and wants 3–24 characters; `null` = fine. */\nexport function nameProblem(name: string): \"short\" | \"long\" | null {\n const n = name.trim().length;\n if (n < NAME_MIN) return \"short\";\n if (n > NAME_MAX) return \"long\";\n return null;\n}\n\nexport function volumePercent(volume: number | null | undefined): number {\n return Math.round(Math.min(1, Math.max(0, Number(volume ?? 1))) * 100);\n}\n\n/** Deleting an account can't be undone — the player types the word to unlock the button. */\nexport function deleteUnlocked(typed: string, word: string): boolean {\n return typed.trim().toUpperCase() === word.toUpperCase();\n}\n\n/** \"0x1234…abcd\" — enough to recognise a wallet without a line-wide hash. */\nexport function shortAddress(address: string | null | undefined): string {\n const a = (address ?? \"\").trim();\n return a.length > 14 ? `${a.slice(0, 6)}…${a.slice(-4)}` : a;\n}\n\n/** The server's refusal of an unlink → the key of a sentence the player understands; null — show as is. */\nexport function unlinkRefusal(\n code: string | null | undefined,\n): \"unlinkLogin\" | \"unlinkTooMany\" | \"unlinkMissing\" | null {\n switch (code) {\n case \"WALLET_IS_LOGIN_WALLET\":\n return \"unlinkLogin\";\n case \"WALLET_CHANGES_TOO_FREQUENT\":\n return \"unlinkTooMany\";\n case \"WALLET_NOT_LINKED\":\n return \"unlinkMissing\";\n default:\n return null;\n }\n}\n\n/** Languages the title offers, in its order; the picker shows them only when there are two or more. */\nexport function languageChoices(\n locales:\n | ReadonlyArray<{\n Locale?: string | null;\n DisplayName?: string | null;\n Order?: number | null;\n }>\n | null\n | undefined,\n): Array<{ id: string; name: string }> {\n return [...(locales ?? [])]\n .filter((l) => l.Locale)\n .sort((a, b) => Number(a.Order ?? 0) - Number(b.Order ?? 0))\n .map((l) => ({ id: l.Locale!, name: l.DisplayName || l.Locale! }));\n}\n"
60
+ },
61
+ {
62
+ "path": "module.ts",
63
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makeProfileScreen } from \"./ProfileScreen\";\nimport { t } from \"./i18n\";\n\n// Profile and settings as a feature of the game. The showcase lobby opens it from its profile\n// button (features.open(\"profile\")); the shared HUD lists it in its ☰.\nexport const profileUiModule: Module = defineModule({\n id: \"profile-ui\",\n meta: { name: \"Profile\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"profile\",\n label: t(\"title\"),\n icon: \"user\",\n group: \"profile\",\n order: 100,\n Screen: makeProfileScreen(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"profile\" }),\n describeActions: {},\n });\n },\n});\n"
64
+ },
65
+ {
66
+ "path": "ProfileScreen.tsx",
67
+ "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type { PlayAccessWallet } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n ConfirmPopup,\n Icon,\n Popup,\n Tabs,\n errorText,\n outlined,\n panel,\n usePlayerPrefs,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n deleteUnlocked,\n languageChoices,\n nameProblem,\n shortAddress,\n unlinkRefusal,\n volumePercent,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Profile and settings (Unity Alikhan/Username, UI/HUD/Settings, Runtime/UI/Consent, Localization):\n// the name and ID, sound, volume and motion (kept on this device), the game's language, analytics\n// consent, tutorials, linked wallets, logging out and deleting the account. Renaming, unlinking a\n// wallet and deleting are popups.\n\nexport function makeProfileScreen(features: FeatureRegistry) {\n return function ProfileScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const kit = useUiKit();\n const [prefs, setPrefs] = usePlayerPrefs();\n const [name, setName] = useState<string | null>(null);\n const [renaming, setRenaming] = useState(false);\n const [ask, setAsk] = useState<\"logout\" | \"delete\" | null>(null);\n const [locale, setLocale] = useState(client.localization.locale ?? \"\");\n const [analytics, setAnalytics] = useState(client.analytics.isEnabled);\n const id = client.auth.context?.userID ?? \"\";\n const shown = name ?? state?.PublicData?.Username ?? t(\"guest\");\n const languages = languageChoices(client.localization.locales);\n\n const switchLanguage = async (next: string) => {\n const res = await client.localization.setLocale(next);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n setLocale(res.data);\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n maxWidth: 560,\n margin: \"0 auto\",\n width: \"100%\",\n }}\n >\n <div\n style={{\n ...panel,\n padding: 16,\n display: \"grid\",\n justifyItems: \"center\",\n gap: 8,\n }}\n >\n <div\n style={{\n width: 84,\n height: 84,\n borderRadius: \"50%\",\n display: \"grid\",\n placeItems: \"center\",\n background: `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`,\n border: \"3px solid rgba(255,255,255,.7)\",\n ...outlined,\n fontSize: 36,\n }}\n >\n {(shown.trim()[0] ?? \"?\").toUpperCase()}\n </div>\n <div style={{ ...outlined, fontSize: 20 }}>{shown}</div>\n <Button\n size=\"sm\"\n tone=\"blue\"\n onClick={() => setRenaming(true)}\n data-tutorial-anchor=\"profile:rename\"\n >\n <Icon glyph=\"edit\" size={16} /> {t(\"rename\")}\n </Button>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n maxWidth: \"100%\",\n }}\n >\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"playerID\")}\n </span>\n <span\n style={{\n fontFamily: \"monospace\",\n fontSize: 12,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n }}\n >\n {id}\n </span>\n <button\n type=\"button\"\n aria-label={t(\"playerID\")}\n onClick={() =>\n void navigator.clipboard\n ?.writeText(id)\n .then(() => toast(t(\"copied\")))\n }\n style={{\n background: \"none\",\n border: \"none\",\n cursor: \"pointer\",\n padding: 2,\n }}\n >\n <Icon glyph=\"copy\" size={16} />\n </button>\n </div>\n </div>\n\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 12 }}>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"settings\")}\n </div>\n <Toggle\n glyph={prefs.muted ? \"mute\" : \"sound\"}\n label={t(\"sound\")}\n value={!prefs.muted}\n onChange={(on) => {\n setPrefs({ muted: !on });\n if (on) kit.play(\"click\");\n }}\n />\n <div\n style={{ display: \"grid\", gap: 4, opacity: prefs.muted ? 0.5 : 1 }}\n >\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span>{t(\"volume\")}</span>\n <span>{volumePercent(prefs.volume)}%</span>\n </div>\n <input\n type=\"range\"\n min={0}\n max={100}\n step={5}\n disabled={prefs.muted}\n value={volumePercent(prefs.volume)}\n onChange={(e) =>\n setPrefs({ volume: Number(e.target.value) / 100 })\n }\n onPointerUp={() => kit.play(\"coin\")}\n style={{ width: \"100%\", accentColor: v.gold }}\n />\n </div>\n <Toggle\n glyph=\"flame\"\n label={t(\"motion\")}\n value={prefs.reducedMotion}\n onChange={(on) => setPrefs({ reducedMotion: on })}\n />\n <Toggle\n glyph=\"globe\"\n label={t(\"analytics\")}\n value={analytics}\n onChange={(on) => {\n client.analytics.setEnabled(on);\n setAnalytics(client.analytics.isEnabled);\n }}\n />\n {languages.length > 1 ? (\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13 }}>{t(\"language\")}</div>\n <Tabs\n tabs={languages.map((l) => ({ id: l.id, label: l.name }))}\n value={\n languages.find((l) => locale.startsWith(l.id))?.id ??\n languages[0]!.id\n }\n onChange={(l) => void switchLanguage(l)}\n />\n </div>\n ) : null}\n </div>\n\n <WalletsSection />\n\n {features.get(\"tutorials\") ? (\n <Button tone=\"blue\" onClick={() => features.open(\"tutorials\")}>\n <Icon glyph=\"bulb\" size={18} /> {t(\"tutorials\")}\n </Button>\n ) : null}\n <Button tone=\"grey\" onClick={() => setAsk(\"logout\")}>\n <Icon glyph=\"logout\" size={18} /> {t(\"logout\")}\n </Button>\n <Button tone=\"red\" onClick={() => setAsk(\"delete\")}>\n <Icon glyph=\"trash\" size={18} /> {t(\"delete\")}\n </Button>\n\n {renaming ? (\n <RenamePopup\n current={shown}\n onClose={() => setRenaming(false)}\n onDone={(n) => {\n setName(n);\n setRenaming(false);\n }}\n />\n ) : null}\n {ask === \"logout\" ? (\n <ConfirmPopup\n title={t(\"logout\")}\n tone=\"red\"\n onClose={() => setAsk(null)}\n onConfirm={() => {\n client.auth.logout();\n location.reload();\n }}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"logoutAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n {ask === \"delete\" ? <DeletePopup onClose={() => setAsk(null)} /> : null}\n </div>\n );\n };\n}\n\nfunction Toggle({\n glyph,\n label,\n value,\n onChange,\n}: {\n glyph: \"sound\" | \"mute\" | \"flame\" | \"globe\";\n label: string;\n value: boolean;\n onChange: (on: boolean) => void;\n}): ReactNode {\n return (\n <button\n type=\"button\"\n role=\"switch\"\n aria-checked={value}\n onClick={() => onChange(!value)}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n background: \"none\",\n border: \"none\",\n color: v.text,\n cursor: \"pointer\",\n padding: 0,\n textAlign: \"left\",\n }}\n >\n <Icon glyph={glyph} size={24} />\n <span style={{ flex: 1, ...outlined, fontSize: 14 }}>{label}</span>\n <span\n style={{\n width: 52,\n height: 30,\n borderRadius: 15,\n background: value ? v.green : \"rgba(255,255,255,.2)\",\n position: \"relative\",\n transition: \"background .2s\",\n }}\n >\n <span\n style={{\n position: \"absolute\",\n top: 3,\n left: value ? 25 : 3,\n width: 24,\n height: 24,\n borderRadius: \"50%\",\n background: \"#fff\",\n transition: \"left .2s\",\n boxShadow: \"0 2px 4px rgba(0,0,0,.3)\",\n }}\n />\n </span>\n </button>\n );\n}\n\n/**\n * Wallets linked by signature, each with \"Unlink\" (`client.auth.unlinkWallet`). Hidden while there are\n * none. The wallet an account signs in with has no button: the server refuses it (`CanUnlink`).\n */\nfunction WalletsSection(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [wallets, setWallets] = useState<PlayAccessWallet[]>([]);\n const [asking, setAsking] = useState<PlayAccessWallet | null>(null);\n\n const load = useCallback(async () => {\n const res = await client.blockchain.getPlayAccess();\n if (res.ok) setWallets(res.data.LinkedWallets ?? []);\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n // The popup shows the spinner and closes itself when this resolves.\n const unlink = async (wallet: PlayAccessWallet) => {\n const res = await client.auth.unlinkWallet(wallet.NetworkID ?? \"\");\n if (!res.ok) {\n const known = unlinkRefusal(res.error);\n toast(known ? t(known) : errorText(res.error), \"error\");\n } else toast(t(\"unlinked\"), \"success\");\n await load();\n };\n\n if (wallets.length === 0) return null;\n return (\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 10 }}>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"wallets\")}\n </div>\n {wallets.map((w) => (\n <div\n key={`${w.NetworkID}:${w.Address}`}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n minWidth: 0,\n }}\n >\n <Icon glyph=\"link\" size={22} />\n <div style={{ flex: 1, minWidth: 0, display: \"grid\" }}>\n <span\n style={{\n fontFamily: \"monospace\",\n fontSize: 13,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n }}\n >\n {shortAddress(w.Address)}\n </span>\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {(w.NetworkID ?? \"\").toUpperCase()}\n {w.CanUnlink === false ? ` · ${t(\"loginWallet\")}` : \"\"}\n </span>\n </div>\n {w.CanUnlink !== false ? (\n <Button size=\"sm\" tone=\"grey\" onClick={() => setAsking(w)}>\n {t(\"unlink\")}\n </Button>\n ) : null}\n </div>\n ))}\n {asking ? (\n <ConfirmPopup\n title={t(\"unlinkTitle\")}\n confirmLabel={t(\"unlink\")}\n tone=\"red\"\n onClose={() => setAsking(null)}\n onConfirm={() => unlink(asking)}\n >\n <div\n style={{\n fontFamily: \"monospace\",\n fontSize: 14,\n textAlign: \"center\",\n }}\n >\n {shortAddress(asking.Address)}\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 13,\n textAlign: \"center\",\n lineHeight: 1.4,\n }}\n >\n {t(\"unlinkAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n </div>\n );\n}\n\nfunction RenamePopup({\n current,\n onClose,\n onDone,\n}: {\n current: string;\n onClose: () => void;\n onDone: (name: string) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [value, setValue] = useState(current);\n const [busy, setBusy] = useState(false);\n const problem = nameProblem(value);\n const save = async () => {\n if (problem) return;\n setBusy(true);\n const res = await client.user.changeUsername(value.trim());\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"renamed\"), \"success\");\n // The SDK does not patch the cached name — the screen shows the server's answer.\n onDone(res.data.Username ?? value.trim());\n };\n return (\n <Popup title={t(\"rename\")} onClose={onClose} width={400}>\n <input\n value={value}\n autoFocus\n maxLength={40}\n onChange={(e) => setValue(e.target.value)}\n onKeyDown={(e) => e.key === \"Enter\" && void save()}\n placeholder={t(\"newName\")}\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 16,\n color: v.text,\n outline: \"none\",\n }}\n />\n {problem ? (\n <div style={{ ...outlined, fontSize: 12, color: v.red }}>\n {problem === \"short\" ? t(\"nameShort\") : t(\"nameLong\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!!problem}\n busy={busy}\n onClick={() => void save()}\n >\n {t(\"save\")}\n </Button>\n </Popup>\n );\n}\n\nfunction DeletePopup({ onClose }: { onClose: () => void }): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [typed, setTyped] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const word = t(\"deleteWord\");\n const remove = async () => {\n setBusy(true);\n const res = await client.user.deleteUserAccount();\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"deleted\"));\n client.auth.logout();\n location.reload();\n };\n return (\n <Popup title={t(\"delete\")} onClose={onClose} width={420}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"deleteAsk\")} <span style={{ color: v.red }}>{word}</span>\n </div>\n <input\n value={typed}\n onChange={(e) => setTyped(e.target.value)}\n placeholder={word}\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 16,\n color: v.text,\n outline: \"none\",\n textAlign: \"center\",\n }}\n />\n <Button\n tone=\"red\"\n disabled={!deleteUnlocked(typed, word)}\n busy={busy}\n onClick={() => void remove()}\n >\n {t(\"delete\")}\n </Button>\n </Popup>\n );\n}\n"
68
+ },
69
+ {
70
+ "path": "module.meta.json",
71
+ "content": "{\n \"id\": \"profile-ui\",\n \"type\": \"feature\",\n \"summary\": \"Profile and settings for any game: name and ID, sound, volume and motion, language, analytics consent, tutorials, log out and account deletion.\",\n \"description\": \"A ready profile screen (Unity Alikhan/Username, UI/HUD/Settings, Runtime/UI/Consent, Localization) for ANY game, built on the @idosgames/react/ui kit. The player's name (rename in a popup with the server's rules: 3–24 characters, trimmed) and ID with copy; settings: sound on/off, volume and 'fewer animations' — the kit's player prefs, kept on this device and applied to every screen at once — the game's language (the title's languages, when there are two or more) and consent to anonymous analytics (client.analytics). Buttons: Tutorials (when tutorial-ui is installed), Log out (confirmed) and Delete account — a popup that unlocks only after the player types the confirmation word, then deletes this game's data for the player. Registers the 'profile' feature; the showcase lobby's profile button opens it. No title settings needed.\",\n \"provides\": [\n \"player name and ID\",\n \"rename popup\",\n \"sound, volume and fewer animations\",\n \"language picker\",\n \"analytics consent\",\n \"log out\",\n \"account deletion with confirmation\"\n ],\n \"tags\": [\n \"profile\",\n \"settings\",\n \"account\",\n \"sound\",\n \"language\",\n \"privacy\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
72
+ }
73
+ ]
74
+ }
@@ -0,0 +1,72 @@
1
+ {
2
+ "id": "pvp-ui",
3
+ "meta": {
4
+ "name": "Arena",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "catalog": {
9
+ "type": "feature",
10
+ "summary": "PvP duels for any game: open duels to join, your duels, creating a duel for coins, a zone strategy and the fight replayed round by round.",
11
+ "description": "A ready PvP arena (Unity Alikhan/PVP/Lobby) for ANY game, built on the @idosgames/react/ui kit, client.match and client.character. Tabs: open duels (join with one of your heroes) and your duels (waiting — cancel returns the entry; finished — won / lost / draw). Popups: a new duel (rule, hero by power, entry within the rule's bounds and what the winner takes after the burn), the battle strategy (attack and defence zone — head, body, legs — per round, saved as the default) and the result, replayed round by round (whose blow, where, hit / critical / block / dodge, damage, HP left). The server fights the battle instantly from the heroes' stats; a draw returns the entries. Without a hero the screen offers the Heroes feature. Registers the 'pvp' feature. Ships title-seed.json: a coin duel (10–500, 10% burned) fought by the showcase heroes' Attack and Health.",
12
+ "provides": [
13
+ "open duels to join",
14
+ "my duels with cancel",
15
+ "create a duel with an entry",
16
+ "zone battle strategy",
17
+ "fight result replayed round by round",
18
+ "demo duel rule"
19
+ ],
20
+ "tags": [
21
+ "pvp",
22
+ "duel",
23
+ "arena",
24
+ "battle",
25
+ "matchmaking",
26
+ "ui",
27
+ "screen"
28
+ ],
29
+ "author": {
30
+ "name": "iDos Games",
31
+ "url": "https://idosgames.com"
32
+ },
33
+ "version": "0.1.0"
34
+ },
35
+ "dependencies": {
36
+ "@idosgames/core": "0.19.0",
37
+ "@idosgames/module-sdk": "0.5.0",
38
+ "@idosgames/react": "0.3.0",
39
+ "react": "19.2.7"
40
+ },
41
+ "contentHash": "fd37397dfd33215ffcf0cf1322b0d7dccca1ebe171a0d06a07e866666346a3c3",
42
+ "files": [
43
+ {
44
+ "path": "i18n.ts",
45
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Arena\",\n open: \"Open duels\",\n mine: \"My duels\",\n create: \"New duel\",\n strategy: \"Strategy\",\n noOpen: \"No open duels — create one!\",\n noMine: \"You have no duels yet.\",\n entry: \"Entry\",\n winnerTakes: \"Winner takes\",\n hero: \"Hero\",\n rule: \"Rule\",\n fight: \"Fight!\",\n cancel: \"Cancel duel\",\n cancelled: \"Cancelled\",\n waiting: \"Waiting for an opponent\",\n won: \"Victory!\",\n lost: \"Defeat\",\n draw: \"Draw — entries returned\",\n youWon: \"You won\",\n youLost: \"You lost\",\n rounds: \"Rounds\",\n attack: \"Attack\",\n defense: \"Defence\",\n round: \"Round\",\n save: \"Save\",\n saved: \"Strategy saved\",\n addRound: \"+ round\",\n head: \"Head\",\n torso: \"Body\",\n legs: \"Legs\",\n hHit: \"hit\",\n hCritical: \"critical!\",\n hBlock: \"blocked\",\n hDodge: \"dodged\",\n you: \"You\",\n foe: \"Foe\",\n hp: \"HP\",\n player: \"Player\",\n noHero: \"You need a hero to fight.\",\n toHeroes: \"Heroes\",\n yours: \"Your duel\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Арена\",\n open: \"Доступные\",\n mine: \"Мои дуэли\",\n create: \"Новая дуэль\",\n strategy: \"Стратегия\",\n noOpen: \"Открытых дуэлей нет — создайте свою!\",\n noMine: \"У вас пока нет дуэлей.\",\n entry: \"Взнос\",\n winnerTakes: \"Победитель получит\",\n hero: \"Герой\",\n rule: \"Правило\",\n fight: \"В бой!\",\n cancel: \"Отменить дуэль\",\n cancelled: \"Отменена\",\n waiting: \"Ждёт соперника\",\n won: \"Победа!\",\n lost: \"Поражение\",\n draw: \"Ничья — взносы вернулись\",\n youWon: \"Вы выиграли\",\n youLost: \"Вы проиграли\",\n rounds: \"Раунды\",\n attack: \"Удар\",\n defense: \"Защита\",\n round: \"Раунд\",\n save: \"Сохранить\",\n saved: \"Стратегия сохранена\",\n addRound: \"+ раунд\",\n head: \"Голова\",\n torso: \"Корпус\",\n legs: \"Ноги\",\n hHit: \"удар\",\n hCritical: \"крит!\",\n hBlock: \"блок\",\n hDodge: \"уворот\",\n you: \"Вы\",\n foe: \"Соперник\",\n hp: \"ОЗ\",\n player: \"Игрок\",\n noHero: \"Для боя нужен герой.\",\n toHeroes: \"Герои\",\n yours: \"Ваша дуэль\",\n});\n"
46
+ },
47
+ {
48
+ "path": "index.ts",
49
+ "content": "// @idosgames/mod-pvp-ui — PvP duels for any game: open duels, your duels, creating a duel for coins, a battle strategy and the fight's result.\n\nexport { pvpUiModule } from \"./module\";\n"
50
+ },
51
+ {
52
+ "path": "model.test.ts",
53
+ "content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport {\n BUILT_IN_RULE,\n clampEntry,\n duelRules,\n matchEntry,\n outcomeFor,\n roundLines,\n strategyOrDefault,\n winnerTakes,\n} from \"./model\";\n\nconst defs = (seed as unknown as { Match: never }).Match;\n\ndescribe(\"pvp\", () => {\n it(\"reads the seeded duel: coins from 10 to 500, 10% burned\", () => {\n const [duel] = duelRules(defs, \"CO\");\n expect(duel).toMatchObject({\n id: \"duel\",\n name: \"Duel\",\n currency: \"CO\",\n min: 10,\n max: 500,\n burnRate: 0.1,\n });\n expect(winnerTakes(50, duel!.burnRate)).toBe(90);\n expect(clampEntry(5, duel!)).toBe(10);\n expect(clampEntry(9999, duel!)).toBe(500);\n });\n\n it(\"a title without rules still has the built-in duel\", () => {\n expect(duelRules(null, \"CO\")).toEqual([\n {\n id: BUILT_IN_RULE,\n name: \"Duel\",\n description: null,\n currency: \"CO\",\n min: 1,\n max: Number.MAX_SAFE_INTEGER,\n burnRate: 0,\n },\n ]);\n });\n\n it(\"the saved strategy, capped at 10 rounds, or three rounds to start from\", () => {\n expect(strategyOrDefault(null)).toHaveLength(3);\n const saved = Array.from({ length: 12 }, () => ({\n AttackTarget: \"Legs\",\n DefenseTarget: \"Nonsense\",\n }));\n const steps = strategyOrDefault(saved);\n expect(steps).toHaveLength(10);\n expect(steps[0]).toEqual({ AttackTarget: \"Legs\", DefenseTarget: \"Head\" });\n });\n\n it(\"outcome and rounds from my side\", () => {\n expect(outcomeFor({ WinnerUserID: \"me\" }, \"me\")).toBe(\"win\");\n expect(outcomeFor({ WinnerUserID: \"you\" }, \"me\")).toBe(\"lose\");\n expect(outcomeFor({ IsDraw: true }, \"me\")).toBe(\"draw\");\n const lines = roundLines(\n [\n {\n RoundIndex: 1,\n AttackerID: \"me\",\n AttackZone: \"Torso\",\n HitType: \"Critical\",\n DamageDealt: 17.6,\n DefenderHpRemaining: 102.4,\n },\n ],\n \"me\",\n );\n expect(lines).toEqual([\n {\n round: 1,\n mine: true,\n zone: \"Torso\",\n hit: \"Critical\",\n damage: 18,\n hpLeft: 102,\n },\n ]);\n });\n\n it(\"reads a listed duel's entry\", () => {\n expect(\n matchEntry({ Entry: { Entries: [{ CurrencyID: \"CO\", Amount: 50 }] } }),\n ).toEqual({ currency: \"CO\", amount: 50 });\n expect(matchEntry({})).toEqual({ currency: null, amount: 0 });\n });\n});\n"
54
+ },
55
+ {
56
+ "path": "model.ts",
57
+ "content": "// Pure helpers of the PvP screen (Unity Alikhan/PVP/Lobby): the title's duel rules and their entry\n// bounds, the strategy (attack / defence zone per round), the outcome of a fight and its rounds.\n// The server fights the battle — nothing here computes damage. Covered by model.test.ts.\n\nexport type Zone = \"Head\" | \"Torso\" | \"Legs\";\nexport const ZONES: Zone[] = [\"Head\", \"Torso\", \"Legs\"];\nexport type Step = { AttackTarget: Zone; DefenseTarget: Zone };\n\n/** The built-in rule every title has, used when the title configures none. */\nexport const BUILT_IN_RULE = \"InstantBattle1v1\";\n\nexport interface DuelRule {\n id: string;\n name: string;\n description: string | null;\n currency: string | null;\n min: number;\n max: number;\n burnRate: number;\n}\n\ntype RuleDef = {\n RuleID?: string | null;\n DisplayName?: string | null;\n Description?: string | null;\n Economy?: { BurnRate?: number | null } | null;\n Entry?: {\n Allowed?: Array<{\n Kind?: string | null;\n CurrencyID?: string | null;\n MinAmount?: number | null;\n MaxAmount?: number | null;\n } | null> | null;\n } | null;\n};\n\n/** The duel rules and their currency entry bounds (the first currency rule of the whitelist). */\nexport function duelRules(\n defs:\n | {\n InstantBattle?: {\n Rules?: Record<string, RuleDef | null> | null;\n } | null;\n }\n | null\n | undefined,\n fallbackCurrency: string | null,\n): DuelRule[] {\n const rules = Object.entries(defs?.InstantBattle?.Rules ?? {})\n .filter(([, r]) => r)\n .map(([id, r]) => {\n const allowed = (r!.Entry?.Allowed ?? []).find(\n (a) => a?.Kind === \"VirtualCurrency\",\n );\n return {\n id: r!.RuleID || id,\n name: r!.DisplayName || id,\n description: r!.Description ?? null,\n currency: allowed?.CurrencyID ?? fallbackCurrency,\n min: Math.max(1, Number(allowed?.MinAmount ?? 1)),\n max:\n Number(allowed?.MaxAmount ?? 0) > 0\n ? Number(allowed!.MaxAmount)\n : Number.MAX_SAFE_INTEGER,\n burnRate: Number(r!.Economy?.BurnRate ?? 0),\n } satisfies DuelRule;\n });\n return rules.length > 0\n ? rules\n : [\n {\n id: BUILT_IN_RULE,\n name: \"Duel\",\n description: null,\n currency: fallbackCurrency,\n min: 1,\n max: Number.MAX_SAFE_INTEGER,\n burnRate: 0,\n },\n ];\n}\n\n/** What the winner takes: both entries minus the burn. */\nexport function winnerTakes(entry: number, burnRate: number): number {\n return Math.floor(entry * 2 * (1 - Math.min(1, Math.max(0, burnRate))));\n}\n\nexport function clampEntry(value: number, rule: DuelRule): number {\n return Math.min(rule.max, Math.max(rule.min, Math.floor(value)));\n}\n\n/** The entry of a listed match: its first currency line. */\nexport function matchEntry(match: {\n Entry?: {\n Entries?: Array<{\n CurrencyID?: string | null;\n Amount?: number | null;\n } | null> | null;\n } | null;\n}): { currency: string | null; amount: number } {\n const e = match.Entry?.Entries?.find((x) => x?.CurrencyID);\n return { currency: e?.CurrencyID ?? null, amount: Number(e?.Amount ?? 0) };\n}\n\n/** The saved strategy, or three sensible rounds to start from. */\nexport function strategyOrDefault(\n saved:\n | ReadonlyArray<{\n AttackTarget?: string | null;\n DefenseTarget?: string | null;\n }>\n | null\n | undefined,\n): Step[] {\n const steps = (saved ?? [])\n .map((s) => ({\n AttackTarget: asZone(s.AttackTarget),\n DefenseTarget: asZone(s.DefenseTarget),\n }))\n .slice(0, 10);\n return steps.length > 0\n ? steps\n : [\n { AttackTarget: \"Head\", DefenseTarget: \"Torso\" },\n { AttackTarget: \"Torso\", DefenseTarget: \"Head\" },\n { AttackTarget: \"Legs\", DefenseTarget: \"Torso\" },\n ];\n}\n\nfunction asZone(z: string | null | undefined): Zone {\n return z === \"Torso\" || z === \"Legs\" ? z : \"Head\";\n}\n\nexport type Outcome = \"win\" | \"lose\" | \"draw\";\n\nexport function outcomeFor(\n battle:\n | { WinnerUserID?: string | null; IsDraw?: boolean | null }\n | null\n | undefined,\n me: string | null | undefined,\n): Outcome {\n if (!battle || battle.IsDraw || !battle.WinnerUserID) return \"draw\";\n return battle.WinnerUserID === me ? \"win\" : \"lose\";\n}\n\nexport interface RoundLine {\n round: number;\n mine: boolean;\n zone: Zone;\n hit: \"Hit\" | \"Critical\" | \"Block\" | \"Dodge\";\n damage: number;\n hpLeft: number;\n}\n\n/** The fight round by round, from my side: whose blow, where, how it landed, HP left. */\nexport function roundLines(\n log:\n | ReadonlyArray<{\n RoundIndex?: number | null;\n AttackerID?: string | null;\n AttackZone?: string | null;\n HitType?: string | null;\n DamageDealt?: number | null;\n DefenderHpRemaining?: number | null;\n }>\n | null\n | undefined,\n me: string | null | undefined,\n): RoundLine[] {\n return (log ?? []).map((e, i) => ({\n // The engine counts rounds from 1.\n round: Number(e.RoundIndex ?? i + 1),\n mine: e.AttackerID === me,\n zone: asZone(e.AttackZone),\n hit:\n e.HitType === \"Critical\" || e.HitType === \"Block\" || e.HitType === \"Dodge\"\n ? e.HitType\n : \"Hit\",\n damage: Math.round(Number(e.DamageDealt ?? 0)),\n hpLeft: Math.max(0, Math.round(Number(e.DefenderHpRemaining ?? 0))),\n }));\n}\n"
58
+ },
59
+ {
60
+ "path": "module.ts",
61
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makePvpScreen } from \"./PvpScreen\";\nimport { t } from \"./i18n\";\n\n// PvP duels as a feature of the game: open duels to join, my duels, and creating one. The fight is\n// resolved instantly by the server from the heroes' stats and the saved strategy.\nexport const pvpUiModule: Module = defineModule({\n id: \"pvp-ui\",\n meta: { name: \"Arena\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"pvp\",\n label: t(\"title\"),\n icon: \"swords\",\n group: \"social\",\n order: 95,\n Screen: makePvpScreen(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"pvp\" }),\n describeActions: {},\n });\n },\n});\n"
62
+ },
63
+ {
64
+ "path": "PvpScreen.tsx",
65
+ "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n InstantBattleResponse,\n MatchDefinitions,\n PvPMatch,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n Spinner,\n Tabs,\n canAfford,\n errorText,\n formatAmount,\n outlined,\n panel,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n ZONES,\n clampEntry,\n duelRules,\n matchEntry,\n outcomeFor,\n roundLines,\n strategyOrDefault,\n winnerTakes,\n type DuelRule,\n type Step,\n type Zone,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// PvP duels (Unity Alikhan/PVP/Lobby): open duels to join and my duels as tabs. Creating a duel\n// (rule, entry, hero), the battle strategy (attack / defence zone per round) and the fight's result\n// round by round are popups. The server fights the battle instantly; the winner takes both entries\n// minus the burn, a draw returns them.\n\ntype Tab = \"open\" | \"mine\";\ntype Hero = { id: string; name: string; power: number };\n\nfunction useHeroes(): Hero[] | null {\n const client = useIDosGamesClient();\n const [heroes, setHeroes] = useState<Hero[] | null>(null);\n useEffect(() => {\n void client.character.getUserCharacters().then((r) => {\n const defs =\n (\n client.data.config.getSection(\"Character\") as\n | {\n Definitions?: Record<\n string,\n { Identity?: { DisplayName?: string | null } | null } | null\n >;\n }\n | undefined\n )?.Definitions ?? {};\n const owned = r.ok ? Object.values(r.data.Characters ?? {}) : [];\n setHeroes(\n owned\n .filter((c) => c?.CharacterID)\n .map((c) => ({\n id: c!.CharacterID!,\n name:\n defs[c!.CharacterID!]?.Identity?.DisplayName ?? c!.CharacterID!,\n power: Number(c!.Power ?? 0),\n }))\n .sort((a, b) => b.power - a.power),\n );\n });\n }, [client]);\n return heroes;\n}\n\nexport function makePvpScreen(features: FeatureRegistry) {\n return function PvpScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const [defs, setDefs] = useState<MatchDefinitions | null | undefined>(\n undefined,\n );\n const [tab, setTab] = useState<Tab>(\"open\");\n const [creating, setCreating] = useState(false);\n const [editing, setEditing] = useState(false);\n const [joining, setJoining] = useState<PvPMatch | null>(null);\n const [result, setResult] = useState<InstantBattleResponse | null>(null);\n const [version, setVersion] = useState(0);\n const heroes = useHeroes();\n useEffect(() => {\n void client.match\n .getDefinitions()\n .then((r) => setDefs(r.ok ? r.data : null));\n }, [client]);\n const firstCurrency =\n Object.keys(\n (\n client.data.config.getSection(\"Currency\") as\n { VirtualCurrencies?: Record<string, unknown> } | undefined\n )?.VirtualCurrencies ?? {},\n )[0] ?? null;\n const rules = duelRules(defs as never, firstCurrency);\n const refresh = () => setVersion((n) => n + 1);\n\n if (defs === undefined || !heroes)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (heroes.length === 0)\n return (\n <div style={{ display: \"grid\", gap: 12, justifyItems: \"center\" }}>\n <EmptyState glyph=\"swords\" text={t(\"noHero\")} />\n {features.get(\"character\") ? (\n <Button tone=\"blue\" onClick={() => features.open(\"character\")}>\n {t(\"toHeroes\")}\n </Button>\n ) : null}\n </div>\n );\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <div style={{ display: \"flex\", gap: 8, flexWrap: \"wrap\" }}>\n <Button\n tone=\"gold\"\n attract\n onClick={() => setCreating(true)}\n style={{ flex: 1 }}\n data-tutorial-anchor=\"pvp:create\"\n >\n <Icon glyph=\"swords\" size={18} /> {t(\"create\")}\n </Button>\n <Button tone=\"blue\" onClick={() => setEditing(true)}>\n <Icon glyph=\"gear\" size={18} /> {t(\"strategy\")}\n </Button>\n </div>\n <Tabs\n tabs={[\n { id: \"open\", label: t(\"open\") },\n { id: \"mine\", label: t(\"mine\") },\n ]}\n value={tab}\n onChange={setTab}\n />\n <div key={`${tab}:${version}`}>\n {tab === \"open\" ? (\n <OpenDuels rules={rules} onJoin={setJoining} />\n ) : (\n <MyDuels rules={rules} onChange={refresh} />\n )}\n </div>\n {creating ? (\n <CreatePopup\n rules={rules}\n heroes={heroes}\n onClose={() => setCreating(false)}\n onCreated={() => {\n setTab(\"mine\");\n refresh();\n }}\n />\n ) : null}\n {editing ? <StrategyPopup onClose={() => setEditing(false)} /> : null}\n {joining ? (\n <JoinPopup\n match={joining}\n rule={rules.find((r) => r.id === joining.RuleID) ?? rules[0]!}\n heroes={heroes}\n onClose={() => setJoining(null)}\n onFought={(res) => {\n setJoining(null);\n setResult(res);\n refresh();\n }}\n />\n ) : null}\n {result ? (\n <ResultPopup result={result} onClose={() => setResult(null)} />\n ) : null}\n </div>\n );\n };\n}\n\nfunction shortID(id: string | null | undefined): string {\n if (!id) return \"?\";\n return id.length > 10 ? `${id.slice(0, 5)}…${id.slice(-4)}` : id;\n}\n\nfunction DuelRow({\n match,\n rules,\n index,\n right,\n onPick,\n}: {\n match: PvPMatch;\n rules: DuelRule[];\n index: number;\n right?: ReactNode;\n onPick?: () => void;\n}): ReactNode {\n const stagger = useStagger();\n const e = matchEntry(match);\n const rule = rules.find((r) => r.id === match.RuleID);\n return (\n <div\n onClick={onPick}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: onPick ? \"pointer\" : undefined,\n ...stagger(index),\n }}\n >\n <Icon glyph=\"swords\" size={30} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {rule?.name ?? match.RuleID} · {t(\"player\")}{\" \"}\n {shortID(match.CreatorID)}\n </div>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n }}\n >\n {t(\"entry\")}{\" \"}\n {e.currency ? (\n <ResourceList\n lines={[{ kind: \"currency\", id: e.currency, amount: e.amount }]}\n size={16}\n />\n ) : null}\n {match.CreatorCharacterID ? ` · ${match.CreatorCharacterID}` : \"\"}\n </div>\n </div>\n {right}\n </div>\n );\n}\n\nfunction OpenDuels({\n rules,\n onJoin,\n}: {\n rules: DuelRule[];\n onJoin: (m: PvPMatch) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const [list, setList] = useState<PvPMatch[] | null>(null);\n useEffect(() => {\n void client.match\n .getAvailableMatches(0, 30)\n .then((r) => setList(r.ok ? (r.data.Matches ?? []) : []));\n }, [client]);\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"swords\" text={t(\"noOpen\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((m, i) => (\n <DuelRow\n key={m.MatchID}\n match={m}\n rules={rules}\n index={i}\n onPick={() => onJoin(m)}\n right={\n <Button size=\"sm\" tone=\"green\">\n {t(\"fight\")}\n </Button>\n }\n />\n ))}\n </div>\n );\n}\n\nfunction MyDuels({\n rules,\n onChange,\n}: {\n rules: DuelRule[];\n onChange: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const me = client.auth.context?.userID;\n const [list, setList] = useState<PvPMatch[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n useEffect(() => {\n void client.match\n .getMyMatches(0, 30)\n .then((r) => setList(r.ok ? (r.data.Matches ?? []) : []));\n }, [client]);\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"swords\" text={t(\"noMine\")} />;\n const cancel = async (id: string) => {\n setBusy(id);\n const res = await client.match.cancelMatch(id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n onChange();\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((m, i) => (\n <DuelRow\n key={m.MatchID}\n match={m}\n rules={rules}\n index={i}\n right={\n m.Status === \"Open\" && m.CreatorID === me ? (\n <div style={{ display: \"grid\", gap: 4, justifyItems: \"end\" }}>\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"waiting\")}\n </span>\n <Button\n size=\"sm\"\n tone=\"grey\"\n busy={busy === m.MatchID}\n onClick={() => void cancel(m.MatchID)}\n >\n {t(\"cancel\")}\n </Button>\n </div>\n ) : m.Status === \"Cancelled\" ? (\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"cancelled\")}\n </span>\n ) : m.Status === \"Completed\" ? (\n <span\n style={{\n ...outlined,\n fontSize: 13,\n color: !m.WinnerUserID\n ? v.textDim\n : m.WinnerUserID === me\n ? v.green\n : v.red,\n }}\n >\n {!m.WinnerUserID\n ? t(\"draw\")\n : m.WinnerUserID === me\n ? t(\"youWon\")\n : t(\"youLost\")}\n </span>\n ) : null\n }\n />\n ))}\n </div>\n );\n}\n\nfunction HeroPicker({\n heroes,\n value,\n onChange,\n}: {\n heroes: Hero[];\n value: string;\n onChange: (id: string) => void;\n}): ReactNode {\n // One hero: nothing to pick, but the player still sees who fights (tabs need two).\n if (heroes.length === 1)\n return (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 14,\n }}\n >\n <Icon glyph=\"hero\" size={22} /> {heroes[0]!.name} ·{\" \"}\n {formatAmount(heroes[0]!.power)}\n </div>\n );\n return (\n <Tabs\n tabs={heroes.map((h) => ({\n id: h.id,\n label: `${h.name} · ${formatAmount(h.power)}`,\n }))}\n value={value}\n onChange={onChange}\n />\n );\n}\n\nfunction CreatePopup({\n rules,\n heroes,\n onClose,\n onCreated,\n}: {\n rules: DuelRule[];\n heroes: Hero[];\n onClose: () => void;\n onCreated: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const kit = useUiKit();\n const [ruleID, setRuleID] = useState(rules[0]!.id);\n const rule = rules.find((r) => r.id === ruleID) ?? rules[0]!;\n const [amount, setAmount] = useState(clampEntry(50, rule));\n const [hero, setHero] = useState(heroes[0]!.id);\n const [busy, setBusy] = useState(false);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const entryLine = rule.currency\n ? [{ kind: \"currency\" as const, id: rule.currency, amount }]\n : [];\n const affordable = canAfford(entryLine, balances);\n const create = async () => {\n if (!rule.currency) return;\n setBusy(true);\n const saved = client.data.user.state?.Match?.PvPBattleStrategy;\n const res = await client.match.createMatch(\n {\n Entries: [\n {\n Type: \"VirtualCurrency\",\n CurrencyID: rule.currency,\n Amount: amount,\n },\n ],\n } as never,\n rule.id,\n hero,\n saved ?? undefined,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"purchase\");\n onCreated();\n onClose();\n };\n return (\n <Popup title={t(\"create\")} onClose={onClose} width={440}>\n {rules.length > 1 ? (\n <Tabs\n tabs={rules.map((r) => ({ id: r.id, label: r.name }))}\n value={ruleID}\n onChange={(id) => {\n setRuleID(id);\n const r = rules.find((x) => x.id === id)!;\n setAmount(clampEntry(amount, r));\n }}\n />\n ) : null}\n {rule.description ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {rule.description}\n </div>\n ) : null}\n <Field label={t(\"hero\")}>\n <HeroPicker heroes={heroes} value={hero} onChange={setHero} />\n </Field>\n <Field label={t(\"entry\")}>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n justifyContent: \"center\",\n }}\n >\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() => setAmount(clampEntry(amount - 10, rule))}\n >\n −\n </Button>\n <ResourceList lines={entryLine} size={26} dimIf={() => !affordable} />\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() => setAmount(clampEntry(amount + 10, rule))}\n >\n +\n </Button>\n </div>\n </Field>\n {rule.currency ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 6,\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n {t(\"winnerTakes\")}{\" \"}\n <ResourceList\n lines={[\n {\n kind: \"currency\",\n id: rule.currency,\n amount: winnerTakes(amount, rule.burnRate),\n },\n ]}\n size={18}\n />\n </div>\n ) : null}\n <Button\n tone=\"gold\"\n disabled={!affordable}\n busy={busy}\n onClick={() => void create()}\n >\n {t(\"create\")}\n </Button>\n </Popup>\n );\n}\n\nfunction JoinPopup({\n match,\n rule,\n heroes,\n onClose,\n onFought,\n}: {\n match: PvPMatch;\n rule: DuelRule;\n heroes: Hero[];\n onClose: () => void;\n onFought: (r: InstantBattleResponse) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const [hero, setHero] = useState(heroes[0]!.id);\n const [busy, setBusy] = useState(false);\n const e = matchEntry(match);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const entryLine = e.currency\n ? [{ kind: \"currency\" as const, id: e.currency, amount: e.amount }]\n : [];\n const affordable = canAfford(entryLine, balances);\n const fight = async () => {\n setBusy(true);\n const saved = client.data.user.state?.Match?.PvPBattleStrategy;\n const res = await client.match.instantBattle(\n match.MatchID,\n match.RuleID ?? rule.id,\n hero,\n saved ?? undefined,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n onFought(res.data);\n };\n return (\n <Popup\n title={`${rule.name} · ${t(\"player\")} ${shortID(match.CreatorID)}`}\n onClose={onClose}\n width={440}\n >\n <Field label={t(\"hero\")}>\n <HeroPicker heroes={heroes} value={hero} onChange={setHero} />\n </Field>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"entry\")}\n </div>\n <ResourceList lines={entryLine} size={28} dimIf={() => !affordable} />\n {e.currency ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n {t(\"winnerTakes\")}{\" \"}\n <ResourceList\n lines={[\n {\n kind: \"currency\",\n id: e.currency,\n amount: winnerTakes(e.amount, rule.burnRate),\n },\n ]}\n size={18}\n />\n </div>\n ) : null}\n </div>\n <Button\n tone=\"green\"\n attract={affordable}\n disabled={!affordable}\n busy={busy}\n onClick={() => void fight()}\n data-tutorial-anchor=\"pvp:fight\"\n >\n <Icon glyph=\"swords\" size={18} /> {t(\"fight\")}\n </Button>\n </Popup>\n );\n}\n\nconst ZONE_LABEL: Record<Zone, Parameters<typeof t>[0]> = {\n Head: \"head\",\n Torso: \"torso\",\n Legs: \"legs\",\n};\n\nfunction StrategyPopup({ onClose }: { onClose: () => void }): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [steps, setSteps] = useState<Step[]>(() =>\n strategyOrDefault(client.data.user.state?.Match?.PvPBattleStrategy),\n );\n const [busy, setBusy] = useState(false);\n const set = (i: number, key: keyof Step, zone: Zone) =>\n setSteps((s) => s.map((st, j) => (j === i ? { ...st, [key]: zone } : st)));\n const save = async () => {\n setBusy(true);\n const res = await client.match.saveStrategy(steps);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"saved\"));\n onClose();\n };\n const zoneTabs = ZONES.map((z) => ({ id: z, label: t(ZONE_LABEL[z]) }));\n return (\n <Popup title={t(\"strategy\")} onClose={onClose} width={460}>\n <div style={{ display: \"grid\", gap: 10 }}>\n {steps.map((s, i) => (\n <div\n key={i}\n style={{ ...panel, padding: 10, display: \"grid\", gap: 6 }}\n >\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span>\n {t(\"round\")} {i + 1}\n </span>\n {steps.length > 1 ? (\n <button\n type=\"button\"\n onClick={() => setSteps((st) => st.filter((_, j) => j !== i))}\n style={{\n background: \"none\",\n border: \"none\",\n color: v.textDim,\n cursor: \"pointer\",\n }}\n >\n ✕\n </button>\n ) : null}\n </div>\n <div style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"attack\")}\n </div>\n <Tabs\n tabs={zoneTabs}\n value={s.AttackTarget}\n onChange={(z) => set(i, \"AttackTarget\", z)}\n />\n <div style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"defense\")}\n </div>\n <Tabs\n tabs={zoneTabs}\n value={s.DefenseTarget}\n onChange={(z) => set(i, \"DefenseTarget\", z)}\n />\n </div>\n ))}\n {steps.length < 10 ? (\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() =>\n setSteps((s) => [\n ...s,\n { AttackTarget: \"Head\", DefenseTarget: \"Torso\" },\n ])\n }\n >\n {t(\"addRound\")}\n </Button>\n ) : null}\n <Button tone=\"green\" busy={busy} onClick={() => void save()}>\n {t(\"save\")}\n </Button>\n </div>\n </Popup>\n );\n}\n\nconst HIT_TEXT = {\n Hit: \"hHit\",\n Critical: \"hCritical\",\n Block: \"hBlock\",\n Dodge: \"hDodge\",\n} as const;\n\nfunction ResultPopup({\n result,\n onClose,\n}: {\n result: InstantBattleResponse;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const kit = useUiKit();\n const me = client.auth.context?.userID;\n const outcome = outcomeFor(result.Battle, me);\n const rounds = roundLines(result.Battle?.BattleLog, me);\n const stagger = useStagger();\n const [shown, setShown] = useState(0);\n const reward =\n result.Battle?.NetReward?.Entries?.filter((e) => e?.CurrencyID).map(\n (e) => ({\n kind: \"currency\" as const,\n id: e!.CurrencyID!,\n amount: Number(e!.Amount ?? 0),\n }),\n ) ?? [];\n useEffect(() => {\n kit.play(\n outcome === \"win\" ? \"levelUp\" : outcome === \"lose\" ? \"error\" : \"notify\",\n );\n }, []);\n // Rounds appear one by one — a short replay of the fight.\n const next = useCallback(\n () => setShown((n) => Math.min(rounds.length, n + 1)),\n [rounds.length],\n );\n useEffect(() => {\n if (shown >= rounds.length) return;\n const id = setTimeout(next, 220);\n return () => clearTimeout(id);\n }, [shown, next, rounds.length]);\n return (\n <Popup\n title={\n outcome === \"win\"\n ? t(\"won\")\n : outcome === \"lose\"\n ? t(\"lost\")\n : t(\"draw\")\n }\n onClose={onClose}\n width={460}\n variant=\"center\"\n >\n <div\n className=\"idos-bounce\"\n style={{ display: \"grid\", justifyItems: \"center\" }}\n >\n <Icon glyph={outcome === \"win\" ? \"trophy\" : \"swords\"} size={64} />\n </div>\n {outcome === \"win\" && reward.length > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <ResourceList lines={reward} size={30} />\n </div>\n ) : null}\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"rounds\")}\n </div>\n <div\n className=\"idos-scroll\"\n style={{ display: \"grid\", gap: 4, maxHeight: 260, overflowY: \"auto\" }}\n >\n {rounds.slice(0, shown).map((r, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: \"6px 10px\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n fontSize: 13,\n borderColor: r.mine ? v.green : v.red,\n ...stagger(0),\n }}\n >\n <span style={{ ...outlined, minWidth: 28 }}>{r.round}</span>\n <span style={{ ...outlined, color: r.mine ? v.green : v.red }}>\n {r.mine ? t(\"you\") : t(\"foe\")}\n </span>\n <span style={{ flex: 1 }}>\n → {t(ZONE_LABEL[r.zone])}, {t(HIT_TEXT[r.hit])}\n </span>\n <span style={{ ...outlined }}>−{r.damage}</span>\n <span\n style={{ color: v.textDim, minWidth: 54, textAlign: \"right\" }}\n >\n {r.hpLeft} {t(\"hp\")}\n </span>\n </div>\n ))}\n </div>\n <Button tone=\"blue\" onClick={onClose}>\n OK\n </Button>\n </Popup>\n );\n}\n\nfunction Field({\n label,\n children,\n}: {\n label: string;\n children: ReactNode;\n}): ReactNode {\n return (\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>{label}</div>\n {children}\n </div>\n );\n}\n"
66
+ },
67
+ {
68
+ "path": "module.meta.json",
69
+ "content": "{\n \"id\": \"pvp-ui\",\n \"type\": \"feature\",\n \"summary\": \"PvP duels for any game: open duels to join, your duels, creating a duel for coins, a zone strategy and the fight replayed round by round.\",\n \"description\": \"A ready PvP arena (Unity Alikhan/PVP/Lobby) for ANY game, built on the @idosgames/react/ui kit, client.match and client.character. Tabs: open duels (join with one of your heroes) and your duels (waiting — cancel returns the entry; finished — won / lost / draw). Popups: a new duel (rule, hero by power, entry within the rule's bounds and what the winner takes after the burn), the battle strategy (attack and defence zone — head, body, legs — per round, saved as the default) and the result, replayed round by round (whose blow, where, hit / critical / block / dodge, damage, HP left). The server fights the battle instantly from the heroes' stats; a draw returns the entries. Without a hero the screen offers the Heroes feature. Registers the 'pvp' feature. Ships title-seed.json: a coin duel (10–500, 10% burned) fought by the showcase heroes' Attack and Health.\",\n \"provides\": [\n \"open duels to join\",\n \"my duels with cancel\",\n \"create a duel with an entry\",\n \"zone battle strategy\",\n \"fight result replayed round by round\",\n \"demo duel rule\"\n ],\n \"tags\": [\"pvp\", \"duel\", \"arena\", \"battle\", \"matchmaking\", \"ui\", \"screen\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
70
+ }
71
+ ]
72
+ }
@@ -0,0 +1,73 @@
1
+ {
2
+ "id": "referral-ui",
3
+ "meta": {
4
+ "name": "Invite",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "catalog": {
9
+ "type": "feature",
10
+ "summary": "Invite friends for any game: your code and link to share, friends who came by it, rewards for them, and entering a friend's code in a popup.",
11
+ "description": "A ready invite-a-friend screen (Unity V2/Runtime/UI/Referral) for ANY game, built on the @idosgames/react/ui kit and client.referral. The player's own short code (minted by the server when the screen opens) with 'Copy' and 'Share link' (the system share sheet, else the clipboard), how many friends came by it and the reward ladder for them (claim one or all). Entering a friend's code is a popup: it shows the newcomer's gift, reads the code the way the server does (8 letters, separators and case ignored) and pays the gift once. A player bound at login by an invite link sees 'You joined by a friend's code'. Registers the 'invite' feature; its badge counts invite rewards ready. Ships title-seed.json: a gift for the newcomer and rewards for 1, 3 and 5 friends.",
12
+ "provides": [
13
+ "own invite code and link",
14
+ "share or copy the invite",
15
+ "friends who came by your code",
16
+ "invite reward ladder with claim all",
17
+ "enter a friend's code popup",
18
+ "invite badge",
19
+ "demo referral rewards"
20
+ ],
21
+ "tags": [
22
+ "referral",
23
+ "invite",
24
+ "friends",
25
+ "growth",
26
+ "social",
27
+ "ui",
28
+ "screen"
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.0",
38
+ "@idosgames/module-sdk": "0.5.0",
39
+ "@idosgames/react": "0.3.0",
40
+ "react": "19.2.7"
41
+ },
42
+ "contentHash": "f78e3211a36e341d0e6a338b21b82d3b0084b975a4157eaee80ebea2e814e396",
43
+ "files": [
44
+ {
45
+ "path": "i18n.ts",
46
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Invite\",\n disabled: \"Invites are turned off in this game.\",\n yourCode: \"Your invite code\",\n copyCode: \"Copy code\",\n share: \"Share link\",\n copied: \"Copied\",\n joined: \"Friends who came by your code\",\n rewards: \"Rewards for friends\",\n friends: \"friend|friends\",\n claim: \"Claim\",\n claimAll: \"Claim all\",\n claimed: \"Claimed\",\n enterCode: \"Enter a friend's code\",\n enterHint: \"Got a code from a friend? Enter it and you both get a gift.\",\n youGet: \"You get\",\n activate: \"Activate\",\n badCode: \"A code is 8 letters, like WDJB-MJHT\",\n activated: \"Code activated!\",\n youCameBy: \"You joined by a friend's code\",\n inviteText: \"Join me in the game! My code:\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Пригласить\",\n disabled: \"Приглашения в этой игре выключены.\",\n yourCode: \"Ваш код приглашения\",\n copyCode: \"Скопировать код\",\n share: \"Поделиться ссылкой\",\n copied: \"Скопировано\",\n joined: \"Пришли по вашему коду\",\n rewards: \"Награды за друзей\",\n friends: \"друг|друга|друзей\",\n claim: \"Забрать\",\n claimAll: \"Забрать всё\",\n claimed: \"Получено\",\n enterCode: \"Ввести код друга\",\n enterHint: \"Друг дал вам код? Введите его — подарок получите вы оба.\",\n youGet: \"Вы получите\",\n activate: \"Активировать\",\n badCode: \"Код — это 8 букв, например WDJB-MJHT\",\n activated: \"Код активирован!\",\n youCameBy: \"Вы пришли по коду друга\",\n inviteText: \"Заходи в игру! Мой код:\",\n});\n"
47
+ },
48
+ {
49
+ "path": "index.ts",
50
+ "content": "// @idosgames/mod-referral-ui — Invite friends for any game: your code and link, entering a friend's code, and rewards for friends who joined.\n\nexport { referralUiModule } from \"./module\";\n"
51
+ },
52
+ {
53
+ "path": "model.test.ts",
54
+ "content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport { inviteRows, normalizeCode, prettyCode, referralBadge } from \"./model\";\n\nconst defs = (\n seed as unknown as {\n Referral: { IsEnabled: boolean; InviteRewards: Record<string, never> };\n }\n).Referral;\n\ndescribe(\"invite\", () => {\n it(\"the seeded ladder: 1, 3 and 5 friends\", () => {\n expect(defs.IsEnabled).toBe(true);\n const rows = inviteRows(defs.InviteRewards, { FollowersCount: 0 });\n expect(rows.map((r) => r.required)).toEqual([1, 3, 5]);\n expect(rows[0]!.reward).toEqual([\n { kind: \"currency\", id: \"GE\", amount: 10 },\n ]);\n expect(rows.every((r) => r.state === \"locked\")).toBe(true);\n });\n\n it(\"friends who came open steps; a claimed step stays claimed\", () => {\n const rows = inviteRows(defs.InviteRewards, {\n FollowersCount: 3,\n InviteRewardStates: { invite_1: { IsClaimed: true } },\n });\n expect(rows.map((r) => r.state)).toEqual([\"claimed\", \"ready\", \"locked\"]);\n expect(referralBadge(rows)).toBe(1);\n });\n\n it(\"reads a typed code the way the server does: separators dropped, upper case, its alphabet only\", () => {\n expect(normalizeCode(\"wdjb-mjht\")).toBe(\"WDJBMJHT\");\n expect(normalizeCode(\" WDJB MJHT \")).toBe(\"WDJBMJHT\");\n expect(normalizeCode(\"WDJBMJH\")).toBeNull();\n expect(normalizeCode(\"ABCDEFGH\")).toBeNull(); // vowels are not in the alphabet\n expect(normalizeCode(\"B0CDFGHJ\")).toBeNull();\n expect(prettyCode(\"WDJBMJHT\")).toBe(\"WDJB-MJHT\");\n });\n});\n"
55
+ },
56
+ {
57
+ "path": "model.ts",
58
+ "content": "// Pure helpers of the invite screen (Unity V2/Runtime/UI/Referral): the code a player types, the\n// ladder of rewards for friends who came by your code, and the badge. The server validates codes,\n// counts friends and pays — this only decides what the screen shows. Covered by model.test.ts.\n\nimport { grantLines, type ResourceLine } from \"@idosgames/react/ui\";\n\n/** The code alphabet: no vowels, no 0/O, no 1/I/L — so a code read aloud is typed right. */\nconst CODE_ALPHABET = /^[BCDFGHJKMNPQRSTVWXZ]{8}$/;\n\n/** A code as the player typed it: separators dropped, upper case; `null` when it can't be a code. */\nexport function normalizeCode(input: string): string | null {\n const code = input.replace(/[\\s\\-_.]/g, \"\").toUpperCase();\n return CODE_ALPHABET.test(code) ? code : null;\n}\n\n/** \"WDJB-MJHT\" — easier to read out and copy by eye. */\nexport function prettyCode(code: string | null | undefined): string {\n if (!code) return \"\";\n return code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;\n}\n\nexport interface InviteRow {\n id: string;\n name: string | null;\n required: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"locked\";\n}\n\nexport function inviteRows(\n rewards:\n | Record<\n string,\n {\n MilestoneID?: string | null;\n DisplayName?: string | null;\n RequiredProgress?: number | null;\n Rewards?: unknown;\n } | null\n >\n | null\n | undefined,\n state:\n | {\n FollowersCount?: number | null;\n InviteRewardStates?: Record<\n string,\n { IsClaimed?: boolean | null } | null\n > | null;\n }\n | null\n | undefined,\n): InviteRow[] {\n const friends = Number(state?.FollowersCount ?? 0);\n return Object.entries(rewards ?? {})\n .filter(([, m]) => m)\n .map(([key, m]) => {\n const id = m!.MilestoneID || key;\n const required = Number(m!.RequiredProgress ?? 0);\n const claimed = state?.InviteRewardStates?.[id]?.IsClaimed === true;\n return {\n id,\n name: m!.DisplayName ?? null,\n required,\n reward: grantLines(m!.Rewards as never),\n state: claimed ? \"claimed\" : friends >= required ? \"ready\" : \"locked\",\n } satisfies InviteRow;\n })\n .sort((a, b) => a.required - b.required || a.id.localeCompare(b.id));\n}\n\n/** Badge: invite rewards ready to take. */\nexport function referralBadge(rows: InviteRow[]): number {\n return rows.filter((r) => r.state === \"ready\").length;\n}\n"
59
+ },
60
+ {
61
+ "path": "module.ts",
62
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makeReferralScreen, makeReferralWatcher } from \"./ReferralScreen\";\nimport { t } from \"./i18n\";\n\n// Invite friends as a feature of the game. The watcher sets the badge (invite rewards ready) once\n// on login and whenever the cached referral state changes.\nexport const referralUiModule: Module = defineModule({\n id: \"referral-ui\",\n meta: { name: \"Invite\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"invite\",\n label: t(\"title\"),\n icon: \"link\",\n group: \"social\",\n order: 90,\n Screen: makeReferralScreen(ctx.features),\n });\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeReferralWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"invite\" }),\n describeActions: {},\n });\n },\n});\n"
63
+ },
64
+ {
65
+ "path": "ReferralScreen.tsx",
66
+ "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ReferralDefinitions } 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 ResourceList,\n Spinner,\n errorText,\n formatAmount,\n grantLines,\n grantedBy,\n outlined,\n panel,\n plural,\n useCelebrate,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n inviteRows,\n normalizeCode,\n prettyCode,\n referralBadge,\n type InviteRow,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Invite friends (Unity V2/Runtime/UI/Referral): the player's own code and link to share, how many\n// friends came by it and the reward ladder for them. Entering a friend's code is a popup (it pays\n// the newcomer's gift). Most players arrive bound already — the SDK sends ?ref= with the login.\n\ntype ReferralState = {\n Code?: string | null;\n SubscribedToUserID?: string | null;\n FollowersCount?: number | null;\n InviteRewardStates?: Record<\n string,\n { IsClaimed?: boolean | null } | null\n > | null;\n};\n\n/** Badge on login: invite rewards ready (definitions + state are one read each, once). */\nexport function makeReferralWatcher(features: FeatureRegistry) {\n return function ReferralWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const defs = useRef<ReferralDefinitions | null>(null);\n useEffect(() => {\n void client.referral.getDefinitions().then((r) => {\n defs.current = r.ok ? (r.data.ReferralDefinitions ?? null) : null;\n features.setBadge(\n \"invite\",\n referralBadge(\n inviteRows(\n defs.current?.InviteRewards,\n client.data.user.state?.Referral as ReferralState | undefined,\n ),\n ),\n );\n });\n }, [client]);\n useEffect(() => {\n if (defs.current)\n features.setBadge(\n \"invite\",\n referralBadge(\n inviteRows(\n defs.current.InviteRewards,\n state?.Referral as ReferralState | undefined,\n ),\n ),\n );\n }, [state?.Referral]);\n return null;\n };\n}\n\nexport function makeReferralScreen(_features: FeatureRegistry) {\n return function ReferralScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const stagger = useStagger();\n const [defs, setDefs] = useState<ReferralDefinitions | null | undefined>(\n undefined,\n );\n const [me, setMe] = useState<ReferralState | null>(null);\n const [url, setUrl] = useState<string | null>(null);\n const [entering, setEntering] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n\n // getUserState is not a pure read: it mints the code lazily and settles a pending gift —\n // so it runs every time the screen opens.\n const load = useCallback(async () => {\n const [d, s] = await Promise.all([\n client.referral.getDefinitions(),\n client.referral.getUserState(),\n ]);\n setDefs(d.ok ? (d.data.ReferralDefinitions ?? null) : null);\n if (s.ok) {\n setMe((s.data.Referral as ReferralState | null) ?? null);\n setUrl(s.data.InviteUrl ?? null);\n }\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n if (defs === undefined)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (!defs || defs.IsEnabled === false)\n return <EmptyState glyph=\"link\" text={t(\"disabled\")} />;\n\n const rows = inviteRows(defs.InviteRewards, me);\n const ready = rows.filter((r) => r.state === \"ready\");\n\n const copy = (text: string) =>\n void navigator.clipboard?.writeText(text).then(() => toast(t(\"copied\")));\n const share = async () => {\n const link = url ?? \"\";\n const text = `${t(\"inviteText\")} ${prettyCode(me?.Code)}`;\n if (navigator.share) {\n try {\n await navigator.share({ text, url: link || undefined });\n return;\n } catch {\n // The player closed the share sheet — fall back to copying.\n }\n }\n copy(link ? `${text} ${link}` : text);\n };\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.referral.claimInviteRewardsBatch(\n ready.map((r) => r.id),\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : ready.flatMap((r) => r.reward));\n await load();\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Card\n style={{\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n background: \"linear-gradient(180deg, #2fb36b 0%, #146b3d 100%)\",\n }}\n >\n <div style={{ ...outlined, fontSize: 14 }}>{t(\"yourCode\")}</div>\n <div\n className=\"idos-bounce\"\n style={{\n ...outlined,\n fontSize: 34,\n letterSpacing: 3,\n fontFamily: \"monospace\",\n }}\n >\n {me?.Code ? prettyCode(me.Code) : \"…\"}\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n <Button\n size=\"sm\"\n tone=\"blue\"\n disabled={!me?.Code}\n onClick={() => me?.Code && copy(me.Code)}\n >\n <Icon glyph=\"copy\" size={16} /> {t(\"copyCode\")}\n </Button>\n <Button\n size=\"sm\"\n tone=\"gold\"\n attract\n disabled={!me?.Code}\n onClick={() => void share()}\n data-tutorial-anchor=\"invite:share\"\n >\n <Icon glyph=\"link\" size={16} /> {t(\"share\")}\n </Button>\n </div>\n </Card>\n\n <div\n style={{\n ...panel,\n padding: 12,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon glyph=\"friends\" size={30} />\n <div style={{ flex: 1, ...outlined, fontSize: 14 }}>\n {t(\"joined\")}\n </div>\n <div style={{ ...outlined, fontSize: 24, color: v.gold }}>\n {formatAmount(Number(me?.FollowersCount ?? 0))}\n </div>\n </div>\n\n {rows.length > 0 ? (\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"rewards\")}\n </div>\n ) : null}\n {ready.length > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n >\n {t(\"claimAll\")} · {ready.length}\n </Button>\n ) : null}\n <div style={{ display: \"grid\", gap: 8 }}>\n {rows.map((row, i) => (\n <InviteCard\n key={row.id}\n row={row}\n onClaimed={load}\n style={stagger(i)}\n />\n ))}\n </div>\n\n {me?.SubscribedToUserID ? (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 13,\n color: v.green,\n }}\n >\n <Icon glyph=\"check\" size={20} /> {t(\"youCameBy\")}\n </div>\n ) : (\n <Button\n tone=\"blue\"\n onClick={() => setEntering(true)}\n data-tutorial-anchor=\"invite:enter\"\n >\n {t(\"enterCode\")}\n </Button>\n )}\n {entering ? (\n <EnterCodePopup\n defs={defs}\n onClose={() => setEntering(false)}\n onDone={load}\n />\n ) : null}\n </div>\n );\n };\n}\n\nfunction InviteCard({\n row,\n onClaimed,\n style,\n}: {\n row: InviteRow;\n onClaimed: () => Promise<void>;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const claim = async () => {\n setBusy(true);\n const res = await client.referral.claimInviteReward(row.id);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : row.reward);\n await onClaimed();\n };\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n border: row.state === \"ready\" ? `2px solid ${v.gold}` : panel.border,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", minWidth: 52 }}>\n <Icon glyph={row.state === \"locked\" ? \"lock\" : \"friends\"} size={24} />\n <span style={{ ...outlined, fontSize: 12 }}>\n {row.required} {plural(row.required, t(\"friends\"))}\n </span>\n </div>\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 4 }}>\n {row.name ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {row.name}\n </div>\n ) : null}\n <ResourceList lines={row.reward} size={26} />\n </div>\n {row.state === \"ready\" ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n >\n {t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <Icon glyph=\"check\" size={22} />\n ) : null}\n </div>\n );\n}\n\nfunction EnterCodePopup({\n defs,\n onClose,\n onDone,\n}: {\n defs: ReferralDefinitions;\n onClose: () => void;\n onDone: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const code = normalizeCode(value);\n const gift = grantLines(defs.ActivationReward);\n const activate = async () => {\n if (!code) return;\n setBusy(true);\n const res = await client.referral.activateReferralCode(code);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"activated\"), \"success\");\n const got = grantedBy(res.data);\n if (res.data.IsFirstActivation) celebrate(got.length > 0 ? got : gift);\n await onDone();\n onClose();\n };\n return (\n <Popup title={t(\"enterCode\")} onClose={onClose} width={420}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"enterHint\")}\n </div>\n {gift.length > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"youGet\")}\n </div>\n <ResourceList lines={gift} size={32} gap={12} />\n </div>\n ) : null}\n <input\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder=\"WDJB-MJHT\"\n autoFocus\n maxLength={12}\n onKeyDown={(e) => e.key === \"Enter\" && void activate()}\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 22,\n letterSpacing: 3,\n textAlign: \"center\",\n textTransform: \"uppercase\",\n color: v.text,\n outline: \"none\",\n fontFamily: \"monospace\",\n }}\n />\n {value && !code ? (\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.red,\n textAlign: \"center\",\n }}\n >\n {t(\"badCode\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!code}\n busy={busy}\n onClick={() => void activate()}\n >\n {t(\"activate\")}\n </Button>\n </Popup>\n );\n}\n"
67
+ },
68
+ {
69
+ "path": "module.meta.json",
70
+ "content": "{\n \"id\": \"referral-ui\",\n \"type\": \"feature\",\n \"summary\": \"Invite friends for any game: your code and link to share, friends who came by it, rewards for them, and entering a friend's code in a popup.\",\n \"description\": \"A ready invite-a-friend screen (Unity V2/Runtime/UI/Referral) for ANY game, built on the @idosgames/react/ui kit and client.referral. The player's own short code (minted by the server when the screen opens) with 'Copy' and 'Share link' (the system share sheet, else the clipboard), how many friends came by it and the reward ladder for them (claim one or all). Entering a friend's code is a popup: it shows the newcomer's gift, reads the code the way the server does (8 letters, separators and case ignored) and pays the gift once. A player bound at login by an invite link sees 'You joined by a friend's code'. Registers the 'invite' feature; its badge counts invite rewards ready. Ships title-seed.json: a gift for the newcomer and rewards for 1, 3 and 5 friends.\",\n \"provides\": [\n \"own invite code and link\",\n \"share or copy the invite\",\n \"friends who came by your code\",\n \"invite reward ladder with claim all\",\n \"enter a friend's code popup\",\n \"invite badge\",\n \"demo referral rewards\"\n ],\n \"tags\": [\"referral\", \"invite\", \"friends\", \"growth\", \"social\", \"ui\", \"screen\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
71
+ }
72
+ ]
73
+ }