@idosgames/mcp 0.1.19 → 0.1.21
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 +13 -13
- package/registry/index.json +474 -147
- package/registry/modules/ai.json +65 -0
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/boosts.json +66 -0
- package/registry/modules/character.json +7 -8
- package/registry/modules/chat.json +7 -8
- package/registry/modules/collections.json +69 -0
- package/registry/modules/coop-events.json +67 -0
- package/registry/modules/craft.json +7 -8
- package/registry/modules/deals.json +7 -8
- package/registry/modules/events.json +10 -15
- package/registry/modules/friends.json +8 -8
- package/registry/modules/idle-rpg.json +4 -4
- package/registry/modules/inventory.json +16 -19
- package/registry/modules/invite.json +9 -9
- package/registry/modules/leaderboard.json +7 -8
- package/registry/modules/lootboxes.json +67 -0
- package/registry/modules/mailbox.json +71 -0
- package/registry/modules/marketplace.json +7 -8
- package/registry/modules/mini-game.json +7 -7
- package/registry/modules/multiplayer.json +74 -0
- package/registry/modules/premium.json +67 -0
- package/registry/modules/profile.json +11 -12
- package/registry/modules/pvp.json +5 -6
- package/registry/modules/quests.json +69 -0
- package/registry/modules/rewards.json +17 -23
- package/registry/modules/season.json +10 -15
- package/registry/modules/store.json +7 -8
- package/registry/modules/tutorials.json +7 -8
- package/registry/modules/voxelcraft.json +16 -12
- package/registry/modules/workshop.json +7 -8
- 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-project-structure.json +1 -1
- 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,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "multiplayer",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "Multiplayer",
|
|
5
|
+
"type": "app",
|
|
6
|
+
"engine": "dom"
|
|
7
|
+
},
|
|
8
|
+
"catalog": {
|
|
9
|
+
"type": "feature",
|
|
10
|
+
"summary": "The rooms system for any game: open rooms of the app's online games, public or private rooms by invite, members, ready and chat — the host takes the whole room into a game.",
|
|
11
|
+
"description": "A ready multiplayer lobby (engine Multiplayer) for ANY game, built on the @idosgames/react/ui kit, client.multiplayer and RealtimeSession (@idosgames/core/realtime). It OWNS the player's room and its WebRTC session: the rooms of the games this app plays online (each room remembers its game), 'Create a room' for one of them (name, 2/4/8 players, public or private with an invite code, the game's own room settings), joining by invite, members (host, ready, offline), 'Ready', the room chat (server relay, when the game has no chat of its own), removing a member and leaving. The host's 'Play' takes the whole room into the game: every device switches to the game's mode and the game gets the room in its start() — games never create, list or join rooms themselves. A game plays online by registering in ctx.multiplayer (registerGame: topology, chat mode, room size, joinInProgress for late joiners, room settings, start(room)); the lobby then draws 'With friends' next to its 'Play'. Opened with { mode } the screen shows one game's rooms, with { mode, create: true } straight on 'Create'. Registers the 'multiplayer' feature, shown while multiplayer is on for the title and the app has a game that plays online. Ships title-seed.json: multiplayer on, up to 8 players per room.",
|
|
12
|
+
"provides": [
|
|
13
|
+
"multiplayer rooms lobby",
|
|
14
|
+
"rooms of one game",
|
|
15
|
+
"public and private rooms by invite",
|
|
16
|
+
"room settings of the game",
|
|
17
|
+
"room members with ready and host",
|
|
18
|
+
"room chat",
|
|
19
|
+
"host starts the game for the whole room",
|
|
20
|
+
"late joiners into a running game"
|
|
21
|
+
],
|
|
22
|
+
"tags": [
|
|
23
|
+
"multiplayer",
|
|
24
|
+
"rooms",
|
|
25
|
+
"lobby",
|
|
26
|
+
"realtime",
|
|
27
|
+
"social",
|
|
28
|
+
"ui",
|
|
29
|
+
"screen"
|
|
30
|
+
],
|
|
31
|
+
"author": {
|
|
32
|
+
"name": "iDos Games",
|
|
33
|
+
"url": "https://idosgames.com"
|
|
34
|
+
},
|
|
35
|
+
"version": "0.1.1"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@idosgames/core": "0.19.1",
|
|
39
|
+
"@idosgames/module-sdk": "0.8.0",
|
|
40
|
+
"@idosgames/react": "0.4.0",
|
|
41
|
+
"react": "19.2.7"
|
|
42
|
+
},
|
|
43
|
+
"contentHash": "14799b9981c650f938ddccbe51d243f5f6aeaf53105d7bd2536dffe6eabf818b",
|
|
44
|
+
"files": [
|
|
45
|
+
{
|
|
46
|
+
"path": "i18n.ts",
|
|
47
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Multiplayer\",\n create: \"Create a room\",\n byCode: \"By invite\",\n open: \"Open rooms\",\n roomsOf: \"Rooms:\",\n noRooms: \"No open rooms — create one and invite friends.\",\n noGames: \"No game here plays online yet.\",\n room: \"Room\",\n roomName: \"Room name\",\n invitePlaceholder: \"Invite: room ID or ID:code\",\n invite: \"Invite\",\n copied: \"Copied!\",\n players: \"Players\",\n join: \"Join\",\n public: \"Anyone can join\",\n private: \"Private — by invite\",\n members: \"In the room\",\n you: \"you\",\n player: \"Player\",\n kick: \"Remove\",\n ready: \"Ready\",\n notReady: \"Not ready\",\n waitHost: \"The host starts the game.\",\n play: \"Play\",\n backToGame: \"Back to the game\",\n inProgress: \"The game in this room has already started.\",\n noGame: \"This app does not have the room's game.\",\n startFailed: \"The game could not start the room.\",\n message: \"Message\",\n send: \"Send\",\n noChat: \"Say hi to the room!\",\n leave: \"Leave the room\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Мультиплеер\",\n create: \"Создать комнату\",\n byCode: \"По приглашению\",\n open: \"Открытые комнаты\",\n roomsOf: \"Комнаты:\",\n noRooms: \"Открытых комнат нет — создайте свою и позовите друзей.\",\n noGames: \"Здесь пока нет игр для игры по сети.\",\n room: \"Комната\",\n roomName: \"Название комнаты\",\n invitePlaceholder: \"Приглашение: ID комнаты или ID:код\",\n invite: \"Приглашение\",\n copied: \"Скопировано!\",\n players: \"Игроков\",\n join: \"Войти\",\n public: \"Может войти любой\",\n private: \"Закрытая — по приглашению\",\n members: \"В комнате\",\n you: \"вы\",\n player: \"Игрок\",\n kick: \"Убрать\",\n ready: \"Готов\",\n notReady: \"Не готов\",\n waitHost: \"Игру запускает хост.\",\n play: \"Играть\",\n backToGame: \"Вернуться в игру\",\n inProgress: \"Игра в этой комнате уже началась.\",\n noGame: \"В этом приложении нет игры этой комнаты.\",\n startFailed: \"Игра не смогла принять комнату.\",\n message: \"Сообщение\",\n send: \"Отправить\",\n noChat: \"Поздоровайтесь с комнатой!\",\n leave: \"Выйти из комнаты\",\n});\n"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"path": "index.ts",
|
|
51
|
+
"content": "// @idosgames/mod-multiplayer — The rooms system for any game: rooms of the app's online games, public or private rooms by invite, members, ready and chat - the host takes the whole room into a game.\n\nexport { multiplayerModule } from \"./module\";\n"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"path": "model.ts",
|
|
55
|
+
"content": "// Pure helpers of the rooms system — covered by model.test.ts. No React, no network.\n\nimport type { MultiplayerGame } from \"@idosgames/module-sdk\";\n\n/** The title's Multiplayer section, as far as the rooms system cares. */\nexport interface MultiplayerSection {\n SystemState?: { Enabled?: boolean | null } | null;\n MaxPlayersPerRoom?: number | null;\n MemberTimeoutMs?: number | null;\n}\n\nexport interface RoomMember {\n UserID?: string | null;\n Username?: string | null;\n Ready?: boolean | null;\n IsHost?: boolean | null;\n LastSeenMs?: number | null;\n State?: Record<string, string> | null;\n}\n\nexport interface MemberRow {\n userID: string;\n name: string;\n host: boolean;\n ready: boolean;\n online: boolean;\n me: boolean;\n}\n\n/** The room's members: host first, then by name; a member silent past the timeout is offline. */\nexport function memberRows(\n members: Iterable<RoomMember> | null | undefined,\n me: string | null | undefined,\n hostId: string | null | undefined,\n nowMs: number,\n timeoutMs: number,\n): MemberRow[] {\n return [...(members ?? [])]\n .filter((m): m is RoomMember & { UserID: string } => !!m?.UserID)\n .map((m) => ({\n userID: m.UserID,\n name: m.Username || m.UserID,\n host: hostId ? m.UserID === hostId : m.IsHost === true,\n ready: m.Ready === true,\n online: !m.LastSeenMs || nowMs - m.LastSeenMs <= timeoutMs,\n me: m.UserID === me,\n }))\n .sort(\n (a, b) => Number(b.host) - Number(a.host) || a.name.localeCompare(b.name),\n );\n}\n\n// ── The host's member state: the game's settings and \"the game started\" ─────────────────────\n//\n// The server keeps a small string map per member. The host's map carries the room's settings\n// (`set:<id>`) from the moment the room is created, and `start` once \"Play\" is pressed — so a player\n// who joins later reads both from the snapshot, and a guest who missed the DataChannel message still\n// learns about the start from the next member refresh.\n\nexport const START_KEY = \"start\";\nconst SETTING_PREFIX = \"set:\";\n\n/** The room's settings as the host's member state stores them. */\nexport function settingsToState(\n settings: Record<string, string | boolean>,\n): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [id, value] of Object.entries(settings))\n out[SETTING_PREFIX + id] =\n typeof value === \"boolean\" ? (value ? \"1\" : \"0\") : value;\n return out;\n}\n\n/** The room's settings read back from the host's member state, defaults filled in from the game. */\nexport function settingsFromState(\n game: Pick<MultiplayerGame, \"settings\"> | undefined,\n state: Record<string, string> | null | undefined,\n): Record<string, string | boolean> {\n const out: Record<string, string | boolean> = {};\n for (const field of game?.settings ?? []) {\n const raw = state?.[SETTING_PREFIX + field.id];\n out[field.id] =\n raw === undefined\n ? field.default\n : field.kind === \"toggle\"\n ? raw === \"1\"\n : field.options?.some((o) => o.value === raw)\n ? raw\n : field.default;\n }\n return out;\n}\n\n/** Default settings of a game (what \"Create a room\" starts with). */\nexport function defaultSettings(\n game: Pick<MultiplayerGame, \"settings\"> | undefined,\n): Record<string, string | boolean> {\n return settingsFromState(game, null);\n}\n\n/** The game mode the host started for the room (null — still in the lobby). */\nexport function startedMode(\n host: RoomMember | null | undefined,\n): string | null {\n return host?.State?.[START_KEY] || null;\n}\n\n// ── The lobby's own DataChannel message ──────────────────────────────────────────────────────\n//\n// \"Play\" reaches guests at once over the links the lobby already opened (the member-state poll of a\n// guest is slow on purpose). The game owns the channels only after it started, and the host sends\n// this before any game message, on the ordered channel — so a game never sees it.\n\nconst START_TAG = \"idos.lobby.start\";\n\nexport function encodeStart(mode: string): Uint8Array {\n return new TextEncoder().encode(JSON.stringify({ t: START_TAG, mode }));\n}\n\n/** The mode a lobby start message carries, or null for anything else. */\nexport function decodeStart(data: Uint8Array): string | null {\n if (data.length > 256 || data[0] !== 0x7b) return null;\n try {\n const msg = JSON.parse(new TextDecoder().decode(data)) as {\n t?: unknown;\n mode?: unknown;\n };\n return msg.t === START_TAG && typeof msg.mode === \"string\"\n ? msg.mode\n : null;\n } catch {\n return null;\n }\n}\n\n// ── Rooms list ───────────────────────────────────────────────────────────────────────────────\n\nexport interface ListedRoom {\n RoomID?: string | null;\n Name?: string | null;\n Mode?: string | null;\n MemberCount?: number | null;\n MaxPlayers?: number | null;\n}\n\n/** Rooms the player can see: only those of a game this app has; of one game when asked. */\nexport function visibleRooms(\n rooms: ListedRoom[],\n games: string[],\n onlyMode: string | null | undefined,\n): ListedRoom[] {\n return rooms.filter(\n (r) =>\n !!r.RoomID &&\n !!r.Mode &&\n games.includes(r.Mode) &&\n (!onlyMode || r.Mode === onlyMode),\n );\n}\n\n/** A join code the way players type it: letters and digits, upper case. */\nexport function cleanCode(value: string): string {\n return value\n .toUpperCase()\n .replace(/[^A-Z0-9]/g, \"\")\n .slice(0, 12);\n}\n\n/** A fresh private-room code: six letters and digits without look-alikes. */\nexport function newCode(random: () => number = Math.random): string {\n const abc = \"ABCDEFGHJKLMNPQRSTUVWXYZ23456789\";\n let out = \"\";\n for (let i = 0; i < 6; i++) out += abc[Math.floor(random() * abc.length)];\n return out;\n}\n\n/** Whether multiplayer is on for the title. */\nexport function hasTitleData(section: MultiplayerSection | undefined): boolean {\n return !!section && section.SystemState?.Enabled !== false;\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 { makeMultiplayerScreen } from \"./MultiplayerScreen\";\nimport { hasTitleData, type MultiplayerSection } from \"./model\";\nimport { createRooms } from \"./rooms\";\nimport { t } from \"./i18n\";\n\n// The rooms system of the app: the lobby tab with the rooms, and the owner of the player's room and\n// its connection. Games that play online register in `ctx.multiplayer` and take the room in `start`;\n// they never create or join rooms. The tab shows while multiplayer is on for the title AND the app has\n// a game that plays online. No badge and no startup request.\nexport const multiplayerModule: Module = defineModule({\n id: \"multiplayer\",\n meta: { name: \"Multiplayer\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n const rooms = createRooms({\n client: ctx.client,\n games: ctx.multiplayer,\n navigate: (mode) => ctx.navigate(mode),\n });\n ctx.features.register({\n id: \"multiplayer\",\n label: t(\"title\"),\n icon: \"globe\",\n group: \"social\",\n order: 75,\n available: false,\n Screen: makeMultiplayerScreen({\n rooms,\n games: ctx.multiplayer,\n modes: ctx.modes,\n }),\n });\n const on = hasTitleData(\n configSection<MultiplayerSection>(ctx.client, \"Multiplayer\"),\n );\n // Games register in their own setup() — before or after this one: follow the registry.\n const refresh = () =>\n ctx.features.setAvailable(\n \"multiplayer\",\n on && ctx.multiplayer.listGames().length > 0,\n on\n ? \"no game of the app plays online (ctx.multiplayer.registerGame)\"\n : \"multiplayer is off on the title (Multiplayer.SystemState.Enabled)\",\n );\n refresh();\n ctx.multiplayer.subscribe(refresh);\n ctx.exposeToAgent({\n state: () => {\n const s = rooms.get();\n return {\n ui: \"dom\",\n feature: \"multiplayer\",\n games: ctx.multiplayer.listGames().map((g) => g.mode),\n room: s.session\n ? {\n id: s.session.roomId,\n mode: s.session.mode,\n host: s.session.isHost,\n members: s.session.members.size,\n started: s.started,\n }\n : null,\n };\n },\n describeActions: {},\n });\n },\n});\n"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"path": "MultiplayerScreen.tsx",
|
|
63
|
+
"content": "import {\n useCallback,\n useEffect,\n useMemo,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from \"react\";\nimport type { RoomListItem } from \"@idosgames/core\";\nimport type {\n FeatureScreenProps,\n ModeRegistry,\n MultiplayerGame,\n MultiplayerRegistry,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n Popup,\n SectionTitle,\n Spinner,\n configSection,\n errorText,\n outlined,\n panel,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n cleanCode,\n defaultSettings,\n memberRows,\n newCode,\n settingsFromState,\n visibleRooms,\n type MultiplayerSection,\n} from \"./model\";\nimport type { Rooms } from \"./rooms\";\nimport { t } from \"./i18n\";\n\n// The rooms system's screen (a lobby tab): the rooms of the games this app plays online, \"Create a\n// room\" for one of them, joining by code — and, once in a room, its members, \"Ready\", the room chat and\n// the host's \"Play\", which takes the whole room into the game. Opened with `{ mode }` it shows one\n// game's rooms (the lobby's \"With friends\"); with `{ mode, create: true }` straight on \"Create\" (a\n// game's \"Open for friends\").\n\nexport interface ScreenDeps {\n rooms: Rooms;\n games: MultiplayerRegistry;\n modes: ModeRegistry;\n}\n\nconst useGames = (games: MultiplayerRegistry) =>\n useSyncExternalStore(games.subscribe, games.listGames, games.listGames);\n\nexport function makeMultiplayerScreen(deps: ScreenDeps) {\n return function MultiplayerScreen({ args }: FeatureScreenProps): ReactNode {\n const state = useSyncExternalStore(\n deps.rooms.subscribe,\n deps.rooms.get,\n deps.rooms.get,\n );\n const mode = typeof args?.mode === \"string\" ? args.mode : null;\n return state.session ? (\n <Room deps={deps} />\n ) : (\n <Rooms deps={deps} mode={mode} create={args?.create === true} />\n );\n };\n}\n\n/** A game's label: its own, else its route's. */\nfunction gameLabel(\n game: MultiplayerGame | undefined,\n modes: ModeRegistry,\n mode: string | null | undefined,\n): string {\n return (\n game?.label ?? modes.list().find((m) => m.id === mode)?.label ?? mode ?? \"\"\n );\n}\n\n// ── The list of rooms ────────────────────────────────────────────────────────────────────────\n\nfunction Rooms({\n deps,\n mode,\n create,\n}: {\n deps: ScreenDeps;\n mode: string | null;\n create: boolean;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const stagger = useStagger();\n const games = useGames(deps.games);\n const [rooms, setRooms] = useState<RoomListItem[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const [popup, setPopup] = useState<\"create\" | \"code\" | null>(\n create ? \"create\" : null,\n );\n const modesList = useMemo(() => games.map((g) => g.mode), [games]);\n\n const load = useCallback(async () => {\n const res = await client.multiplayer.listRooms(0, 50);\n setRooms(res.ok ? (res.data.Rooms ?? []) : []);\n }, [client]);\n useEffect(() => {\n void load();\n const id = setInterval(() => void load(), 5_000);\n return () => clearInterval(id);\n }, [load]);\n\n const join = async (roomID: string, code?: string) => {\n setBusy(roomID);\n const error = await deps.rooms.join(roomID, code);\n setBusy(null);\n if (error)\n toast(\n error === \"inProgress\" ? t(\"inProgress\") : errorText(error),\n \"error\",\n );\n };\n\n const shown = visibleRooms(rooms ?? [], modesList, mode);\n const title = mode\n ? `${t(\"roomsOf\")} ${gameLabel(deps.games.getGame(mode), deps.modes, mode)}`\n : t(\"open\");\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <div style={{ display: \"flex\", gap: 8 }}>\n <Button\n tone=\"green\"\n attract\n onClick={() => setPopup(\"create\")}\n style={{ flex: 1 }}\n data-tutorial-anchor=\"multiplayer:create\"\n >\n <Icon glyph=\"plus\" size={18} /> {t(\"create\")}\n </Button>\n <Button tone=\"blue\" onClick={() => setPopup(\"code\")}>\n {t(\"byCode\")}\n </Button>\n </div>\n <SectionTitle>{title}</SectionTitle>\n {!rooms ? (\n <Center>\n <Spinner />\n </Center>\n ) : shown.length === 0 ? (\n <EmptyState glyph=\"globe\" text={t(\"noRooms\")} />\n ) : (\n shown.map((r, i) => (\n <Card\n key={r.RoomID}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"auto 1fr auto\",\n alignItems: \"center\",\n gap: 12,\n ...stagger(i),\n }}\n >\n <Icon glyph=\"globe\" size={34} />\n <div style={{ minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {r.Name || t(\"room\")}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {gameLabel(\n deps.games.getGame(r.Mode ?? \"\"),\n deps.modes,\n r.Mode,\n )}{\" \"}\n · {t(\"players\")}: {r.MemberCount ?? 0} / {r.MaxPlayers ?? 8}\n </div>\n </div>\n <Button\n tone=\"green\"\n size=\"sm\"\n busy={busy === r.RoomID}\n disabled={(r.MemberCount ?? 0) >= (r.MaxPlayers ?? 8)}\n onClick={() => void join(r.RoomID ?? \"\")}\n >\n {t(\"join\")}\n </Button>\n </Card>\n ))\n )}\n {popup === \"create\" ? (\n <Popup title={t(\"create\")} onClose={() => setPopup(null)} width={420}>\n <CreateForm deps={deps} games={games} preset={mode} />\n </Popup>\n ) : popup === \"code\" ? (\n <Popup title={t(\"byCode\")} onClose={() => setPopup(null)} width={400}>\n <CodeForm onJoin={(id, code) => void join(id, code)} />\n </Popup>\n ) : null}\n </div>\n );\n}\n\nconst input = {\n ...panel,\n padding: \"10px 12px\",\n color: v.text,\n fontSize: 15,\n width: \"100%\",\n boxSizing: \"border-box\" as const,\n};\n\nfunction CreateForm({\n deps,\n games,\n preset,\n}: {\n deps: ScreenDeps;\n games: MultiplayerGame[];\n preset: string | null;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const close = usePopupClose();\n const [mode, setMode] = useState(\n games.find((g) => g.mode === preset)?.mode ?? games[0]?.mode ?? \"\",\n );\n const game = games.find((g) => g.mode === mode);\n const cap = Math.min(\n Number(\n configSection<MultiplayerSection>(client, \"Multiplayer\")\n ?.MaxPlayersPerRoom ?? 8,\n ),\n game?.maxPlayers ?? 8,\n );\n const sizes = [2, 4, 8].filter((n) => n <= cap);\n const [name, setName] = useState(\"\");\n const [max, setMax] = useState(sizes[sizes.length - 1] ?? 2);\n const [priv, setPriv] = useState(false);\n const [settings, setSettings] = useState(() => defaultSettings(game));\n const [busy, setBusy] = useState(false);\n useEffect(() => setSettings(defaultSettings(game)), [mode]);\n\n if (!game) return <EmptyState glyph=\"globe\" text={t(\"noGames\")} />;\n\n const create = async () => {\n setBusy(true);\n const error = await deps.rooms.create({\n name: name.trim() || t(\"room\"),\n mode,\n maxPlayers: max,\n private: priv,\n joinCode: newCode(),\n settings,\n });\n setBusy(false);\n if (error) return toast(errorText(error), \"error\");\n close();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n {games.length > 1 ? (\n <div style={{ display: \"flex\", gap: 6, flexWrap: \"wrap\" }}>\n {games.map((g) => (\n <Button\n key={g.mode}\n size=\"sm\"\n tone={g.mode === mode ? \"gold\" : \"grey\"}\n onClick={() => setMode(g.mode)}\n >\n {gameLabel(g, deps.modes, g.mode)}\n </Button>\n ))}\n </div>\n ) : null}\n <input\n value={name}\n maxLength={40}\n placeholder={t(\"roomName\")}\n onChange={(e) => setName(e.target.value)}\n style={input}\n />\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 14,\n }}\n >\n {t(\"players\")}:\n {sizes.map((n) => (\n <Button\n key={n}\n size=\"sm\"\n tone={max === n ? \"gold\" : \"grey\"}\n onClick={() => setMax(n)}\n >\n {n}\n </Button>\n ))}\n </div>\n <Button\n tone={priv ? \"gold\" : \"grey\"}\n size=\"sm\"\n onClick={() => setPriv(!priv)}\n >\n <Icon glyph=\"lock\" size={16} /> {priv ? t(\"private\") : t(\"public\")}\n </Button>\n {(game.settings ?? []).map((field) =>\n field.kind === \"toggle\" ? (\n <Button\n key={field.id}\n size=\"sm\"\n tone={settings[field.id] === true ? \"gold\" : \"grey\"}\n onClick={() =>\n setSettings({\n ...settings,\n [field.id]: settings[field.id] !== true,\n })\n }\n >\n {settings[field.id] === true ? \"✓ \" : \"\"}\n {field.label}\n </Button>\n ) : (\n <div key={field.id} style={{ display: \"grid\", gap: 6 }}>\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {field.label}\n </span>\n <div style={{ display: \"flex\", gap: 6, flexWrap: \"wrap\" }}>\n {(field.options ?? []).map((o) => (\n <Button\n key={o.value}\n size=\"sm\"\n tone={settings[field.id] === o.value ? \"gold\" : \"grey\"}\n onClick={() =>\n setSettings({ ...settings, [field.id]: o.value })\n }\n >\n {o.label}\n </Button>\n ))}\n </div>\n </div>\n ),\n )}\n <Button tone=\"green\" busy={busy} onClick={() => void create()}>\n {t(\"create\")}\n </Button>\n </div>\n );\n}\n\nfunction CodeForm({\n onJoin,\n}: {\n onJoin: (roomID: string, code: string) => void;\n}): ReactNode {\n const close = usePopupClose();\n const [invite, setInvite] = useState(\"\");\n // An invite is \"<room id>:<code>\" (what the room screen shows for copying); a bare id works too.\n const [roomId, code = \"\"] = invite.trim().split(\":\");\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n <input\n value={invite}\n placeholder={t(\"invitePlaceholder\")}\n onChange={(e) => setInvite(e.target.value)}\n style={input}\n />\n <Button\n tone=\"green\"\n disabled={!roomId}\n onClick={() => {\n close();\n onJoin(roomId ?? \"\", cleanCode(code));\n }}\n >\n {t(\"join\")}\n </Button>\n </div>\n );\n}\n\n// ── In a room ────────────────────────────────────────────────────────────────────────────────\n\nfunction Room({ deps }: { deps: ScreenDeps }): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const now = useNow();\n const state = useSyncExternalStore(\n deps.rooms.subscribe,\n deps.rooms.get,\n deps.rooms.get,\n );\n const [text, setText] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const session = state.session!;\n const game = deps.games.getGame(session.mode ?? \"\");\n const timeout = Number(\n configSection<MultiplayerSection>(client, \"Multiplayer\")?.MemberTimeoutMs ??\n 15_000,\n );\n const rows = memberRows(\n session.members.values(),\n session.selfId,\n session.hostId,\n now,\n timeout,\n );\n const me = rows.find((r) => r.me);\n const nameOf = (id: string) =>\n rows.find((r) => r.userID === id)?.name ?? t(\"player\");\n const settings = settingsFromState(\n game,\n session.members.get(session.hostId)?.State,\n );\n const invite = `${session.roomId}${state.joinCode ? `:${state.joinCode}` : \"\"}`;\n\n const act = async (run: () => Promise<string | null>) => {\n setBusy(true);\n const error = await run();\n setBusy(false);\n if (error) toast(errorText(error), \"error\");\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 4 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {gameLabel(game, deps.modes, session.mode)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"players\")}: {rows.length}\n {(game?.settings ?? []).map((f) => {\n const value = settings[f.id];\n const shown =\n f.kind === \"toggle\"\n ? value === true\n ? f.label\n : null\n : `${f.label}: ${f.options?.find((o) => o.value === value)?.label ?? value}`;\n return shown ? ` · ${shown}` : null;\n })}\n </div>\n <button\n type=\"button\"\n className=\"idos-press\"\n onClick={() =>\n void navigator.clipboard\n ?.writeText(invite)\n .then(() => toast(t(\"copied\")))\n }\n style={{\n border: \"none\",\n background: \"none\",\n padding: 0,\n textAlign: \"left\",\n cursor: \"pointer\",\n ...outlined,\n fontSize: 12,\n color: v.gold,\n }}\n >\n <Icon glyph=\"copy\" size={14} /> {t(\"invite\")}: {invite}\n </button>\n </div>\n\n {state.started ? (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n onClick={() => deps.rooms.resume()}\n >\n {t(\"backToGame\")}\n </Button>\n ) : null}\n {state.error ? (\n <div style={{ ...outlined, fontSize: 13, color: v.red }}>\n {t(state.error === \"noGame\" ? \"noGame\" : \"startFailed\")}\n </div>\n ) : null}\n\n <SectionTitle>{t(\"members\")}</SectionTitle>\n {rows.map((m) => (\n <Card\n key={m.userID}\n highlight={m.me}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n opacity: m.online ? 1 : 0.5,\n }}\n >\n <Icon glyph={m.host ? \"crown\" : \"user\"} size={24} />\n <span style={{ ...outlined, fontSize: 14, flex: 1 }}>\n {m.me ? `${m.name} (${t(\"you\")})` : m.name}\n </span>\n {m.ready ? <Icon glyph=\"check\" size={20} /> : null}\n {session.isHost && !m.me && !state.started ? (\n <Button\n tone=\"grey\"\n size=\"sm\"\n onClick={() => void act(() => deps.rooms.kick(m.userID))}\n >\n {t(\"kick\")}\n </Button>\n ) : null}\n </Card>\n ))}\n\n {state.started ? null : session.isHost ? (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n disabled={!game}\n onClick={() => void act(() => deps.rooms.start())}\n data-tutorial-anchor=\"multiplayer:play\"\n >\n {t(\"play\")} · {gameLabel(game, deps.modes, session.mode)}\n </Button>\n ) : (\n <>\n <Button\n tone={me?.ready ? \"grey\" : \"green\"}\n busy={busy}\n onClick={() => void act(() => deps.rooms.setReady(!me?.ready))}\n >\n {me?.ready ? t(\"notReady\") : t(\"ready\")}\n </Button>\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"waitHost\")}\n </div>\n </>\n )}\n\n {session.chatMode === \"ServerRelay\" ? (\n <div style={{ ...panel, padding: 10, display: \"grid\", gap: 8 }}>\n <div\n style={{\n display: \"grid\",\n gap: 4,\n maxHeight: 180,\n overflowY: \"auto\",\n }}\n >\n {state.chat.length === 0 ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"noChat\")}\n </div>\n ) : (\n state.chat.map((c, i) => (\n <div key={i} style={{ fontSize: 13, color: v.text }}>\n <b>{nameOf(c.from)}:</b> {c.text}\n </div>\n ))\n )}\n </div>\n <div style={{ display: \"flex\", gap: 8 }}>\n <input\n value={text}\n maxLength={200}\n placeholder={t(\"message\")}\n onChange={(e) => setText(e.target.value)}\n onKeyDown={(e) => {\n if (e.key !== \"Enter\") return;\n const msg = text;\n setText(\"\");\n void act(() => deps.rooms.sendChat(msg));\n }}\n style={{ ...input, flex: 1 }}\n />\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => {\n const msg = text;\n setText(\"\");\n void act(() => deps.rooms.sendChat(msg));\n }}\n >\n {t(\"send\")}\n </Button>\n </div>\n </div>\n ) : null}\n\n <Button tone=\"grey\" size=\"sm\" onClick={() => void deps.rooms.leave()}>\n {t(\"leave\")}\n </Button>\n </div>\n );\n}\n"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"path": "rooms.ts",
|
|
67
|
+
"content": "import type { IDosGamesClient } from \"@idosgames/core\";\nimport { RealtimeSession } from \"@idosgames/core/realtime\";\nimport type {\n MultiplayerRegistry,\n MultiplayerRoom,\n} from \"@idosgames/module-sdk\";\nimport {\n START_KEY,\n decodeStart,\n encodeStart,\n settingsFromState,\n settingsToState,\n startedMode,\n} from \"./model\";\n\n// The player's room, owned by the module rather than by its screen: it has to outlive the screen —\n// the room goes into a game and comes back to the lobby, and the session (with its WebRTC links) is\n// the same all along. The screen only draws this state and calls these actions.\n//\n// Start protocol: the host writes `start = <mode>` into its member state (for late joiners and for a\n// guest who missed the message) and sends a start message over the links the lobby already opened\n// (instant, before any game message on the ordered channel). Every device then switches to the game's\n// mode and hands the room to the game's `start`. From that moment the game owns the channels.\n\nexport interface ChatLine {\n from: string;\n text: string;\n at: number;\n}\n\nexport interface RoomsState {\n session: RealtimeSession | null;\n /** The private room's code — known to its creator (to share it) and to whoever typed it. */\n joinCode: string | null;\n /** The game the room went into (null — still in the lobby). */\n started: string | null;\n chat: ChatLine[];\n /** A problem to show (a game the app does not have, a start that failed). */\n error: string | null;\n}\n\nexport interface RoomsDeps {\n client: IDosGamesClient;\n games: MultiplayerRegistry;\n navigate: (modeId: string) => void;\n}\n\n/** Test seam: how sessions are opened. */\nexport interface SessionFactory {\n create: typeof RealtimeSession.create;\n join: typeof RealtimeSession.join;\n}\n\nexport interface CreateOptions {\n name: string;\n mode: string;\n maxPlayers: number;\n private: boolean;\n joinCode: string;\n settings: Record<string, string | boolean>;\n}\n\nexport function createRooms(\n deps: RoomsDeps,\n factory: SessionFactory = {\n create: (...a) => RealtimeSession.create(...a),\n join: (...a) => RealtimeSession.join(...a),\n },\n) {\n let state: RoomsState = {\n session: null,\n joinCode: null,\n started: null,\n chat: [],\n error: null,\n };\n const listeners = new Set<() => void>();\n let offs: Array<() => void> = [];\n const set = (patch: Partial<RoomsState>) => {\n state = { ...state, ...patch };\n for (const l of listeners) l();\n };\n\n const reset = () => {\n for (const off of offs.splice(0)) off();\n set({\n session: null,\n joinCode: null,\n started: null,\n chat: [],\n error: null,\n });\n };\n\n /** Hand the room to the game — once per room. */\n const enter = (mode: string, lateJoin: boolean) => {\n const session = state.session;\n if (!session || state.started) return;\n const game = deps.games.getGame(mode);\n if (!game) {\n set({ error: \"noGame\" });\n return;\n }\n const host = session.members.get(session.hostId);\n const room: MultiplayerRoom = {\n session,\n mode,\n isHost: session.isHost,\n settings: settingsFromState(game, host?.State),\n lateJoin,\n leave: () => api.leave(),\n };\n set({ started: mode, error: null });\n deps.navigate(mode);\n // After the switch: the game's scene becomes active on the next render.\n setTimeout(() => {\n void Promise.resolve(game.start(room)).catch(() =>\n set({ error: \"startFailed\" }),\n );\n }, 0);\n };\n\n const attach = (session: RealtimeSession, joinCode: string | null) => {\n for (const off of offs.splice(0)) off();\n set({ session, joinCode, started: null, chat: [], error: null });\n offs = [\n session.on(\"message\", ({ from, data }) => {\n if (state.started || from !== session.hostId) return;\n const mode = decodeStart(data);\n if (mode) enter(mode, false);\n }),\n session.on(\"membersUpdated\", () => {\n const mode = startedMode(session.members.get(session.hostId));\n if (mode && !state.started) enter(mode, false);\n else set({});\n }),\n session.on(\"hostChanged\", () => set({})),\n session.on(\"chat\", ({ from, text }) =>\n set({\n chat: [...state.chat, { from, text, at: Date.now() }].slice(-100),\n }),\n ),\n session.on(\"roomClosed\", () => reset()),\n session.on(\"removed\", () => reset()),\n ];\n };\n\n const api = {\n get: () => state,\n subscribe(listener: () => void) {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n\n /** Create a room for a game — this player hosts it. */\n async create(o: CreateOptions): Promise<string | null> {\n const game = deps.games.getGame(o.mode);\n if (!game) return \"noGame\";\n const res = await factory.create(deps.client, o.name, {\n topology: game.topology ?? \"Mesh\",\n chatMode: game.chatMode ?? \"ServerRelay\",\n maxPlayers: o.maxPlayers,\n visibility: o.private ? \"Private\" : \"Public\",\n joinCode: o.private ? o.joinCode : undefined,\n mode: o.mode,\n });\n if (!res.ok) return res.error;\n attach(res.data, o.private ? o.joinCode : null);\n // The settings live in the host's member state: whoever joins reads them from the snapshot.\n const saved = await deps.client.multiplayer.setMemberState(\n res.data.roomId,\n undefined,\n settingsToState(o.settings),\n );\n if (!saved.ok) return saved.error;\n await res.data.refreshMembers();\n return null;\n },\n\n /** Join a room; into its game right away when the game is running and lets players in. */\n async join(roomId: string, joinCode?: string): Promise<string | null> {\n const res = await factory.join(\n deps.client,\n roomId,\n joinCode || undefined,\n );\n if (!res.ok) return res.error;\n const session = res.data;\n attach(session, joinCode || null);\n const running = startedMode(session.members.get(session.hostId));\n if (running) {\n if (deps.games.getGame(running)?.joinInProgress) enter(running, true);\n else {\n await api.leave();\n return \"inProgress\";\n }\n }\n return null;\n },\n\n async setReady(ready: boolean): Promise<string | null> {\n const s = state.session;\n if (!s) return null;\n const res = await deps.client.multiplayer.setMemberState(s.roomId, ready);\n return res.ok ? null : res.error;\n },\n\n async sendChat(text: string): Promise<string | null> {\n const s = state.session;\n if (!s || !text.trim()) return null;\n const res = await deps.client.multiplayer.sendChat(s.roomId, text.trim());\n return res.ok ? null : res.error;\n },\n\n /** The host takes the whole room into its game. */\n async start(): Promise<string | null> {\n const s = state.session;\n const mode = s?.mode;\n if (!s || !mode || !s.isHost) return null;\n const host = s.members.get(s.selfId);\n const res = await deps.client.multiplayer.setMemberState(s.roomId, true, {\n ...(host?.State ?? {}),\n [START_KEY]: mode,\n });\n if (!res.ok) return res.error;\n s.broadcast(\"rel\", encodeStart(mode));\n enter(mode, false);\n return null;\n },\n\n /** Back to the running game from the lobby (the room is still on). */\n resume() {\n if (state.started) deps.navigate(state.started);\n },\n\n async kick(userId: string): Promise<string | null> {\n const s = state.session;\n if (!s) return null;\n const res = await s.kick(userId);\n return res.ok ? null : res.error;\n },\n\n async leave(): Promise<void> {\n const s = state.session;\n reset();\n await s?.leave();\n },\n };\n return api;\n}\n\nexport type Rooms = ReturnType<typeof createRooms>;\n"
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"path": "module.meta.json",
|
|
71
|
+
"content": "{\n \"id\": \"multiplayer\",\n \"type\": \"feature\",\n \"summary\": \"The rooms system for any game: open rooms of the app's online games, public or private rooms by invite, members, ready and chat — the host takes the whole room into a game.\",\n \"description\": \"A ready multiplayer lobby (engine Multiplayer) for ANY game, built on the @idosgames/react/ui kit, client.multiplayer and RealtimeSession (@idosgames/core/realtime). It OWNS the player's room and its WebRTC session: the rooms of the games this app plays online (each room remembers its game), 'Create a room' for one of them (name, 2/4/8 players, public or private with an invite code, the game's own room settings), joining by invite, members (host, ready, offline), 'Ready', the room chat (server relay, when the game has no chat of its own), removing a member and leaving. The host's 'Play' takes the whole room into the game: every device switches to the game's mode and the game gets the room in its start() — games never create, list or join rooms themselves. A game plays online by registering in ctx.multiplayer (registerGame: topology, chat mode, room size, joinInProgress for late joiners, room settings, start(room)); the lobby then draws 'With friends' next to its 'Play'. Opened with { mode } the screen shows one game's rooms, with { mode, create: true } straight on 'Create'. Registers the 'multiplayer' feature, shown while multiplayer is on for the title and the app has a game that plays online. Ships title-seed.json: multiplayer on, up to 8 players per room.\",\n \"provides\": [\n \"multiplayer rooms lobby\",\n \"rooms of one game\",\n \"public and private rooms by invite\",\n \"room settings of the game\",\n \"room members with ready and host\",\n \"room chat\",\n \"host starts the game for the whole room\",\n \"late joiners into a running game\"\n ],\n \"tags\": [\n \"multiplayer\",\n \"rooms\",\n \"lobby\",\n \"realtime\",\n \"social\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.1\"\n}\n"
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "premium",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "VIP",
|
|
5
|
+
"type": "app",
|
|
6
|
+
"engine": "dom"
|
|
7
|
+
},
|
|
8
|
+
"catalog": {
|
|
9
|
+
"type": "feature",
|
|
10
|
+
"summary": "VIP for any game: premium passes with benefits, until when the player holds one, a free trial and buying with in-game currency.",
|
|
11
|
+
"description": "A ready VIP screen (Unity PremiumPopUp) for ANY game, built on the @idosgames/react/ui kit and client.premium. Every premium pass of the title, lowest tier first: its name, benefits and duration, a timer while the player holds it, a one-time free trial, and buying (or extending) it with currencies or items. A pass paid in a store (real money) is the platform's checkout and is not sold here. Events and season send the player here for their VIP prizes (ctx.features.open('premium', { premiumID }) marks that pass). Registers the 'premium' feature; its badge shows a free trial waiting. The demo pass comes with the base's settings.",
|
|
12
|
+
"provides": [
|
|
13
|
+
"VIP passes screen",
|
|
14
|
+
"free VIP trial",
|
|
15
|
+
"buy or extend VIP with in-game currency",
|
|
16
|
+
"VIP timer",
|
|
17
|
+
"badge for a free trial"
|
|
18
|
+
],
|
|
19
|
+
"tags": [
|
|
20
|
+
"vip",
|
|
21
|
+
"premium",
|
|
22
|
+
"subscription",
|
|
23
|
+
"monetization",
|
|
24
|
+
"ui",
|
|
25
|
+
"screen",
|
|
26
|
+
"economy"
|
|
27
|
+
],
|
|
28
|
+
"author": {
|
|
29
|
+
"name": "iDos Games",
|
|
30
|
+
"url": "https://idosgames.com"
|
|
31
|
+
},
|
|
32
|
+
"version": "0.1.1"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@idosgames/core": "0.19.1",
|
|
36
|
+
"@idosgames/module-sdk": "0.8.0",
|
|
37
|
+
"@idosgames/react": "0.4.0",
|
|
38
|
+
"react": "19.2.7"
|
|
39
|
+
},
|
|
40
|
+
"contentHash": "5f2472fa890edc2500ec4f0703aa320a84862d0768d2ce2b576f0460adcc5eca",
|
|
41
|
+
"files": [
|
|
42
|
+
{
|
|
43
|
+
"path": "i18n.ts",
|
|
44
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"VIP\",\n active: \"VIP is active!\",\n activeFor: \"Active for\",\n days: \"days\",\n trial: \"Try free\",\n buy: \"Buy\",\n extend: \"Extend\",\n noPasses: \"No VIP in this game yet. Add it in LiveOps → Premium.\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"VIP\",\n active: \"VIP активен!\",\n activeFor: \"Действует ещё\",\n days: \"дн.\",\n trial: \"Попробовать бесплатно\",\n buy: \"Купить\",\n extend: \"Продлить\",\n noPasses: \"VIP в этой игре пока нет. Добавьте его в LiveOps → Premium.\",\n});\n"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"path": "index.ts",
|
|
48
|
+
"content": "// @idosgames/mod-premium — VIP for any game: the title's premium passes with their benefits, a free trial and buying a pass with in-game currency.\n\nexport { premiumModule } from \"./module\";\n"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"path": "model.ts",
|
|
52
|
+
"content": "// Pure helpers of the VIP screen — covered by model.test.ts. The server is the authority on what a\n// player holds; these only decide what the screen draws.\n\nimport { costLines, type ResourceLine } from \"@idosgames/react/ui\";\n\nexport interface PremiumDefinitionView {\n PremiumID?: string | null;\n DisplayName?: string | null;\n Tier?: number | null;\n DurationDays?: number | null;\n TrialDurationDays?: number | null;\n Benefits?: Record<string, string> | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Cost?: unknown } | null\n > | null;\n}\n\n/** The title's Premium section, as far as the VIP screen cares. */\nexport interface PremiumSection {\n Definitions?: Record<string, PremiumDefinitionView | null> | null;\n}\n\n/** The player's premium state (UserState.Premium). */\nexport interface PremiumStateView {\n Subscriptions?: Record<\n string,\n { ExpirationDate?: string | null } | null | undefined\n > | null;\n ActivatedTrialIDs?: string[] | null;\n}\n\nexport interface PassRow {\n premiumID: string;\n name: string;\n tier: number;\n durationDays: number;\n benefits: string[];\n /** Ways to buy with currencies/items (a pass paid in a store is the platform's checkout). */\n options: Array<{ id: string; price: ResourceLine[] }>;\n /** Until when the player holds it (ISO), null — not held. */\n activeUntil: string | null;\n /** A free trial the player has not used yet (and does not hold the pass now). */\n trialDays: number;\n}\n\n/** Every pass of the title, lowest tier first, with the player's state. */\nexport function passRows(\n section: PremiumSection | null | undefined,\n state: PremiumStateView | null | undefined,\n nowMs: number,\n): PassRow[] {\n const rows: PassRow[] = [];\n for (const [id, def] of Object.entries(section?.Definitions ?? {})) {\n if (!def) continue;\n const premiumID = def.PremiumID || id;\n const until = state?.Subscriptions?.[premiumID]?.ExpirationDate ?? null;\n const active = !!until && new Date(until).getTime() > nowMs ? until : null;\n const trialUsed = (state?.ActivatedTrialIDs ?? []).includes(premiumID);\n rows.push({\n premiumID,\n name: def.DisplayName || premiumID,\n tier: Number(def.Tier ?? 0),\n durationDays: Number(def.DurationDays ?? 0),\n benefits: Object.values(def.Benefits ?? {}).filter(Boolean),\n options: Object.entries(def.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 activeUntil: active,\n trialDays:\n !active && !trialUsed\n ? Math.max(0, Number(def.TrialDurationDays ?? 0))\n : 0,\n });\n }\n return rows.sort(\n (a, b) => a.tier - b.tier || a.premiumID.localeCompare(b.premiumID),\n );\n}\n\n/** Badge: free trials waiting to be taken. */\nexport function premiumBadge(rows: PassRow[]): number {\n return rows.filter((r) => r.trialDays > 0).length;\n}\n\n/** Whether the title has a pass — until it does, the lobby hides the tab. */\nexport function hasTitleData(section: PremiumSection | undefined): boolean {\n return Object.values(section?.Definitions ?? {}).some(Boolean);\n}\n"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"path": "module.ts",
|
|
56
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { PremiumScreen, makePremiumWatcher } from \"./PremiumScreen\";\nimport { hasTitleData, type PremiumSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// VIP as a feature of the game. Events and season send the player here for their VIP prizes\n// (features.open(\"premium\", { premiumID })). Badge: a free trial waiting.\nexport const premiumModule: Module = defineModule({\n id: \"premium\",\n meta: { name: \"VIP\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"premium\",\n label: t(\"title\"),\n icon: \"crown\",\n group: \"economy\",\n order: 12,\n available: false,\n Screen: PremiumScreen,\n });\n const enabled = hasTitleData(\n configSection<PremiumSection>(ctx.client, \"Premium\"),\n );\n ctx.features.setAvailable(\n \"premium\",\n enabled,\n \"no premium pass on the title (Premium)\",\n );\n if (enabled)\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makePremiumWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"premium\" }),\n describeActions: {},\n });\n },\n});\n"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"path": "PremiumScreen.tsx",
|
|
60
|
+
"content": "import {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n canAfford,\n Card,\n configSection,\n EmptyState,\n errorText,\n Icon,\n outlined,\n panel,\n ResourceList,\n Timer,\n toneSurface,\n useCatalog,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n passRows,\n premiumBadge,\n type PassRow,\n type PremiumSection,\n type PremiumStateView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// VIP (Unity PremiumPopUp): every premium pass of the title, lowest tier first — what it gives, how\n// long it lasts, until when the player holds it, a free trial once, and buying it with in-game\n// currency. A pass paid in a store (real money) is the platform's checkout, not sold here. Other\n// systems (events, season) send the player here for their VIP prizes:\n// `features.open(\"premium\", { premiumID })` — that pass is marked.\n\nexport function PremiumScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const now = useNow();\n const stagger = useStagger();\n const wanted = typeof args?.premiumID === \"string\" ? args.premiumID : null;\n const rows = passRows(\n configSection<PremiumSection>(client, \"Premium\"),\n state?.Premium as PremiumStateView | undefined,\n now,\n );\n // Fresh state on open: a pass may have run out since the last read.\n useEffect(() => {\n void client.premium.getUserState();\n }, [client]);\n\n if (rows.length === 0)\n return <EmptyState glyph=\"crown\" text={t(\"noPasses\")} />;\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {rows.map((row, i) => (\n <PassCard\n key={row.premiumID}\n row={row}\n marked={row.premiumID === wanted}\n style={stagger(i)}\n />\n ))}\n </div>\n );\n}\n\nfunction PassCard({\n row,\n marked,\n style,\n}: {\n row: PassRow;\n marked: boolean;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const now = useNow();\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\n useEffect(() => {\n if (marked) ref.current?.scrollIntoView({ block: \"center\" });\n }, [marked]);\n\n const done = (res: { ok: true } | { ok: false; error: string }) => {\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"purchase\");\n kit.fx.burst(ref.current);\n toast(t(\"active\"), \"success\");\n };\n const trial = async () => {\n setBusy(\"trial\");\n const res = await client.premium.activateTrial(\n row.premiumID,\n crypto.randomUUID(),\n );\n done(res);\n };\n const buy = async (optionID: string) => {\n setBusy(optionID);\n const res = await client.premium.purchaseItemOrCurrency(\n row.premiumID,\n crypto.randomUUID(),\n optionID,\n );\n done(res);\n };\n\n return (\n <div ref={ref}>\n <Card\n highlight={marked}\n style={{\n display: \"grid\",\n gap: 10,\n ...toneSurface(\"gold\"),\n ...style,\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 12 }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"crown\" size={52} />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {catalog.localize(row.name)}\n </div>\n {row.activeUntil ? (\n <Timer at={row.activeUntil} now={now} label={t(\"activeFor\")} />\n ) : row.durationDays > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {row.durationDays} {t(\"days\")}\n </div>\n ) : null}\n </div>\n {row.activeUntil ? <Icon glyph=\"check\" size={30} /> : null}\n </div>\n {row.benefits.length > 0 ? (\n <div style={{ display: \"grid\", gap: 4 }}>\n {row.benefits.map((b, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: 8,\n display: \"flex\",\n gap: 8,\n alignItems: \"center\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <Icon glyph=\"check\" size={18} /> {catalog.localize(b)}\n </div>\n ))}\n </div>\n ) : null}\n {row.trialDays > 0 ? (\n <Button\n tone=\"green\"\n attract\n busy={busy === \"trial\"}\n onClick={() => void trial()}\n data-tutorial-anchor={`premium:trial:${row.premiumID}`}\n >\n {t(\"trial\")} · {row.trialDays} {t(\"days\")}\n </Button>\n ) : null}\n {row.options.map((o) => {\n const affordable = canAfford(o.price, balances);\n return (\n <Button\n key={o.id}\n tone=\"gold\"\n attract={affordable && !row.activeUntil}\n disabled={!affordable}\n busy={busy === o.id}\n onClick={() => void buy(o.id)}\n data-tutorial-anchor={`premium:buy:${row.premiumID}`}\n >\n {row.activeUntil ? t(\"extend\") : t(\"buy\")}{\" \"}\n <ResourceList\n lines={o.price}\n size={18}\n dimIf={() => !affordable}\n />\n </Button>\n );\n })}\n </Card>\n </div>\n );\n}\n\n/** Badge: a free trial waiting — from the config and the cached state, no request of its own. */\nexport function makePremiumWatcher(features: FeatureRegistry) {\n return function PremiumBadge(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n useEffect(() => {\n features.setBadge(\n \"premium\",\n premiumBadge(\n passRows(\n configSection<PremiumSection>(client, \"Premium\"),\n state?.Premium as PremiumStateView | undefined,\n Date.now(),\n ),\n ),\n );\n }, [client, state?.Premium]);\n return null;\n };\n}\n"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"path": "module.meta.json",
|
|
64
|
+
"content": "{\n \"id\": \"premium\",\n \"type\": \"feature\",\n \"summary\": \"VIP for any game: premium passes with benefits, until when the player holds one, a free trial and buying with in-game currency.\",\n \"description\": \"A ready VIP screen (Unity PremiumPopUp) for ANY game, built on the @idosgames/react/ui kit and client.premium. Every premium pass of the title, lowest tier first: its name, benefits and duration, a timer while the player holds it, a one-time free trial, and buying (or extending) it with currencies or items. A pass paid in a store (real money) is the platform's checkout and is not sold here. Events and season send the player here for their VIP prizes (ctx.features.open('premium', { premiumID }) marks that pass). Registers the 'premium' feature; its badge shows a free trial waiting. The demo pass comes with the base's settings.\",\n \"provides\": [\n \"VIP passes screen\",\n \"free VIP trial\",\n \"buy or extend VIP with in-game currency\",\n \"VIP timer\",\n \"badge for a free trial\"\n ],\n \"tags\": [\n \"vip\",\n \"premium\",\n \"subscription\",\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.1\"\n}\n"
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
}
|
|
@@ -8,14 +8,13 @@
|
|
|
8
8
|
"catalog": {
|
|
9
9
|
"type": "feature",
|
|
10
10
|
"inBase": true,
|
|
11
|
-
"summary": "Profile and settings for any game: name and ID, sound, volume and motion, language,
|
|
12
|
-
"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)
|
|
11
|
+
"summary": "Profile and settings for any game: name and ID, sound, volume and motion, language, tutorials, log out and account deletion.",
|
|
12
|
+
"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). Buttons: Tutorials (when tutorials 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 lobby's profile button opens it. No title settings needed.",
|
|
13
13
|
"provides": [
|
|
14
14
|
"player name and ID",
|
|
15
15
|
"rename popup",
|
|
16
16
|
"sound, volume and fewer animations",
|
|
17
17
|
"language picker",
|
|
18
|
-
"analytics consent",
|
|
19
18
|
"log out",
|
|
20
19
|
"account deletion with confirmation"
|
|
21
20
|
],
|
|
@@ -33,23 +32,23 @@
|
|
|
33
32
|
"name": "iDos Games",
|
|
34
33
|
"url": "https://idosgames.com"
|
|
35
34
|
},
|
|
36
|
-
"version": "0.1.
|
|
35
|
+
"version": "0.1.1"
|
|
37
36
|
},
|
|
38
37
|
"dependencies": {
|
|
39
|
-
"@idosgames/core": "0.19.
|
|
40
|
-
"@idosgames/module-sdk": "0.
|
|
41
|
-
"@idosgames/react": "0.
|
|
38
|
+
"@idosgames/core": "0.19.1",
|
|
39
|
+
"@idosgames/module-sdk": "0.8.0",
|
|
40
|
+
"@idosgames/react": "0.4.0",
|
|
42
41
|
"react": "19.2.7"
|
|
43
42
|
},
|
|
44
|
-
"contentHash": "
|
|
43
|
+
"contentHash": "3b0189d3b8e57b0b678b240ab66af6ff1ea2dc41f2f5584f4424721b9aadced3",
|
|
45
44
|
"files": [
|
|
46
45
|
{
|
|
47
46
|
"path": "i18n.ts",
|
|
48
|
-
"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
|
|
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 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 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"
|
|
49
48
|
},
|
|
50
49
|
{
|
|
51
50
|
"path": "index.ts",
|
|
52
|
-
"content": "// @idosgames/mod-profile — Profile for any game: name, ID, language, sound and motion settings,
|
|
51
|
+
"content": "// @idosgames/mod-profile — Profile for any game: name, ID, language, sound and motion settings, log out and account deletion.\n\nexport { profileModule } from \"./module\";\n"
|
|
53
52
|
},
|
|
54
53
|
{
|
|
55
54
|
"path": "model.ts",
|
|
@@ -61,11 +60,11 @@
|
|
|
61
60
|
},
|
|
62
61
|
{
|
|
63
62
|
"path": "ProfileScreen.tsx",
|
|
64
|
-
"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"
|
|
63
|
+
"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, tutorials,\n// linked wallets, logging out and deleting the account. Renaming, unlinking a wallet and deleting are\n// popups. No analytics switch (owner's decision 29.09.2026): anonymous analytics stays on as the SDK\n// ships it (client.analytics).\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 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 color-mix(in srgb, ${v.text} 70%, transparent)`,\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 {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 : v.wellSoft,\n boxShadow: `inset 0 0 0 1px ${v.line}`,\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"
|
|
65
64
|
},
|
|
66
65
|
{
|
|
67
66
|
"path": "module.meta.json",
|
|
68
|
-
"content": "{\n \"id\": \"profile\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Profile and settings for any game: name and ID, sound, volume and motion, language,
|
|
67
|
+
"content": "{\n \"id\": \"profile\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Profile and settings for any game: name and ID, sound, volume and motion, language, 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). Buttons: Tutorials (when tutorials 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 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 \"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.1\"\n}\n"
|
|
69
68
|
}
|
|
70
69
|
]
|
|
71
70
|
}
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
},
|
|
8
8
|
"catalog": {
|
|
9
9
|
"type": "feature",
|
|
10
|
-
"inBase": true,
|
|
11
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.",
|
|
12
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 demo heroes' Attack and Health.",
|
|
13
12
|
"provides": [
|
|
@@ -34,12 +33,12 @@
|
|
|
34
33
|
"version": "0.1.0"
|
|
35
34
|
},
|
|
36
35
|
"dependencies": {
|
|
37
|
-
"@idosgames/core": "0.19.
|
|
38
|
-
"@idosgames/module-sdk": "0.
|
|
39
|
-
"@idosgames/react": "0.
|
|
36
|
+
"@idosgames/core": "0.19.1",
|
|
37
|
+
"@idosgames/module-sdk": "0.8.0",
|
|
38
|
+
"@idosgames/react": "0.4.0",
|
|
40
39
|
"react": "19.2.7"
|
|
41
40
|
},
|
|
42
|
-
"contentHash": "
|
|
41
|
+
"contentHash": "bafe16c7215f36a5ef659c24f879c07b02fdb5d7e7cedc1cdc4ef996e687879c",
|
|
43
42
|
"files": [
|
|
44
43
|
{
|
|
45
44
|
"path": "i18n.ts",
|
|
@@ -63,7 +62,7 @@
|
|
|
63
62
|
},
|
|
64
63
|
{
|
|
65
64
|
"path": "module.meta.json",
|
|
66
|
-
"content": "{\n \"id\": \"pvp\",\n \"type\": \"feature\",\n \"
|
|
65
|
+
"content": "{\n \"id\": \"pvp\",\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 demo 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"
|
|
67
66
|
}
|
|
68
67
|
]
|
|
69
68
|
}
|