@idosgames/mcp 0.1.18 → 0.1.19
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 +69 -25
- package/registry/index.json +360 -412
- package/registry/modules/board-game.json +7 -7
- package/registry/modules/{character-ui.json → character.json} +8 -11
- package/registry/modules/chat.json +71 -0
- package/registry/modules/{craft-ui.json → craft.json} +8 -11
- package/registry/modules/{deal-offer-ui.json → deals.json} +7 -10
- package/registry/modules/{events-ui.json → events.json} +8 -11
- package/registry/modules/{friends-ui.json → friends.json} +8 -11
- package/registry/modules/idle-rpg.json +6 -6
- package/registry/modules/{inventory-ui.json → inventory.json} +9 -12
- package/registry/modules/{referral-ui.json → invite.json} +8 -11
- package/registry/modules/{leaderboard-ui.json → leaderboard.json} +8 -11
- package/registry/modules/marketplace.json +72 -0
- package/registry/modules/mini-game.json +69 -0
- package/registry/modules/{profile-ui.json → profile.json} +8 -11
- package/registry/modules/{pvp-ui.json → pvp.json} +9 -12
- package/registry/modules/{rewards-ui.json → rewards.json} +8 -11
- package/registry/modules/{season-ui.json → season.json} +8 -11
- package/registry/modules/{store-ui.json → store.json} +9 -12
- package/registry/modules/{tutorial-ui.json → tutorials.json} +8 -11
- package/registry/modules/voxelcraft.json +2 -446
- package/registry/modules/workshop.json +11 -6
- package/registry/skills/dev-test-loop.json +1 -1
- package/registry/skills/idosgames-compose-modules.json +2 -2
- package/registry/skills/idosgames-getting-started.json +2 -2
- 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/push-notifications.json +1 -1
- package/registry/skills/workshop-system.json +1 -1
- package/registry/modules/chat-ui.json +0 -74
- package/registry/modules/game-hud.json +0 -104
- package/registry/modules/marketplace-ui.json +0 -75
- package/registry/modules/showcase.json +0 -92
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idosgames-project-structure",
|
|
3
3
|
"description": "The layout standard for an iDosGames game project (host shell + composable modules) and how it grows without turning into a mess: where a new feature goes (a catalog module, an existing module, or a new module), the folder layout inside a module, module boundaries (no cross-module imports), the game's shared code in src/shared/, module.meta.json, src/modules.ts being platform-generated, idos.modules.lock.json and customized catalog modules, file size, and project documentation (IDOS.md vs docs/feature-history/). Use this BEFORE creating a module, creating or changing src/shared/, moving code between files or folders, adding a system that spans several modules, or writing project documentation.",
|
|
4
|
-
"content": "---\nname: idosgames-project-structure\ndescription: >-\n The layout standard for an iDosGames game project (host shell + composable modules) and how it\n grows without turning into a mess: where a new feature goes (a catalog module, an existing module,\n or a new module), the folder layout inside a module, module boundaries (no cross-module imports),\n the game's shared code in src/shared/, module.meta.json, src/modules.ts being platform-generated,\n idos.modules.lock.json and customized catalog modules, file size, and project documentation\n (IDOS.md vs docs/feature-history/). Use this BEFORE creating a module, creating or changing\n src/shared/, moving code between files or folders, adding a system that spans several modules, or\n writing project documentation.\n---\n\n# Project structure (iDosGames game projects)\n\n## The project at a glance\n\n```\nIDOS.md project guide every agent reads first — short, evergreen\nAGENTS.md, CLAUDE.md pointers to IDOS.md for external tools (Cursor, Codex, Claude Code…)\nidos.modules.lock.json platform-owned: catalog modules, their versions and file fingerprints\ndocs/feature-history/ one file per game system + README.md index\npackage.json · vite.config.ts · tsconfig.json · index.html\nsrc/\n main.tsx the host: ONE SDK client + mountHost(...)\n modules.ts the composition list — generated by the platform\n idos.title.ts the project's identity — generated, never edit\n config.ts title / environment resolution — platform-owned, never edit\n env.ts build-time values (.env.local)\n LoginScreen.tsx the login screen — restyle freely\n shared/ optional: code two or more of THIS game's modules need\n ui/ types/ utils/\n modules/\n <id>/ one folder per module: catalog templates, catalog features, the game's own\n```\n\nEverything the game does lives in a module. Ready-made catalog modules and the game's own modules\nshare the one `src/modules/` folder — where a module came from is recorded in\n`idos.modules.lock.json`, not in its path, because a catalog template becomes the creator's code the\nmoment they start changing it. `src/shared/` is the one place for code several of the game's own\nmodules use; there is no project-level `utils/` or `components/` besides it.\n\n## Where does a new feature go?\n\nDecide in this order:\n\n1. **The catalog already has it** → install that module (the AI editor's InstallModule, or\n `get_module` over MCP) and adapt it. Don't rebuild what exists.\n2. **It extends an existing module's gameplay** (a new tile type in the board game, a new enemy in\n the idle RPG) → change that module, inside its folder.\n3. **It is its own mode, screen or system** (a shop, a clan screen, a mini-game, a quest board) → a\n **new module** `src/modules/<feature-id>/`.\n4. **It is chrome for every mode.** Balances, the wallet, the status line, Log out / player ID are\n shared-UI roles → a module that **provides** them (`sharedUi: { provides: [...] }` in module.ts):\n install `game-hud` from the catalog, or change it; templates hide their own copies by\n themselves. Other UI every mode shows (a global menu) → a panel with `activeOnly: false` in a\n no-scene, no-route module (`type: \"app\"`, `engine: \"dom\"`). See **idosgames-compose-modules**.\n5. **It is code two or more of the game's modules need** (a shared type, the game's UI kit, a\n formatting helper) → `src/shared/` (see below).\n\nBetween 2 and 3: if it would get its own nav tab, or could be switched off on its own, it is its own\nmodule.\n\n## Inside a module\n\n```\nsrc/modules/<id>/\n index.ts export { <camelCaseId>Module } from \"./module\";\n module.ts defineModule({ id, meta, setup(ctx) { … } })\n module.meta.json manifest: type, summary, provides, tags, version, author\n components/ React panels and UI pieces\n game/ engine and simulation (engine subfolders are fine: game/phaser/, game/three/)\n data/ static tables and tuning (levels, tiles, item lists)\n react/ hooks, contexts, the scene↔panel controller bridge\n```\n\n- The folder id is kebab-case `[a-z0-9-]`, at most 64 characters. The export name is derived from it\n — `daily-quests` → `dailyQuestsModule` — and the platform registers the module by that exact name.\n- Create only the folders you need; a small module can be `index.ts` + `module.ts` + one component.\n- `voxelcraft` is a ported vanilla game with its own layout — leave it as it is. New modules follow\n the layout above.\n\n### module.meta.json\n\n```json\n{\n \"id\": \"daily-quests\",\n \"type\": \"feature\",\n \"summary\": \"One-line pitch shown in the Modules dialog.\",\n \"description\": \"What it does, in a few sentences.\",\n \"provides\": [\"daily quest board\", \"streak rewards\"],\n \"tags\": [\"quests\", \"retention\"],\n \"version\": \"0.1.0\",\n \"author\": { \"name\": \"…\" },\n \"events\": {\n \"emits\": [\n {\n \"topic\": \"daily-quests:quest-completed@1\",\n \"when\": \"a quest's reward was claimed\",\n \"payload\": { \"questId\": \"string\" }\n }\n ],\n \"listens\": [\n {\n \"topic\": \"idle-rpg:character-upgraded@1\",\n \"why\": \"progress 'level a hero' quests\"\n }\n ]\n }\n}\n```\n\n`type` is `template` (a complete game to start from) or `feature` (a capability added to a game).\n`events` declares every `defineTopic(...)` the module uses — its own topics under `emits` (with the\nsame payload descriptor it passes to `shape()`), other modules' under `listens`; omit it when the\nmodule has none.\n`provides` is what an agent matches a request against — keep it accurate. Every module carries this\nfile, the game's own included: it is how a module shows up in the Modules dialog, and what lets it be\npublished to the shared catalog for other creators later.\n\n### Register it\n\n`src/modules.ts` has exactly this shape — imports plus the array, nothing else:\n\n```ts\nimport type { Module } from \"@idosgames/module-sdk\";\nimport { boardGameModule } from \"./modules/board-game\";\nimport { dailyQuestsModule } from \"./modules/daily-quests\";\n\nexport const modules: Module[] = [boardGameModule, dailyQuestsModule];\n```\n\nThe platform regenerates this file whenever modules are installed or removed; any other code in it is\nlost. Array order is mount order (and nav order).\n\n## Module boundaries\n\nA module imports only:\n\n- files inside its own folder;\n- `src/shared/` — the game's shared code;\n- npm packages (`@idosgames/*`, `react`, `three`, `phaser`, …).\n\nNever another module's files (`../board-game/…`) and never host files (`../../main`, `../../config`).\nModules are mixed into different games — a module that reaches into its neighbours breaks the moment\none of them is removed or replaced. Cooperate instead through:\n\n- **Durable shared state** (currency, inventory, characters, progress) → the ONE SDK client every\n module receives as `ctx.client`. Gold earned in one mode is spendable in another with no plumbing.\n- **Live signals** → `ctx.events` with typed topics `<module-id>:<event>@<major>`\n (`defineTopic(\"idle-rpg:character-upgraded@1\", shape({ … }))`), declared in `module.meta.json`.\n The emitter doesn't know who listens; a listener keeps its own copy of the topic. Topics shared by\n several of THIS game's own modules can live in `src/shared/events/` and be imported on both sides.\n- **Code several modules need** → `src/shared/`.\n- **Logic that must be shared and trusted** → it belongs on the server (SDK services, CloudCode),\n not in a client file.\n\n## src/shared/ — the game's shared code\n\nFor code that two or more of THIS game's own modules need.\n\n- **Created on demand.** Code only one module needs stays inside that module; move it to\n `src/shared/` when a second module needs it, not in advance.\n- **One-way dependency.** Modules import from `src/shared/`; `src/shared/` NEVER imports a module.\n Otherwise every module using the shared code silently drags another module in with it.\n- **No game state, no game logic.** Types, constants, the game's UI components and theme, pure\n helpers. Progress and data go through the SDK client, signals through `ctx.events`.\n- **Organised by purpose:** `src/shared/ui/`, `src/shared/types/`, `src/shared/utils/` — not one pile.\n- **Catalog modules never depend on it.** A module that ships in the catalog must install into any\n game, so it is fully self-contained. A game's own module that imports `src/shared/` is tied to this\n game — fine for your own game; when you publish such a module, its shared code is copied into it\n (the Modules dialog marks these modules \"uses shared code\").\n\n## Catalog modules you change\n\nA module installed from the catalog is source in your project — change it freely. At install the\nplatform records its file fingerprints in `idos.modules.lock.json`; once any file differs, the module\ncounts as **customized**, and updating it from the catalog is refused until the user confirms\noverwriting their changes in the Modules dialog. An agent never forces that overwrite. Write down\nnon-obvious customizations in `docs/feature-history/` so the next person knows why the module differs\nfrom the catalog.\n\n## Files\n\n- Keep files focused. When a change adds a new responsibility to a file past ~400 lines, move that\n part into its own file — as part of that change, not as a separate drive-by refactor.\n- `src/idos.title.ts`, `src/config.ts` and `idos.modules.lock.json` are platform-owned: never edit\n them. The platform's build packs its own `idos.title.ts` and `config.ts` over the project's, so\n an edit there never reaches the game anyway.\n- Never read the title or the environment from the URL: a link is editable by anyone. Use\n `client.titleID` (or `ctx.titleId` in a module's `setup()`).\n\n## Documentation\n\n- **IDOS.md** — the always-loaded guide: what the game is (\"This game\"), layout, conventions,\n \"never do X\" constraints, lasting user preferences. Short and evergreen; change a line when a fact\n changes. The \"Installed modules\" block is maintained by the platform.\n- **docs/feature-history/<slug>.md** — one file per game system or feature: what was built, why,\n and the decisions and constraints the code does not show. Update the system's file when it\n changes, and give every file ONE line in `docs/feature-history/README.md`:\n `- [Title](slug.md) — one-line gist`.\n- Never append a feature write-up to IDOS.md. It is loaded on every run, so every paragraph there is\n paid for by all future work — that is exactly how instruction files bloat.\n- Feature-history entries are not loaded automatically: open the relevant one before changing that\n system.\n",
|
|
4
|
+
"content": "---\nname: idosgames-project-structure\ndescription: >-\n The layout standard for an iDosGames game project (host shell + composable modules) and how it\n grows without turning into a mess: where a new feature goes (a catalog module, an existing module,\n or a new module), the folder layout inside a module, module boundaries (no cross-module imports),\n the game's shared code in src/shared/, module.meta.json, src/modules.ts being platform-generated,\n idos.modules.lock.json and customized catalog modules, file size, and project documentation\n (IDOS.md vs docs/feature-history/). Use this BEFORE creating a module, creating or changing\n src/shared/, moving code between files or folders, adding a system that spans several modules, or\n writing project documentation.\n---\n\n# Project structure (iDosGames game projects)\n\n## The project at a glance\n\n```\nIDOS.md project guide every agent reads first — short, evergreen\nAGENTS.md, CLAUDE.md pointers to IDOS.md for external tools (Cursor, Codex, Claude Code…)\nidos.modules.lock.json platform-owned: catalog modules, their versions and file fingerprints\ndocs/feature-history/ one file per game system + README.md index\npackage.json · vite.config.ts · tsconfig.json · index.html\nsrc/\n main.tsx the host: ONE SDK client + mountHost([baseModule, ...modules])\n app.config.ts what happens after sign-in (start) and the lobby's tab order\n ui.config.ts the look, animations and sounds of every screen\n base/ THE BASE: login/ (sign-in), lobby/ (tabs, balances, wallet, profile, \"Play\"),\n module.ts (baseModule) — ordinary project code\n modules.ts the composition list — generated by the platform\n idos.title.ts the project's identity — generated, never edit\n config.ts title / environment resolution — platform-owned, never edit\n env.ts build-time values (.env.local)\n shared/ optional: code two or more of THIS game's modules need\n ui/ types/ utils/\n modules/\n <id>/ one folder per module: the catalog's systems and games, the game's own\n```\n\nSign-in and the lobby are the base; everything else the game does lives in a module — a **system**\n(its screen is a lobby tab: shop, heroes, leaderboards…) or a **game** (its mode gets a \"Play\"\nbutton). Ready-made catalog modules and the game's own modules\nshare the one `src/modules/` folder — where a module came from is recorded in\n`idos.modules.lock.json`, not in its path, because a catalog template becomes the creator's code the\nmoment they start changing it. `src/shared/` is the one place for code several of the game's own\nmodules use; there is no project-level `utils/` or `components/` besides it.\n\n## Where does a new feature go?\n\nDecide in this order:\n\n1. **The catalog already has it** → install that module (the AI editor's InstallModule, or\n `get_module` over MCP) and adapt it. Don't rebuild what exists.\n2. **It extends an existing module's gameplay** (a new tile type in the board game, a new enemy in\n the idle RPG) → change that module, inside its folder.\n3. **It is its own mode, screen or system** (a shop, a clan screen, a mini-game, a quest board) → a\n **new module** `src/modules/<feature-id>/`.\n4. **It is the lobby, sign-in or chrome everybody shares.** Balances, the wallet, the profile, the\n tabs and \"Play\" are the base (`src/base/`) — change it there; games hide their own copies of the\n balances and wallet by themselves. Other UI shown over every mode → a panel with\n `activeOnly: false`. See **idosgames-compose-modules**.\n5. **It is code two or more of the game's modules need** (a shared type, the game's UI kit, a\n formatting helper) → `src/shared/` (see below).\n\nBetween 2 and 3: if it would get its own lobby tab or \"Play\" button, or could be switched off on its\nown, it is its own module.\n\n## Inside a module\n\n```\nsrc/modules/<id>/\n index.ts export { <camelCaseId>Module } from \"./module\";\n module.ts defineModule({ id, meta, setup(ctx) { … } })\n module.meta.json manifest: type, summary, provides, tags, version, author\n components/ React panels and UI pieces\n game/ engine and simulation (engine subfolders are fine: game/phaser/, game/three/)\n data/ static tables and tuning (levels, tiles, item lists)\n react/ hooks, contexts, the scene↔panel controller bridge\n```\n\n- The folder id is kebab-case `[a-z0-9-]`, at most 64 characters. The export name is derived from it\n — `daily-quests` → `dailyQuestsModule` — and the platform registers the module by that exact name.\n- Create only the folders you need; a small module can be `index.ts` + `module.ts` + one component.\n- `voxelcraft` is a ported vanilla game with its own layout — leave it as it is. New modules follow\n the layout above.\n\n### module.meta.json\n\n```json\n{\n \"id\": \"daily-quests\",\n \"type\": \"feature\",\n \"summary\": \"One-line pitch shown in the Modules dialog.\",\n \"description\": \"What it does, in a few sentences.\",\n \"provides\": [\"daily quest board\", \"streak rewards\"],\n \"tags\": [\"quests\", \"retention\"],\n \"version\": \"0.1.0\",\n \"author\": { \"name\": \"…\" },\n \"events\": {\n \"emits\": [\n {\n \"topic\": \"daily-quests:quest-completed@1\",\n \"when\": \"a quest's reward was claimed\",\n \"payload\": { \"questId\": \"string\" }\n }\n ],\n \"listens\": [\n {\n \"topic\": \"idle-rpg:character-upgraded@1\",\n \"why\": \"progress 'level a hero' quests\"\n }\n ]\n }\n}\n```\n\n`type` is `template` (a complete game to start from) or `feature` (a capability added to a game).\n`events` declares every `defineTopic(...)` the module uses — its own topics under `emits` (with the\nsame payload descriptor it passes to `shape()`), other modules' under `listens`; omit it when the\nmodule has none.\n`provides` is what an agent matches a request against — keep it accurate. Every module carries this\nfile, the game's own included: it is how a module shows up in the Modules dialog, and what lets it be\npublished to the shared catalog for other creators later.\n\n### Register it\n\n`src/modules.ts` has exactly this shape — imports plus the array, nothing else:\n\n```ts\nimport type { Module } from \"@idosgames/module-sdk\";\nimport { boardGameModule } from \"./modules/board-game\";\nimport { dailyQuestsModule } from \"./modules/daily-quests\";\n\nexport const modules: Module[] = [boardGameModule, dailyQuestsModule];\n```\n\nThe platform regenerates this file whenever modules are installed or removed; any other code in it is\nlost. Array order is mount order (and nav order).\n\n## Module boundaries\n\nA module imports only:\n\n- files inside its own folder;\n- `src/shared/` — the game's shared code;\n- npm packages (`@idosgames/*`, `react`, `three`, `phaser`, …).\n\nNever another module's files (`../board-game/…`) and never host files (`../../main`, `../../config`).\nModules are mixed into different games — a module that reaches into its neighbours breaks the moment\none of them is removed or replaced. Cooperate instead through:\n\n- **Durable shared state** (currency, inventory, characters, progress) → the ONE SDK client every\n module receives as `ctx.client`. Gold earned in one mode is spendable in another with no plumbing.\n- **Live signals** → `ctx.events` with typed topics `<module-id>:<event>@<major>`\n (`defineTopic(\"idle-rpg:character-upgraded@1\", shape({ … }))`), declared in `module.meta.json`.\n The emitter doesn't know who listens; a listener keeps its own copy of the topic. Topics shared by\n several of THIS game's own modules can live in `src/shared/events/` and be imported on both sides.\n- **Code several modules need** → `src/shared/`.\n- **Logic that must be shared and trusted** → it belongs on the server (SDK services, CloudCode),\n not in a client file.\n\n## src/shared/ — the game's shared code\n\nFor code that two or more of THIS game's own modules need.\n\n- **Created on demand.** Code only one module needs stays inside that module; move it to\n `src/shared/` when a second module needs it, not in advance.\n- **One-way dependency.** Modules import from `src/shared/`; `src/shared/` NEVER imports a module.\n Otherwise every module using the shared code silently drags another module in with it.\n- **No game state, no game logic.** Types, constants, the game's UI components and theme, pure\n helpers. Progress and data go through the SDK client, signals through `ctx.events`.\n- **Organised by purpose:** `src/shared/ui/`, `src/shared/types/`, `src/shared/utils/` — not one pile.\n- **Catalog modules never depend on it.** A module that ships in the catalog must install into any\n game, so it is fully self-contained. A game's own module that imports `src/shared/` is tied to this\n game — fine for your own game; when you publish such a module, its shared code is copied into it\n (the Modules dialog marks these modules \"uses shared code\").\n\n## Catalog modules you change\n\nA module installed from the catalog is source in your project — change it freely. At install the\nplatform records its file fingerprints in `idos.modules.lock.json`; once any file differs, the module\ncounts as **customized**, and updating it from the catalog is refused until the user confirms\noverwriting their changes in the Modules dialog. An agent never forces that overwrite. Write down\nnon-obvious customizations in `docs/feature-history/` so the next person knows why the module differs\nfrom the catalog.\n\n## Files\n\n- Keep files focused. When a change adds a new responsibility to a file past ~400 lines, move that\n part into its own file — as part of that change, not as a separate drive-by refactor.\n- `src/idos.title.ts`, `src/config.ts` and `idos.modules.lock.json` are platform-owned: never edit\n them. The platform's build packs its own `idos.title.ts` and `config.ts` over the project's, so\n an edit there never reaches the game anyway.\n- Never read the title or the environment from the URL: a link is editable by anyone. Use\n `client.titleID` (or `ctx.titleId` in a module's `setup()`).\n\n## Documentation\n\n- **IDOS.md** — the always-loaded guide: what the game is (\"This game\"), layout, conventions,\n \"never do X\" constraints, lasting user preferences. Short and evergreen; change a line when a fact\n changes. The \"Installed modules\" block is maintained by the platform.\n- **docs/feature-history/<slug>.md** — one file per game system or feature: what was built, why,\n and the decisions and constraints the code does not show. Update the system's file when it\n changes, and give every file ONE line in `docs/feature-history/README.md`:\n `- [Title](slug.md) — one-line gist`.\n- Never append a feature write-up to IDOS.md. It is loaded on every run, so every paragraph there is\n paid for by all future work — that is exactly how instruction files bloat.\n- Feature-history entries are not loaded automatically: open the relevant one before changing that\n system.\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idosgames-ui-kit",
|
|
3
|
-
"description": "Build and restyle game screens with the iDosGames UI kit (@idosgames/react/ui) and the ready
|
|
4
|
-
"content": "---\nname: idosgames-ui-kit\ndescription: >-\n Build and restyle game screens with the iDosGames UI kit (@idosgames/react/ui) and the ready\n screen modules (*-ui): change colours, animations and sounds in ONE file (src/ui.config.ts),\n install a shop / heroes / inventory / leaderboard / rewards / craft / marketplace / offers /\n events / season / friends / chat / invite / arena / profile / tutorials screen instead of writing\n it, open another module's screen with ctx.features.open, register a new screen with\n ctx.features.register, split a dense screen into popups (Popup, ConfirmPopup, bottom sheet on a\n phone), play UI sounds, celebrate a reward (icons fly to the balance counters), mark elements for\n tutorial hints (data-tutorial-anchor), and read the player's sound / motion prefs. Use this\n whenever the user asks to change how the game's interface looks, sounds or animates, to add or\n build a game screen or menu, a popup, a reward animation, a bottom navigation, a \"More\" menu, a\n settings screen, or touches @idosgames/react/ui, defineUiConfig, ui.config.ts, ctx.features,\n FeatureScreenProps, useCelebrate, usePlayerPrefs, Popup, Tabs or ResourceList.\n---\n\n# The UI kit and the screen modules\n\nThe game's interface is built from **screen modules** on one **UI kit**:\n\n- `@idosgames/react/ui` — the kit: components (buttons, tabs, popups, resource lists, item tiles,\n progress bars, timers), the reward popup, toasts, UI sounds (synthesized, no files), animations,\n and the player's own sound / motion prefs.\n- `*-ui` modules — ready screens of the platform's systems, each a `feature` module:\n\n| Module | Feature id | Screen |\n| ---------------- | ------------- | -------------------------------------------------------------- |\n| `store-ui` | `store` | shop: stores as tabs, offers, purchase popup |\n| `character-ui` | `character` | heroes: gear slots, \"Equip best\", stats, ranks, skins (popups) |\n| `inventory-ui` | `inventory` | items by rarity, item card, lootbox opening |\n| `leaderboard-ui` | `leaderboard` | tables, my place, rank rewards, last cycle's prize |\n| `rewards-ui` | `rewards` | daily calendar, quests, points track |\n| `craft-ui` | `craft` | recipes, contract popup |\n| `marketplace-ui` | `marketplace` | lots, auctions, selling, trades |\n| `deal-offer-ui` | `deals` | special offers, auto-popup of a new deal |\n| `events-ui` | `events` | limited-time events, milestones, VIP prize |\n| `season-ui` | `season` | season tiers, VIP prize |\n| `friends-ui` | `friends` | friends, requests, suggestions, news |\n| `chat-ui` | `chat` | channels, private chats |\n| `referral-ui` | `invite` | own code and link, friend's code, rewards |\n| `pvp-ui` | `pvp` | duels, strategy, fight replay |\n| `profile-ui` | `profile` | name, sound, language, consent, delete account |\n| `tutorial-ui` | `tutorials` | hint overlay + replay list |\n\n**Need one of these? Install the module — don't write the screen.** Every screen follows the\nserver's rules and ships demo title settings (`title-seed.json`).\n\n## Change the look, animations and sounds — only in `src/ui.config.ts`\n\n```ts\nimport { defineUiConfig } from \"@idosgames/react/ui\";\n\nexport default defineUiConfig({\n theme: {\n bgTop: \"#10263f\",\n panel: \"#1b3a5c\",\n gold: \"#ffcc33\",\n radius: 12,\n rarity: { Mythic: \"#ff3df5\" },\n },\n motion: { speed: 1.5, intensity: 0.5, particles: false },\n sound: {\n volume: 0.4,\n events: {\n click: \"tab\",\n countTick: false,\n reward: { url: \"https://…/win.mp3\" },\n },\n },\n});\n```\n\n- Every field is optional; what you leave out keeps its default. The host passes the file to\n `mountHost({ ui })`, and every module on the kit follows it at once.\n- ⚠ **Never restyle a catalog module by editing it.** An edited module is marked \"customized\" and\n stops getting catalog updates. Colours, radii, fonts, animation speed and sounds are all here.\n- Theme tokens: `bgTop`, `bgBottom`, `panel`, `panelDeep`, `panelEdge`, `text`, `textDim`, `gold`,\n `goldDeep`, `green`, `greenDeep`, `blue`, `blueDeep`, `red`, `shadow`, `backdrop`, `radius`,\n `buttonRadius`, `font`, `rarity` (rarity id → frame colour).\n- Motion: `enabled`, `speed` (2 = twice as fast), `intensity` (0 = no bounce), `rewardFlight`,\n `particles`, `stagger`, `respectReducedMotion`.\n- Sound events: `click`, `tab`, `open`, `close`, `reward`, `coin`, `purchase`, `claim`, `error`,\n `notify`, `lootbox`, `levelUp`, `countTick`. Each: another event's name, `{ url }`, `{ synth: {\nnotes: [{ freq, dur, wave }] } }` or `false`.\n- The PLAYER's own sound / volume / \"fewer animations\" live on the device (profile-ui draws them):\n `const [prefs, setPrefs] = usePlayerPrefs()` → `{ muted, volume, reducedMotion }`.\n\n## Features: screens that find each other\n\nA screen module registers a **feature**; whoever draws the menu shows it — the showcase lobby as\ntabs (phone: 4 + \"More\", tablet: more, desktop: a side rail), `game-hud` in its ☰ menu (shared-UI\nrole `\"menu\"`), or the host's floating ☰ when nobody takes the role.\n\n```ts\n// module.ts\nsetup(ctx) {\n ctx.features.register({\n id: \"forge\", label: \"Forge\", icon: \"hammer\", group: \"economy\", order: 60,\n primary: false, // true = a main tab of the lobby\n Screen: ForgeScreen, // (props: FeatureScreenProps) => ReactNode; props.args, props.close()\n });\n // Badge (\"something to claim\"): ctx.features.setBadge(\"forge\", 2)\n}\n```\n\n- Open another module's screen without importing it: `ctx.features.open(\"store\")`,\n `ctx.features.open(\"chat\", { userID })`. Check first with `ctx.features.get(\"store\")` — the\n module may not be installed.\n- Groups: `economy`, `events`, `social`, `profile`. Icons are kit glyph names (`shop`, `hero`,\n `bag`, `trophy`, `gift`, `hammer`, `market`, `offer`, `flame`, `friends`, `chat`, `link`,\n `swords`, `user`, `bulb`, …) or an image URL.\n- Something that must be on screen all the time (a daily auto-popup, a badge watcher, an overlay) is\n a panel: `ctx.registerPanel({ id, slot: \"modal\", activeOnly: false, component })`.\n\n## Building a screen on the kit\n\nKeep the screen a short list; put every detail in a **popup**.\n\n```tsx\nimport {\n Button,\n Popup,\n ResourceList,\n Tabs,\n useCelebrate,\n useToast,\n useUiKit,\n errorText,\n grantedBy,\n panel,\n outlined,\n v,\n} from \"@idosgames/react/ui\";\n\nfunction ForgeScreen(): ReactNode {\n const [open, setOpen] = useState<string | null>(null);\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n {/* rows… onClick={() => setOpen(id)} */}\n {open ? (\n <Popup title=\"Forge\" onClose={() => setOpen(null)} width={420}>\n …\n </Popup>\n ) : null}\n </div>\n );\n}\n```\n\n- `Popup` is a bottom sheet on a phone and a centred window elsewhere (`variant=\"center\"` forces\n centred); it animates in and out and plays open/close sounds. Close from inside with\n `usePopupClose()`. `ConfirmPopup` for yes/no. A popup that opens BY ITSELF (not on a tap) passes\n `queued` — auto-popups then show one at a time instead of stacking.\n- `useCelebrate()(lines)` shows \"You got\" and flies the icons to any element marked\n `data-resource-counter=\"{currencyID}\"` (the lobby and game-hud balances are). Build the lines\n from a response with `grantedBy(data)`; a reward with premium (VIP) bundles needs them added.\n- `useUiKit().play(\"claim\")`, `useUiKit().fx.burst(element)` — a sound and a particle burst.\n- `useToast()(text, \"error\")` for refusals; show `errorText(res.error)`, never re-check server rules.\n- Lines of resources: `grantLines(grant)`, `costLines(cost)`, `bundleLines(bundle)`,\n `canAfford(lines, balances)`, `<ResourceList lines={…} />`. Names and icons: `useCatalog()`.\n- Words: `makeT(EN, RU)` for your strings, `plural(n, \"лот|лота|лотов\")` for counts.\n- ⚠ A root `display: grid` with a wide child (a row of tabs) grows past a phone screen — give it\n `gridTemplateColumns: \"minmax(0, 1fr)\"`.\n\n## Tutorial hints\n\n`tutorial-ui` highlights any element marked `data-tutorial-anchor=\"…\"` — the lobby's tabs are\n`tab:{featureID}`, its profile button `lobby:profile`. Mark the controls of your own screen the same\nway; the publisher points tutorial steps at them in LiveOps (Tutorial → step → AnchorID).\n",
|
|
3
|
+
"description": "Build and restyle game screens with the iDosGames UI kit (@idosgames/react/ui) and the ready system modules (store, character, inventory, …): change colours, animations and sounds in ONE file (src/ui.config.ts), install a shop / heroes / inventory / leaderboard / rewards / craft / marketplace / offers / events / season / friends / chat / invite / arena / profile / tutorials / workshop system instead of writing it, open another module's screen with ctx.features.open, register a new screen (a lobby tab) with ctx.features.register and hide it while the title has no data (setAvailable), split a dense screen into popups (Popup, ConfirmPopup, bottom sheet on a phone), play UI sounds, celebrate a reward (icons fly to the balance counters), mark elements for tutorial hints (data-tutorial-anchor), and read the player's sound / motion prefs. Use this whenever the user asks to change how the game's interface looks, sounds or animates, to add or build a game screen or menu, a popup, a reward animation, a bottom navigation, a \"More\" menu, a settings screen, or touches @idosgames/react/ui, defineUiConfig, ui.config.ts, ctx.features, FeatureScreenProps, useCelebrate, usePlayerPrefs, Popup, Tabs or ResourceList.",
|
|
4
|
+
"content": "---\nname: idosgames-ui-kit\ndescription: >-\n Build and restyle game screens with the iDosGames UI kit (@idosgames/react/ui) and the ready\n system modules (store, character, inventory, …): change colours, animations and sounds in ONE file (src/ui.config.ts),\n install a shop / heroes / inventory / leaderboard / rewards / craft / marketplace / offers /\n events / season / friends / chat / invite / arena / profile / tutorials / workshop system instead of\n writing it, open another module's screen with ctx.features.open, register a new screen (a lobby\n tab) with ctx.features.register and hide it while the title has no data (setAvailable), split a dense screen into popups (Popup, ConfirmPopup, bottom sheet on a\n phone), play UI sounds, celebrate a reward (icons fly to the balance counters), mark elements for\n tutorial hints (data-tutorial-anchor), and read the player's sound / motion prefs. Use this\n whenever the user asks to change how the game's interface looks, sounds or animates, to add or\n build a game screen or menu, a popup, a reward animation, a bottom navigation, a \"More\" menu, a\n settings screen, or touches @idosgames/react/ui, defineUiConfig, ui.config.ts, ctx.features,\n FeatureScreenProps, useCelebrate, usePlayerPrefs, Popup, Tabs or ResourceList.\n---\n\n# The UI kit and the system modules\n\nThe app's interface is the base's lobby (`src/base/`) plus **system modules**, all on one **UI kit**:\n\n- `@idosgames/react/ui` — the kit: components (buttons, tabs, popups, resource lists, item tiles,\n progress bars, timers), the reward popup, toasts, UI sounds (synthesized, no files), animations,\n and the player's own sound / motion prefs.\n- system modules — the platform's systems, each a whole `feature` module (its screen = a lobby\n tab, its client logic, its demo settings), all installed with the base into every new project\n (`inBase` in the catalog). The module id is the feature id:\n\n| Module | Screen |\n| ------------- | ---------------------------------------------------------------- |\n| `store` | shop: stores as tabs, offers, purchase popup |\n| `character` | heroes: gear slots, \"Equip best\", stats, ranks, skins (popups) |\n| `inventory` | items by rarity, item card, lootbox opening |\n| `leaderboard` | tables, my place, rank rewards, last cycle's prize |\n| `rewards` | daily calendar, quests, points track |\n| `craft` | recipes, contract popup |\n| `marketplace` | lots, auctions, selling, trades |\n| `deals` | special offers, auto-popup of a new deal |\n| `events` | limited-time events, milestones, VIP prize |\n| `season` | season tiers, VIP prize |\n| `friends` | friends, requests, suggestions, news |\n| `chat` | channels, private chats |\n| `invite` | own code and link, friend's code, rewards |\n| `pvp` | duels, strategy, fight replay |\n| `profile` | name, sound, language, consent, delete account |\n| `tutorials` | hint overlay + replay list |\n| `workshop` | players' and the publisher's content: catalog, publish, licenses |\n\n**Need one of these? Install the module — don't write the screen.** Every screen follows the\nserver's rules and ships demo title settings (`title-seed.json`). The app does not need one → remove it\nfrom `src/modules.ts`, its tab is gone. The lobby itself (tabs, balances, wallet, profile, \"Play\") is\nthe base, `src/base/lobby/`.\n\n## Change the look, animations and sounds — only in `src/ui.config.ts`\n\n```ts\nimport { defineUiConfig } from \"@idosgames/react/ui\";\n\nexport default defineUiConfig({\n theme: {\n bgTop: \"#10263f\",\n panel: \"#1b3a5c\",\n gold: \"#ffcc33\",\n radius: 12,\n rarity: { Mythic: \"#ff3df5\" },\n },\n motion: { speed: 1.5, intensity: 0.5, particles: false },\n sound: {\n volume: 0.4,\n events: {\n click: \"tab\",\n countTick: false,\n reward: { url: \"https://…/win.mp3\" },\n },\n },\n});\n```\n\n- Every field is optional; what you leave out keeps its default. The host passes the file to\n `mountHost({ ui })`, and every module on the kit follows it at once.\n- ⚠ **Never restyle a catalog module by editing it.** An edited module is marked \"customized\" and\n stops getting catalog updates. Colours, radii, fonts, animation speed and sounds are all here.\n- Theme tokens: `bgTop`, `bgBottom`, `panel`, `panelDeep`, `panelEdge`, `text`, `textDim`, `gold`,\n `goldDeep`, `green`, `greenDeep`, `blue`, `blueDeep`, `red`, `shadow`, `backdrop`, `radius`,\n `buttonRadius`, `font`, `rarity` (rarity id → frame colour).\n- Motion: `enabled`, `speed` (2 = twice as fast), `intensity` (0 = no bounce), `rewardFlight`,\n `particles`, `stagger`, `respectReducedMotion`.\n- Sound events: `click`, `tab`, `open`, `close`, `reward`, `coin`, `purchase`, `claim`, `error`,\n `notify`, `lootbox`, `levelUp`, `countTick`. Each: another event's name, `{ url }`, `{ synth: {\nnotes: [{ freq, dur, wave }] } }` or `false`.\n- The PLAYER's own sound / volume / \"fewer animations\" live on the device (the profile module draws\n them):\n `const [prefs, setPrefs] = usePlayerPrefs()` → `{ muted, volume, reducedMotion }`.\n\n## Features: screens that find each other\n\nA module registers a **feature**; whoever draws the menu shows it — the base's lobby as tabs (phone:\n4 + \"More\", tablet: more, desktop: a side rail; the base takes the shared-UI role `\"menu\"`), or the\nhost's floating ☰ when nobody takes the role. Tab order and main tabs can be overridden in\n`src/app.config.ts` (`lobby.order`, `lobby.primary`).\n\n```ts\n// module.ts\nsetup(ctx) {\n ctx.features.register({\n id: \"forge\", label: \"Forge\", icon: \"hammer\", group: \"economy\", order: 60,\n primary: false, // true = a main tab of the lobby\n Screen: ForgeScreen, // (props: FeatureScreenProps) => ReactNode; props.args, props.close()\n available: false, // hidden until the title has something for it — see below\n });\n // Badge (\"something to claim\"): ctx.features.setBadge(\"forge\", 2)\n // The title has forge recipes → show the tab; the reason is for the AI Coder's agent only:\n ctx.features.setAvailable(\"forge\", hasRecipes(configSection(ctx.client, \"Craft\")), \"no forge recipes\");\n}\n```\n\n- Open another module's screen without importing it: `ctx.features.open(\"store\")`,\n `ctx.features.open(\"chat\", { userID })`. It returns `false` when the module is not installed or its\n tab is hidden (`ctx.features.get(\"store\")?.available`). From a game (the lobby is off screen) the\n host shows the screen in a window.\n- A screen that depends on title data registers with `available: false` and calls\n `ctx.features.setAvailable(id, ok, reason)` once it knows — from the client config\n (`configSection(client, \"Store\")`) or a read it makes anyway (a badge watcher); no extra request\n at start-up: every module's first reads arrive together and brush the per-player rate limit.\n- Groups: `economy`, `events`, `social`, `profile`. Icons are kit glyph names (`shop`, `hero`,\n `bag`, `trophy`, `gift`, `hammer`, `market`, `offer`, `flame`, `friends`, `chat`, `link`,\n `swords`, `user`, `bulb`, …) or an image URL.\n- Something that must be on screen all the time (a daily auto-popup, a badge watcher, an overlay) is\n a panel: `ctx.registerPanel({ id, slot: \"modal\", activeOnly: false, component })`.\n\n## Building a screen on the kit\n\nKeep the screen a short list; put every detail in a **popup**.\n\n```tsx\nimport {\n Button,\n Popup,\n ResourceList,\n Tabs,\n useCelebrate,\n useToast,\n useUiKit,\n errorText,\n grantedBy,\n panel,\n outlined,\n v,\n} from \"@idosgames/react/ui\";\n\nfunction ForgeScreen(): ReactNode {\n const [open, setOpen] = useState<string | null>(null);\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n {/* rows… onClick={() => setOpen(id)} */}\n {open ? (\n <Popup title=\"Forge\" onClose={() => setOpen(null)} width={420}>\n …\n </Popup>\n ) : null}\n </div>\n );\n}\n```\n\n- `Popup` is a bottom sheet on a phone and a centred window elsewhere (`variant=\"center\"` forces\n centred); it animates in and out and plays open/close sounds. Close from inside with\n `usePopupClose()`. `ConfirmPopup` for yes/no. A popup that opens BY ITSELF (not on a tap) passes\n `queued` — auto-popups then show one at a time instead of stacking.\n- `useCelebrate()(lines)` shows \"You got\" and flies the icons to any element marked\n `data-resource-counter=\"{currencyID}\"` (the lobby's balances are). Build the lines\n from a response with `grantedBy(data)`; a reward with premium (VIP) bundles needs them added.\n- `useUiKit().play(\"claim\")`, `useUiKit().fx.burst(element)` — a sound and a particle burst.\n- `useToast()(text, \"error\")` for refusals; show `errorText(res.error)`, never re-check server rules.\n- Lines of resources: `grantLines(grant)`, `costLines(cost)`, `bundleLines(bundle)`,\n `canAfford(lines, balances)`, `<ResourceList lines={…} />`. Names and icons: `useCatalog()`.\n- Words: `makeT(EN, RU)` for your strings, `plural(n, \"лот|лота|лотов\")` for counts.\n- ⚠ A root `display: grid` with a wide child (a row of tabs) grows past a phone screen — give it\n `gridTemplateColumns: \"minmax(0, 1fr)\"`.\n\n## Tutorial hints\n\nThe `tutorials` module highlights any element marked `data-tutorial-anchor=\"…\"` — the lobby's tabs\nare `tab:{featureID}`, its profile button `lobby:profile`, its wallet `lobby:wallet`, a game's \"Play\"\nbutton `play:{modeID}`. Mark the controls of your own screen the same\nway; the publisher points tutorial steps at them in LiveOps (Tutorial → step → AnchorID).\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "push-notifications",
|
|
3
3
|
"description": "Add push notifications to a game on the iDosGames TypeScript SDK (@idosgames/core) via client.push (PushService): ask the player for permission from a tap, register the browser's Web Push subscription with the server, draw the right button state, list and remove the player's devices, and ship the service worker that displays a notification. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg, voxelcraft) and wants push notifications, browser notifications, web push, a \"notify me\" or \"enable notifications\" toggle, a re-engagement or comeback reminder, an energy-refilled or build-finished alert, a service worker, sw.js, VAPID, PushManager, Notification.permission, or otherwise touches client.push, PushService, PushConfigResponse, PushSubscriptionView or PushPermissionState — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: push-notifications\ndescription: >-\n Add push notifications to a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.push (PushService): ask the player for\n permission from a tap, register the browser's Web Push subscription with the\n server, draw the right button state, list and remove the player's devices,\n and ship the service worker that displays a notification. Use this whenever\n the user is working in the iDosGames TS SDK or its game templates\n (board-game, idle-rpg, voxelcraft) and wants push notifications, browser\n notifications, web push, a \"notify me\" or \"enable notifications\" toggle, a\n re-engagement or comeback reminder, an energy-refilled or build-finished\n alert, a service worker, sw.js, VAPID, PushManager, Notification.permission,\n or otherwise touches client.push, PushService, PushConfigResponse,\n PushSubscriptionView or PushPermissionState — even if they don't name the\n module explicitly.\n---\n\n# Push notifications (iDosGames TS SDK)\n\nPush reaches a player who is **not in the game**. That single fact shapes the\nwhole module: the text is resolved on the server (nobody is around to ask what\nlanguage to use), the sending is done by the backend from a queue, and the only\nthing the game does is get the browser registered and ship a service worker\nthat displays what arrives.\n\nThis skill is for **using** the production `PushService`. A refusal is almost\nalways the browser or the publisher's config, not a bug to work around.\n\n## The one rule that outranks everything else here\n\n**`client.push.subscribe()` runs from a real tap. Never on load, never in a\n`useEffect`, never \"just to check\".**\n\nBrowsers reject `Notification.requestPermission()` outside a user gesture, and\nChrome **permanently blocks an origin** after a few dismissals. So an automatic\nprompt does not merely fail — it takes away the player's ability to ever say\nyes, and nothing in your code can undo that afterwards. Only the person, in\ntheir browser's site settings.\n\nAsk when the player has just been given a reason to want it (\"tell me when my\nenergy is full\"), not when the game starts.\n\n## Drawing the button\n\n```ts\nimport { PushPermissionState } from \"@idosgames/core\";\n\nconst state = await client.push.getState();\n```\n\nFive values, and **four of them mean \"no button\"** — for different reasons:\n\n| State | What it means | What to draw |\n| ------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------ |\n| `idle` | Available, nobody asked yet | **The button.** This is the only state that gets one |\n| `subscribed` | This browser is registered | \"Notifications on\", plus a way to turn them off |\n| `blocked` | The person said no, or Chrome said it for them | Nothing, or a line explaining that browser settings can undo it. **Do not offer to retry** |\n| `unsupported` | No Push API, or the publisher has not enabled the feature | Nothing at all |\n| `embedded` | Running inside the portal iframe | Nothing — the site owns this, see below |\n\n`unsupported` deliberately does not distinguish \"the platform has no keys\" from\n\"the publisher did not opt in\": in both cases there must be no button, and a\nfeature that cannot work has to be invisible rather than broken.\n\n## Subscribing\n\n```ts\nasync function onEnableNotificationsTapped() {\n const result = await client.push.subscribe();\n if (!result.ok) return showToast(result.error.message);\n\n // \"portal\" means the WEBSITE subscribed on the player's behalf — see below.\n showToast(result.data.Where === \"portal\" ? \"Notifications on\" : \"All set\");\n}\n```\n\nIdempotent: a browser that is already subscribed re-sends the same endpoint,\nwhich refreshes the row's language and timezone instead of creating a second\none. Calling it twice is harmless.\n\n`subscribe()` registers `./sw.js` by default — **relative on purpose**. A hosted\nbuild lives at `cloud.idosgames.com/drive/app/{titleID}/`, so a leading slash\nwould point at another game's folder, and the relative form also scopes the\nregistration to exactly this game's directory. That scope is what gives each\ngame its own subscription on a shared origin. Pass `swPath` only if your worker\nlives somewhere else.\n\n## ⚠⚠ Inside the portal it works differently, and that is not fixable\n\nA game opened on `idosgames.com/app/{id}` runs in a **cross-origin iframe**,\nwhere the browser blocks the permission prompt outright. Not a policy anyone can\nrelax — it is how permissions work.\n\nSo there `subscribe()` asks the **website** to do it, on its own top-level\ndocument. The site creates the row against the player's platform identity, the\n**engine still does the sending**, and the player's game id reaches that row on\ntheir next platform sign-in. `result.data.Where` tells you which happened; a\nportal subscription is real, it just does not belong to the game's origin, and\n`Subscription` comes back `null` because the game never sees its keys.\n\nConsequences for your UI: `getState()` returns `embedded` there, so the button\nis hidden by default. If you want an \"enable notifications\" affordance in the\nportal too, call `subscribe()` from a tap anyway and branch on the result — but\ndo not try to read `Notification.permission` or `pushManager`, which describe\nthe iframe, not the page the player is looking at.\n\n## The service worker\n\n`
|
|
4
|
+
"content": "---\nname: push-notifications\ndescription: >-\n Add push notifications to a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.push (PushService): ask the player for\n permission from a tap, register the browser's Web Push subscription with the\n server, draw the right button state, list and remove the player's devices,\n and ship the service worker that displays a notification. Use this whenever\n the user is working in the iDosGames TS SDK or its game templates\n (board-game, idle-rpg, voxelcraft) and wants push notifications, browser\n notifications, web push, a \"notify me\" or \"enable notifications\" toggle, a\n re-engagement or comeback reminder, an energy-refilled or build-finished\n alert, a service worker, sw.js, VAPID, PushManager, Notification.permission,\n or otherwise touches client.push, PushService, PushConfigResponse,\n PushSubscriptionView or PushPermissionState — even if they don't name the\n module explicitly.\n---\n\n# Push notifications (iDosGames TS SDK)\n\nPush reaches a player who is **not in the game**. That single fact shapes the\nwhole module: the text is resolved on the server (nobody is around to ask what\nlanguage to use), the sending is done by the backend from a queue, and the only\nthing the game does is get the browser registered and ship a service worker\nthat displays what arrives.\n\nThis skill is for **using** the production `PushService`. A refusal is almost\nalways the browser or the publisher's config, not a bug to work around.\n\n## The one rule that outranks everything else here\n\n**`client.push.subscribe()` runs from a real tap. Never on load, never in a\n`useEffect`, never \"just to check\".**\n\nBrowsers reject `Notification.requestPermission()` outside a user gesture, and\nChrome **permanently blocks an origin** after a few dismissals. So an automatic\nprompt does not merely fail — it takes away the player's ability to ever say\nyes, and nothing in your code can undo that afterwards. Only the person, in\ntheir browser's site settings.\n\nAsk when the player has just been given a reason to want it (\"tell me when my\nenergy is full\"), not when the game starts.\n\n## Drawing the button\n\n```ts\nimport { PushPermissionState } from \"@idosgames/core\";\n\nconst state = await client.push.getState();\n```\n\nFive values, and **four of them mean \"no button\"** — for different reasons:\n\n| State | What it means | What to draw |\n| ------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------ |\n| `idle` | Available, nobody asked yet | **The button.** This is the only state that gets one |\n| `subscribed` | This browser is registered | \"Notifications on\", plus a way to turn them off |\n| `blocked` | The person said no, or Chrome said it for them | Nothing, or a line explaining that browser settings can undo it. **Do not offer to retry** |\n| `unsupported` | No Push API, or the publisher has not enabled the feature | Nothing at all |\n| `embedded` | Running inside the portal iframe | Nothing — the site owns this, see below |\n\n`unsupported` deliberately does not distinguish \"the platform has no keys\" from\n\"the publisher did not opt in\": in both cases there must be no button, and a\nfeature that cannot work has to be invisible rather than broken.\n\n## Subscribing\n\n```ts\nasync function onEnableNotificationsTapped() {\n const result = await client.push.subscribe();\n if (!result.ok) return showToast(result.error.message);\n\n // \"portal\" means the WEBSITE subscribed on the player's behalf — see below.\n showToast(result.data.Where === \"portal\" ? \"Notifications on\" : \"All set\");\n}\n```\n\nIdempotent: a browser that is already subscribed re-sends the same endpoint,\nwhich refreshes the row's language and timezone instead of creating a second\none. Calling it twice is harmless.\n\n`subscribe()` registers `./sw.js` by default — **relative on purpose**. A hosted\nbuild lives at `cloud.idosgames.com/drive/app/{titleID}/`, so a leading slash\nwould point at another game's folder, and the relative form also scopes the\nregistration to exactly this game's directory. That scope is what gives each\ngame its own subscription on a shared origin. Pass `swPath` only if your worker\nlives somewhere else.\n\n## ⚠⚠ Inside the portal it works differently, and that is not fixable\n\nA game opened on `idosgames.com/app/{id}` runs in a **cross-origin iframe**,\nwhere the browser blocks the permission prompt outright. Not a policy anyone can\nrelax — it is how permissions work.\n\nSo there `subscribe()` asks the **website** to do it, on its own top-level\ndocument. The site creates the row against the player's platform identity, the\n**engine still does the sending**, and the player's game id reaches that row on\ntheir next platform sign-in. `result.data.Where` tells you which happened; a\nportal subscription is real, it just does not belong to the game's origin, and\n`Subscription` comes back `null` because the game never sees its keys.\n\nConsequences for your UI: `getState()` returns `embedded` there, so the button\nis hidden by default. If you want an \"enable notifications\" affordance in the\nportal too, call `subscribe()` from a tap anyway and branch on the result — but\ndo not try to read `Notification.permission` or `pushManager`, which describe\nthe iframe, not the page the player is looking at.\n\n## The service worker\n\n`public/sw.js` of the base (`templates/base`) ships as a starting point you own. Two\nthings in it are not negotiable:\n\n1. **⚠⚠ No `fetch` handler. Ever.** A caching worker looks like a free win and\n is the most expensive mistake available here: a cached `index.html` points at\n content-hashed chunks that the next deploy deletes, and the player gets a\n white screen served from _inside their own browser_, where neither a CDN\n purge nor Ctrl+F5 reaches. The platform already paid for that bug once in the\n publisher dashboard. If you want offline support, do it deliberately and\n never cache the document.\n2. **Always call `showNotification`.** The subscription was created with\n `userVisibleOnly: true`, which is a promise to display every message. Break it\n and the browser first warns the player about background activity, then revokes\n the permission.\n\nThe payload the server sends is small and already localised:\n`{ title, body?, url?, icon?, tag? }`. Do not translate it in the worker — the\ntext was resolved into the **device's** language at send time, using the locale\ncaptured when the subscription was created.\n\n## Managing devices\n\n```ts\nconst list = await client.push.getSubscriptions(); // every device, this title\nawait client.push.unsubscribeByHash(row.EndpointHash); // remove one of them\nawait client.push.unsubscribe(); // remove THIS browser\n```\n\nA subscription belongs to a **(browser, registration)** pair, not to an account:\none person's phone, laptop and portal tab are three separate rows. Clearing site\ndata destroys one silently, with no event — which is why `getState()` asks the\nregistration rather than trusting `Notification.permission`, and why every call\nhere is idempotent.\n\n`unsubscribe()` does both halves — the browser and the server — and both matter:\ndropping only the server row leaves a browser holding a live endpoint, and\ndropping only the browser one leaves a row that keeps being sent to until a push\nservice answers `410`.\n\n## Things not to do\n\n- **Do not compute `EndpointHash`.** It is `sha256(endpoint)` and trivial to\n reproduce — and it addresses a shared database that already has two\n implementations guarded by golden vectors. A third one, in another language\n and without those vectors, drifts silently and leaves the player looking at a\n button in the wrong state. Use the value the server returned.\n- **Do not bake the VAPID key into the build.** It arrives from `getConfig()`\n because it is a platform key shared by both backends; baking it in means a key\n rotation requires rebuilding every game.\n- **Do not poll `getState()`.** Nothing changes it except the player, in a\n prompt you started.\n- **Do not send notifications from the client.** There is no such call and there\n will not be one: producing a notification is a server concern, gated by the\n publisher's per-player and per-title daily caps and quiet hours. The publisher\n API quota does not even see sends — those caps are the only ceiling there is.\n- **Do not ask again after `blocked`.** There is nothing to ask.\n\n## What the publisher controls (and you cannot)\n\nIn the title config, under `Push`: the master switch (**off by default** — this\nspends a real person's attention), a per-player daily cap, a title-wide daily\ncap, quiet hours, and a default icon. Quiet hours are evaluated in the\n**device's** timezone, captured at subscribe time and separate from the\nlanguage — Portuguese is spoken in Lisbon and in São Paulo.\n\nNone of this is visible to the game. A notification that was capped or fell into\nquiet hours simply never arrives, and there is no client-side signal for it.\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "workshop-system",
|
|
3
3
|
"description": "Let players share what they make — maps, levels, worlds, skins, 3D models, any file — through the Workshop of the iDosGames TypeScript SDK (@idosgames/core client.workshop, WorkshopService): configure content types for a title, publish with files and a thumbnail, set access (free, a price in in-game resources, or \"hold these resources to unlock\" — while held or once), browse the catalog with filters and publisher collections, acquire, download and open content, likes, favorites, following authors, reports, official content. Also covers plugging a game into the ready-made `workshop` module via ctx.content (ContentTypeHandler: listLocal / capture / open). Use this whenever the user wants user-generated content, a level/map sharing screen, selling maps or skins between players, unlocking content for holders of an item, a creator catalog, or touches client.workshop, WorkshopService, publish, acquire, downloadFiles, WorkshopAccessOption, WorkshopDefinitions, ContentTypes, ctx.content or the workshop module — even if they don't name the module.",
|
|
4
|
-
"content": "---\nname: workshop-system\ndescription: >-\n Let players share what they make — maps, levels, worlds, skins, 3D models, any\n file — through the Workshop of the iDosGames TypeScript SDK\n (@idosgames/core client.workshop, WorkshopService): configure content types for\n a title, publish with files and a thumbnail, set access (free, a price in\n in-game resources, or \"hold these resources to unlock\" — while held or once),\n browse the catalog with filters and publisher collections, acquire, download\n and open content, likes, favorites, following authors, reports, official\n content. Also covers plugging a game into the ready-made `workshop` module via\n ctx.content (ContentTypeHandler: listLocal / capture / open). Use this whenever\n the user wants user-generated content, a level/map sharing screen, selling\n maps or skins between players, unlocking content for holders of an item, a\n creator catalog, or touches client.workshop, WorkshopService, publish,\n acquire, downloadFiles, WorkshopAccessOption, WorkshopDefinitions,\n ContentTypes, ctx.content or the workshop module — even if they don't name\n the module.\n---\n\n# Workshop (iDosGames TS SDK)\n\nThe Workshop is a catalog of content made by players (and by the publisher — \"official\"). It is\n**not** the Marketplace: nothing is transferred. Acquiring grants a **license** to a digital copy —\none publication is acquired by many players and the author keeps it. For trading actual items\nbetween players use **marketplace-system**.\n\nEverything game-specific lives in the title config (`Workshop` section, edited on the dashboard\npage LiveOps → Workshop): which content types exist, which files each has, who may publish, which\naccess modes authors may offer, commission, moderation. The server enforces all of it — surface a\nrefusal, don't re-implement the check.\n\n## Two layers\n\n| You want | Use |\n| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |\n| A ready catalog screen in a composed game | install the **`workshop` module** and register your content type with `ctx.content` (below) — no Workshop code of your own |\n| Your own UI, or a game without the module system | call `client.workshop.*` directly |\n\n## Config: content types\n\n`Workshop.ContentTypes` is a dictionary keyed by the type id your game sends:\n\n```jsonc\n\"Workshop\": {\n \"Enabled\": true,\n \"ContentTypes\": {\n \"voxelcraft.world\": {\n \"DisplayName\": \"World\",\n \"Files\": { \"main\": { \"AllowedMimeTypes\": [\"application/json\"], \"MaxBytes\": 10485760 } },\n // \"extra\": { \"AllowedMimeTypes\": [\"model/gltf-binary\"], \"MaxCount\": 4, \"Required\": false }\n \"AllowedAccessModes\": [\"Free\", \"Price\", \"Holding\"], // null = all\n \"PublishFeeOptions\": null, // PriceOptions, Standard part only\n \"MaxPublishedPerPlayer\": 50, \"DailyPublishCap\": 10\n }\n },\n \"Moderation\": { \"AutoHideReportThreshold\": 5, \"RequireApproval\": false }\n}\n```\n\n- `Files: null` = one required `main` JSON file ≤ 10 MB. The server checks size and **file\n signature** against the declared MIME type; `application/octet-stream` passes only if listed.\n- The section is **not** in the public title config the client caches (it holds the moderation\n blocklist). Read what the client may know with `client.workshop.getDefinitions()` — which also\n says which types this player may publish and which collections are live.\n\n## Access options — ANY one opens\n\nA publication carries a list of options; the player needs to satisfy **one**:\n\n```ts\naccess: [\n {\n Mode: \"Holding\",\n Holding: {\n Match: \"Any\",\n Mode: \"WhileHeld\",\n Requirements: [\n { Type: \"Item\", CatalogID: \"keys\", ItemID: \"gold_key\", Amount: 1 },\n ],\n },\n },\n {\n Mode: \"Price\",\n Price: {\n Entries: [{ Type: \"VirtualCurrency\", CurrencyID: \"GOLD\", Amount: 100 }],\n },\n },\n];\n// = free for holders of a gold key, 100 GOLD for everyone else\n```\n\n- `Free` — anyone.\n- `Price` — the buyer pays, the author receives the price minus the title's commission. Official\n content: the whole price goes to the title.\n- `Holding` — nothing is spent. `WhileHeld`: checked on **every download**, spend the key and access\n is gone (no license is written). `UnlockOnce`: checked once, then a permanent license. `Match: All`\n needs every requirement, `Any` one of them. Items may carry `MinLevel`.\n\n## Client API (`client.workshop`)\n\n```ts\nconst defs = await client.workshop.getDefinitions();\nconst page = await client.workshop.browse({\n contentType: \"voxelcraft.world\",\n sort: \"Popular\",\n});\nconst card = await client.workshop.getContent(contentID); // + per-option availability\n\nconst pub = await client.workshop.publish({\n contentType: \"voxelcraft.world\",\n files: [\n {\n role: \"main\",\n contentType: \"application/json\",\n data: JSON.stringify(save),\n },\n ],\n thumbnail: { contentType: \"image/webp\", data: webpBlob },\n title: \"Frost Keep\",\n tags: [\"castle\"],\n visibility: \"Public\", // Public | Unlisted | Friends\n access: [{ Mode: \"Free\" }],\n});\n\nconst got = await client.workshop.acquire(contentID, option); // pass the option you SHOWED\nconst dl = await client.workshop.downloadFiles(contentID); // bytes of every file\n```\n\n- `publish` does declare → upload straight to storage by signed URLs → verify and release. With\n `contentID` it uploads a new revision of your own publication. A file from `client.ai` can be\n published by URL: `{ role, contentType, sourceAssetUrl }` — the server copies it.\n- **`acquire(contentID, option)` sends `ExpectedPrice = option.Price`.** If the author changed the\n price meanwhile the server refuses instead of charging a price the player never saw — re-read the\n card and ask again. Acquiring twice is safe: the second answer says `AlreadyOwned`, nothing charged.\n- `getDownload` / `downloadFiles` return short-lived signed links — fetch right away, don't store them.\n- Also: `updateContent`, `unpublish` (buyers keep access), `getMyContent`, `getMyLicenses`,\n `getMyFavorites`, `getCreatorProfile`, `getCollection`.\n- ⚠ **Likes, favorites, following an author, comments and reports are `client.social`, not\n `client.workshop`** (see the `social-system` skill): `client.social.like(\"WorkshopItem\", id)`,\n `favorite(\"WorkshopItem\", id)`, `follow(\"User\", creatorUserID)`,\n `report(\"WorkshopItem\", id, \"Broken\")`, `addComment(\"WorkshopItem\", id, text)`. The Workshop\n rules still apply to them (gate, visibility, \"can't like your own\", \"you may report what you\n already got\"). Cards keep `Stats.Likes`/`Favorites` and `Liked`/`Favorited`; the author's follower\n count and \"am I following\" come from `getCreatorProfile` or `client.social.getEntityStates(\"User\", …)`.\n- Events: `workshop:definitionsLoaded | published | updated | acquired` (reactions emit\n `social:entityStateChanged`).\n\n## Plugging a game into the `workshop` module\n\n```ts\nsetup(ctx) {\n ctx.content.registerType({\n type: \"level\", // = key of Workshop.ContentTypes\n label: \"Level\", icon: \"🧩\", modeId: \"my-game\",\n listLocal: async () => myLevels.map((l) => ({ id: l.id, name: l.name })),\n capture: async (id) => ({ files: [{ role: \"main\", contentType: \"application/json\",\n data: JSON.stringify(load(id)) }], suggestedTitle: load(id).name }),\n open: async (c) => startLevel(JSON.parse(new TextDecoder().decode(c.files[0].data))),\n });\n}\n```\n\nThe module lists your local items, publishes them, and after `open
|
|
4
|
+
"content": "---\nname: workshop-system\ndescription: >-\n Let players share what they make — maps, levels, worlds, skins, 3D models, any\n file — through the Workshop of the iDosGames TypeScript SDK\n (@idosgames/core client.workshop, WorkshopService): configure content types for\n a title, publish with files and a thumbnail, set access (free, a price in\n in-game resources, or \"hold these resources to unlock\" — while held or once),\n browse the catalog with filters and publisher collections, acquire, download\n and open content, likes, favorites, following authors, reports, official\n content. Also covers plugging a game into the ready-made `workshop` module via\n ctx.content (ContentTypeHandler: listLocal / capture / open). Use this whenever\n the user wants user-generated content, a level/map sharing screen, selling\n maps or skins between players, unlocking content for holders of an item, a\n creator catalog, or touches client.workshop, WorkshopService, publish,\n acquire, downloadFiles, WorkshopAccessOption, WorkshopDefinitions,\n ContentTypes, ctx.content or the workshop module — even if they don't name\n the module.\n---\n\n# Workshop (iDosGames TS SDK)\n\nThe Workshop is a catalog of content made by players (and by the publisher — \"official\"). It is\n**not** the Marketplace: nothing is transferred. Acquiring grants a **license** to a digital copy —\none publication is acquired by many players and the author keeps it. For trading actual items\nbetween players use **marketplace-system**.\n\nEverything game-specific lives in the title config (`Workshop` section, edited on the dashboard\npage LiveOps → Workshop): which content types exist, which files each has, who may publish, which\naccess modes authors may offer, commission, moderation. The server enforces all of it — surface a\nrefusal, don't re-implement the check.\n\n## Two layers\n\n| You want | Use |\n| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |\n| A ready catalog screen in a composed game | install the **`workshop` module** and register your content type with `ctx.content` (below) — no Workshop code of your own |\n| Your own UI, or a game without the module system | call `client.workshop.*` directly |\n\n## Config: content types\n\n`Workshop.ContentTypes` is a dictionary keyed by the type id your game sends:\n\n```jsonc\n\"Workshop\": {\n \"Enabled\": true,\n \"ContentTypes\": {\n \"voxelcraft.world\": {\n \"DisplayName\": \"World\",\n \"Files\": { \"main\": { \"AllowedMimeTypes\": [\"application/json\"], \"MaxBytes\": 10485760 } },\n // \"extra\": { \"AllowedMimeTypes\": [\"model/gltf-binary\"], \"MaxCount\": 4, \"Required\": false }\n \"AllowedAccessModes\": [\"Free\", \"Price\", \"Holding\"], // null = all\n \"PublishFeeOptions\": null, // PriceOptions, Standard part only\n \"MaxPublishedPerPlayer\": 50, \"DailyPublishCap\": 10\n }\n },\n \"Moderation\": { \"AutoHideReportThreshold\": 5, \"RequireApproval\": false }\n}\n```\n\n- `Files: null` = one required `main` JSON file ≤ 10 MB. The server checks size and **file\n signature** against the declared MIME type; `application/octet-stream` passes only if listed.\n- The section is **not** in the public title config the client caches (it holds the moderation\n blocklist). Read what the client may know with `client.workshop.getDefinitions()` — which also\n says which types this player may publish and which collections are live.\n\n## Access options — ANY one opens\n\nA publication carries a list of options; the player needs to satisfy **one**:\n\n```ts\naccess: [\n {\n Mode: \"Holding\",\n Holding: {\n Match: \"Any\",\n Mode: \"WhileHeld\",\n Requirements: [\n { Type: \"Item\", CatalogID: \"keys\", ItemID: \"gold_key\", Amount: 1 },\n ],\n },\n },\n {\n Mode: \"Price\",\n Price: {\n Entries: [{ Type: \"VirtualCurrency\", CurrencyID: \"GOLD\", Amount: 100 }],\n },\n },\n];\n// = free for holders of a gold key, 100 GOLD for everyone else\n```\n\n- `Free` — anyone.\n- `Price` — the buyer pays, the author receives the price minus the title's commission. Official\n content: the whole price goes to the title.\n- `Holding` — nothing is spent. `WhileHeld`: checked on **every download**, spend the key and access\n is gone (no license is written). `UnlockOnce`: checked once, then a permanent license. `Match: All`\n needs every requirement, `Any` one of them. Items may carry `MinLevel`.\n\n## Client API (`client.workshop`)\n\n```ts\nconst defs = await client.workshop.getDefinitions();\nconst page = await client.workshop.browse({\n contentType: \"voxelcraft.world\",\n sort: \"Popular\",\n});\nconst card = await client.workshop.getContent(contentID); // + per-option availability\n\nconst pub = await client.workshop.publish({\n contentType: \"voxelcraft.world\",\n files: [\n {\n role: \"main\",\n contentType: \"application/json\",\n data: JSON.stringify(save),\n },\n ],\n thumbnail: { contentType: \"image/webp\", data: webpBlob },\n title: \"Frost Keep\",\n tags: [\"castle\"],\n visibility: \"Public\", // Public | Unlisted | Friends\n access: [{ Mode: \"Free\" }],\n});\n\nconst got = await client.workshop.acquire(contentID, option); // pass the option you SHOWED\nconst dl = await client.workshop.downloadFiles(contentID); // bytes of every file\n```\n\n- `publish` does declare → upload straight to storage by signed URLs → verify and release. With\n `contentID` it uploads a new revision of your own publication. A file from `client.ai` can be\n published by URL: `{ role, contentType, sourceAssetUrl }` — the server copies it.\n- **`acquire(contentID, option)` sends `ExpectedPrice = option.Price`.** If the author changed the\n price meanwhile the server refuses instead of charging a price the player never saw — re-read the\n card and ask again. Acquiring twice is safe: the second answer says `AlreadyOwned`, nothing charged.\n- `getDownload` / `downloadFiles` return short-lived signed links — fetch right away, don't store them.\n- Also: `updateContent`, `unpublish` (buyers keep access), `getMyContent`, `getMyLicenses`,\n `getMyFavorites`, `getCreatorProfile`, `getCollection`.\n- ⚠ **Likes, favorites, following an author, comments and reports are `client.social`, not\n `client.workshop`** (see the `social-system` skill): `client.social.like(\"WorkshopItem\", id)`,\n `favorite(\"WorkshopItem\", id)`, `follow(\"User\", creatorUserID)`,\n `report(\"WorkshopItem\", id, \"Broken\")`, `addComment(\"WorkshopItem\", id, text)`. The Workshop\n rules still apply to them (gate, visibility, \"can't like your own\", \"you may report what you\n already got\"). Cards keep `Stats.Likes`/`Favorites` and `Liked`/`Favorited`; the author's follower\n count and \"am I following\" come from `getCreatorProfile` or `client.social.getEntityStates(\"User\", …)`.\n- Events: `workshop:definitionsLoaded | published | updated | acquired` (reactions emit\n `social:entityStateChanged`).\n\n## Plugging a game into the `workshop` module\n\n```ts\nsetup(ctx) {\n ctx.content.registerType({\n type: \"level\", // = key of Workshop.ContentTypes\n label: \"Level\", icon: \"🧩\", modeId: \"my-game\",\n listLocal: async () => myLevels.map((l) => ({ id: l.id, name: l.name })),\n capture: async (id) => ({ files: [{ role: \"main\", contentType: \"application/json\",\n data: JSON.stringify(load(id)) }], suggestedTitle: load(id).name }),\n open: async (c) => startLevel(JSON.parse(new TextDecoder().decode(c.files[0].data))),\n });\n}\n```\n\nThe module is a tab of the lobby (installed with the base in every new project); it stays hidden until\nsome game registers a content type. It lists your local items, publishes them, and after `open`\nswitches to `modeId`. A type with no handler is still browsable and acquirable — it just can't be\npublished or opened from the game. Contract details: **idosgames-module-contract**.\n\n## Don't\n\n- Don't gate access on the client — show `getContent`'s option availability and let `acquire` decide.\n- Don't write a \"license\" of your own for `WhileHeld` content: access must follow the player's\n inventory.\n- Don't cache signed URLs or put private files in the public config.\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "chat-ui",
|
|
3
|
-
"meta": {
|
|
4
|
-
"name": "Chat",
|
|
5
|
-
"type": "app",
|
|
6
|
-
"engine": "dom"
|
|
7
|
-
},
|
|
8
|
-
"catalog": {
|
|
9
|
-
"type": "feature",
|
|
10
|
-
"summary": "Chat for any game: channels as tabs, private chats, a feed with earlier messages, slow mode, and report / ignore / write privately in a popup.",
|
|
11
|
-
"description": "A ready player chat (Unity V2/Runtime/UI/Chat) for ANY game, built on the @idosgames/react/ui kit and client.chat. The title's channels are tabs (names are localization keys) plus 'Private' — 1-on-1 chats (with friends when the title says so). The feed loads its history, pages back to earlier messages, shows the server's copy of what was sent (moderation may mask a word) and counts down the slow mode; a channel the player may not write to shows the server's reason. ONE poll covers every room and runs only while the screen is open, at the interval the server sets; rooms are marked read when the player leaves them. Popups: a message's actions (write privately, report with a reason, ignore the player) and the channel list to join or leave opt-in channels. Registers the 'chat' feature; features.open('chat', { userID }) opens a private chat (the friends card uses it). Its badge is the unread count, read once per session — a closed chat costs nothing. Ships title-seed.json: a world channel, an opt-in trade channel, private chats between friends.",
|
|
12
|
-
"provides": [
|
|
13
|
-
"chat channels as tabs",
|
|
14
|
-
"private chats",
|
|
15
|
-
"message feed with earlier messages",
|
|
16
|
-
"slow mode countdown",
|
|
17
|
-
"report, ignore and write privately",
|
|
18
|
-
"join and leave channels",
|
|
19
|
-
"chat badge",
|
|
20
|
-
"demo channels"
|
|
21
|
-
],
|
|
22
|
-
"tags": [
|
|
23
|
-
"chat",
|
|
24
|
-
"social",
|
|
25
|
-
"messages",
|
|
26
|
-
"dm",
|
|
27
|
-
"moderation",
|
|
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": "3e02bb18c0e156cf14736377a4c0bde15149eab92138d7f0252a906377c53307",
|
|
44
|
-
"files": [
|
|
45
|
-
{
|
|
46
|
-
"path": "ChatScreen.tsx",
|
|
47
|
-
"content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport type {\n ChatCursor,\n ChatReportReason,\n IDosGamesClient,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n EmptyState,\n Icon,\n IconButton,\n Popup,\n Spinner,\n Tabs,\n errorText,\n outlined,\n panel,\n useCatalog,\n useNow,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n authorName,\n channelChoices,\n chatBadge,\n isSystem,\n lastCursor,\n mergeMessages,\n orderedChannels,\n sendable,\n slowModeLeft,\n unreadLabel,\n type ChannelView,\n type MessageView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Chat (Unity V2/Runtime/UI/Chat): the title's channels as tabs plus \"Private\" (1-on-1 chats with\n// friends). One poll covers every room and runs ONLY while this screen is open, at the interval the\n// server sets; rooms are marked read when the player leaves them. Message actions (report, ignore,\n// write privately) and joining / leaving channels are popups. Another module opens a private chat\n// with features.open(\"chat\", { userID }).\n\ntype Room =\n | { kind: \"channel\"; channel: ChannelView }\n | { kind: \"direct\"; roomID: string; peerName: string };\ntype Conversation = {\n RoomID?: string | null;\n PeerUserID?: string | null;\n PeerProfile?: { Username?: string | null } | null;\n UnreadCount?: number | null;\n LastMessage?: MessageView | null;\n};\nconst DIRECT = \"__direct\";\ntype ChatDefs = {\n Direct?: { IsEnabled?: boolean | null } | null;\n Channels?: Parameters<typeof channelChoices>[0];\n};\n\n/** Badge once per session (a closed chat costs nothing: no background polling). */\nexport function makeChatWatcher(features: FeatureRegistry) {\n return function ChatWatcher(): ReactNode {\n const client = useIDosGamesClient();\n useEffect(() => {\n void client.chat\n .getChannels()\n .then(\n (r) =>\n r.ok && features.setBadge(\"chat\", chatBadge(r.data.Channels ?? [])),\n );\n }, [client]);\n return null;\n };\n}\n\nexport function makeChatScreen(features: FeatureRegistry) {\n return function ChatScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const [channels, setChannels] = useState<ChannelView[] | null>(null);\n const [interval, setIntervalSec] = useState(5);\n const [mutedUntil, setMutedUntil] = useState<string | null>(null);\n const [tab, setTab] = useState<string | null>(null);\n const [room, setRoom] = useState<Room | null>(null);\n const [feeds, setFeeds] = useState<Record<string, MessageView[]>>({});\n const [unread, setUnread] = useState<Record<string, number | null>>({});\n const [managing, setManaging] = useState(false);\n // The Chat section is not part of the client config — the definitions come from their own call.\n const [defs, setDefs] = useState<ChatDefs | null>(null);\n const roomRef = useRef<Room | null>(null);\n roomRef.current = room;\n\n const loadChannels = useCallback(async () => {\n const res = await client.chat.getChannels();\n if (!res.ok) return setChannels([]);\n const list = orderedChannels(res.data.Channels ?? []);\n setChannels(list);\n setIntervalSec(Math.max(2, Number(res.data.PollIntervalSeconds ?? 5)));\n setMutedUntil(res.data.MutedUntil ?? null);\n setUnread(\n Object.fromEntries(\n list.map((c) => [c.RoomID ?? \"\", c.UnreadCount ?? null]),\n ),\n );\n features.setBadge(\"chat\", 0);\n return list;\n }, [client]);\n\n useEffect(() => {\n void client.chat\n .getChatDefinitions()\n .then((r) => setDefs(r.ok ? (r.data as ChatDefs) : {}));\n }, [client]);\n\n useEffect(() => {\n void loadChannels().then((list) => {\n const target = (args as { userID?: string } | undefined)?.userID;\n if (target)\n return void openDirect(client, target, toast, (r) => {\n setTab(DIRECT);\n setRoom(r);\n });\n const first = list?.[0];\n if (first) {\n setTab(first.ChannelID ?? null);\n setRoom({ kind: \"channel\", channel: first });\n }\n });\n }, [loadChannels]);\n\n // One poll for every room, only while the screen is open.\n useEffect(() => {\n let stopped = false;\n let timer: ReturnType<typeof setTimeout> | undefined;\n const tick = async () => {\n const res = await client.chat.poll();\n if (stopped) return;\n if (res.ok) {\n const current = roomID(roomRef.current);\n for (const r of res.data.Rooms ?? []) {\n if (!r.RoomID) continue;\n const msgs = r.Messages ?? [];\n if (msgs.length > 0)\n setFeeds((f) => ({\n ...f,\n [r.RoomID!]: mergeMessages(f[r.RoomID!] ?? [], msgs),\n }));\n if (r.RoomID !== current)\n setUnread((u) => ({\n ...u,\n [r.RoomID!]: r.UnreadCount ?? u[r.RoomID!] ?? null,\n }));\n }\n if (res.data.PollIntervalSeconds)\n setIntervalSec(Math.max(2, res.data.PollIntervalSeconds));\n }\n timer = setTimeout(() => void tick(), interval * 1000);\n };\n timer = setTimeout(() => void tick(), interval * 1000);\n return () => {\n stopped = true;\n if (timer) clearTimeout(timer);\n };\n }, [client, interval]);\n\n if (!channels || !defs)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const directEnabled = !!defs.Direct?.IsEnabled;\n if (channels.length === 0 && !directEnabled)\n return <EmptyState glyph=\"chat\" text={t(\"disabled\")} />;\n\n const pick = (id: string) => {\n setTab(id);\n if (id === DIRECT) return setRoom(null);\n const ch = channels.find((c) => c.ChannelID === id);\n if (ch) setRoom({ kind: \"channel\", channel: ch });\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 10,\n }}\n >\n <div style={{ display: \"flex\", gap: 8, alignItems: \"center\" }}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <Tabs\n tabs={[\n ...channels.map((c) => ({\n id: c.ChannelID!,\n label: catalog.localize(c.DisplayNameKey ?? c.ChannelID),\n badge:\n Number(\n unreadLabel(unread[c.RoomID ?? \"\"]).replace(\"+\", \"\"),\n ) || undefined,\n })),\n ...(directEnabled ? [{ id: DIRECT, label: t(\"direct\") }] : []),\n ]}\n value={tab ?? \"\"}\n onChange={pick}\n />\n </div>\n <IconButton\n glyph=\"gear\"\n label={t(\"channels\")}\n onClick={() => setManaging(true)}\n size={36}\n tone=\"blue\"\n />\n </div>\n {mutedUntil && new Date(mutedUntil).getTime() > Date.now() ? (\n <div\n style={{\n ...panel,\n padding: 8,\n ...outlined,\n fontSize: 13,\n color: v.red,\n textAlign: \"center\",\n }}\n >\n {t(\"muted\")} {new Date(mutedUntil).toLocaleString()}\n </div>\n ) : null}\n {tab === DIRECT && !room ? (\n <Conversations onOpen={(r) => setRoom(r)} />\n ) : room ? (\n <RoomView\n key={roomID(room)}\n room={room}\n feed={feeds[roomID(room)] ?? []}\n setFeed={(fn) =>\n setFeeds((f) => ({\n ...f,\n [roomID(room)]: fn(f[roomID(room)] ?? []),\n }))\n }\n onBack={room.kind === \"direct\" ? () => setRoom(null) : undefined}\n onOpenDirect={(r) => {\n setTab(DIRECT);\n setRoom(r);\n }}\n directEnabled={directEnabled}\n />\n ) : null}\n {managing ? (\n <ChannelsPopup\n defs={defs}\n joined={channels.map((c) => c.ChannelID ?? \"\")}\n onClose={() => setManaging(false)}\n onChanged={() => void loadChannels()}\n />\n ) : null}\n </div>\n );\n };\n}\n\nfunction roomID(room: Room | null): string {\n if (!room) return \"\";\n return room.kind === \"channel\" ? (room.channel.RoomID ?? \"\") : room.roomID;\n}\n\nasync function openDirect(\n client: IDosGamesClient,\n userID: string,\n toast: (text: string, kind?: \"error\") => void,\n open: (r: Room) => void,\n): Promise<void> {\n const res = await client.chat.openDirectChannel(userID);\n if (!res.ok || !res.data.RoomID)\n return toast(errorText(res.ok ? \"\" : res.error), \"error\");\n open({\n kind: \"direct\",\n roomID: res.data.RoomID,\n peerName: res.data.PeerProfile?.Username ?? userID,\n });\n}\n\nfunction RoomView({\n room,\n feed,\n setFeed,\n onBack,\n onOpenDirect,\n directEnabled,\n}: {\n room: Room;\n feed: MessageView[];\n setFeed: (fn: (prev: MessageView[]) => MessageView[]) => void;\n onBack?: () => void;\n onOpenDirect: (r: Room) => void;\n directEnabled: boolean;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const now = useNow();\n const id = roomID(room);\n const [older, setOlder] = useState<ChatCursor | null | undefined>(undefined);\n const [loading, setLoading] = useState(true);\n const [text, setText] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [lastSent, setLastSent] = useState<number | null>(null);\n const [picked, setPicked] = useState<MessageView | null>(null);\n const feedRef = useRef<HTMLDivElement>(null);\n const feedNow = useRef(feed);\n feedNow.current = feed;\n const me = client.auth.context?.userID;\n const channel = room.kind === \"channel\" ? room.channel : null;\n const maxLength = channel?.MaxLength ?? 500;\n\n // History first, then mark read up to what was drawn; on leaving, read up to the last message.\n useEffect(() => {\n let alive = true;\n void client.chat.getHistory(id).then((res) => {\n if (!alive) return;\n setLoading(false);\n if (!res.ok) return;\n const merged = mergeMessages(feedNow.current, res.data.Messages ?? []);\n setFeed(() => merged);\n setOlder(res.data.NextBefore ?? null);\n const cursor = lastCursor(id, merged);\n if (cursor) void client.chat.markRead(id, cursor);\n });\n return () => {\n alive = false;\n const cursor = lastCursor(id, feedNow.current);\n if (cursor) void client.chat.markRead(id, cursor);\n };\n }, [client, id]);\n\n // Keep the newest message in view.\n useEffect(() => {\n const el = feedRef.current;\n if (el) el.scrollTop = el.scrollHeight;\n }, [feed.length]);\n\n const loadOlder = async () => {\n if (!older) return;\n const res = await client.chat.getHistory(id, older);\n if (!res.ok) return;\n setFeed((prev) => mergeMessages(prev, res.data.Messages ?? []));\n setOlder(res.data.NextBefore ?? null);\n };\n\n const wait = slowModeLeft(lastSent, channel?.SlowModeSeconds, now);\n const message = sendable(text, maxLength);\n const canWrite = channel ? channel.CanWrite !== false : true;\n\n const send = async () => {\n if (!message || wait > 0) return;\n setBusy(true);\n const res = channel\n ? await client.chat.sendMessage(channel.ChannelID!, message)\n : await client.chat.sendDirect(id, message);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"tab\");\n setText(\"\");\n setLastSent(Date.now());\n // Render the SERVER's copy: moderation may have masked a word.\n if (res.data.Message)\n setFeed((prev) => mergeMessages(prev, [res.data.Message!]));\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateRows: \"auto minmax(0, 1fr) auto\",\n gap: 8,\n height: \"min(70vh, 640px)\",\n minHeight: 320,\n }}\n >\n {onBack ? (\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n <Button size=\"sm\" tone=\"grey\" onClick={onBack}>\n ← {t(\"back\")}\n </Button>\n <span style={{ ...outlined, fontSize: 15 }}>\n {room.kind === \"direct\" ? room.peerName : \"\"}\n </span>\n </div>\n ) : (\n <div />\n )}\n <div\n ref={feedRef}\n className=\"idos-scroll\"\n style={{\n ...panel,\n padding: 10,\n overflowY: \"auto\",\n display: \"grid\",\n alignContent: \"start\",\n gap: 6,\n }}\n >\n {older ? (\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() => void loadOlder()}\n style={{ justifySelf: \"center\" }}\n >\n {t(\"older\")}\n </Button>\n ) : null}\n {loading ? (\n <Center minHeight={120}>\n <Spinner />\n </Center>\n ) : feed.length === 0 ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n padding: 20,\n }}\n >\n {t(\"noMessages\")}\n </div>\n ) : null}\n {feed.map((m) => {\n const mine = m.SenderUserID === me;\n const system = isSystem(m);\n return (\n <div\n key={m.MessageID}\n style={{\n display: \"flex\",\n justifyContent: system\n ? \"center\"\n : mine\n ? \"flex-end\"\n : \"flex-start\",\n }}\n >\n <div\n onClick={!mine && !system ? () => setPicked(m) : undefined}\n style={{\n maxWidth: \"80%\",\n padding: \"6px 10px\",\n borderRadius: 12,\n background: system\n ? \"rgba(255,200,60,.18)\"\n : mine\n ? `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`\n : \"rgba(255,255,255,.1)\",\n cursor: !mine && !system ? \"pointer\" : undefined,\n overflowWrap: \"anywhere\",\n }}\n >\n {!mine ? (\n <div\n style={{\n ...outlined,\n fontSize: 11,\n color: system ? v.gold : v.textDim,\n }}\n >\n {system ? t(\"system\") : authorName(m)}\n </div>\n ) : null}\n <div style={{ fontSize: 14, fontWeight: 600 }}>{m.Text}</div>\n <div\n style={{\n fontSize: 10,\n color: \"rgba(255,255,255,.6)\",\n textAlign: \"right\",\n }}\n >\n {m.SentAt\n ? new Date(m.SentAt).toLocaleTimeString([], {\n hour: \"2-digit\",\n minute: \"2-digit\",\n })\n : \"\"}\n </div>\n </div>\n </div>\n );\n })}\n </div>\n {canWrite ? (\n <div style={{ display: \"flex\", gap: 8 }}>\n <input\n value={text}\n maxLength={maxLength}\n onChange={(e) => setText(e.target.value)}\n onKeyDown={(e) => e.key === \"Enter\" && void send()}\n placeholder={t(\"placeholder\")}\n style={{\n ...panel,\n flex: 1,\n minWidth: 0,\n padding: \"10px 12px\",\n fontSize: 15,\n color: v.text,\n outline: \"none\",\n }}\n data-tutorial-anchor=\"chat:input\"\n />\n <Button\n tone=\"green\"\n disabled={!message || wait > 0}\n busy={busy}\n onClick={() => void send()}\n >\n {wait > 0 ? `${wait}${t(\"sec\")}` : <Icon glyph=\"chat\" size={20} />}\n </Button>\n </div>\n ) : (\n <div\n style={{\n ...panel,\n padding: 10,\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {channel?.WriteBlockedReason ?? \"—\"}\n </div>\n )}\n {picked ? (\n <MessagePopup\n message={picked}\n roomID={id}\n directEnabled={directEnabled}\n onOpenDirect={onOpenDirect}\n onClose={() => setPicked(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction Conversations({ onOpen }: { onOpen: (r: Room) => void }): ReactNode {\n const client = useIDosGamesClient();\n const [list, setList] = useState<Conversation[] | null>(null);\n useEffect(() => {\n void client.chat\n .getConversations()\n .then((r) =>\n setList(r.ok ? ((r.data.Conversations ?? []) as Conversation[]) : []),\n );\n }, [client]);\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"chat\" text={t(\"noConversations\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((c) => {\n const name = c.PeerProfile?.Username ?? c.PeerUserID ?? \"?\";\n return (\n <div\n key={c.RoomID}\n onClick={() =>\n c.RoomID &&\n onOpen({ kind: \"direct\", roomID: c.RoomID, peerName: name })\n }\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: \"pointer\",\n }}\n >\n <Icon glyph=\"user\" size={28} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>{name}</div>\n <div\n style={{\n fontSize: 12,\n color: v.textDim,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {c.LastMessage?.Text ?? \"\"}\n </div>\n </div>\n {unreadLabel(c.UnreadCount) ? (\n <span\n style={{\n ...outlined,\n fontSize: 12,\n background: v.red,\n borderRadius: 10,\n padding: \"2px 7px\",\n }}\n >\n {unreadLabel(c.UnreadCount)}\n </span>\n ) : null}\n </div>\n );\n })}\n </div>\n );\n}\n\nconst REASONS: Array<{ id: ChatReportReason; label: Parameters<typeof t>[0] }> =\n [\n { id: \"Spam\", label: \"rSpam\" },\n { id: \"Abuse\", label: \"rAbuse\" },\n { id: \"Scam\", label: \"rScam\" },\n { id: \"Inappropriate\", label: \"rInappropriate\" },\n { id: \"Other\", label: \"rOther\" },\n ];\n\nfunction MessagePopup({\n message,\n roomID,\n directEnabled,\n onOpenDirect,\n onClose,\n}: {\n message: MessageView;\n roomID: string;\n directEnabled: boolean;\n onOpenDirect: (r: Room) => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [reason, setReason] = useState<ChatReportReason>(\"Spam\");\n const [busy, setBusy] = useState<string | null>(null);\n const sender = message.SenderUserID!;\n const run = async (what: string, fn: () => Promise<void>) => {\n setBusy(what);\n await fn();\n setBusy(null);\n };\n return (\n <Popup title={authorName(message)} onClose={onClose} width={420}>\n <div\n style={{\n ...panel,\n padding: 10,\n fontSize: 14,\n fontWeight: 600,\n overflowWrap: \"anywhere\",\n }}\n >\n {message.Text}\n </div>\n {directEnabled ? (\n <Button\n tone=\"blue\"\n busy={busy === \"dm\"}\n onClick={() =>\n void run(\"dm\", async () => {\n await openDirect(client, sender, toast, (r) => {\n onOpenDirect(r);\n onClose();\n });\n })\n }\n >\n <Icon glyph=\"chat\" size={18} /> {t(\"writePrivately\")}\n </Button>\n ) : null}\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"reason\")}\n </div>\n <Tabs\n tabs={REASONS.map((r) => ({ id: r.id, label: t(r.label) }))}\n value={reason}\n onChange={setReason}\n />\n <Button\n tone=\"red\"\n busy={busy === \"report\"}\n onClick={() =>\n void run(\"report\", async () => {\n const res = await client.chat.reportMessage(\n roomID,\n message.MessageID!,\n reason,\n );\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(\n (res.data as { AlreadyReported?: boolean | null })\n .AlreadyReported\n ? t(\"alreadyReported\")\n : t(\"reported\"),\n );\n onClose();\n })\n }\n >\n {t(\"report\")}\n </Button>\n </div>\n <Button\n tone=\"grey\"\n busy={busy === \"ignore\"}\n onClick={() =>\n void run(\"ignore\", async () => {\n const res = await client.chat.ignoreUser(sender);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"ignored\"));\n onClose();\n })\n }\n >\n {t(\"ignore\")}\n </Button>\n </Popup>\n );\n}\n\nfunction ChannelsPopup({\n defs,\n joined,\n onClose,\n onChanged,\n}: {\n defs: ChatDefs;\n joined: string[];\n onClose: () => void;\n onChanged: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const [busy, setBusy] = useState<string | null>(null);\n const [ids, setIds] = useState(joined);\n const choices = channelChoices(defs.Channels, ids);\n const toggle = async (id: string, join: boolean) => {\n setBusy(id);\n const res = join\n ? await client.chat.joinChannel(id)\n : await client.chat.leaveChannel(id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n setIds((prev) => (join ? [...prev, id] : prev.filter((x) => x !== id)));\n onChanged();\n };\n return (\n <Popup title={t(\"channels\")} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {choices.map((c) => (\n <div\n key={c.id}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon glyph=\"chat\" size={24} />\n <div style={{ flex: 1, ...outlined, fontSize: 15 }}>\n {catalog.localize(c.name)}\n </div>\n <Button\n size=\"sm\"\n tone={c.joined ? \"grey\" : \"green\"}\n busy={busy === c.id}\n onClick={() => void toggle(c.id, !c.joined)}\n >\n {c.joined ? t(\"leave\") : t(\"join\")}\n </Button>\n </div>\n ))}\n </div>\n </Popup>\n );\n}\n"
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
"path": "i18n.ts",
|
|
51
|
-
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Chat\",\n direct: \"Private\",\n channels: \"Channels\",\n join: \"Join\",\n leave: \"Leave\",\n noMessages: \"No messages yet — say hi!\",\n noConversations: \"No private chats yet. Start one from a friend's card.\",\n disabled: \"Chat is turned off in this game.\",\n placeholder: \"Message\",\n send: \"Send\",\n wait: \"Wait\",\n sec: \"s\",\n older: \"Earlier messages\",\n muted: \"You are muted until\",\n report: \"Report\",\n reported: \"Thanks — the report was sent.\",\n alreadyReported: \"You already reported this message.\",\n ignore: \"Ignore player\",\n ignored: \"You won't see this player's messages any more.\",\n writePrivately: \"Write privately\",\n reason: \"Reason\",\n rSpam: \"Spam\",\n rAbuse: \"Abuse\",\n rScam: \"Scam\",\n rInappropriate: \"Inappropriate\",\n rOther: \"Other\",\n back: \"Back\",\n system: \"System\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Чат\",\n direct: \"Личные\",\n channels: \"Каналы\",\n join: \"Вступить\",\n leave: \"Выйти\",\n noMessages: \"Сообщений пока нет — поздоровайтесь!\",\n noConversations: \"Личных переписок пока нет. Начните её из карточки друга.\",\n disabled: \"Чат в этой игре выключен.\",\n placeholder: \"Сообщение\",\n send: \"Отправить\",\n wait: \"Подождите\",\n sec: \"с\",\n older: \"Ранние сообщения\",\n muted: \"Вы в муте до\",\n report: \"Пожаловаться\",\n reported: \"Спасибо — жалоба отправлена.\",\n alreadyReported: \"Вы уже жаловались на это сообщение.\",\n ignore: \"Игнорировать игрока\",\n ignored: \"Сообщения этого игрока вы больше не увидите.\",\n writePrivately: \"Написать лично\",\n reason: \"Причина\",\n rSpam: \"Спам\",\n rAbuse: \"Оскорбления\",\n rScam: \"Мошенничество\",\n rInappropriate: \"Неприемлемо\",\n rOther: \"Другое\",\n back: \"Назад\",\n system: \"Система\",\n});\n"
|
|
52
|
-
},
|
|
53
|
-
{
|
|
54
|
-
"path": "index.ts",
|
|
55
|
-
"content": "// @idosgames/mod-chat-ui — Chat for any game: channels as tabs, direct messages with friends, and message actions (report, ignore) in a popup.\n\nexport { chatUiModule } from \"./module\";\n"
|
|
56
|
-
},
|
|
57
|
-
{
|
|
58
|
-
"path": "model.test.ts",
|
|
59
|
-
"content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport {\n authorName,\n channelChoices,\n chatBadge,\n isSystem,\n lastCursor,\n mergeMessages,\n orderedChannels,\n sendable,\n slowModeLeft,\n unreadLabel,\n} from \"./model\";\n\nconst msg = (id: string, at: string, extra: Record<string, unknown> = {}) => ({\n MessageID: id,\n SentAt: at,\n SenderUserID: \"U\",\n Text: id,\n ...extra,\n});\n\ndescribe(\"chat\", () => {\n it(\"the seeded chat: a world channel everyone is in, an opt-in trade channel, DMs between friends\", () => {\n const chat = (\n seed as unknown as {\n Chat: {\n Channels: Record<string, never>;\n Direct: { IsEnabled: boolean; WhoCanMessage: string };\n };\n }\n ).Chat;\n expect(channelChoices(chat.Channels, [\"world\"])).toEqual([\n { id: \"world\", name: \"World\", joined: true },\n { id: \"trade\", name: \"Trade\", joined: false },\n ]);\n expect(chat.Direct).toMatchObject({\n IsEnabled: true,\n WhoCanMessage: \"Friends\",\n });\n });\n\n it(\"merges history and polls without duplicates, oldest first\", () => {\n const merged = mergeMessages(\n [msg(\"b\", \"2026-09-28T10:00:02Z\"), msg(\"a\", \"2026-09-28T10:00:01Z\")],\n [msg(\"b\", \"2026-09-28T10:00:02Z\"), msg(\"c\", \"2026-09-28T10:00:03Z\")],\n );\n expect(merged.map((m) => m.MessageID)).toEqual([\"a\", \"b\", \"c\"]);\n expect(lastCursor(\"world\", merged)).toEqual({\n RoomID: \"world\",\n SentAt: \"2026-09-28T10:00:03Z\",\n MessageID: \"c\",\n });\n expect(lastCursor(\"world\", [])).toBeUndefined();\n });\n\n it(\"an uncounted room is not zero, and big counts are capped\", () => {\n expect(unreadLabel(null)).toBe(\"\");\n expect(unreadLabel(0)).toBe(\"\");\n expect(unreadLabel(7)).toBe(\"7\");\n expect(unreadLabel(150)).toBe(\"99+\");\n expect(\n chatBadge([\n { UnreadCount: 2 },\n { UnreadCount: null },\n { UnreadCount: 3 },\n ]),\n ).toBe(5);\n });\n\n it(\"sends trimmed text within the limit and counts down the slow mode\", () => {\n expect(sendable(\" hi \", 200)).toBe(\"hi\");\n expect(sendable(\" \", 200)).toBeNull();\n expect(sendable(\"x\".repeat(201), 200)).toBeNull();\n expect(slowModeLeft(1_000, 3, 2_500)).toBe(2);\n expect(slowModeLeft(1_000, 3, 5_000)).toBe(0);\n expect(slowModeLeft(null, 3, 5_000)).toBe(0);\n });\n\n it(\"orders channels and names authors; system lines have no author to act on\", () => {\n expect(\n orderedChannels([\n { ChannelID: \"b\", SortOrder: 1 },\n { ChannelID: \"a\", SortOrder: 0 },\n ]).map((c) => c.ChannelID),\n ).toEqual([\"a\", \"b\"]);\n expect(\n authorName({\n SenderUserID: \"ABCDEFGHIJKLMN\",\n SenderProfile: { Username: \"Ann\" },\n }),\n ).toBe(\"Ann\");\n expect(authorName({ SenderUserID: \"ABCDEFGHIJKLMN\" })).toBe(\"ABCDE…KLMN\");\n expect(isSystem({ SenderUserID: null })).toBe(true);\n expect(isSystem({ SenderUserID: \"U\", AuthorType: \"Publisher\" })).toBe(true);\n expect(isSystem({ SenderUserID: \"U\", AuthorType: \"Player\" })).toBe(false);\n });\n});\n"
|
|
60
|
-
},
|
|
61
|
-
{
|
|
62
|
-
"path": "model.ts",
|
|
63
|
-
"content": "// Pure helpers of the chat screen (Unity V2/Runtime/UI/Chat): channel order, the feed (merging\n// history and polls without duplicates), read cursors, the slow mode and the badge. The server\n// decides rooms, moderation and who may write — nothing here re-checks it. Covered by model.test.ts.\n\nexport interface MessageView {\n RoomID?: string | null;\n MessageID?: string | null;\n SenderUserID?: string | null;\n SenderProfile?: { Username?: string | null } | null;\n AuthorType?: string | null;\n Text?: string | null;\n SentAt?: string | null;\n}\n\nexport interface ChannelView {\n ChannelID?: string | null;\n RoomID?: string | null;\n DisplayNameKey?: string | null;\n SortOrder?: number | null;\n CanWrite?: boolean | null;\n WriteBlockedReason?: string | null;\n SlowModeSeconds?: number | null;\n MaxLength?: number | null;\n UnreadCount?: number | null;\n}\n\nexport function orderedChannels<T extends ChannelView>(\n channels: readonly T[],\n): T[] {\n return [...channels].sort(\n (a, b) =>\n Number(a.SortOrder ?? 0) - Number(b.SortOrder ?? 0) ||\n String(a.ChannelID).localeCompare(String(b.ChannelID)),\n );\n}\n\n/** Badge text of a room: `null` from the server means \"not counted\", which is not zero. */\nexport function unreadLabel(count: number | null | undefined): string {\n if (count === null || count === undefined) return \"\";\n if (count <= 0) return \"\";\n return count > 99 ? \"99+\" : String(count);\n}\n\nconst FEED_LIMIT = 200;\n\n/** History and polled messages merged: no duplicates, oldest first, the newest FEED_LIMIT kept. */\nexport function mergeMessages(\n existing: readonly MessageView[],\n incoming: readonly MessageView[],\n): MessageView[] {\n const byID = new Map<string, MessageView>();\n for (const m of [...existing, ...incoming])\n if (m.MessageID) byID.set(m.MessageID, m);\n const sorted = [...byID.values()].sort(\n (a, b) =>\n String(a.SentAt ?? \"\").localeCompare(String(b.SentAt ?? \"\")) ||\n String(a.MessageID).localeCompare(String(b.MessageID)),\n );\n return sorted.slice(-FEED_LIMIT);\n}\n\n/** The read cursor up to the newest message drawn (a pair — neighbours share a millisecond). */\nexport function lastCursor(\n roomID: string,\n messages: readonly MessageView[],\n):\n | { RoomID: string; SentAt?: string | null; MessageID?: string | null }\n | undefined {\n const last = messages[messages.length - 1];\n return last\n ? { RoomID: roomID, SentAt: last.SentAt, MessageID: last.MessageID }\n : undefined;\n}\n\nexport function authorName(m: MessageView): string {\n const name = m.SenderProfile?.Username?.trim();\n if (name) return name;\n const id = m.SenderUserID ?? \"\";\n return id.length > 10 ? `${id.slice(0, 5)}…${id.slice(-4)}` : id;\n}\n\n/** A publisher announcement or a system line: no author to report or ignore. */\nexport function isSystem(m: MessageView): boolean {\n return !m.SenderUserID || (m.AuthorType != null && m.AuthorType !== \"Player\");\n}\n\n/** What can be sent: trimmed, not empty, within the room's limit. */\nexport function sendable(\n text: string,\n maxLength: number | null | undefined,\n): string | null {\n const s = text.trim();\n if (!s) return null;\n return maxLength && s.length > maxLength ? null : s;\n}\n\n/** Seconds left of the slow mode after my last message in this room. */\nexport function slowModeLeft(\n lastSentMs: number | null | undefined,\n seconds: number | null | undefined,\n nowMs: number,\n): number {\n if (!lastSentMs || !seconds) return 0;\n return Math.max(0, Math.ceil((lastSentMs + seconds * 1000 - nowMs) / 1000));\n}\n\nexport interface ChannelChoice {\n id: string;\n name: string;\n joined: boolean;\n}\n\n/** Every channel of the title with whether the player is in it — for the join / leave list. */\nexport function channelChoices(\n definitions:\n | Record<\n string,\n {\n Identity?: {\n DisplayNameKey?: string | null;\n SortOrder?: number | null;\n } | null;\n Type?: string | null;\n } | null\n >\n | null\n | undefined,\n joinedIDs: readonly string[],\n): ChannelChoice[] {\n return Object.entries(definitions ?? {})\n .filter(([, d]) => d && d.Type !== \"Announcement\" && d.Type !== \"Group\")\n .sort(\n ([, a], [, b]) =>\n Number(a?.Identity?.SortOrder ?? 0) -\n Number(b?.Identity?.SortOrder ?? 0),\n )\n .map(([id, d]) => ({\n id,\n name: d?.Identity?.DisplayNameKey ?? id,\n joined: joinedIDs.includes(id),\n }));\n}\n\n/** Badge: counted unread messages across the rooms (uncounted rooms add nothing). */\nexport function chatBadge(\n rooms: ReadonlyArray<{ UnreadCount?: number | null }>,\n): number {\n return rooms.reduce((n, r) => n + Math.max(0, Number(r.UnreadCount ?? 0)), 0);\n}\n"
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
"path": "module.ts",
|
|
67
|
-
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makeChatScreen, makeChatWatcher } from \"./ChatScreen\";\nimport { t } from \"./i18n\";\n\n// Chat as a feature of the game. The watcher reads the unread count once per session — a closed\n// chat polls nothing (every request is billed to the publisher). features.open(\"chat\", { userID })\n// opens a private chat with that player.\nexport const chatUiModule: Module = defineModule({\n id: \"chat-ui\",\n meta: { name: \"Chat\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"chat\",\n label: t(\"title\"),\n icon: \"chat\",\n group: \"social\",\n order: 85,\n Screen: makeChatScreen(ctx.features),\n });\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeChatWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"chat\" }),\n describeActions: {},\n });\n },\n});\n"
|
|
68
|
-
},
|
|
69
|
-
{
|
|
70
|
-
"path": "module.meta.json",
|
|
71
|
-
"content": "{\n \"id\": \"chat-ui\",\n \"type\": \"feature\",\n \"summary\": \"Chat for any game: channels as tabs, private chats, a feed with earlier messages, slow mode, and report / ignore / write privately in a popup.\",\n \"description\": \"A ready player chat (Unity V2/Runtime/UI/Chat) for ANY game, built on the @idosgames/react/ui kit and client.chat. The title's channels are tabs (names are localization keys) plus 'Private' — 1-on-1 chats (with friends when the title says so). The feed loads its history, pages back to earlier messages, shows the server's copy of what was sent (moderation may mask a word) and counts down the slow mode; a channel the player may not write to shows the server's reason. ONE poll covers every room and runs only while the screen is open, at the interval the server sets; rooms are marked read when the player leaves them. Popups: a message's actions (write privately, report with a reason, ignore the player) and the channel list to join or leave opt-in channels. Registers the 'chat' feature; features.open('chat', { userID }) opens a private chat (the friends card uses it). Its badge is the unread count, read once per session — a closed chat costs nothing. Ships title-seed.json: a world channel, an opt-in trade channel, private chats between friends.\",\n \"provides\": [\n \"chat channels as tabs\",\n \"private chats\",\n \"message feed with earlier messages\",\n \"slow mode countdown\",\n \"report, ignore and write privately\",\n \"join and leave channels\",\n \"chat badge\",\n \"demo channels\"\n ],\n \"tags\": [\"chat\", \"social\", \"messages\", \"dm\", \"moderation\", \"ui\", \"screen\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
|
|
72
|
-
}
|
|
73
|
-
]
|
|
74
|
-
}
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "game-hud",
|
|
3
|
-
"meta": {
|
|
4
|
-
"name": "Game HUD",
|
|
5
|
-
"type": "app",
|
|
6
|
-
"engine": "dom"
|
|
7
|
-
},
|
|
8
|
-
"sharedUi": {
|
|
9
|
-
"provides": [
|
|
10
|
-
"currency-bar",
|
|
11
|
-
"wallet",
|
|
12
|
-
"status",
|
|
13
|
-
"account",
|
|
14
|
-
"menu"
|
|
15
|
-
]
|
|
16
|
-
},
|
|
17
|
-
"catalog": {
|
|
18
|
-
"type": "feature",
|
|
19
|
-
"summary": "Shared game HUD: one currency bar, wallet button, status line and account chip across every mode.",
|
|
20
|
-
"description": "The shared chrome for a game made of several modules. It takes every shared-UI role (currency-bar, wallet, status, account), so templates mixed into the same game hide their own balances, wallet and status line automatically — without editing them. Stays on screen in every mode (a HUD panel with activeOnly: false), clears the status on each mode switch, and is ordinary editable React: restyle or replace it like any other module. Installed automatically when a project starts from two or more templates.",
|
|
21
|
-
"provides": [
|
|
22
|
-
"one shared currency balance bar for every mode",
|
|
23
|
-
"one crypto wallet button (deposit / withdraw) for the whole game",
|
|
24
|
-
"shared status line / toast messages across modes",
|
|
25
|
-
"player ID with copy and a log-out button",
|
|
26
|
-
"consistent HUD when mixing several game templates"
|
|
27
|
-
],
|
|
28
|
-
"tags": [
|
|
29
|
-
"hud",
|
|
30
|
-
"ui",
|
|
31
|
-
"shared",
|
|
32
|
-
"wallet",
|
|
33
|
-
"currency",
|
|
34
|
-
"status",
|
|
35
|
-
"chrome"
|
|
36
|
-
],
|
|
37
|
-
"author": {
|
|
38
|
-
"name": "iDos Games",
|
|
39
|
-
"url": "https://idosgames.com"
|
|
40
|
-
},
|
|
41
|
-
"version": "0.1.0",
|
|
42
|
-
"events": {
|
|
43
|
-
"listens": [
|
|
44
|
-
{
|
|
45
|
-
"topic": "host:mode-changed@1",
|
|
46
|
-
"why": "clears the previous mode's status message when the player switches modes"
|
|
47
|
-
}
|
|
48
|
-
]
|
|
49
|
-
}
|
|
50
|
-
},
|
|
51
|
-
"dependencies": {
|
|
52
|
-
"@idosgames/core": "0.19.0",
|
|
53
|
-
"@idosgames/module-sdk": "0.5.0",
|
|
54
|
-
"@idosgames/react": "0.3.0",
|
|
55
|
-
"@idosgames/wallet": "0.5.1",
|
|
56
|
-
"@tanstack/react-query": "5.101.2",
|
|
57
|
-
"react": "19.2.7",
|
|
58
|
-
"viem": "2.55.2",
|
|
59
|
-
"wagmi": "3.7.2"
|
|
60
|
-
},
|
|
61
|
-
"contentHash": "08e902f30f360b5638c6432dd938b2b1cc120cc2bc9fbab9dde4ca926b914a5c",
|
|
62
|
-
"files": [
|
|
63
|
-
{
|
|
64
|
-
"path": "AccountChip.tsx",
|
|
65
|
-
"content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\n\n// The player's ID (click to copy) and \"Log out\". This HUD takes the \"account\" role, so the host does\n// not draw its own copy in the bottom-left corner. Logging out goes through the SDK: it emits\n// \"auth:loggedOut\", and the host returns to the login screen and tears the modules down.\n//\n// `compact` (a phone): one profile button with the ID and \"Log out\" in a dropdown under it — side by\n// side with the wallet they took the whole HUD row and pushed the balances into a column.\n\nexport function AccountChip({\n compact = false,\n}: {\n compact?: boolean;\n}): ReactNode {\n const client = useIDosGamesClient();\n const userID = client.auth.context?.userID ?? null;\n const [copied, setCopied] = useState(false);\n const [open, setOpen] = useState(false);\n\n const copy = (): void => {\n if (!userID) return;\n void (async () => {\n try {\n await navigator.clipboard.writeText(userID);\n } catch {\n // Clipboard API needs a secure context; the drag-select fallback still works.\n const area = document.createElement(\"textarea\");\n area.value = userID;\n document.body.appendChild(area);\n area.select();\n document.execCommand(\"copy\");\n area.remove();\n }\n setCopied(true);\n window.setTimeout(() => setCopied(false), 1500);\n })();\n };\n\n // Shown truncated, but the click always copies the COMPLETE id; hovering shows it in the tooltip.\n const shortID =\n userID && userID.length > 12\n ? `${userID.slice(0, 5)}…${userID.slice(-5)}`\n : userID;\n\n const idButton = userID && (\n <button\n type=\"button\"\n style={idStyle}\n onClick={copy}\n title={`Copy your player ID: ${userID}`}\n >\n {copied ? \"✓ Copied\" : `ID: ${shortID} ⧉`}\n </button>\n );\n const logoutButton = (\n <button\n type=\"button\"\n style={buttonStyle}\n onClick={() => void client.auth.logout()}\n title=\"Log out\"\n >\n 🚪 Log out\n </button>\n );\n\n if (!compact)\n return (\n <>\n {idButton}\n {logoutButton}\n </>\n );\n\n // The dropdown is absolutely positioned, so opening it does not change the HUD's height — the host\n // measures that height to place every mode below it.\n return (\n <div style={anchorStyle}>\n <button\n type=\"button\"\n style={profileStyle}\n onClick={() => setOpen((value) => !value)}\n aria-expanded={open}\n title=\"Account\"\n >\n 👤\n </button>\n {open && (\n <div style={dropdownStyle}>\n {idButton}\n {logoutButton}\n </div>\n )}\n </div>\n );\n}\n\nconst buttonStyle: CSSProperties = {\n border: \"none\",\n borderRadius: 12,\n padding: \"8px 14px\",\n fontWeight: 700,\n fontSize: 13,\n color: \"#cfe0ff\",\n background: \"rgba(4,32,90,0.72)\",\n backdropFilter: \"blur(6px)\",\n cursor: \"pointer\",\n pointerEvents: \"auto\",\n whiteSpace: \"nowrap\",\n};\nconst idStyle: CSSProperties = {\n ...buttonStyle,\n fontWeight: 600,\n fontFamily: \"ui-monospace, Consolas, monospace\",\n};\nconst profileStyle: CSSProperties = {\n ...buttonStyle,\n padding: \"6px 10px\",\n fontSize: 16,\n lineHeight: 1,\n};\nconst anchorStyle: CSSProperties = {\n position: \"relative\",\n pointerEvents: \"auto\",\n};\nconst dropdownStyle: CSSProperties = {\n position: \"absolute\",\n top: \"calc(100% + 8px)\",\n right: 0,\n zIndex: 5,\n display: \"flex\",\n flexDirection: \"column\",\n alignItems: \"stretch\",\n gap: 6,\n padding: 6,\n borderRadius: 14,\n background: \"rgba(4,32,90,0.92)\",\n boxShadow: \"0 8px 24px rgba(0,0,0,.35)\",\n};\n"
|
|
66
|
-
},
|
|
67
|
-
{
|
|
68
|
-
"path": "CurrencyChips.tsx",
|
|
69
|
-
"content": "import type { CSSProperties, ReactNode } from \"react\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport { useCounterValue } from \"@idosgames/react/ui\";\n\n// The player's virtual-currency balances, one chip each. Labels come from the title's currency\n// configuration (`Currency.VirtualCurrencies[id].DisplayName`), translated through the title's\n// localization when that name is a localization key; a currency without a definition shows its id.\n\nexport function CurrencyChips(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n\n const balances = state?.InventoryV2?.VirtualCurrencies ?? {};\n // `currencyDefinitions`, not `getSection(\"Currency\")`: the latter holds only sections fetched on\n // their own (GetDefinitions), so on a fresh sign-in it was empty and the chips showed raw ids.\n const definitions =\n client.data.config.currencyDefinitions?.VirtualCurrencies ?? {};\n\n const label = (id: string): string => {\n const name = definitions[id]?.DisplayName;\n if (!name) return id;\n // DisplayName is either plain text or a localization key — translate only when it IS a key, so\n // a title with MissingKeyMode \"ReturnMarkedKey\" does not show ⟦Gold⟧.\n return client.localization.has(name) ? client.localization.t(name) : name;\n };\n\n const entries = Object.entries(balances);\n if (entries.length === 0) return null;\n\n return (\n <>\n {entries.map(([id, currency]) => (\n <Chip\n key={id}\n id={id}\n label={label(id)}\n amount={Number(currency.Amount ?? 0)}\n />\n ))}\n </>\n );\n}\n\n// `data-resource-counter` makes the chip a target of the kit's reward animation: icons of a claimed\n// reward fly here, and the number holds until they land, then ticks up (useCounterValue).\nfunction Chip({\n id,\n label,\n amount,\n}: {\n id: string;\n label: string;\n amount: number;\n}): ReactNode {\n const shown = useCounterValue(id, amount);\n return (\n <span data-resource-counter={id} style={chipStyle} title={id}>\n {label}: {shown}\n </span>\n );\n}\n\nconst chipStyle: CSSProperties = {\n background: \"#052e7ecc\",\n color: \"#ffd479\",\n borderRadius: 999,\n padding: \"4px 12px\",\n fontSize: 13,\n fontWeight: 600,\n backdropFilter: \"blur(2px)\",\n whiteSpace: \"nowrap\",\n pointerEvents: \"auto\",\n};\n"
|
|
70
|
-
},
|
|
71
|
-
{
|
|
72
|
-
"path": "FeatureMenu.tsx",
|
|
73
|
-
"content": "import {\n useEffect,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from \"react\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport {\n FeatureGrid,\n IconButton,\n Popup,\n Window,\n uiT,\n} from \"@idosgames/react/ui\";\n\n// ☰ in the HUD: this module took the \"menu\" shared-UI role, so the game's features (ctx.features —\n// the shop, the marketplace, friends…) are listed here in every mode, and a picked feature's screen\n// opens in a full-screen window. It is also the presenter of `ctx.features.open(id)`, so \"not enough\n// coins → open the shop\" works from any module.\n\nexport function FeatureMenu({\n features,\n}: {\n features: FeatureRegistry;\n}): ReactNode {\n const list = useSyncExternalStore(\n features.subscribe,\n features.list,\n features.list,\n );\n const [menuOpen, setMenuOpen] = useState(false);\n const [screen, setScreen] = useState<{\n id: string;\n args?: Record<string, unknown>;\n } | null>(null);\n\n useEffect(\n () =>\n features.setPresenter((id, args) => {\n setMenuOpen(false);\n setScreen({ id, args });\n return true;\n }),\n [features],\n );\n\n if (list.length === 0) return null;\n const badges = list.reduce((sum, f) => sum + (f.badge > 0 ? 1 : 0), 0);\n const current = screen ? features.get(screen.id) : undefined;\n const Screen = current?.Screen;\n\n return (\n <>\n <span style={{ pointerEvents: \"auto\" }}>\n <IconButton\n glyph=\"menu\"\n label={uiT(\"menu\")}\n onClick={() => setMenuOpen(true)}\n size={38}\n tone=\"blue\"\n badge={badges}\n />\n </span>\n {menuOpen ? (\n <Popup\n title={uiT(\"menu\")}\n onClose={() => setMenuOpen(false)}\n width={520}\n >\n <FeatureGrid\n items={list}\n onOpen={(id) => {\n setMenuOpen(false);\n setScreen({ id });\n }}\n />\n </Popup>\n ) : null}\n {current && Screen ? (\n <Window\n key={current.id}\n title={current.label}\n icon={current.icon}\n onClose={() => setScreen(null)}\n >\n <Screen args={screen?.args} close={() => setScreen(null)} />\n </Window>\n ) : null}\n </>\n );\n}\n"
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
"path": "GameHud.tsx",
|
|
77
|
-
"content": "import {\n useEffect,\n useLayoutEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type ComponentType,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport { useStatus } from \"@idosgames/react\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport type { ModeStore } from \"./modeStore\";\nimport { FeatureMenu } from \"./FeatureMenu\";\nimport { CurrencyChips } from \"./CurrencyChips\";\nimport { WalletButton } from \"./WalletButton\";\nimport { StatusToast } from \"./StatusToast\";\nimport { AccountChip } from \"./AccountChip\";\n\n// The HUD row: balances on the left, wallet + account on the right, the status toast floating under\n// the middle. It renders in the host's \"hud\" slot, which is a flex row across the top of the screen;\n// the host measures that row and moves every mode's overlay below it, so templates never slide under\n// the HUD.\n//\n// Pointer events: the row itself lets clicks through to the game (voxelcraft needs the canvas), only\n// the actual controls take them.\n\n/** Which parts this HUD draws — the shared-UI roles it won (see module.ts). */\nexport interface HudParts {\n currencies: boolean;\n wallet: boolean;\n status: boolean;\n account: boolean;\n /** ☰ — the menu of the game's features (ctx.features). */\n menu: boolean;\n}\n\nexport function makeGameHudPanel(\n mode: ModeStore,\n parts: HudParts,\n features: FeatureRegistry,\n): ComponentType {\n return function GameHudPanel(): ReactNode {\n return <GameHud mode={mode} parts={parts} features={features} />;\n };\n}\n\nexport function GameHud({\n mode,\n parts,\n features,\n}: {\n mode: ModeStore;\n parts: HudParts;\n features: FeatureRegistry;\n}): ReactNode {\n const current = useSyncExternalStore(mode.subscribe, mode.get);\n const { clear } = useStatus();\n\n // A status message belongs to the mode that wrote it: the board's \"Rolling…\" must not linger over\n // Idle RPG. (Hide parts per mode here too if a mode does not need them — `current` is its id.)\n useEffect(() => {\n clear();\n }, [current, clear]);\n\n // On a phone the ID and \"Log out\" fold into one profile button: side by side with the wallet\n // they took the whole row and pushed the balances into a column.\n const barRef = useRef<HTMLDivElement>(null);\n const compact = useNarrow(barRef, COMPACT_BELOW_PX);\n\n return (\n <div ref={barRef} style={barStyle} data-mode={current ?? undefined}>\n <div style={balancesSideStyle}>\n {parts.currencies && <CurrencyChips />}\n </div>\n <div style={controlsSideStyle}>\n {parts.menu && <FeatureMenu features={features} />}\n {parts.wallet && <WalletButton />}\n {parts.account && <AccountChip compact={compact} />}\n </div>\n {parts.status && <StatusToast />}\n </div>\n );\n}\n\nconst barStyle: CSSProperties = {\n flex: 1,\n minWidth: 0,\n position: \"relative\",\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"flex-start\",\n gap: 12,\n color: \"#fff\",\n fontFamily: \"system-ui, sans-serif\",\n pointerEvents: \"none\",\n};\n/** Narrower than this, the account row folds into a profile button. */\nconst COMPACT_BELOW_PX = 600;\n\n/** True while the element is narrower than `px` (its own width, not the window's). */\nfunction useNarrow(ref: { current: HTMLElement | null }, px: number): boolean {\n const [narrow, setNarrow] = useState(false);\n useLayoutEffect(() => {\n const el = ref.current;\n if (!el) return;\n const measure = (): void =>\n setNarrow(el.getBoundingClientRect().width < px);\n measure();\n if (typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(measure);\n observer.observe(el);\n return () => observer.disconnect();\n }, [ref, px]);\n return narrow;\n}\n\nconst sideStyle: CSSProperties = {\n display: \"flex\",\n flexWrap: \"wrap\",\n alignItems: \"center\",\n gap: 8,\n minWidth: 0,\n};\nconst balancesSideStyle: CSSProperties = { ...sideStyle, flex: \"1 1 auto\" };\nconst controlsSideStyle: CSSProperties = {\n ...sideStyle,\n flex: \"0 1 auto\",\n justifyContent: \"flex-end\",\n};\n"
|
|
78
|
-
},
|
|
79
|
-
{
|
|
80
|
-
"path": "index.ts",
|
|
81
|
-
"content": "// @idosgames/mod-game-hud — the shared HUD for games made of several modules.\n//\n// Primary export is the module manifest; the rest is exposed so a project can recompose the pieces\n// after copying this into src/modules/.\n\nexport { gameHudModule } from \"./module\";\n\nexport { GameHud, makeGameHudPanel, type HudParts } from \"./GameHud\";\nexport { CurrencyChips } from \"./CurrencyChips\";\nexport { WalletButton } from \"./WalletButton\";\nexport { StatusToast } from \"./StatusToast\";\nexport { AccountChip } from \"./AccountChip\";\nexport { createModeStore, type ModeStore } from \"./modeStore\";\n"
|
|
82
|
-
},
|
|
83
|
-
{
|
|
84
|
-
"path": "modeStore.ts",
|
|
85
|
-
"content": "// The active mode, as announced by the host (`host:mode-changed@1`). A store, not React state: the\n// subscription lives in `setup()` for the whole session, the panel reads it with\n// useSyncExternalStore.\n\nexport interface ModeStore {\n subscribe(listener: () => void): () => void;\n get(): string | null;\n set(mode: string): void;\n}\n\nexport function createModeStore(): ModeStore {\n let current: string | null = null;\n const listeners = new Set<() => void>();\n return {\n subscribe(listener) {\n listeners.add(listener);\n return () => listeners.delete(listener);\n },\n get: () => current,\n set(mode) {\n if (mode === current) return;\n current = mode;\n for (const listener of [...listeners]) listener();\n },\n };\n}\n"
|
|
86
|
-
},
|
|
87
|
-
{
|
|
88
|
-
"path": "module.ts",
|
|
89
|
-
"content": "import { defineModule, hostTopics, type Module } from \"@idosgames/module-sdk\";\nimport { createModeStore } from \"./modeStore\";\nimport { makeGameHudPanel, type HudParts } from \"./GameHud\";\n\n// The Game HUD feature module: no scene, no route — one HUD panel that stays on screen in every mode\n// and draws the chrome every mode shares. It declares every shared-UI role, so templates mixed into\n// the same game ask `ctx.sharedUi.shouldDraw(role)`, get `false` and hide their own copies.\nexport const gameHudModule: Module = defineModule({\n id: \"game-hud\",\n meta: {\n name: \"Game HUD\",\n type: \"app\",\n engine: \"dom\",\n },\n sharedUi: {\n provides: [\"currency-bar\", \"wallet\", \"status\", \"account\", \"menu\"],\n },\n setup(ctx) {\n // Draw only the roles this module actually won: if another provider comes first in\n // src/modules.ts and takes, say, \"wallet\", this HUD hides its wallet button like any template.\n const parts: HudParts = {\n currencies: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n account: ctx.sharedUi.shouldDraw(\"account\"),\n menu: ctx.sharedUi.shouldDraw(\"menu\"),\n };\n\n // Subscribed here, not in the panel: setup() runs before the shell mounts, so even the first\n // activation is seen. The panel reads the current mode from the store.\n const mode = createModeStore();\n ctx.events.on(hostTopics.modeChanged, ({ to }) => mode.set(to));\n\n ctx.registerPanel({\n id: \"hud\",\n slot: \"hud\",\n activeOnly: false,\n component: makeGameHudPanel(mode, parts, ctx.features),\n });\n\n ctx.exposeToAgent({\n state: () => ({ mode: mode.get(), draws: parts }),\n describeActions: {},\n });\n },\n});\n"
|
|
90
|
-
},
|
|
91
|
-
{
|
|
92
|
-
"path": "StatusToast.tsx",
|
|
93
|
-
"content": "import type { CSSProperties, ReactNode } from \"react\";\nimport { useStatus } from \"@idosgames/react\";\n\n// The one status line for the whole game: whatever any module writes with `useStatus().setStatus`\n// lands here, because the host wraps every module in a single root StatusProvider. Floats under the\n// HUD (absolute), so a message appearing does not push the modes' layout down. Click to dismiss; the\n// HUD also clears it on every mode switch.\n\nconst colors: Record<string, string> = {\n info: \"#a9c6ff\",\n success: \"#5ad19a\",\n error: \"#ff6b6b\",\n};\n\nexport function StatusToast(): ReactNode {\n const { message, clear } = useStatus();\n if (!message) return null;\n return (\n <button\n type=\"button\"\n style={{ ...toastStyle, color: colors[message.kind] ?? \"#fff\" }}\n onClick={clear}\n title=\"Dismiss\"\n >\n {message.kind === \"error\" ? \"⚠ \" : message.kind === \"success\" ? \"✓ \" : \"\"}\n {message.text}\n </button>\n );\n}\n\nconst toastStyle: CSSProperties = {\n position: \"absolute\",\n top: \"calc(100% + 8px)\",\n left: \"50%\",\n transform: \"translateX(-50%)\",\n maxWidth: \"min(560px, 80vw)\",\n border: \"none\",\n borderRadius: 12,\n padding: \"6px 14px\",\n fontSize: 13,\n fontWeight: 600,\n fontFamily: \"system-ui, sans-serif\",\n background: \"rgba(4,32,90,0.85)\",\n backdropFilter: \"blur(6px)\",\n cursor: \"pointer\",\n pointerEvents: \"auto\",\n};\n"
|
|
94
|
-
},
|
|
95
|
-
{
|
|
96
|
-
"path": "WalletButton.tsx",
|
|
97
|
-
"content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport { LazyWalletPanel } from \"@idosgames/wallet/react/lazy\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\n\n// The one wallet entry for the whole game (deposit/withdraw), opened as a dropdown under the button.\n// The dropdown is absolutely positioned, so opening it does not change the HUD's height — the host\n// measures that height to place every mode below it.\n//\n// Import is \"@idosgames/wallet/react/lazy\", NOT \"@idosgames/wallet/react\": the latter pulls Reown\n// AppKit into the startup graph and blanks the preview for web3 titles. The wallet config is memoised\n// per project id, so a wallet connected on the sign-in screen is already connected here.\n\nexport function WalletButton(): ReactNode {\n const client = useIDosGamesClient();\n const [open, setOpen] = useState(false);\n\n return (\n <div style={anchorStyle}>\n <button\n type=\"button\"\n style={buttonStyle}\n onClick={() => setOpen((value) => !value)}\n >\n 💰 Wallet\n </button>\n {open && (\n <div style={dropdownStyle}>\n <LazyWalletPanel\n client={client}\n appName=\"iDosGames\"\n onClose={() => setOpen(false)}\n />\n </div>\n )}\n </div>\n );\n}\n\nconst anchorStyle: CSSProperties = {\n position: \"relative\",\n pointerEvents: \"auto\",\n};\nconst buttonStyle: CSSProperties = {\n background: \"#0d66fe\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 10,\n padding: \"8px 14px\",\n fontWeight: 700,\n cursor: \"pointer\",\n};\nconst dropdownStyle: CSSProperties = {\n position: \"absolute\",\n top: \"calc(100% + 8px)\",\n right: 0,\n zIndex: 5,\n};\n"
|
|
98
|
-
},
|
|
99
|
-
{
|
|
100
|
-
"path": "module.meta.json",
|
|
101
|
-
"content": "{\n \"id\": \"game-hud\",\n \"type\": \"feature\",\n \"summary\": \"Shared game HUD: one currency bar, wallet button, status line and account chip across every mode.\",\n \"description\": \"The shared chrome for a game made of several modules. It takes every shared-UI role (currency-bar, wallet, status, account), so templates mixed into the same game hide their own balances, wallet and status line automatically — without editing them. Stays on screen in every mode (a HUD panel with activeOnly: false), clears the status on each mode switch, and is ordinary editable React: restyle or replace it like any other module. Installed automatically when a project starts from two or more templates.\",\n \"provides\": [\n \"one shared currency balance bar for every mode\",\n \"one crypto wallet button (deposit / withdraw) for the whole game\",\n \"shared status line / toast messages across modes\",\n \"player ID with copy and a log-out button\",\n \"consistent HUD when mixing several game templates\"\n ],\n \"tags\": [\"hud\", \"ui\", \"shared\", \"wallet\", \"currency\", \"status\", \"chrome\"],\n \"author\": { \"name\": \"iDos Games\", \"url\": \"https://idosgames.com\" },\n \"version\": \"0.1.0\",\n \"events\": {\n \"listens\": [\n {\n \"topic\": \"host:mode-changed@1\",\n \"why\": \"clears the previous mode's status message when the player switches modes\"\n }\n ]\n }\n}\n"
|
|
102
|
-
}
|
|
103
|
-
]
|
|
104
|
-
}
|