@idosgames/mcp 0.1.20 → 0.1.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/registry/host.json +10 -10
- package/registry/index.json +107 -107
- package/registry/modules/ai.json +6 -6
- package/registry/modules/board-game.json +2 -2
- package/registry/modules/boosts.json +2 -2
- package/registry/modules/character.json +6 -6
- package/registry/modules/chat.json +6 -6
- package/registry/modules/collections.json +6 -6
- package/registry/modules/coop-events.json +6 -6
- package/registry/modules/craft.json +6 -6
- package/registry/modules/deals.json +6 -6
- package/registry/modules/events.json +6 -6
- package/registry/modules/friends.json +6 -6
- package/registry/modules/idle-rpg.json +2 -2
- package/registry/modules/inventory.json +2 -2
- package/registry/modules/invite.json +6 -6
- package/registry/modules/leaderboard.json +6 -6
- package/registry/modules/lootboxes.json +2 -2
- package/registry/modules/mailbox.json +2 -2
- package/registry/modules/marketplace.json +6 -6
- package/registry/modules/mini-game.json +6 -6
- package/registry/modules/multiplayer.json +6 -6
- package/registry/modules/premium.json +6 -6
- package/registry/modules/profile.json +6 -6
- package/registry/modules/pvp.json +2 -2
- package/registry/modules/quests.json +2 -2
- package/registry/modules/rewards.json +6 -6
- package/registry/modules/season.json +6 -6
- package/registry/modules/store.json +6 -6
- package/registry/modules/tutorials.json +6 -6
- package/registry/modules/voxelcraft.json +5 -5
- package/registry/modules/workshop.json +6 -6
- package/registry/skills/idosgames-module-contract.json +1 -1
- package/registry/skills/idosgames-project-structure.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +1 -1
|
@@ -66,14 +66,14 @@
|
|
|
66
66
|
"name": "iDos Games",
|
|
67
67
|
"url": "https://idosgames.com"
|
|
68
68
|
},
|
|
69
|
-
"version": "0.4.
|
|
69
|
+
"version": "0.4.1"
|
|
70
70
|
},
|
|
71
71
|
"dependencies": {
|
|
72
72
|
"@idosgames/core": "0.19.1",
|
|
73
|
-
"@idosgames/module-sdk": "0.
|
|
73
|
+
"@idosgames/module-sdk": "0.8.0",
|
|
74
74
|
"three": "0.185.1"
|
|
75
75
|
},
|
|
76
|
-
"contentHash": "
|
|
76
|
+
"contentHash": "a4ed25dd430145920db6e2bc98a5b5ec8a0eec78e5f98d7a8ea9be56ed06302f",
|
|
77
77
|
"files": [
|
|
78
78
|
{
|
|
79
79
|
"path": "agent.ts",
|
|
@@ -1117,7 +1117,7 @@
|
|
|
1117
1117
|
},
|
|
1118
1118
|
{
|
|
1119
1119
|
"path": "scene.ts",
|
|
1120
|
-
"content": "import type {\n ContentTypeHandler,\n EngineScene,\n ModuleAgentApi,\n SceneMountContext,\n} from \"@idosgames/module-sdk\";\nimport { createVoxelcraftAgentApi } from \"./agent\";\nimport { createVoxelWorldContentType } from \"./workshop\";\nimport { Game, type GameOptions } from \"./main\";\nimport \"./style.css\";\n\n// VoxelCraft is a standalone vanilla-Three game with its own DOM HUD (queried by id) and its own\n// RAF loop — no React, no SDK. It becomes a composable module by (1) injecting the DOM it expects\n// into the host surface instead of relying on index.html, and (2) exposing mount/suspend/destroy.\n// Because the host surface is in the document, the game's document.getElementById(...) lookups keep\n// working unchanged — no rewrite of its HUD/input code needed.\n//\n// This is the \"engine scene with NO React panels\" branch of the contract: the module registers a\n// scene and a nav entry, and nothing else.\n\nconst VOXEL_DOM = `\n <canvas id=\"game\"></canvas>\n <div id=\"crosshair\"></div>\n <div id=\"fps\"></div>\n <div id=\"debug\"></div>\n <div id=\"timeIndicator\"></div>\n <div id=\"hearts\"></div>\n <div id=\"hunger\"></div>\n <div id=\"hotbar\"></div>\n <div id=\"hurtFlash\"></div>\n <div id=\"message\"></div>\n <div id=\"invScreen\" class=\"hidden\">\n <div class=\"invPanel\">\n <h3 id=\"invTitle\">Inventory</h3>\n <div class=\"craftRow\">\n <div id=\"craftGrid\"></div>\n <div class=\"arrow\">→</div>\n <div id=\"craftResult\" class=\"slot result\"></div>\n </div>\n <div id=\"invGrid\"></div>\n <div id=\"invHotbar\"></div>\n </div>\n </div>\n <div id=\"cursorStack\" class=\"hidden\"><img draggable=\"false\" /><span class=\"count\"></span></div>\n <div id=\"overlay\">\n <div class=\"panel\">\n <h1 id=\"overlayTitle\">VoxelCraft</h1>\n <p id=\"overlayHint\">Click to play</p>\n <div class=\"controls\">\n <div><b>WASD</b> move, <b>Space</b> jump / swim up, <b>Shift</b> / <b>W W</b> sprint, <b>Ctrl</b> sneak</div>\n <div><b>LMB</b> (hold) break / attack, <b>RMB</b> place block / open door</div>\n <div><b>Wheel / 1-9</b> hotbar slot, <b>E</b> inventory & 2×2 craft</div>\n <div><b>RMB (hold) with food</b> eat (watch the hunger bar)</div>\n <div><b>RMB on a workbench</b> 3×3 craft, <b>C</b> creative (double <b>Space</b> to fly)</div>\n <div><b>Esc</b> pause</div>\n </div>\n <div class=\"buttons\">\n <button id=\"btnWorlds\" class=\"accent\">Create world</button>\n <button id=\"btnExport\">Export world</button>\n <button id=\"btnImport\">Import world</button>\n <button id=\"btnNewSeed\">New seed</button>\n </div>\n </div>\n </div>\n <div id=\"worldScreen\" class=\"hidden\">\n <div class=\"wsPanel\">\n <div class=\"wsHead\">\n <button class=\"wsTab active\" data-tab=\"new\">Новый мир</button>\n <button class=\"wsTab\" data-tab=\"mine\">Мои миры</button>\n <button id=\"wsClose\" class=\"wsClose\" title=\"Закрыть (Esc)\">×</button>\n </div>\n <div id=\"wsNew\" class=\"wsBody\">\n <div class=\"wsForm\">\n <div class=\"wsLabel\">Картинки-референсы <span class=\"wsNote\">до 4, можно один объект с разных сторон</span></div>\n <div id=\"wsDrop\" class=\"wsDrop\" title=\"Клик, перетаскивание или Ctrl+V\">\n <div id=\"wsThumbs\" class=\"wsThumbs hidden\"></div>\n <span id=\"wsDropHint\">Перетащите картинки, вставьте Ctrl+V или нажмите, чтобы выбрать</span>\n </div>\n <div class=\"wsRow\">\n <label title=\"ИИ сам поймёт по описанию, что на картинках: объект, который надо построить из блоков, стиль или место\">\n <input type=\"radio\" name=\"wsRead\" value=\"reference\" checked /> Образец для ИИ</label>\n <label><input type=\"radio\" name=\"wsRead\" value=\"topdown\" /> Карта сверху</label>\n <label><input type=\"radio\" name=\"wsRead\" value=\"mood\" /> Настроение</label>\n <button id=\"wsClearImg\" class=\"wsLink hidden\">убрать картинки</button>\n </div>\n <label class=\"wsLabel\" for=\"wsText\">Описание</label>\n <textarea id=\"wsText\" rows=\"3\" maxlength=\"600\"\n placeholder=\"Например: тропический остров, на взлётной полосе стоит самолёт с картинок\"></textarea>\n <div class=\"wsLabel\">Размер</div>\n <div class=\"wsRow\">\n <label><input type=\"radio\" name=\"wsSize\" value=\"island\" checked /> Остров 256</label>\n <label><input type=\"radio\" name=\"wsSize\" value=\"large\" /> Большой 512</label>\n <label><input type=\"radio\" name=\"wsSize\" value=\"infinite\" /> Бесконечный</label>\n <label><input type=\"radio\" name=\"wsSize\" value=\"custom\" /> Свой</label>\n <span id=\"wsCustom\" class=\"hidden\">\n <input id=\"wsW\" type=\"number\" min=\"4\" max=\"48\" value=\"24\" /> ×\n <input id=\"wsD\" type=\"number\" min=\"4\" max=\"48\" value=\"24\" /> чанков\n </span>\n </div>\n <div class=\"wsLabel\">Как строить</div>\n <div class=\"wsRow\">\n <label><input type=\"radio\" name=\"wsMode\" value=\"local\" checked /> Без ИИ</label>\n <label id=\"wsAiLabel\"><input type=\"radio\" name=\"wsMode\" value=\"ai\" disabled /> С ИИ</label>\n <span id=\"wsAiNote\" class=\"wsNote\"></span>\n </div>\n <div class=\"wsButtons\">\n <button id=\"wsGenerate\" class=\"wsPrimary\">Сгенерировать</button>\n <button id=\"wsLoadSpec\">Загрузить рецепт</button>\n </div>\n <div id=\"wsStatus\" class=\"wsStatus\"></div>\n </div>\n <div class=\"wsPreview\">\n <canvas id=\"wsMap\" width=\"256\" height=\"256\"></canvas>\n <div id=\"wsInfo\" class=\"wsInfo\">Здесь появится карта мира</div>\n <div class=\"wsButtons\">\n <button id=\"wsPlay\" class=\"wsPrimary\" disabled>Играть в этом мире</button>\n <button id=\"wsSaveSpec\" disabled>Скачать рецепт</button>\n </div>\n </div>\n </div>\n <div id=\"wsMine\" class=\"wsBody hidden\"><div id=\"wsList\" class=\"wsList\"></div></div>\n </div>\n </div>\n`;\n\n/**\n * Сцена + debug-поверхность для агента + тип контента для Мастерской: все смотрят на ОДИН экземпляр\n * игры, который создаётся в `mount()` и обнуляется в `destroy()`. Поэтому они получают не объект, а\n * доступ к текущему `game` — иначе после пересоздания сцены держали бы ссылку на мёртвую игру.\n */\nexport function createVoxelcraft(opts: GameOptions = {}): {\n scene: EngineScene;\n agent: ModuleAgentApi;\n content: ContentTypeHandler;\n /** The game once the scene is mounted (the room from the lobby may arrive before the first frame). */\n whenGame(): Promise<Game>;\n} {\n let game: Game | null = null;\n const waiting: Array<(g: Game) => void> = [];\n const scene = buildScene(\n () => game,\n (next) => {\n game = next;\n if (next) for (const resolve of waiting.splice(0)) resolve(next);\n },\n opts,\n );\n return {\n scene,\n agent: createVoxelcraftAgentApi(() => game),\n content: createVoxelWorldContentType(() => game),\n whenGame: () =>\n game ? Promise.resolve(game) : new Promise<Game>((r) => waiting.push(r)),\n };\n}\n\nfunction buildScene(\n getGame: () => Game | null,\n setGame: (game: Game | null) => void,\n opts: GameOptions,\n): EngineScene {\n return {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext): void {\n // The game reads its elements by id from the document; provide them inside the host surface.\n ctx.host.innerHTML = VOXEL_DOM;\n setGame(new Game(opts));\n },\n activate(): void {\n getGame()?.setRunning(true);\n },\n suspend(): void {\n getGame()?.setRunning(false);\n },\n capture(): Promise<Blob | null> {\n return getGame()?.capture() ?? Promise.resolve(null);\n },\n destroy(): void {\n getGame()?.destroy();\n setGame(null);\n },\n };\n}\n\n/** @deprecated Используйте `createVoxelcraft()` — он отдаёт ещё и поверхность для агента. */\nexport function createVoxelScene(): EngineScene {\n return createVoxelcraft().scene;\n}\n"
|
|
1120
|
+
"content": "import type {\n ContentTypeHandler,\n EngineScene,\n ModuleAgentApi,\n SceneMountContext,\n} from \"@idosgames/module-sdk\";\nimport { createVoxelcraftAgentApi } from \"./agent\";\nimport { createVoxelWorldContentType } from \"./workshop\";\nimport { Game, type GameOptions } from \"./main\";\nimport \"./style.css\";\n\n// VoxelCraft is a standalone vanilla-Three game with its own DOM HUD (queried by id) and its own\n// RAF loop — no React, no SDK. It becomes a composable module by (1) injecting the DOM it expects\n// into the host surface instead of relying on index.html, and (2) exposing mount/suspend/destroy.\n// Because the host surface is in the document, the game's document.getElementById(...) lookups keep\n// working unchanged — no rewrite of its HUD/input code needed.\n//\n// This is the \"engine scene with NO React panels\" branch of the contract: the module registers a\n// scene and a nav entry, and nothing else.\n\nconst VOXEL_DOM = `\n <canvas id=\"game\"></canvas>\n <div id=\"crosshair\"></div>\n <div id=\"fps\"></div>\n <div id=\"debug\"></div>\n <div id=\"timeIndicator\"></div>\n <div id=\"hearts\"></div>\n <div id=\"hunger\"></div>\n <div id=\"hotbar\"></div>\n <div id=\"hurtFlash\"></div>\n <div id=\"message\"></div>\n <div id=\"invScreen\" class=\"hidden\">\n <div class=\"invPanel\">\n <h3 id=\"invTitle\">Inventory</h3>\n <div class=\"craftRow\">\n <div id=\"craftGrid\"></div>\n <div class=\"arrow\">→</div>\n <div id=\"craftResult\" class=\"slot result\"></div>\n </div>\n <div id=\"invGrid\"></div>\n <div id=\"invHotbar\"></div>\n </div>\n </div>\n <div id=\"cursorStack\" class=\"hidden\"><img draggable=\"false\" /><span class=\"count\"></span></div>\n <div id=\"overlay\">\n <div class=\"panel\">\n <h1 id=\"overlayTitle\">VoxelCraft</h1>\n <p id=\"overlayHint\">Click to play</p>\n <div class=\"controls\">\n <div><b>WASD</b> move, <b>Space</b> jump / swim up, <b>Shift</b> / <b>W W</b> sprint, <b>Ctrl</b> sneak</div>\n <div><b>LMB</b> (hold) break / attack, <b>RMB</b> place block / open door</div>\n <div><b>Wheel / 1-9</b> hotbar slot, <b>E</b> inventory & 2×2 craft</div>\n <div><b>RMB (hold) with food</b> eat (watch the hunger bar)</div>\n <div><b>RMB on a workbench</b> 3×3 craft, <b>C</b> creative (double <b>Space</b> to fly)</div>\n <div><b>Esc</b> pause</div>\n </div>\n <div class=\"buttons\">\n <button id=\"btnWorlds\" class=\"accent\">Create world</button>\n <button id=\"btnExport\">Export world</button>\n <button id=\"btnImport\">Import world</button>\n <button id=\"btnNewSeed\">New seed</button>\n </div>\n </div>\n </div>\n <div id=\"worldScreen\" class=\"hidden\">\n <div class=\"wsPanel\">\n <div class=\"wsHead\">\n <button class=\"wsTab active\" data-tab=\"new\">Новый мир</button>\n <button class=\"wsTab\" data-tab=\"mine\">Мои миры</button>\n <button id=\"wsClose\" class=\"wsClose\" title=\"Закрыть (Esc)\">×</button>\n </div>\n <div id=\"wsNew\" class=\"wsBody\">\n <div class=\"wsForm\">\n <div class=\"wsLabel\">Картинки-референсы <span class=\"wsNote\">до 4, можно один объект с разных сторон</span></div>\n <div id=\"wsDrop\" class=\"wsDrop\" title=\"Клик, перетаскивание или Ctrl+V\">\n <div id=\"wsThumbs\" class=\"wsThumbs hidden\"></div>\n <span id=\"wsDropHint\">Перетащите картинки, вставьте Ctrl+V или нажмите, чтобы выбрать</span>\n </div>\n <div class=\"wsRow\">\n <label title=\"ИИ сам поймёт по описанию, что на картинках: объект, который надо построить из блоков, стиль или место\">\n <input type=\"radio\" name=\"wsRead\" value=\"reference\" checked /> Образец для ИИ</label>\n <label><input type=\"radio\" name=\"wsRead\" value=\"topdown\" /> Карта сверху</label>\n <label><input type=\"radio\" name=\"wsRead\" value=\"mood\" /> Настроение</label>\n <button id=\"wsClearImg\" class=\"wsLink hidden\">убрать картинки</button>\n </div>\n <label class=\"wsLabel\" for=\"wsText\">Описание</label>\n <textarea id=\"wsText\" rows=\"3\" maxlength=\"600\"\n placeholder=\"Например: тропический остров, на взлётной полосе стоит самолёт с картинок\"></textarea>\n <div class=\"wsLabel\">Размер</div>\n <div class=\"wsRow\">\n <label><input type=\"radio\" name=\"wsSize\" value=\"island\" checked /> Остров 256</label>\n <label><input type=\"radio\" name=\"wsSize\" value=\"large\" /> Большой 512</label>\n <label><input type=\"radio\" name=\"wsSize\" value=\"infinite\" /> Бесконечный</label>\n <label><input type=\"radio\" name=\"wsSize\" value=\"custom\" /> Свой</label>\n <span id=\"wsCustom\" class=\"hidden\">\n <input id=\"wsW\" type=\"number\" min=\"4\" max=\"48\" value=\"24\" /> ×\n <input id=\"wsD\" type=\"number\" min=\"4\" max=\"48\" value=\"24\" /> чанков\n </span>\n </div>\n <div class=\"wsLabel\">Как строить</div>\n <div class=\"wsRow\">\n <label><input type=\"radio\" name=\"wsMode\" value=\"local\" checked /> Без ИИ</label>\n <label id=\"wsAiLabel\"><input type=\"radio\" name=\"wsMode\" value=\"ai\" disabled /> С ИИ</label>\n <span id=\"wsAiNote\" class=\"wsNote\"></span>\n </div>\n <div class=\"wsButtons\">\n <button id=\"wsGenerate\" class=\"wsPrimary\">Сгенерировать</button>\n <button id=\"wsLoadSpec\">Загрузить рецепт</button>\n </div>\n <div id=\"wsStatus\" class=\"wsStatus\"></div>\n </div>\n <div class=\"wsPreview\">\n <canvas id=\"wsMap\" width=\"256\" height=\"256\"></canvas>\n <div id=\"wsInfo\" class=\"wsInfo\">Здесь появится карта мира</div>\n <div class=\"wsButtons\">\n <button id=\"wsPlay\" class=\"wsPrimary\" disabled>Играть в этом мире</button>\n <button id=\"wsSaveSpec\" disabled>Скачать рецепт</button>\n </div>\n </div>\n </div>\n <div id=\"wsMine\" class=\"wsBody hidden\"><div id=\"wsList\" class=\"wsList\"></div></div>\n </div>\n </div>\n`;\n\n/**\n * Сцена + debug-поверхность для агента + тип контента для Мастерской: все смотрят на ОДИН экземпляр\n * игры, который создаётся в `mount()` и обнуляется в `destroy()`. Поэтому они получают не объект, а\n * доступ к текущему `game` — иначе после пересоздания сцены держали бы ссылку на мёртвую игру.\n */\nexport function createVoxelcraft(opts: GameOptions = {}): {\n scene: EngineScene;\n agent: ModuleAgentApi;\n content: ContentTypeHandler;\n /** The game once the scene is mounted (the room from the lobby may arrive before the first frame). */\n whenGame(): Promise<Game>;\n} {\n let game: Game | null = null;\n const waiting: Array<(g: Game) => void> = [];\n const scene = buildScene(\n () => game,\n (next) => {\n game = next;\n if (next) for (const resolve of waiting.splice(0)) resolve(next);\n },\n opts,\n );\n return {\n scene,\n agent: createVoxelcraftAgentApi(() => game),\n content: createVoxelWorldContentType(() => game),\n whenGame: () =>\n game ? Promise.resolve(game) : new Promise<Game>((r) => waiting.push(r)),\n };\n}\n\nfunction buildScene(\n getGame: () => Game | null,\n setGame: (game: Game | null) => void,\n opts: GameOptions,\n): EngineScene {\n return {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext): void {\n // The game reads its elements by id from the document; provide them inside the host surface.\n ctx.host.innerHTML = VOXEL_DOM;\n setGame(new Game(opts));\n },\n // The host keeps its loading screen up until the last played world is restored from the\n // player's storage — otherwise the start world flashes first and is swapped a moment later.\n ready(): Promise<void> {\n return getGame()?.restored ?? Promise.resolve();\n },\n activate(): void {\n getGame()?.setRunning(true);\n },\n suspend(): void {\n getGame()?.setRunning(false);\n },\n capture(): Promise<Blob | null> {\n return getGame()?.capture() ?? Promise.resolve(null);\n },\n destroy(): void {\n getGame()?.destroy();\n setGame(null);\n },\n };\n}\n\n/** @deprecated Используйте `createVoxelcraft()` — он отдаёт ещё и поверхность для агента. */\nexport function createVoxelScene(): EngineScene {\n return createVoxelcraft().scene;\n}\n"
|
|
1121
1121
|
},
|
|
1122
1122
|
{
|
|
1123
1123
|
"path": "shims.d.ts",
|
|
@@ -1777,7 +1777,7 @@
|
|
|
1777
1777
|
},
|
|
1778
1778
|
{
|
|
1779
1779
|
"path": "module.meta.json",
|
|
1780
|
-
"content": "{\n \"id\": \"voxelcraft\",\n \"type\": \"template\",\n \"uses\": [\"multiplayer\", \"workshop\"],\n \"summary\": \"Minecraft-like voxel survival game: mine, craft, build, farm and fight from the first night to the dragon — with worlds generated from a picture + description (with or without AI).\",\n \"description\": \"A first-person voxel game rendered in Three.js that follows Minecraft's mechanics 1:1 under its own names (every player-facing name lives in one table, so a commercial game can re-skin it; Minecraft names are accepted as aliases). Survival and creative modes: ores and tool tiers, crafting, smelting, armor, hunger, air and drowning, XP, enchanting, anvils, smithing, potions and beacons; farming, animals, taming and riding, fishing, boats and minecarts; villages with trading villagers, raids, and dozens of hostile and neutral mobs. Three dimensions — the overworld (biomes from deserts and jungles to the deep dark, caves, ocean temples, mansions, trial chambers, archaeology), the Inferno (Nether-like: fortresses, its own mobs and biomes) and the Beyond (End-like: citadel portal, the dragon boss, outer islands and cities, wings). Redstone (wire, torches, repeaters, comparators, pistons, observers, hoppers, dispensers, rails), weather, day/night, block light, fluids, achievements, generated music, a chat console with commands, and touch controls. The 'Create world' screen turns a reference picture and a text description into a world — locally in the browser (the picture read as a top-down map or as a mood, the text by keywords) or with InApp AI (client.ai, title feature \\\"voxel-world\\\") — via a compact WorldSpec recipe. Worlds autosave to 'My worlds'. The agent sees and drives the game through its debug surface (state, lookingAt, console commands). Covers any 'Minecraft clone', survival / crafting / mining game, block-building sandbox or 'generate a world from a picture' request; by far the largest feature module. Co-op multiplayer for up to 8 players through the lobby's rooms (the multiplayer module, ctx.multiplayer): the lobby shows 'With friends' next to the game's 'Play', and the pause menu's 'Play with friends' opens 'Create a room' there (public or by invite, room settings 'guest game mode' and 'commands for everyone'); the host's 'Play' takes the whole room into the host's world, late joiners included; in game the host grants operator, kicks and bans. Peer-to-peer over WebRTC (the SDK transport @idosgames/core/realtime, rooms and signaling on the engine's Multiplayer module; TURN relay from the engine when configured): each live dimension is simulated by one player and players may be in different dimensions at once; everyone's own inventory and progress stay theirs; when the host leaves or its tab dies the world moves to another player without stopping. Needs the title's Multiplayer section enabled (Host topology). Not included yet: AI-driven villagers and a dedicated server.\",\n \"provides\": [\n \"first-person voxel world: mine, place and break blocks\",\n \"survival and creative modes (health, hunger, air, XP, death and respawn)\",\n \"crafting, smelting, tool and armor tiers\",\n \"enchanting, anvil, smithing, potions, beacon\",\n \"farming, animals, taming and riding, fishing, boats\",\n \"villages, trading villagers, raids\",\n \"dozens of hostile and neutral mobs, bosses (dragon, ash lord)\",\n \"three dimensions: overworld, Inferno (Nether-like), Beyond (End-like)\",\n \"biomes, caves and structures (temples, mansions, fortresses, trial chambers, ruins)\",\n \"redstone: wire, repeaters, comparators, pistons, observers, hoppers, rails and minecarts\",\n \"weather, day/night cycle, block light and fluid simulation\",\n \"achievements and procedurally generated music\",\n \"chat console with commands (/give, /tp, /locate, /summon, …)\",\n \"touch controls for mobile\",\n \"world generation from a picture + description (WorldSpec recipe)\",\n \"AI world generation via client.ai (feature \\\"voxel-world\\\")\",\n \"AI builds objects from pictures out of coloured blocks (a plane, a car… — models of shapes)\",\n \"AI recreates landscapes from pictures (terrain sketch, rivers, biomes)\",\n \"co-op multiplayer up to 8 players (P2P over WebRTC) through the lobby's rooms (ctx.multiplayer), late joiners included\",\n \"open the world to the network: invite code or public list, guest game mode, operators, kick and ban\",\n \"players in different dimensions at once; host migration without stopping the world\",\n \"autosave and 'My worlds' (IndexedDB)\",\n \"own player-facing names for a re-skinnable commercial game (Minecraft names as aliases)\",\n \"3D block rendering (Three.js)\"\n ],\n \"tags\": [\n \"voxel\",\n \"sandbox\",\n \"survival\",\n \"crafting\",\n \"minecraft\",\n \"3d\",\n \"first-person\",\n \"open-world\",\n \"redstone\",\n \"mobile\",\n \"three\",\n \"ai\",\n \"world-generation\",\n \"multiplayer\",\n \"co-op\"\n ],\n \"media\": {\n \"image\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/cover.png\",\n \"video\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/demo.mp4\"\n },\n \"demoUrl\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/demo/\",\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.4.
|
|
1780
|
+
"content": "{\n \"id\": \"voxelcraft\",\n \"type\": \"template\",\n \"uses\": [\"multiplayer\", \"workshop\"],\n \"summary\": \"Minecraft-like voxel survival game: mine, craft, build, farm and fight from the first night to the dragon — with worlds generated from a picture + description (with or without AI).\",\n \"description\": \"A first-person voxel game rendered in Three.js that follows Minecraft's mechanics 1:1 under its own names (every player-facing name lives in one table, so a commercial game can re-skin it; Minecraft names are accepted as aliases). Survival and creative modes: ores and tool tiers, crafting, smelting, armor, hunger, air and drowning, XP, enchanting, anvils, smithing, potions and beacons; farming, animals, taming and riding, fishing, boats and minecarts; villages with trading villagers, raids, and dozens of hostile and neutral mobs. Three dimensions — the overworld (biomes from deserts and jungles to the deep dark, caves, ocean temples, mansions, trial chambers, archaeology), the Inferno (Nether-like: fortresses, its own mobs and biomes) and the Beyond (End-like: citadel portal, the dragon boss, outer islands and cities, wings). Redstone (wire, torches, repeaters, comparators, pistons, observers, hoppers, dispensers, rails), weather, day/night, block light, fluids, achievements, generated music, a chat console with commands, and touch controls. The 'Create world' screen turns a reference picture and a text description into a world — locally in the browser (the picture read as a top-down map or as a mood, the text by keywords) or with InApp AI (client.ai, title feature \\\"voxel-world\\\") — via a compact WorldSpec recipe. Worlds autosave to 'My worlds'. The agent sees and drives the game through its debug surface (state, lookingAt, console commands). Covers any 'Minecraft clone', survival / crafting / mining game, block-building sandbox or 'generate a world from a picture' request; by far the largest feature module. Co-op multiplayer for up to 8 players through the lobby's rooms (the multiplayer module, ctx.multiplayer): the lobby shows 'With friends' next to the game's 'Play', and the pause menu's 'Play with friends' opens 'Create a room' there (public or by invite, room settings 'guest game mode' and 'commands for everyone'); the host's 'Play' takes the whole room into the host's world, late joiners included; in game the host grants operator, kicks and bans. Peer-to-peer over WebRTC (the SDK transport @idosgames/core/realtime, rooms and signaling on the engine's Multiplayer module; TURN relay from the engine when configured): each live dimension is simulated by one player and players may be in different dimensions at once; everyone's own inventory and progress stay theirs; when the host leaves or its tab dies the world moves to another player without stopping. Needs the title's Multiplayer section enabled (Host topology). Not included yet: AI-driven villagers and a dedicated server.\",\n \"provides\": [\n \"first-person voxel world: mine, place and break blocks\",\n \"survival and creative modes (health, hunger, air, XP, death and respawn)\",\n \"crafting, smelting, tool and armor tiers\",\n \"enchanting, anvil, smithing, potions, beacon\",\n \"farming, animals, taming and riding, fishing, boats\",\n \"villages, trading villagers, raids\",\n \"dozens of hostile and neutral mobs, bosses (dragon, ash lord)\",\n \"three dimensions: overworld, Inferno (Nether-like), Beyond (End-like)\",\n \"biomes, caves and structures (temples, mansions, fortresses, trial chambers, ruins)\",\n \"redstone: wire, repeaters, comparators, pistons, observers, hoppers, rails and minecarts\",\n \"weather, day/night cycle, block light and fluid simulation\",\n \"achievements and procedurally generated music\",\n \"chat console with commands (/give, /tp, /locate, /summon, …)\",\n \"touch controls for mobile\",\n \"world generation from a picture + description (WorldSpec recipe)\",\n \"AI world generation via client.ai (feature \\\"voxel-world\\\")\",\n \"AI builds objects from pictures out of coloured blocks (a plane, a car… — models of shapes)\",\n \"AI recreates landscapes from pictures (terrain sketch, rivers, biomes)\",\n \"co-op multiplayer up to 8 players (P2P over WebRTC) through the lobby's rooms (ctx.multiplayer), late joiners included\",\n \"open the world to the network: invite code or public list, guest game mode, operators, kick and ban\",\n \"players in different dimensions at once; host migration without stopping the world\",\n \"autosave and 'My worlds' (IndexedDB)\",\n \"own player-facing names for a re-skinnable commercial game (Minecraft names as aliases)\",\n \"3D block rendering (Three.js)\"\n ],\n \"tags\": [\n \"voxel\",\n \"sandbox\",\n \"survival\",\n \"crafting\",\n \"minecraft\",\n \"3d\",\n \"first-person\",\n \"open-world\",\n \"redstone\",\n \"mobile\",\n \"three\",\n \"ai\",\n \"world-generation\",\n \"multiplayer\",\n \"co-op\"\n ],\n \"media\": {\n \"image\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/cover.png\",\n \"video\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/demo.mp4\"\n },\n \"demoUrl\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/demo/\",\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.4.1\"\n}\n"
|
|
1781
1781
|
}
|
|
1782
1782
|
]
|
|
1783
1783
|
}
|
|
@@ -36,15 +36,15 @@
|
|
|
36
36
|
"name": "iDos Games",
|
|
37
37
|
"url": "https://idosgames.com"
|
|
38
38
|
},
|
|
39
|
-
"version": "0.1.
|
|
39
|
+
"version": "0.1.2"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@idosgames/core": "0.19.1",
|
|
43
|
-
"@idosgames/module-sdk": "0.
|
|
44
|
-
"@idosgames/react": "0.
|
|
43
|
+
"@idosgames/module-sdk": "0.8.0",
|
|
44
|
+
"@idosgames/react": "0.4.0",
|
|
45
45
|
"react": "19.2.7"
|
|
46
46
|
},
|
|
47
|
-
"contentHash": "
|
|
47
|
+
"contentHash": "25a1e02d58fb773f2c26574d6985ffca501001350497cac8f8a067774914e490",
|
|
48
48
|
"files": [
|
|
49
49
|
{
|
|
50
50
|
"path": "i18n.ts",
|
|
@@ -60,11 +60,11 @@
|
|
|
60
60
|
},
|
|
61
61
|
{
|
|
62
62
|
"path": "WorkshopApp.tsx",
|
|
63
|
-
"content": "import {\n useCallback,\n useEffect,\n useState,\n type ComponentType,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type {\n WorkshopAccessOption,\n WorkshopAccessOptionInput,\n WorkshopBrowseQuery,\n WorkshopContentResponse,\n WorkshopContentView,\n WorkshopDefinitionsResponse,\n SocialReportReason,\n WorkshopVisibility,\n} from \"@idosgames/core\";\nimport type {\n ContentRegistry,\n ContentTypeHandler,\n FeatureScreenProps,\n IDosGamesClient,\n} from \"@idosgames/module-sdk\";\n\n// The Workshop screen: catalog, content card, publish wizard, \"mine / licenses / favorites\".\n//\n// It is the same for every game. Everything game-specific comes from two places: the title's Workshop\n// config (which types exist, formats, access modes — read via client.workshop.getDefinitions()) and the\n// handlers games register in ctx.content (how to list, capture and open their content). This file is\n// plain editable React with inline styles — restyle or replace it like any other module source.\n\n/** What the panel needs from the host — the module passes it in at setup(). */\nexport interface WorkshopHost {\n client: IDosGamesClient;\n content: ContentRegistry;\n navigate(modeId: string): void;\n}\n\n/** The Workshop as a tab of the lobby (a feature screen); `navigate` hands the screen to a game. */\nexport function makeWorkshopScreen(\n host: WorkshopHost,\n): ComponentType<FeatureScreenProps> {\n return function WorkshopScreen(): ReactNode {\n return <WorkshopApp host={host} />;\n };\n}\n\ntype Tab = \"catalog\" | \"publish\" | \"mine\" | \"licenses\" | \"favorites\";\n\nconst TABS: Array<[Tab, string]> = [\n [\"catalog\", \"Catalog\"],\n [\"publish\", \"Publish\"],\n [\"mine\", \"My content\"],\n [\"licenses\", \"My library\"],\n [\"favorites\", \"Favorites\"],\n];\n\n/** Server error codes → words. Anything else is shown as is (it is already a sentence). */\nconst ERRORS: Record<string, string> = {\n WORKSHOP_DISABLED: \"The Workshop is turned off.\",\n WORKSHOP_NOT_CONFIGURED: \"The Workshop is not set up in this game yet.\",\n WORKSHOP_NOT_AVAILABLE: \"The Workshop is not available to you.\",\n WORKSHOP_CLOSED: \"The Workshop is closed right now.\",\n PRICE_CHANGED: \"The author changed the price — look at it again.\",\n HOLDING_REQUIREMENTS_NOT_MET: \"You don't hold what this option requires.\",\n ACCESS_DENIED: \"You don't have access to this yet.\",\n CONTENT_NOT_FOUND: \"Not found (or not shared with you).\",\n CONTENT_NOT_AVAILABLE: \"This publication is not available now.\",\n CONTENT_TEXT_REJECTED:\n \"The title or description contains words that are not allowed.\",\n PUBLISH_LIMIT_REACHED:\n \"You have reached the limit of publications of this type.\",\n DAILY_PUBLISH_LIMIT_REACHED: \"You have reached today's publishing limit.\",\n PUBLISH_NOT_ALLOWED: \"You can't publish this type of content yet.\",\n CANNOT_LIKE_OWN_CONTENT: \"You can't like your own publication.\",\n WORKSHOP_STORAGE_NOT_CONFIGURED:\n \"File storage is not configured for this game.\",\n TOO_MANY_PENDING_UPLOADS:\n \"You have too many unfinished uploads — try again a bit later.\",\n ACCESS_REQUIRED: \"Add at least one way for others to get it.\",\n OPTION_REQUIRED: \"Choose how you want to get it.\",\n PRICE_NO_LONGER_ALLOWED: \"This price is no longer allowed in this game.\",\n TITLE_REQUIRED: \"Give it a title.\",\n TITLE_TOO_LONG: \"The title is too long.\",\n THUMBNAIL_REQUIRED: \"This type of content needs a preview image.\",\n FILE_TOO_LARGE: \"The file is too large for this type of content.\",\n FILE_TYPE_MISMATCH: \"The file does not match its declared format.\",\n CONTENT_TYPE_NOT_ALLOWED:\n \"This type of content can't be published in this game.\",\n CANNOT_REPORT_OWN_CONTENT: \"You can't report your own publication.\",\n CANNOT_FOLLOW_SELF: \"You can't follow yourself.\",\n};\nconst errorText = (e: string): string => ERRORS[e] ?? e;\n\nexport function WorkshopApp({ host }: { host: WorkshopHost }): ReactNode {\n const [defs, setDefs] = useState<WorkshopDefinitionsResponse | null>(null);\n const [error, setError] = useState<string | null>(null);\n const [tab, setTab] = useState<Tab>(\"catalog\");\n const [openID, setOpenID] = useState<string | null>(null);\n\n useEffect(() => {\n let alive = true;\n void host.client.workshop.getDefinitions().then((r) => {\n if (!alive) return;\n if (r.ok) setDefs(r.data);\n else setError(errorText(r.error));\n });\n return () => {\n alive = false;\n };\n }, [host]);\n\n if (!defs) return <Frame>{error ?? \"Loading the Workshop…\"}</Frame>;\n if (!defs.Definitions?.Enabled || !defs.GatePassed)\n return <Frame>The Workshop is not available in this game right now.</Frame>;\n\n return (\n <Frame>\n <div style={tabsRow}>\n {TABS.map(([id, label]) => (\n <button\n key={id}\n style={id === tab ? tabActive : tabBtn}\n onClick={() => setTab(id)}\n >\n {label}\n </button>\n ))}\n </div>\n {tab === \"catalog\" && (\n <Catalog host={host} defs={defs} onOpen={setOpenID} />\n )}\n {tab === \"publish\" && (\n <PublishWizard\n host={host}\n defs={defs}\n onDone={(id) => {\n setTab(\"mine\");\n setOpenID(id);\n }}\n />\n )}\n {tab === \"mine\" && <MyList host={host} kind=\"mine\" onOpen={setOpenID} />}\n {tab === \"licenses\" && (\n <MyList host={host} kind=\"licenses\" onOpen={setOpenID} />\n )}\n {tab === \"favorites\" && (\n <MyList host={host} kind=\"favorites\" onOpen={setOpenID} />\n )}\n {openID && (\n <ContentDetails\n host={host}\n contentID={openID}\n onClose={() => setOpenID(null)}\n />\n )}\n </Frame>\n );\n}\n\n// ────────────────────────────── Catalog ──────────────────────────────\n\nfunction Catalog({\n host,\n defs,\n onOpen,\n}: {\n host: WorkshopHost;\n defs: WorkshopDefinitionsResponse;\n onOpen(id: string): void;\n}): ReactNode {\n const handlers = useContentTypes(host.content);\n const [query, setQuery] = useState<WorkshopBrowseQuery>({ sort: \"New\" });\n const [collection, setCollection] = useState<string | null>(null);\n const [items, setItems] = useState<WorkshopContentView[]>([]);\n const [cursor, setCursor] = useState<string | null>(null);\n const [busy, setBusy] = useState(false);\n const [error, setError] = useState<string | null>(null);\n\n const load = useCallback(\n async (more: boolean) => {\n setBusy(true);\n setError(null);\n if (collection) {\n const r = await host.client.workshop.getCollection(collection);\n if (r.ok) {\n setItems(r.data.Items ?? []);\n setCursor(null);\n } else setError(errorText(r.error));\n } else {\n const r = await host.client.workshop.browse({\n ...query,\n pageSize: 24,\n continuationToken: more ? (cursor ?? undefined) : undefined,\n });\n if (r.ok) {\n setItems((prev) =>\n more ? [...prev, ...(r.data.Items ?? [])] : (r.data.Items ?? []),\n );\n setCursor(r.data.ContinuationToken ?? null);\n } else setError(errorText(r.error));\n }\n setBusy(false);\n },\n [host, query, collection, cursor],\n );\n\n // Reload from the first page whenever the filter changes (not when the cursor does).\n useEffect(() => {\n void load(false);\n }, [query, collection]);\n\n const types = Object.entries(defs.Definitions?.ContentTypes ?? {}).filter(\n ([, t]) => t.Enabled !== false,\n );\n\n return (\n <div>\n {(defs.Collections?.length ?? 0) > 0 && (\n <div style={chipsRow}>\n <button\n style={collection === null ? chipActive : chip}\n onClick={() => setCollection(null)}\n >\n All\n </button>\n {defs.Collections?.map((c) => (\n <button\n key={c.CollectionID ?? \"\"}\n style={collection === c.CollectionID ? chipActive : chip}\n onClick={() => setCollection(c.CollectionID ?? null)}\n >\n ★ {c.Name ?? c.CollectionID}\n </button>\n ))}\n </div>\n )}\n {!collection && (\n <div style={filtersRow}>\n <select\n style={input}\n value={query.contentType ?? \"\"}\n onChange={(e) =>\n setQuery({ ...query, contentType: e.target.value || undefined })\n }\n >\n <option value=\"\">All types</option>\n {types.map(([id, t]) => (\n <option key={id} value={id}>\n {handlers.find((h) => h.type === id)?.label ??\n t.DisplayName ??\n id}\n </option>\n ))}\n </select>\n <select\n style={input}\n value={query.sort ?? \"New\"}\n onChange={(e) =>\n setQuery({\n ...query,\n sort: e.target.value as WorkshopBrowseQuery[\"sort\"],\n })\n }\n >\n <option value=\"New\">Newest</option>\n <option value=\"Popular\">Most acquired</option>\n <option value=\"MostLiked\">Most liked</option>\n </select>\n <input\n style={input}\n placeholder=\"Tag\"\n defaultValue={query.tag ?? \"\"}\n onKeyDown={(e) => {\n if (e.key === \"Enter\")\n setQuery({\n ...query,\n tag: (e.target as HTMLInputElement).value.trim() || undefined,\n });\n }}\n />\n <label style={checkLabel}>\n <input\n type=\"checkbox\"\n checked={query.fromFollowing === true}\n onChange={(e) =>\n setQuery({\n ...query,\n fromFollowing: e.target.checked || undefined,\n })\n }\n />\n From authors I follow\n </label>\n </div>\n )}\n {error && <div style={errorBox}>{error}</div>}\n {items.length === 0 && !busy && (\n <div style={muted}>Nothing here yet.</div>\n )}\n <div style={grid}>\n {items.map((item) => (\n <ContentCard\n key={item.ContentID ?? \"\"}\n item={item}\n handlers={handlers}\n onClick={() => item.ContentID && onOpen(item.ContentID)}\n />\n ))}\n </div>\n {cursor && (\n <button\n style={secondaryBtn}\n disabled={busy}\n onClick={() => void load(true)}\n >\n {busy ? \"Loading…\" : \"Show more\"}\n </button>\n )}\n </div>\n );\n}\n\nfunction ContentCard({\n item,\n handlers,\n onClick,\n}: {\n item: WorkshopContentView;\n handlers: ContentTypeHandler[];\n onClick(): void;\n}): ReactNode {\n const handler = handlers.find((h) => h.type === item.ContentType);\n return (\n <button style={card} onClick={onClick}>\n <div style={thumbBox}>\n {item.ThumbnailUrl ? (\n <img src={item.ThumbnailUrl} alt=\"\" style={thumbImg} />\n ) : (\n <span style={{ fontSize: 34 }}>{handler?.icon ?? \"📦\"}</span>\n )}\n </div>\n <div style={cardTitle}>\n {item.Title ||\n (item.Status === \"Uploading\" ? \"Unfinished upload\" : \"Untitled\")}\n </div>\n <div style={cardMeta}>\n {item.IsOfficial\n ? \"Official\"\n : (item.CreatorPublicData?.Username ?? \"Player\")}{\" \"}\n · ♥ {item.Stats?.Likes ?? 0}\n </div>\n <div style={badgesRow}>\n {item.IsMine ? (\n <span style={badgeOwned}>Yours</span>\n ) : item.Owned ? (\n <span style={badgeOwned}>In library</span>\n ) : (\n (item.Access ?? []).map((o) => (\n <span key={o.OptionID ?? \"\"} style={badge}>\n {accessLabel(o)}\n </span>\n ))\n )}\n {item.Status && item.Status !== \"Published\" && (\n <span style={badgeWarn}>{item.Status}</span>\n )}\n </div>\n </button>\n );\n}\n\n// ────────────────────────────── Details ──────────────────────────────\n\nconst REPORT_REASONS: SocialReportReason[] = [\n \"Inappropriate\",\n \"Spam\",\n \"Stolen\",\n \"Broken\",\n \"Harassment\",\n \"Other\",\n];\n\nfunction ContentDetails({\n host,\n contentID,\n onClose,\n}: {\n host: WorkshopHost;\n contentID: string;\n onClose(): void;\n}): ReactNode {\n const [data, setData] = useState<WorkshopContentResponse | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const [message, setMessage] = useState<string | null>(null);\n const [following, setFollowing] = useState<boolean | null>(null);\n const [reportReason, setReportReason] =\n useState<SocialReportReason>(\"Inappropriate\");\n\n const reload = useCallback(async () => {\n const r = await host.client.workshop.getContent(contentID);\n if (!r.ok) {\n setMessage(errorText(r.error));\n return;\n }\n setData(r.data);\n // Following the author is a social-layer edge on the PLAYER, not part of the card.\n const creator = r.data.Content?.CreatorUserID;\n if (creator && !r.data.Content?.IsMine) {\n const s = await host.client.social.getEntityStates(\"User\", [creator]);\n if (s.ok) setFollowing(s.data.Items?.[0]?.Following === true);\n }\n }, [host, contentID]);\n\n useEffect(() => {\n void reload();\n }, [reload]);\n\n const view = data?.Content;\n const handler = view?.ContentType\n ? host.content.getType(view.ContentType)\n : undefined;\n\n const run = async (\n label: string,\n work: () => Promise<string | null>,\n ): Promise<void> => {\n setBusy(label);\n setMessage(null);\n try {\n setMessage(await work());\n } catch (e) {\n setMessage(e instanceof Error ? e.message : String(e));\n } finally {\n setBusy(null);\n }\n };\n\n const open = (): Promise<void> =>\n run(\"open\", async () => {\n if (!view?.ContentID) return null;\n if (!handler?.open)\n return \"This game can't open this type of content here.\";\n const r = await host.client.workshop.downloadFiles(view.ContentID);\n if (!r.ok) return errorText(r.error);\n await handler.open({\n contentID: view.ContentID,\n title: view.Title,\n revision: view.Revision,\n files: r.data.files,\n });\n if (handler.modeId) host.navigate(handler.modeId);\n return null;\n });\n\n const acquire = (option: WorkshopAccessOption): Promise<void> =>\n run(`get:${option.OptionID}`, async () => {\n const r = await host.client.workshop.acquire(contentID, option);\n if (!r.ok) return errorText(r.error);\n await reload();\n return r.data.AlreadyOwned\n ? \"Already in your library.\"\n : \"Added to your library.\";\n });\n\n if (!view) return <Modal onClose={onClose}>{message ?? \"Loading…\"}</Modal>;\n\n const states = new Map(\n (data?.AccessState ?? []).map((s) => [\n s.OptionID ?? \"\",\n s.Available !== false,\n ]),\n );\n const canOpen = Boolean(data?.CanDownload);\n\n return (\n <Modal onClose={onClose}>\n <div style={detailsHead}>\n <div style={detailsThumb}>\n {view.ThumbnailUrl ? (\n <img src={view.ThumbnailUrl} alt=\"\" style={thumbImg} />\n ) : (\n <span style={{ fontSize: 48 }}>{handler?.icon ?? \"📦\"}</span>\n )}\n </div>\n <div style={{ minWidth: 0 }}>\n <h2 style={{ margin: \"0 0 6px\" }}>{view.Title}</h2>\n <div style={muted}>\n {view.IsOfficial\n ? \"Official content\"\n : `by ${view.CreatorPublicData?.Username ?? \"a player\"}`}{\" \"}\n · {handler?.label ?? view.ContentType} · rev. {view.Revision}\n </div>\n <div style={muted}>\n ♥ {view.Stats?.Likes ?? 0} · ★ {view.Stats?.Favorites ?? 0} · ⬇{\" \"}\n {view.Stats?.Acquisitions ?? 0}\n {view.TotalBytes ? ` · ${formatBytes(view.TotalBytes)}` : \"\"}\n </div>\n {(view.Tags?.length ?? 0) > 0 && (\n <div style={badgesRow}>\n {view.Tags?.map((t) => (\n <span key={t} style={badge}>\n #{t}\n </span>\n ))}\n </div>\n )}\n </div>\n </div>\n\n {view.Description && (\n <p style={{ whiteSpace: \"pre-wrap\" }}>{view.Description}</p>\n )}\n {view.IsMine && view.ModerationNote && (\n <div style={errorBox}>Moderation: {view.ModerationNote}</div>\n )}\n {view.IsMine && (view.Access?.length ?? 0) > 0 && (\n <div style={muted}>\n Others get it: {(view.Access ?? []).map(accessLabel).join(\" · \")}\n </div>\n )}\n\n <div style={actionsCol}>\n {canOpen ? (\n <button\n style={primaryBtn}\n disabled={busy !== null || !handler?.open}\n onClick={() => void open()}\n >\n {busy === \"open\"\n ? \"Opening…\"\n : handler?.open\n ? \"Open\"\n : \"Owned (open it in the game)\"}\n </button>\n ) : (\n (view.Access ?? []).map((o) => {\n const available = states.get(o.OptionID ?? \"\") ?? true;\n return (\n <button\n key={o.OptionID ?? \"\"}\n style={available ? primaryBtn : disabledBtn}\n disabled={!available || busy !== null}\n title={\n available\n ? undefined\n : \"You don't meet this option's requirements\"\n }\n onClick={() => void acquire(o)}\n >\n {busy === `get:${o.OptionID}` ? \"…\" : `Get — ${accessLabel(o)}`}\n </button>\n );\n })\n )}\n </div>\n\n <div style={filtersRow}>\n {!view.IsMine && (\n <button\n style={secondaryBtn}\n disabled={busy !== null}\n onClick={() =>\n void run(\"like\", async () => {\n const r = view.Liked\n ? await host.client.social.unlike(\"WorkshopItem\", contentID)\n : await host.client.social.like(\"WorkshopItem\", contentID);\n if (!r.ok) return errorText(r.error);\n await reload();\n return null;\n })\n }\n >\n {view.Liked ? \"♥ Liked\" : \"♡ Like\"}\n </button>\n )}\n <button\n style={secondaryBtn}\n disabled={busy !== null}\n onClick={() =>\n void run(\"fav\", async () => {\n const r = view.Favorited\n ? await host.client.social.unfavorite(\"WorkshopItem\", contentID)\n : await host.client.social.favorite(\"WorkshopItem\", contentID);\n if (!r.ok) return errorText(r.error);\n await reload();\n return null;\n })\n }\n >\n {view.Favorited ? \"★ In favorites\" : \"☆ Favorite\"}\n </button>\n {!view.IsMine && view.CreatorUserID && (\n <button\n style={secondaryBtn}\n disabled={busy !== null}\n onClick={() =>\n void run(\"follow\", async () => {\n const creator = view.CreatorUserID ?? \"\";\n const r = following\n ? await host.client.social.unfollow(\"User\", creator)\n : await host.client.social.follow(\"User\", creator);\n if (!r.ok) return errorText(r.error);\n setFollowing(r.data.Following === true);\n return r.data.Following ? \"You follow this author.\" : null;\n })\n }\n >\n {following ? \"Following ✓\" : \"Follow author\"}\n </button>\n )}\n {view.IsMine && view.Status !== \"Removed\" && (\n <button\n style={dangerBtn}\n disabled={busy !== null}\n onClick={() => {\n if (\n !confirm(\n \"Unpublish for good? Players who already have it keep it.\",\n )\n )\n return;\n void run(\"unpublish\", async () => {\n const r = await host.client.workshop.unpublish(contentID);\n if (!r.ok) return errorText(r.error);\n await reload();\n return \"Unpublished.\";\n });\n }}\n >\n Unpublish\n </button>\n )}\n </div>\n\n {!view.IsMine && (\n <div style={filtersRow}>\n <select\n style={input}\n value={reportReason}\n onChange={(e) =>\n setReportReason(e.target.value as SocialReportReason)\n }\n >\n {REPORT_REASONS.map((r) => (\n <option key={r} value={r}>\n {r}\n </option>\n ))}\n </select>\n <button\n style={secondaryBtn}\n disabled={busy !== null}\n onClick={() =>\n void run(\"report\", async () => {\n const r = await host.client.social.report(\n \"WorkshopItem\",\n contentID,\n reportReason,\n );\n if (!r.ok) return errorText(r.error);\n return r.data.AlreadyReported\n ? \"You already reported this.\"\n : \"Thanks — the publisher will review it.\";\n })\n }\n >\n Report\n </button>\n </div>\n )}\n\n {message && <div style={noticeBox}>{message}</div>}\n </Modal>\n );\n}\n\n// ────────────────────────────── Publish ──────────────────────────────\n\ninterface AccessDraft {\n mode: \"Free\" | \"Price\" | \"Holding\";\n resource: string; // \"vc:GOLD\" | \"item:<catalog>/<item>\"\n amount: number;\n holdMode: \"WhileHeld\" | \"UnlockOnce\";\n}\n\nfunction PublishWizard({\n host,\n defs,\n onDone,\n}: {\n host: WorkshopHost;\n defs: WorkshopDefinitionsResponse;\n onDone(contentID: string): void;\n}): ReactNode {\n const handlers = useContentTypes(host.content).filter(\n (h) => h.listLocal && h.capture && defs.CanPublish?.[h.type] === true,\n );\n const [type, setType] = useState<string | null>(null);\n const [locals, setLocals] = useState<Array<{\n id: string;\n name: string;\n subtitle?: string;\n }> | null>(null);\n const [localId, setLocalId] = useState<string | null>(null);\n const [title, setTitle] = useState(\"\");\n const [description, setDescription] = useState(\"\");\n const [tags, setTags] = useState(\"\");\n const [visibility, setVisibility] = useState<WorkshopVisibility>(\"Public\");\n const [access, setAccess] = useState<AccessDraft[]>([\n { mode: \"Free\", resource: \"\", amount: 1, holdMode: \"UnlockOnce\" },\n ]);\n const [busy, setBusy] = useState(false);\n const [message, setMessage] = useState<string | null>(null);\n\n const handler = handlers.find((h) => h.type === type);\n const typeDef = type ? defs.Definitions?.ContentTypes?.[type] : undefined;\n const allowedModes = typeDef?.AllowedAccessModes?.length\n ? typeDef.AllowedAccessModes\n : [\"Free\", \"Price\", \"Holding\"];\n const resources = useResourceOptions(host.client);\n // Плата за публикацию берётся только ресурсами: вариант с оплатой в сторе сервер отвергнет.\n const fee = Object.entries(typeDef?.PublishFeeOptions ?? {}).find(\n ([, option]) =>\n !(option?.Cost?.Standard?.Entries ?? []).some(\n (e) => e?.Type === \"Purchase\",\n ),\n );\n\n useEffect(() => {\n setLocals(null);\n setLocalId(null);\n if (!handler?.listLocal) return;\n void handler\n .listLocal()\n .then(setLocals, (e: unknown) =>\n setMessage(e instanceof Error ? e.message : String(e)),\n );\n }, [handler]);\n\n if (handlers.length === 0)\n return (\n <div style={muted}>\n Nothing to publish from here: the games in this project either do not\n register their content, or you can't publish it yet.\n </div>\n );\n\n const publish = async (): Promise<void> => {\n if (!handler?.capture || !type || !localId) return;\n // Опция без ресурса молча выпала бы из списка, и публикация ушла бы не с теми способами получить.\n if (access.some((a) => a.mode !== \"Free\" && !a.resource)) {\n setMessage(\"Pick a resource for every paid or hold-to-unlock option.\");\n return;\n }\n setBusy(true);\n setMessage(null);\n try {\n const captured = await handler.capture(localId);\n const r = await host.client.workshop.publish({\n contentType: type,\n files: captured.files,\n thumbnail: captured.thumbnail,\n title: title.trim() || captured.suggestedTitle || \"Untitled\",\n description: description.trim() || captured.suggestedDescription,\n tags: tags\n .split(\",\")\n .map((t) => t.trim())\n .filter(Boolean)\n .concat(captured.tags ?? []),\n metadata: captured.metadata,\n visibility,\n access: access\n .map(toAccessInput)\n .filter((a): a is WorkshopAccessOptionInput => a !== null),\n selectedFeeOptionID: fee?.[0],\n });\n if (!r.ok) setMessage(errorText(r.error));\n else if (r.data.Content?.ContentID) onDone(r.data.Content.ContentID);\n } catch (e) {\n setMessage(e instanceof Error ? e.message : String(e));\n } finally {\n setBusy(false);\n }\n };\n\n return (\n <div style={formCol}>\n <label style={fieldLabel}>What</label>\n <div style={chipsRow}>\n {handlers.map((h) => (\n <button\n key={h.type}\n style={type === h.type ? chipActive : chip}\n onClick={() => setType(h.type)}\n >\n {h.icon} {h.label}\n </button>\n ))}\n </div>\n\n {handler && (\n <>\n <label style={fieldLabel}>Which one</label>\n {!locals ? (\n <div style={muted}>Loading…</div>\n ) : locals.length === 0 ? (\n <div style={muted}>You have nothing of this type yet.</div>\n ) : (\n <div style={formCol}>\n {locals.map((l) => (\n <label key={l.id} style={checkLabel}>\n <input\n type=\"radio\"\n name=\"wsLocal\"\n checked={localId === l.id}\n onChange={() => {\n setLocalId(l.id);\n if (!title) setTitle(l.name);\n }}\n />\n {l.name}{\" \"}\n {l.subtitle && <span style={muted}>· {l.subtitle}</span>}\n </label>\n ))}\n </div>\n )}\n </>\n )}\n\n {localId && (\n <>\n <label style={fieldLabel}>Title</label>\n <input\n style={input}\n value={title}\n maxLength={defs.Definitions?.Limits?.MaxTitleLength ?? 60}\n onChange={(e) => setTitle(e.target.value)}\n />\n <label style={fieldLabel}>Description</label>\n <textarea\n style={{ ...input, minHeight: 70 }}\n value={description}\n onChange={(e) => setDescription(e.target.value)}\n />\n <label style={fieldLabel}>\n Tags (comma separated\n {typeDef?.TagVocabulary?.length\n ? `: ${typeDef.TagVocabulary.join(\", \")}`\n : \"\"}\n )\n </label>\n <input\n style={input}\n value={tags}\n onChange={(e) => setTags(e.target.value)}\n />\n <label style={fieldLabel}>Who can see it</label>\n <select\n style={input}\n value={visibility}\n onChange={(e) =>\n setVisibility(e.target.value as WorkshopVisibility)\n }\n >\n <option value=\"Public\">Everyone (in the catalog)</option>\n <option value=\"Unlisted\">Anyone with the link</option>\n <option value=\"Friends\">Only my friends</option>\n </select>\n\n <label style={fieldLabel}>How others get it — any option works</label>\n {access.map((a, i) => (\n <div key={i} style={filtersRow}>\n <select\n style={input}\n value={a.mode}\n onChange={(e) =>\n setAccess(\n patch(access, i, {\n mode: e.target.value as AccessDraft[\"mode\"],\n }),\n )\n }\n >\n {allowedModes.map((m) => (\n <option key={m} value={m}>\n {m === \"Free\"\n ? \"Free\"\n : m === \"Price\"\n ? \"For resources\"\n : \"Hold to unlock\"}\n </option>\n ))}\n </select>\n {a.mode !== \"Free\" && (\n <>\n <select\n style={input}\n value={a.resource}\n onChange={(e) =>\n setAccess(patch(access, i, { resource: e.target.value }))\n }\n >\n <option value=\"\">— resource —</option>\n {resources\n .filter(\n (r) => a.mode === \"Holding\" || r.startsWith(\"vc:\"),\n )\n .map((r) => (\n <option key={r} value={r}>\n {r.replace(/^vc:/, \"\").replace(/^item:/, \"item \")}\n </option>\n ))}\n </select>\n <input\n style={{ ...input, width: 90 }}\n type=\"number\"\n min={1}\n value={a.amount}\n onChange={(e) =>\n setAccess(\n patch(access, i, {\n amount: Math.max(1, Number(e.target.value) || 1),\n }),\n )\n }\n />\n </>\n )}\n {a.mode === \"Holding\" && (\n <select\n style={input}\n value={a.holdMode}\n onChange={(e) =>\n setAccess(\n patch(access, i, {\n holdMode: e.target.value as AccessDraft[\"holdMode\"],\n }),\n )\n }\n >\n <option value=\"UnlockOnce\">Checked once, then forever</option>\n <option value=\"WhileHeld\">Only while they hold it</option>\n </select>\n )}\n {access.length > 1 && (\n <button\n style={secondaryBtn}\n onClick={() => setAccess(access.filter((_, j) => j !== i))}\n >\n ✕\n </button>\n )}\n </div>\n ))}\n <button\n style={secondaryBtn}\n onClick={() =>\n setAccess([\n ...access,\n {\n mode: allowedModes.includes(\"Price\") ? \"Price\" : \"Free\",\n resource: \"\",\n amount: 10,\n holdMode: \"UnlockOnce\",\n },\n ])\n }\n >\n + Add an option\n </button>\n\n {fee && (\n <div style={muted}>\n Publishing costs {bundleLabel(fee[1].Cost?.Standard)} (charged\n once).\n </div>\n )}\n <button\n style={primaryBtn}\n disabled={busy}\n onClick={() => void publish()}\n >\n {busy ? \"Publishing…\" : \"Publish\"}\n </button>\n </>\n )}\n {message && <div style={errorBox}>{message}</div>}\n </div>\n );\n}\n\nfunction patch<T>(list: T[], index: number, change: Partial<T>): T[] {\n return list.map((item, i) => (i === index ? { ...item, ...change } : item));\n}\n\nfunction toAccessInput(a: AccessDraft): WorkshopAccessOptionInput | null {\n if (a.mode === \"Free\") return { Mode: \"Free\" };\n if (!a.resource) return null;\n const [kind, id = \"\"] = a.resource.split(\":\", 2);\n if (a.mode === \"Price\") {\n return {\n Mode: \"Price\",\n Price: {\n Entries: [\n { Type: \"VirtualCurrency\", CurrencyID: id, Amount: a.amount },\n ],\n },\n };\n }\n const [catalogID, itemID] = id.split(\"/\", 2);\n return {\n Mode: \"Holding\",\n Holding: {\n Mode: a.holdMode,\n Match: \"All\",\n Requirements: [\n kind === \"vc\"\n ? { Type: \"VirtualCurrency\", CurrencyID: id, Amount: a.amount }\n : {\n Type: \"Item\",\n CatalogID: catalogID,\n ItemID: itemID ?? \"\",\n Amount: a.amount,\n },\n ],\n },\n };\n}\n\n// ────────────────────────────── Lists ──────────────────────────────\n\nfunction MyList({\n host,\n kind,\n onOpen,\n}: {\n host: WorkshopHost;\n kind: \"mine\" | \"licenses\" | \"favorites\";\n onOpen(id: string): void;\n}): ReactNode {\n const handlers = useContentTypes(host.content);\n const [items, setItems] = useState<WorkshopContentView[] | null>(null);\n const [error, setError] = useState<string | null>(null);\n\n useEffect(() => {\n let alive = true;\n const load = async (): Promise<void> => {\n const w = host.client.workshop;\n if (kind === \"licenses\") {\n const r = await w.getMyLicenses();\n if (!alive) return;\n if (r.ok)\n setItems(\n (r.data.Licenses ?? [])\n .map((l) => l.Content)\n .filter((c): c is WorkshopContentView => Boolean(c)),\n );\n else setError(errorText(r.error));\n return;\n }\n const r =\n kind === \"mine\" ? await w.getMyContent() : await w.getMyFavorites();\n if (!alive) return;\n if (r.ok) setItems(r.data.Items ?? []);\n else setError(errorText(r.error));\n };\n void load();\n return () => {\n alive = false;\n };\n }, [host, kind]);\n\n if (error) return <div style={errorBox}>{error}</div>;\n if (!items) return <div style={muted}>Loading…</div>;\n if (items.length === 0) return <div style={muted}>Empty.</div>;\n return (\n <div style={grid}>\n {items.map((item) => (\n <ContentCard\n key={item.ContentID ?? \"\"}\n item={item}\n handlers={handlers}\n onClick={() => item.ContentID && onOpen(item.ContentID)}\n />\n ))}\n </div>\n );\n}\n\n// ────────────────────────────── helpers ──────────────────────────────\n\n/**\n * Registered content types, re-read on every registry change. ⚠ Not keyed by the NUMBER of types: a game\n * re-installed after re-login registers the same type again with a new handler, the count stays the\n * same, and a count-based snapshot kept the handler of the destroyed game.\n */\nfunction useContentTypes(content: ContentRegistry): ContentTypeHandler[] {\n const [types, setTypes] = useState(() => content.listTypes());\n useEffect(() => {\n setTypes(content.listTypes());\n return content.subscribe(() => setTypes(content.listTypes()));\n }, [content]);\n return types;\n}\n\n/** \"vc:GOLD\" and \"item:<catalog>/<item>\" for every currency and item the title defines. */\nfunction useResourceOptions(client: IDosGamesClient): string[] {\n const [list] = useState(() => {\n const out: string[] = [];\n for (const id of Object.keys(\n client.data.config.currencyDefinitions?.VirtualCurrencies ?? {},\n ))\n out.push(`vc:${id}`);\n for (const [catalogID, catalog] of Object.entries(\n client.data.config.itemDefinitions?.Catalogs ?? {},\n ))\n for (const itemID of Object.keys(catalog?.Items ?? {}))\n out.push(`item:${catalogID}/${itemID}`);\n return out;\n });\n return list;\n}\n\ntype BundleLike =\n | {\n Entries?: Array<{\n CurrencyID?: string | null;\n ItemID?: string | null;\n Amount?: number | null;\n } | null> | null;\n }\n | null\n | undefined;\n\nfunction bundleLabel(bundle: BundleLike): string {\n const parts = (bundle?.Entries ?? [])\n .filter((e): e is NonNullable<typeof e> => Boolean(e))\n .map((e) => `${e.Amount ?? 0} ${e.CurrencyID ?? e.ItemID ?? \"\"}`.trim());\n return parts.length ? parts.join(\" + \") : \"free\";\n}\n\nfunction accessLabel(o: WorkshopAccessOption): string {\n if (o.Mode === \"Free\") return \"Free\";\n if (o.Mode === \"Price\") return bundleLabel(o.Price);\n const reqs = (o.Holding?.Requirements ?? []).map(\n (r) =>\n `${r.Amount ?? 0} ${r.CurrencyID ?? r.ItemID ?? \"\"}${r.MinLevel ? ` (lvl ${r.MinLevel}+)` : \"\"}`,\n );\n const joined = reqs.join(o.Holding?.Match === \"Any\" ? \" or \" : \" + \");\n return `🔑 hold ${joined}${o.Holding?.Mode === \"WhileHeld\" ? \" (while held)\" : \"\"}`;\n}\n\nfunction formatBytes(n: number): string {\n if (n < 1024) return `${n} B`;\n if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;\n return `${(n / 1024 / 1024).toFixed(1)} MB`;\n}\n\nfunction Frame({ children }: { children: ReactNode }): ReactNode {\n return <div style={frame}>{children}</div>;\n}\n\nfunction Modal({\n children,\n onClose,\n}: {\n children: ReactNode;\n onClose(): void;\n}): ReactNode {\n return (\n <div style={backdrop} onClick={onClose}>\n <div style={modal} onClick={(e) => e.stopPropagation()}>\n <button style={closeBtn} onClick={onClose} aria-label=\"Close\">\n ✕\n </button>\n {children}\n </div>\n </div>\n );\n}\n\n// ────────────────────────────── styles ──────────────────────────────\n\nconst frame: CSSProperties = {\n // A tab of the lobby: the lobby scrolls it and gives it the width.\n minHeight: 360,\n borderRadius: 16,\n padding: \"16px 20px 40px\",\n background: \"#12151c\",\n color: \"#e8e8ea\",\n fontFamily: \"system-ui, sans-serif\",\n pointerEvents: \"auto\",\n};\nconst tabsRow: CSSProperties = {\n display: \"flex\",\n gap: 6,\n flexWrap: \"wrap\",\n marginBottom: 14,\n};\nconst tabBtn: CSSProperties = {\n background: \"transparent\",\n color: \"#aab\",\n border: \"1px solid #2a2f3a\",\n borderRadius: 8,\n padding: \"6px 12px\",\n cursor: \"pointer\",\n};\nconst tabActive: CSSProperties = {\n ...tabBtn,\n background: \"#2d6cdf\",\n color: \"#fff\",\n border: \"1px solid #2d6cdf\",\n};\nconst chipsRow: CSSProperties = {\n display: \"flex\",\n gap: 6,\n flexWrap: \"wrap\",\n margin: \"8px 0\",\n};\nconst chip: CSSProperties = {\n ...tabBtn,\n borderRadius: 999,\n padding: \"4px 10px\",\n fontSize: 13,\n};\nconst chipActive: CSSProperties = {\n ...chip,\n background: \"#394a6b\",\n color: \"#fff\",\n};\nconst filtersRow: CSSProperties = {\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n alignItems: \"center\",\n margin: \"8px 0\",\n};\nconst input: CSSProperties = {\n background: \"#1b2029\",\n color: \"#e8e8ea\",\n border: \"1px solid #2a2f3a\",\n borderRadius: 6,\n padding: \"6px 8px\",\n font: \"inherit\",\n};\nconst checkLabel: CSSProperties = {\n display: \"flex\",\n gap: 6,\n alignItems: \"center\",\n fontSize: 14,\n};\nconst grid: CSSProperties = {\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 12,\n};\nconst card: CSSProperties = {\n textAlign: \"left\",\n background: \"#1b2029\",\n border: \"1px solid #2a2f3a\",\n borderRadius: 10,\n padding: 8,\n color: \"inherit\",\n cursor: \"pointer\",\n font: \"inherit\",\n};\nconst thumbBox: CSSProperties = {\n aspectRatio: \"1 / 1\",\n borderRadius: 8,\n background: \"#0d1016\",\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n overflow: \"hidden\",\n};\nconst thumbImg: CSSProperties = {\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n imageRendering: \"pixelated\",\n};\nconst cardTitle: CSSProperties = {\n fontWeight: 600,\n margin: \"6px 0 2px\",\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n};\nconst cardMeta: CSSProperties = { fontSize: 12, color: \"#8a90a0\" };\nconst badgesRow: CSSProperties = {\n display: \"flex\",\n gap: 4,\n flexWrap: \"wrap\",\n marginTop: 6,\n};\nconst badge: CSSProperties = {\n fontSize: 11,\n background: \"#263043\",\n borderRadius: 4,\n padding: \"2px 6px\",\n};\nconst badgeOwned: CSSProperties = { ...badge, background: \"#1f5134\" };\nconst badgeWarn: CSSProperties = { ...badge, background: \"#6b4a1f\" };\nconst muted: CSSProperties = { color: \"#8a90a0\", fontSize: 13 };\nconst errorBox: CSSProperties = {\n background: \"#3a1d22\",\n color: \"#ffb4b4\",\n borderRadius: 6,\n padding: \"8px 10px\",\n margin: \"8px 0\",\n};\nconst noticeBox: CSSProperties = {\n background: \"#1d2c3a\",\n borderRadius: 6,\n padding: \"8px 10px\",\n marginTop: 10,\n};\nconst primaryBtn: CSSProperties = {\n background: \"#2d6cdf\",\n color: \"#fff\",\n border: 0,\n borderRadius: 8,\n padding: \"9px 14px\",\n cursor: \"pointer\",\n font: \"inherit\",\n fontWeight: 600,\n};\nconst disabledBtn: CSSProperties = {\n ...primaryBtn,\n background: \"#3a3f4a\",\n color: \"#99a\",\n cursor: \"not-allowed\",\n};\nconst secondaryBtn: CSSProperties = { ...tabBtn, color: \"#dde\" };\nconst dangerBtn: CSSProperties = {\n ...tabBtn,\n color: \"#ff9d9d\",\n border: \"1px solid #5a2a30\",\n};\nconst actionsCol: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 8,\n margin: \"12px 0\",\n};\nconst formCol: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 6,\n maxWidth: 640,\n};\nconst fieldLabel: CSSProperties = { fontSize: 13, color: \"#aab\", marginTop: 8 };\nconst backdrop: CSSProperties = {\n position: \"fixed\",\n inset: 0,\n background: \"rgba(0,0,0,.6)\",\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n zIndex: 50,\n};\nconst modal: CSSProperties = {\n position: \"relative\",\n background: \"#171b23\",\n borderRadius: 12,\n padding: 20,\n width: \"min(640px, 94vw)\",\n maxHeight: \"90vh\",\n overflow: \"auto\",\n};\nconst closeBtn: CSSProperties = {\n position: \"absolute\",\n top: 10,\n right: 10,\n background: \"transparent\",\n color: \"#aab\",\n border: 0,\n fontSize: 18,\n cursor: \"pointer\",\n};\nconst detailsHead: CSSProperties = {\n display: \"flex\",\n gap: 14,\n alignItems: \"flex-start\",\n marginRight: 24,\n};\nconst detailsThumb: CSSProperties = {\n ...thumbBox,\n width: 120,\n flex: \"0 0 120px\",\n};\n"
|
|
63
|
+
"content": "import {\n useCallback,\n useEffect,\n useState,\n type ComponentType,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type {\n WorkshopAccessOption,\n WorkshopAccessOptionInput,\n WorkshopBrowseQuery,\n WorkshopContentResponse,\n WorkshopContentView,\n WorkshopDefinitionsResponse,\n SocialReportReason,\n WorkshopVisibility,\n} from \"@idosgames/core\";\nimport type {\n ContentRegistry,\n ContentTypeHandler,\n FeatureScreenProps,\n IDosGamesClient,\n} from \"@idosgames/module-sdk\";\nimport { buttonStyle, panel, v } from \"@idosgames/react/ui\";\n\n// The Workshop screen: catalog, content card, publish wizard, \"mine / licenses / favorites\".\n//\n// It is the same for every game. Everything game-specific comes from two places: the title's Workshop\n// config (which types exist, formats, access modes — read via client.workshop.getDefinitions()) and the\n// handlers games register in ctx.content (how to list, capture and open their content). This file is\n// plain editable React with inline styles — restyle or replace it like any other module source.\n\n/** What the panel needs from the host — the module passes it in at setup(). */\nexport interface WorkshopHost {\n client: IDosGamesClient;\n content: ContentRegistry;\n navigate(modeId: string): void;\n}\n\n/** The Workshop as a tab of the lobby (a feature screen); `navigate` hands the screen to a game. */\nexport function makeWorkshopScreen(\n host: WorkshopHost,\n): ComponentType<FeatureScreenProps> {\n return function WorkshopScreen(): ReactNode {\n return <WorkshopApp host={host} />;\n };\n}\n\ntype Tab = \"catalog\" | \"publish\" | \"mine\" | \"licenses\" | \"favorites\";\n\nconst TABS: Array<[Tab, string]> = [\n [\"catalog\", \"Catalog\"],\n [\"publish\", \"Publish\"],\n [\"mine\", \"My content\"],\n [\"licenses\", \"My library\"],\n [\"favorites\", \"Favorites\"],\n];\n\n/** Server error codes → words. Anything else is shown as is (it is already a sentence). */\nconst ERRORS: Record<string, string> = {\n WORKSHOP_DISABLED: \"The Workshop is turned off.\",\n WORKSHOP_NOT_CONFIGURED: \"The Workshop is not set up in this game yet.\",\n WORKSHOP_NOT_AVAILABLE: \"The Workshop is not available to you.\",\n WORKSHOP_CLOSED: \"The Workshop is closed right now.\",\n PRICE_CHANGED: \"The author changed the price — look at it again.\",\n HOLDING_REQUIREMENTS_NOT_MET: \"You don't hold what this option requires.\",\n ACCESS_DENIED: \"You don't have access to this yet.\",\n CONTENT_NOT_FOUND: \"Not found (or not shared with you).\",\n CONTENT_NOT_AVAILABLE: \"This publication is not available now.\",\n CONTENT_TEXT_REJECTED:\n \"The title or description contains words that are not allowed.\",\n PUBLISH_LIMIT_REACHED:\n \"You have reached the limit of publications of this type.\",\n DAILY_PUBLISH_LIMIT_REACHED: \"You have reached today's publishing limit.\",\n PUBLISH_NOT_ALLOWED: \"You can't publish this type of content yet.\",\n CANNOT_LIKE_OWN_CONTENT: \"You can't like your own publication.\",\n WORKSHOP_STORAGE_NOT_CONFIGURED:\n \"File storage is not configured for this game.\",\n TOO_MANY_PENDING_UPLOADS:\n \"You have too many unfinished uploads — try again a bit later.\",\n ACCESS_REQUIRED: \"Add at least one way for others to get it.\",\n OPTION_REQUIRED: \"Choose how you want to get it.\",\n PRICE_NO_LONGER_ALLOWED: \"This price is no longer allowed in this game.\",\n TITLE_REQUIRED: \"Give it a title.\",\n TITLE_TOO_LONG: \"The title is too long.\",\n THUMBNAIL_REQUIRED: \"This type of content needs a preview image.\",\n FILE_TOO_LARGE: \"The file is too large for this type of content.\",\n FILE_TYPE_MISMATCH: \"The file does not match its declared format.\",\n CONTENT_TYPE_NOT_ALLOWED:\n \"This type of content can't be published in this game.\",\n CANNOT_REPORT_OWN_CONTENT: \"You can't report your own publication.\",\n CANNOT_FOLLOW_SELF: \"You can't follow yourself.\",\n};\nconst errorText = (e: string): string => ERRORS[e] ?? e;\n\nexport function WorkshopApp({ host }: { host: WorkshopHost }): ReactNode {\n const [defs, setDefs] = useState<WorkshopDefinitionsResponse | null>(null);\n const [error, setError] = useState<string | null>(null);\n const [tab, setTab] = useState<Tab>(\"catalog\");\n const [openID, setOpenID] = useState<string | null>(null);\n\n useEffect(() => {\n let alive = true;\n void host.client.workshop.getDefinitions().then((r) => {\n if (!alive) return;\n if (r.ok) setDefs(r.data);\n else setError(errorText(r.error));\n });\n return () => {\n alive = false;\n };\n }, [host]);\n\n if (!defs) return <Frame>{error ?? \"Loading the Workshop…\"}</Frame>;\n if (!defs.Definitions?.Enabled || !defs.GatePassed)\n return <Frame>The Workshop is not available in this game right now.</Frame>;\n\n return (\n <Frame>\n <div style={tabsRow}>\n {TABS.map(([id, label]) => (\n <button\n key={id}\n style={id === tab ? tabActive : tabBtn}\n onClick={() => setTab(id)}\n >\n {label}\n </button>\n ))}\n </div>\n {tab === \"catalog\" && (\n <Catalog host={host} defs={defs} onOpen={setOpenID} />\n )}\n {tab === \"publish\" && (\n <PublishWizard\n host={host}\n defs={defs}\n onDone={(id) => {\n setTab(\"mine\");\n setOpenID(id);\n }}\n />\n )}\n {tab === \"mine\" && <MyList host={host} kind=\"mine\" onOpen={setOpenID} />}\n {tab === \"licenses\" && (\n <MyList host={host} kind=\"licenses\" onOpen={setOpenID} />\n )}\n {tab === \"favorites\" && (\n <MyList host={host} kind=\"favorites\" onOpen={setOpenID} />\n )}\n {openID && (\n <ContentDetails\n host={host}\n contentID={openID}\n onClose={() => setOpenID(null)}\n />\n )}\n </Frame>\n );\n}\n\n// ────────────────────────────── Catalog ──────────────────────────────\n\nfunction Catalog({\n host,\n defs,\n onOpen,\n}: {\n host: WorkshopHost;\n defs: WorkshopDefinitionsResponse;\n onOpen(id: string): void;\n}): ReactNode {\n const handlers = useContentTypes(host.content);\n const [query, setQuery] = useState<WorkshopBrowseQuery>({ sort: \"New\" });\n const [collection, setCollection] = useState<string | null>(null);\n const [items, setItems] = useState<WorkshopContentView[]>([]);\n const [cursor, setCursor] = useState<string | null>(null);\n const [busy, setBusy] = useState(false);\n const [error, setError] = useState<string | null>(null);\n\n const load = useCallback(\n async (more: boolean) => {\n setBusy(true);\n setError(null);\n if (collection) {\n const r = await host.client.workshop.getCollection(collection);\n if (r.ok) {\n setItems(r.data.Items ?? []);\n setCursor(null);\n } else setError(errorText(r.error));\n } else {\n const r = await host.client.workshop.browse({\n ...query,\n pageSize: 24,\n continuationToken: more ? (cursor ?? undefined) : undefined,\n });\n if (r.ok) {\n setItems((prev) =>\n more ? [...prev, ...(r.data.Items ?? [])] : (r.data.Items ?? []),\n );\n setCursor(r.data.ContinuationToken ?? null);\n } else setError(errorText(r.error));\n }\n setBusy(false);\n },\n [host, query, collection, cursor],\n );\n\n // Reload from the first page whenever the filter changes (not when the cursor does).\n useEffect(() => {\n void load(false);\n }, [query, collection]);\n\n const types = Object.entries(defs.Definitions?.ContentTypes ?? {}).filter(\n ([, t]) => t.Enabled !== false,\n );\n\n return (\n <div>\n {(defs.Collections?.length ?? 0) > 0 && (\n <div style={chipsRow}>\n <button\n style={collection === null ? chipActive : chip}\n onClick={() => setCollection(null)}\n >\n All\n </button>\n {defs.Collections?.map((c) => (\n <button\n key={c.CollectionID ?? \"\"}\n style={collection === c.CollectionID ? chipActive : chip}\n onClick={() => setCollection(c.CollectionID ?? null)}\n >\n ★ {c.Name ?? c.CollectionID}\n </button>\n ))}\n </div>\n )}\n {!collection && (\n <div style={filtersRow}>\n <select\n style={input}\n value={query.contentType ?? \"\"}\n onChange={(e) =>\n setQuery({ ...query, contentType: e.target.value || undefined })\n }\n >\n <option value=\"\">All types</option>\n {types.map(([id, t]) => (\n <option key={id} value={id}>\n {handlers.find((h) => h.type === id)?.label ??\n t.DisplayName ??\n id}\n </option>\n ))}\n </select>\n <select\n style={input}\n value={query.sort ?? \"New\"}\n onChange={(e) =>\n setQuery({\n ...query,\n sort: e.target.value as WorkshopBrowseQuery[\"sort\"],\n })\n }\n >\n <option value=\"New\">Newest</option>\n <option value=\"Popular\">Most acquired</option>\n <option value=\"MostLiked\">Most liked</option>\n </select>\n <input\n style={input}\n placeholder=\"Tag\"\n defaultValue={query.tag ?? \"\"}\n onKeyDown={(e) => {\n if (e.key === \"Enter\")\n setQuery({\n ...query,\n tag: (e.target as HTMLInputElement).value.trim() || undefined,\n });\n }}\n />\n <label style={checkLabel}>\n <input\n type=\"checkbox\"\n checked={query.fromFollowing === true}\n onChange={(e) =>\n setQuery({\n ...query,\n fromFollowing: e.target.checked || undefined,\n })\n }\n />\n From authors I follow\n </label>\n </div>\n )}\n {error && <div style={errorBox}>{error}</div>}\n {items.length === 0 && !busy && (\n <div style={muted}>Nothing here yet.</div>\n )}\n <div style={grid}>\n {items.map((item) => (\n <ContentCard\n key={item.ContentID ?? \"\"}\n item={item}\n handlers={handlers}\n onClick={() => item.ContentID && onOpen(item.ContentID)}\n />\n ))}\n </div>\n {cursor && (\n <button\n style={secondaryBtn}\n disabled={busy}\n onClick={() => void load(true)}\n >\n {busy ? \"Loading…\" : \"Show more\"}\n </button>\n )}\n </div>\n );\n}\n\nfunction ContentCard({\n item,\n handlers,\n onClick,\n}: {\n item: WorkshopContentView;\n handlers: ContentTypeHandler[];\n onClick(): void;\n}): ReactNode {\n const handler = handlers.find((h) => h.type === item.ContentType);\n return (\n <button style={card} onClick={onClick}>\n <div style={thumbBox}>\n {item.ThumbnailUrl ? (\n <img src={item.ThumbnailUrl} alt=\"\" style={thumbImg} />\n ) : (\n <span style={{ fontSize: 34 }}>{handler?.icon ?? \"📦\"}</span>\n )}\n </div>\n <div style={cardTitle}>\n {item.Title ||\n (item.Status === \"Uploading\" ? \"Unfinished upload\" : \"Untitled\")}\n </div>\n <div style={cardMeta}>\n {item.IsOfficial\n ? \"Official\"\n : (item.CreatorPublicData?.Username ?? \"Player\")}{\" \"}\n · ♥ {item.Stats?.Likes ?? 0}\n </div>\n <div style={badgesRow}>\n {item.IsMine ? (\n <span style={badgeOwned}>Yours</span>\n ) : item.Owned ? (\n <span style={badgeOwned}>In library</span>\n ) : (\n (item.Access ?? []).map((o) => (\n <span key={o.OptionID ?? \"\"} style={badge}>\n {accessLabel(o)}\n </span>\n ))\n )}\n {item.Status && item.Status !== \"Published\" && (\n <span style={badgeWarn}>{item.Status}</span>\n )}\n </div>\n </button>\n );\n}\n\n// ────────────────────────────── Details ──────────────────────────────\n\nconst REPORT_REASONS: SocialReportReason[] = [\n \"Inappropriate\",\n \"Spam\",\n \"Stolen\",\n \"Broken\",\n \"Harassment\",\n \"Other\",\n];\n\nfunction ContentDetails({\n host,\n contentID,\n onClose,\n}: {\n host: WorkshopHost;\n contentID: string;\n onClose(): void;\n}): ReactNode {\n const [data, setData] = useState<WorkshopContentResponse | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const [message, setMessage] = useState<string | null>(null);\n const [following, setFollowing] = useState<boolean | null>(null);\n const [reportReason, setReportReason] =\n useState<SocialReportReason>(\"Inappropriate\");\n\n const reload = useCallback(async () => {\n const r = await host.client.workshop.getContent(contentID);\n if (!r.ok) {\n setMessage(errorText(r.error));\n return;\n }\n setData(r.data);\n // Following the author is a social-layer edge on the PLAYER, not part of the card.\n const creator = r.data.Content?.CreatorUserID;\n if (creator && !r.data.Content?.IsMine) {\n const s = await host.client.social.getEntityStates(\"User\", [creator]);\n if (s.ok) setFollowing(s.data.Items?.[0]?.Following === true);\n }\n }, [host, contentID]);\n\n useEffect(() => {\n void reload();\n }, [reload]);\n\n const view = data?.Content;\n const handler = view?.ContentType\n ? host.content.getType(view.ContentType)\n : undefined;\n\n const run = async (\n label: string,\n work: () => Promise<string | null>,\n ): Promise<void> => {\n setBusy(label);\n setMessage(null);\n try {\n setMessage(await work());\n } catch (e) {\n setMessage(e instanceof Error ? e.message : String(e));\n } finally {\n setBusy(null);\n }\n };\n\n const open = (): Promise<void> =>\n run(\"open\", async () => {\n if (!view?.ContentID) return null;\n if (!handler?.open)\n return \"This game can't open this type of content here.\";\n const r = await host.client.workshop.downloadFiles(view.ContentID);\n if (!r.ok) return errorText(r.error);\n await handler.open({\n contentID: view.ContentID,\n title: view.Title,\n revision: view.Revision,\n files: r.data.files,\n });\n if (handler.modeId) host.navigate(handler.modeId);\n return null;\n });\n\n const acquire = (option: WorkshopAccessOption): Promise<void> =>\n run(`get:${option.OptionID}`, async () => {\n const r = await host.client.workshop.acquire(contentID, option);\n if (!r.ok) return errorText(r.error);\n await reload();\n return r.data.AlreadyOwned\n ? \"Already in your library.\"\n : \"Added to your library.\";\n });\n\n if (!view) return <Modal onClose={onClose}>{message ?? \"Loading…\"}</Modal>;\n\n const states = new Map(\n (data?.AccessState ?? []).map((s) => [\n s.OptionID ?? \"\",\n s.Available !== false,\n ]),\n );\n const canOpen = Boolean(data?.CanDownload);\n\n return (\n <Modal onClose={onClose}>\n <div style={detailsHead}>\n <div style={detailsThumb}>\n {view.ThumbnailUrl ? (\n <img src={view.ThumbnailUrl} alt=\"\" style={thumbImg} />\n ) : (\n <span style={{ fontSize: 48 }}>{handler?.icon ?? \"📦\"}</span>\n )}\n </div>\n <div style={{ minWidth: 0 }}>\n <h2 style={{ margin: \"0 0 6px\" }}>{view.Title}</h2>\n <div style={muted}>\n {view.IsOfficial\n ? \"Official content\"\n : `by ${view.CreatorPublicData?.Username ?? \"a player\"}`}{\" \"}\n · {handler?.label ?? view.ContentType} · rev. {view.Revision}\n </div>\n <div style={muted}>\n ♥ {view.Stats?.Likes ?? 0} · ★ {view.Stats?.Favorites ?? 0} · ⬇{\" \"}\n {view.Stats?.Acquisitions ?? 0}\n {view.TotalBytes ? ` · ${formatBytes(view.TotalBytes)}` : \"\"}\n </div>\n {(view.Tags?.length ?? 0) > 0 && (\n <div style={badgesRow}>\n {view.Tags?.map((t) => (\n <span key={t} style={badge}>\n #{t}\n </span>\n ))}\n </div>\n )}\n </div>\n </div>\n\n {view.Description && (\n <p style={{ whiteSpace: \"pre-wrap\" }}>{view.Description}</p>\n )}\n {view.IsMine && view.ModerationNote && (\n <div style={errorBox}>Moderation: {view.ModerationNote}</div>\n )}\n {view.IsMine && (view.Access?.length ?? 0) > 0 && (\n <div style={muted}>\n Others get it: {(view.Access ?? []).map(accessLabel).join(\" · \")}\n </div>\n )}\n\n <div style={actionsCol}>\n {canOpen ? (\n <button\n style={primaryBtn}\n disabled={busy !== null || !handler?.open}\n onClick={() => void open()}\n >\n {busy === \"open\"\n ? \"Opening…\"\n : handler?.open\n ? \"Open\"\n : \"Owned (open it in the game)\"}\n </button>\n ) : (\n (view.Access ?? []).map((o) => {\n const available = states.get(o.OptionID ?? \"\") ?? true;\n return (\n <button\n key={o.OptionID ?? \"\"}\n style={available ? primaryBtn : disabledBtn}\n disabled={!available || busy !== null}\n title={\n available\n ? undefined\n : \"You don't meet this option's requirements\"\n }\n onClick={() => void acquire(o)}\n >\n {busy === `get:${o.OptionID}` ? \"…\" : `Get — ${accessLabel(o)}`}\n </button>\n );\n })\n )}\n </div>\n\n <div style={filtersRow}>\n {!view.IsMine && (\n <button\n style={secondaryBtn}\n disabled={busy !== null}\n onClick={() =>\n void run(\"like\", async () => {\n const r = view.Liked\n ? await host.client.social.unlike(\"WorkshopItem\", contentID)\n : await host.client.social.like(\"WorkshopItem\", contentID);\n if (!r.ok) return errorText(r.error);\n await reload();\n return null;\n })\n }\n >\n {view.Liked ? \"♥ Liked\" : \"♡ Like\"}\n </button>\n )}\n <button\n style={secondaryBtn}\n disabled={busy !== null}\n onClick={() =>\n void run(\"fav\", async () => {\n const r = view.Favorited\n ? await host.client.social.unfavorite(\"WorkshopItem\", contentID)\n : await host.client.social.favorite(\"WorkshopItem\", contentID);\n if (!r.ok) return errorText(r.error);\n await reload();\n return null;\n })\n }\n >\n {view.Favorited ? \"★ In favorites\" : \"☆ Favorite\"}\n </button>\n {!view.IsMine && view.CreatorUserID && (\n <button\n style={secondaryBtn}\n disabled={busy !== null}\n onClick={() =>\n void run(\"follow\", async () => {\n const creator = view.CreatorUserID ?? \"\";\n const r = following\n ? await host.client.social.unfollow(\"User\", creator)\n : await host.client.social.follow(\"User\", creator);\n if (!r.ok) return errorText(r.error);\n setFollowing(r.data.Following === true);\n return r.data.Following ? \"You follow this author.\" : null;\n })\n }\n >\n {following ? \"Following ✓\" : \"Follow author\"}\n </button>\n )}\n {view.IsMine && view.Status !== \"Removed\" && (\n <button\n style={dangerBtn}\n disabled={busy !== null}\n onClick={() => {\n if (\n !confirm(\n \"Unpublish for good? Players who already have it keep it.\",\n )\n )\n return;\n void run(\"unpublish\", async () => {\n const r = await host.client.workshop.unpublish(contentID);\n if (!r.ok) return errorText(r.error);\n await reload();\n return \"Unpublished.\";\n });\n }}\n >\n Unpublish\n </button>\n )}\n </div>\n\n {!view.IsMine && (\n <div style={filtersRow}>\n <select\n style={input}\n value={reportReason}\n onChange={(e) =>\n setReportReason(e.target.value as SocialReportReason)\n }\n >\n {REPORT_REASONS.map((r) => (\n <option key={r} value={r}>\n {r}\n </option>\n ))}\n </select>\n <button\n style={secondaryBtn}\n disabled={busy !== null}\n onClick={() =>\n void run(\"report\", async () => {\n const r = await host.client.social.report(\n \"WorkshopItem\",\n contentID,\n reportReason,\n );\n if (!r.ok) return errorText(r.error);\n return r.data.AlreadyReported\n ? \"You already reported this.\"\n : \"Thanks — the publisher will review it.\";\n })\n }\n >\n Report\n </button>\n </div>\n )}\n\n {message && <div style={noticeBox}>{message}</div>}\n </Modal>\n );\n}\n\n// ────────────────────────────── Publish ──────────────────────────────\n\ninterface AccessDraft {\n mode: \"Free\" | \"Price\" | \"Holding\";\n resource: string; // \"vc:GOLD\" | \"item:<catalog>/<item>\"\n amount: number;\n holdMode: \"WhileHeld\" | \"UnlockOnce\";\n}\n\nfunction PublishWizard({\n host,\n defs,\n onDone,\n}: {\n host: WorkshopHost;\n defs: WorkshopDefinitionsResponse;\n onDone(contentID: string): void;\n}): ReactNode {\n const handlers = useContentTypes(host.content).filter(\n (h) => h.listLocal && h.capture && defs.CanPublish?.[h.type] === true,\n );\n const [type, setType] = useState<string | null>(null);\n const [locals, setLocals] = useState<Array<{\n id: string;\n name: string;\n subtitle?: string;\n }> | null>(null);\n const [localId, setLocalId] = useState<string | null>(null);\n const [title, setTitle] = useState(\"\");\n const [description, setDescription] = useState(\"\");\n const [tags, setTags] = useState(\"\");\n const [visibility, setVisibility] = useState<WorkshopVisibility>(\"Public\");\n const [access, setAccess] = useState<AccessDraft[]>([\n { mode: \"Free\", resource: \"\", amount: 1, holdMode: \"UnlockOnce\" },\n ]);\n const [busy, setBusy] = useState(false);\n const [message, setMessage] = useState<string | null>(null);\n\n const handler = handlers.find((h) => h.type === type);\n const typeDef = type ? defs.Definitions?.ContentTypes?.[type] : undefined;\n const allowedModes = typeDef?.AllowedAccessModes?.length\n ? typeDef.AllowedAccessModes\n : [\"Free\", \"Price\", \"Holding\"];\n const resources = useResourceOptions(host.client);\n // Плата за публикацию берётся только ресурсами: вариант с оплатой в сторе сервер отвергнет.\n const fee = Object.entries(typeDef?.PublishFeeOptions ?? {}).find(\n ([, option]) =>\n !(option?.Cost?.Standard?.Entries ?? []).some(\n (e) => e?.Type === \"Purchase\",\n ),\n );\n\n useEffect(() => {\n setLocals(null);\n setLocalId(null);\n if (!handler?.listLocal) return;\n void handler\n .listLocal()\n .then(setLocals, (e: unknown) =>\n setMessage(e instanceof Error ? e.message : String(e)),\n );\n }, [handler]);\n\n if (handlers.length === 0)\n return (\n <div style={muted}>\n Nothing to publish from here: the games in this project either do not\n register their content, or you can't publish it yet.\n </div>\n );\n\n const publish = async (): Promise<void> => {\n if (!handler?.capture || !type || !localId) return;\n // Опция без ресурса молча выпала бы из списка, и публикация ушла бы не с теми способами получить.\n if (access.some((a) => a.mode !== \"Free\" && !a.resource)) {\n setMessage(\"Pick a resource for every paid or hold-to-unlock option.\");\n return;\n }\n setBusy(true);\n setMessage(null);\n try {\n const captured = await handler.capture(localId);\n const r = await host.client.workshop.publish({\n contentType: type,\n files: captured.files,\n thumbnail: captured.thumbnail,\n title: title.trim() || captured.suggestedTitle || \"Untitled\",\n description: description.trim() || captured.suggestedDescription,\n tags: tags\n .split(\",\")\n .map((t) => t.trim())\n .filter(Boolean)\n .concat(captured.tags ?? []),\n metadata: captured.metadata,\n visibility,\n access: access\n .map(toAccessInput)\n .filter((a): a is WorkshopAccessOptionInput => a !== null),\n selectedFeeOptionID: fee?.[0],\n });\n if (!r.ok) setMessage(errorText(r.error));\n else if (r.data.Content?.ContentID) onDone(r.data.Content.ContentID);\n } catch (e) {\n setMessage(e instanceof Error ? e.message : String(e));\n } finally {\n setBusy(false);\n }\n };\n\n return (\n <div style={formCol}>\n <label style={fieldLabel}>What</label>\n <div style={chipsRow}>\n {handlers.map((h) => (\n <button\n key={h.type}\n style={type === h.type ? chipActive : chip}\n onClick={() => setType(h.type)}\n >\n {h.icon} {h.label}\n </button>\n ))}\n </div>\n\n {handler && (\n <>\n <label style={fieldLabel}>Which one</label>\n {!locals ? (\n <div style={muted}>Loading…</div>\n ) : locals.length === 0 ? (\n <div style={muted}>You have nothing of this type yet.</div>\n ) : (\n <div style={formCol}>\n {locals.map((l) => (\n <label key={l.id} style={checkLabel}>\n <input\n type=\"radio\"\n name=\"wsLocal\"\n checked={localId === l.id}\n onChange={() => {\n setLocalId(l.id);\n if (!title) setTitle(l.name);\n }}\n />\n {l.name}{\" \"}\n {l.subtitle && <span style={muted}>· {l.subtitle}</span>}\n </label>\n ))}\n </div>\n )}\n </>\n )}\n\n {localId && (\n <>\n <label style={fieldLabel}>Title</label>\n <input\n style={input}\n value={title}\n maxLength={defs.Definitions?.Limits?.MaxTitleLength ?? 60}\n onChange={(e) => setTitle(e.target.value)}\n />\n <label style={fieldLabel}>Description</label>\n <textarea\n style={{ ...input, minHeight: 70 }}\n value={description}\n onChange={(e) => setDescription(e.target.value)}\n />\n <label style={fieldLabel}>\n Tags (comma separated\n {typeDef?.TagVocabulary?.length\n ? `: ${typeDef.TagVocabulary.join(\", \")}`\n : \"\"}\n )\n </label>\n <input\n style={input}\n value={tags}\n onChange={(e) => setTags(e.target.value)}\n />\n <label style={fieldLabel}>Who can see it</label>\n <select\n style={input}\n value={visibility}\n onChange={(e) =>\n setVisibility(e.target.value as WorkshopVisibility)\n }\n >\n <option value=\"Public\">Everyone (in the catalog)</option>\n <option value=\"Unlisted\">Anyone with the link</option>\n <option value=\"Friends\">Only my friends</option>\n </select>\n\n <label style={fieldLabel}>How others get it — any option works</label>\n {access.map((a, i) => (\n <div key={i} style={filtersRow}>\n <select\n style={input}\n value={a.mode}\n onChange={(e) =>\n setAccess(\n patch(access, i, {\n mode: e.target.value as AccessDraft[\"mode\"],\n }),\n )\n }\n >\n {allowedModes.map((m) => (\n <option key={m} value={m}>\n {m === \"Free\"\n ? \"Free\"\n : m === \"Price\"\n ? \"For resources\"\n : \"Hold to unlock\"}\n </option>\n ))}\n </select>\n {a.mode !== \"Free\" && (\n <>\n <select\n style={input}\n value={a.resource}\n onChange={(e) =>\n setAccess(patch(access, i, { resource: e.target.value }))\n }\n >\n <option value=\"\">— resource —</option>\n {resources\n .filter(\n (r) => a.mode === \"Holding\" || r.startsWith(\"vc:\"),\n )\n .map((r) => (\n <option key={r} value={r}>\n {r.replace(/^vc:/, \"\").replace(/^item:/, \"item \")}\n </option>\n ))}\n </select>\n <input\n style={{ ...input, width: 90 }}\n type=\"number\"\n min={1}\n value={a.amount}\n onChange={(e) =>\n setAccess(\n patch(access, i, {\n amount: Math.max(1, Number(e.target.value) || 1),\n }),\n )\n }\n />\n </>\n )}\n {a.mode === \"Holding\" && (\n <select\n style={input}\n value={a.holdMode}\n onChange={(e) =>\n setAccess(\n patch(access, i, {\n holdMode: e.target.value as AccessDraft[\"holdMode\"],\n }),\n )\n }\n >\n <option value=\"UnlockOnce\">Checked once, then forever</option>\n <option value=\"WhileHeld\">Only while they hold it</option>\n </select>\n )}\n {access.length > 1 && (\n <button\n style={secondaryBtn}\n onClick={() => setAccess(access.filter((_, j) => j !== i))}\n >\n ✕\n </button>\n )}\n </div>\n ))}\n <button\n style={secondaryBtn}\n onClick={() =>\n setAccess([\n ...access,\n {\n mode: allowedModes.includes(\"Price\") ? \"Price\" : \"Free\",\n resource: \"\",\n amount: 10,\n holdMode: \"UnlockOnce\",\n },\n ])\n }\n >\n + Add an option\n </button>\n\n {fee && (\n <div style={muted}>\n Publishing costs {bundleLabel(fee[1].Cost?.Standard)} (charged\n once).\n </div>\n )}\n <button\n style={primaryBtn}\n disabled={busy}\n onClick={() => void publish()}\n >\n {busy ? \"Publishing…\" : \"Publish\"}\n </button>\n </>\n )}\n {message && <div style={errorBox}>{message}</div>}\n </div>\n );\n}\n\nfunction patch<T>(list: T[], index: number, change: Partial<T>): T[] {\n return list.map((item, i) => (i === index ? { ...item, ...change } : item));\n}\n\nfunction toAccessInput(a: AccessDraft): WorkshopAccessOptionInput | null {\n if (a.mode === \"Free\") return { Mode: \"Free\" };\n if (!a.resource) return null;\n const [kind, id = \"\"] = a.resource.split(\":\", 2);\n if (a.mode === \"Price\") {\n return {\n Mode: \"Price\",\n Price: {\n Entries: [\n { Type: \"VirtualCurrency\", CurrencyID: id, Amount: a.amount },\n ],\n },\n };\n }\n const [catalogID, itemID] = id.split(\"/\", 2);\n return {\n Mode: \"Holding\",\n Holding: {\n Mode: a.holdMode,\n Match: \"All\",\n Requirements: [\n kind === \"vc\"\n ? { Type: \"VirtualCurrency\", CurrencyID: id, Amount: a.amount }\n : {\n Type: \"Item\",\n CatalogID: catalogID,\n ItemID: itemID ?? \"\",\n Amount: a.amount,\n },\n ],\n },\n };\n}\n\n// ────────────────────────────── Lists ──────────────────────────────\n\nfunction MyList({\n host,\n kind,\n onOpen,\n}: {\n host: WorkshopHost;\n kind: \"mine\" | \"licenses\" | \"favorites\";\n onOpen(id: string): void;\n}): ReactNode {\n const handlers = useContentTypes(host.content);\n const [items, setItems] = useState<WorkshopContentView[] | null>(null);\n const [error, setError] = useState<string | null>(null);\n\n useEffect(() => {\n let alive = true;\n const load = async (): Promise<void> => {\n const w = host.client.workshop;\n if (kind === \"licenses\") {\n const r = await w.getMyLicenses();\n if (!alive) return;\n if (r.ok)\n setItems(\n (r.data.Licenses ?? [])\n .map((l) => l.Content)\n .filter((c): c is WorkshopContentView => Boolean(c)),\n );\n else setError(errorText(r.error));\n return;\n }\n const r =\n kind === \"mine\" ? await w.getMyContent() : await w.getMyFavorites();\n if (!alive) return;\n if (r.ok) setItems(r.data.Items ?? []);\n else setError(errorText(r.error));\n };\n void load();\n return () => {\n alive = false;\n };\n }, [host, kind]);\n\n if (error) return <div style={errorBox}>{error}</div>;\n if (!items) return <div style={muted}>Loading…</div>;\n if (items.length === 0) return <div style={muted}>Empty.</div>;\n return (\n <div style={grid}>\n {items.map((item) => (\n <ContentCard\n key={item.ContentID ?? \"\"}\n item={item}\n handlers={handlers}\n onClick={() => item.ContentID && onOpen(item.ContentID)}\n />\n ))}\n </div>\n );\n}\n\n// ────────────────────────────── helpers ──────────────────────────────\n\n/**\n * Registered content types, re-read on every registry change. ⚠ Not keyed by the NUMBER of types: a game\n * re-installed after re-login registers the same type again with a new handler, the count stays the\n * same, and a count-based snapshot kept the handler of the destroyed game.\n */\nfunction useContentTypes(content: ContentRegistry): ContentTypeHandler[] {\n const [types, setTypes] = useState(() => content.listTypes());\n useEffect(() => {\n setTypes(content.listTypes());\n return content.subscribe(() => setTypes(content.listTypes()));\n }, [content]);\n return types;\n}\n\n/** \"vc:GOLD\" and \"item:<catalog>/<item>\" for every currency and item the title defines. */\nfunction useResourceOptions(client: IDosGamesClient): string[] {\n const [list] = useState(() => {\n const out: string[] = [];\n for (const id of Object.keys(\n client.data.config.currencyDefinitions?.VirtualCurrencies ?? {},\n ))\n out.push(`vc:${id}`);\n for (const [catalogID, catalog] of Object.entries(\n client.data.config.itemDefinitions?.Catalogs ?? {},\n ))\n for (const itemID of Object.keys(catalog?.Items ?? {}))\n out.push(`item:${catalogID}/${itemID}`);\n return out;\n });\n return list;\n}\n\ntype BundleLike =\n | {\n Entries?: Array<{\n CurrencyID?: string | null;\n ItemID?: string | null;\n Amount?: number | null;\n } | null> | null;\n }\n | null\n | undefined;\n\nfunction bundleLabel(bundle: BundleLike): string {\n const parts = (bundle?.Entries ?? [])\n .filter((e): e is NonNullable<typeof e> => Boolean(e))\n .map((e) => `${e.Amount ?? 0} ${e.CurrencyID ?? e.ItemID ?? \"\"}`.trim());\n return parts.length ? parts.join(\" + \") : \"free\";\n}\n\nfunction accessLabel(o: WorkshopAccessOption): string {\n if (o.Mode === \"Free\") return \"Free\";\n if (o.Mode === \"Price\") return bundleLabel(o.Price);\n const reqs = (o.Holding?.Requirements ?? []).map(\n (r) =>\n `${r.Amount ?? 0} ${r.CurrencyID ?? r.ItemID ?? \"\"}${r.MinLevel ? ` (lvl ${r.MinLevel}+)` : \"\"}`,\n );\n const joined = reqs.join(o.Holding?.Match === \"Any\" ? \" or \" : \" + \");\n return `🔑 hold ${joined}${o.Holding?.Mode === \"WhileHeld\" ? \" (while held)\" : \"\"}`;\n}\n\nfunction formatBytes(n: number): string {\n if (n < 1024) return `${n} B`;\n if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;\n return `${(n / 1024 / 1024).toFixed(1)} MB`;\n}\n\nfunction Frame({ children }: { children: ReactNode }): ReactNode {\n return <div style={frame}>{children}</div>;\n}\n\nfunction Modal({\n children,\n onClose,\n}: {\n children: ReactNode;\n onClose(): void;\n}): ReactNode {\n return (\n <div style={backdrop} onClick={onClose}>\n <div style={modal} onClick={(e) => e.stopPropagation()}>\n <button style={closeBtn} onClick={onClose} aria-label=\"Close\">\n ✕\n </button>\n {children}\n </div>\n </div>\n );\n}\n\n// ────────────────────────────── styles ──────────────────────────────\n// Every colour is a theme token of the UI kit (the project's src/ui.config.ts), like every other\n// system — the workshop must not be a dark island in a light app. Change the theme, not these.\n\nconst frame: CSSProperties = {\n // A tab of the lobby: the lobby scrolls it and gives it the width.\n minHeight: 360,\n borderRadius: 16,\n padding: \"16px 20px 40px\",\n ...panel,\n color: v.text,\n fontFamily: v.font,\n pointerEvents: \"auto\",\n};\nconst tabsRow: CSSProperties = {\n display: \"flex\",\n gap: 6,\n flexWrap: \"wrap\",\n marginBottom: 14,\n};\nconst tabBtn: CSSProperties = {\n background: \"transparent\",\n color: v.textDim,\n border: `1px solid ${v.line}`,\n borderRadius: 8,\n padding: \"6px 12px\",\n cursor: \"pointer\",\n};\nconst tabActive: CSSProperties = {\n ...tabBtn,\n background: `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`,\n color: v.onBlue,\n border: \"1px solid transparent\",\n};\nconst chipsRow: CSSProperties = {\n display: \"flex\",\n gap: 6,\n flexWrap: \"wrap\",\n margin: \"8px 0\",\n};\nconst chip: CSSProperties = {\n ...tabBtn,\n borderRadius: 999,\n padding: \"4px 10px\",\n fontSize: 13,\n};\nconst chipActive: CSSProperties = {\n ...chip,\n background: `color-mix(in srgb, ${v.blue} 30%, transparent)`,\n color: v.text,\n};\nconst filtersRow: CSSProperties = {\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n alignItems: \"center\",\n margin: \"8px 0\",\n};\nconst input: CSSProperties = {\n background: v.well,\n color: v.text,\n border: `1px solid ${v.line}`,\n boxShadow: v.wellShadow,\n borderRadius: 6,\n padding: \"6px 8px\",\n font: \"inherit\",\n};\nconst checkLabel: CSSProperties = {\n display: \"flex\",\n gap: 6,\n alignItems: \"center\",\n fontSize: 14,\n};\nconst grid: CSSProperties = {\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 12,\n};\nconst card: CSSProperties = {\n textAlign: \"left\",\n background: v.panelDeep,\n border: `1px solid ${v.panelEdge}`,\n borderRadius: 10,\n padding: 8,\n color: \"inherit\",\n cursor: \"pointer\",\n font: \"inherit\",\n};\nconst thumbBox: CSSProperties = {\n aspectRatio: \"1 / 1\",\n borderRadius: 8,\n background: v.wellSoft,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n overflow: \"hidden\",\n};\nconst thumbImg: CSSProperties = {\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n imageRendering: \"pixelated\",\n};\nconst cardTitle: CSSProperties = {\n fontWeight: 600,\n margin: \"6px 0 2px\",\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n};\nconst cardMeta: CSSProperties = { fontSize: 12, color: v.textDim };\nconst badgesRow: CSSProperties = {\n display: \"flex\",\n gap: 4,\n flexWrap: \"wrap\",\n marginTop: 6,\n};\nconst badge: CSSProperties = {\n fontSize: 11,\n background: v.wellSoft,\n color: v.text,\n borderRadius: 4,\n padding: \"2px 6px\",\n};\nconst badgeOwned: CSSProperties = {\n ...badge,\n background: `color-mix(in srgb, ${v.green} 35%, transparent)`,\n};\nconst badgeWarn: CSSProperties = {\n ...badge,\n background: `color-mix(in srgb, ${v.gold} 35%, transparent)`,\n};\nconst muted: CSSProperties = { color: v.textDim, fontSize: 13 };\nconst errorBox: CSSProperties = {\n background: `color-mix(in srgb, ${v.red} 18%, transparent)`,\n border: `1px solid color-mix(in srgb, ${v.red} 45%, transparent)`,\n color: v.text,\n borderRadius: 6,\n padding: \"8px 10px\",\n margin: \"8px 0\",\n};\nconst noticeBox: CSSProperties = {\n background: `color-mix(in srgb, ${v.blue} 16%, transparent)`,\n borderRadius: 6,\n padding: \"8px 10px\",\n marginTop: 10,\n};\nconst primaryBtn: CSSProperties = {\n ...buttonStyle(\"blue\", false, \"sm\"),\n font: \"inherit\",\n fontWeight: 800,\n};\nconst disabledBtn: CSSProperties = {\n ...buttonStyle(\"blue\", true, \"sm\"),\n font: \"inherit\",\n fontWeight: 800,\n cursor: \"not-allowed\",\n};\nconst secondaryBtn: CSSProperties = { ...tabBtn, color: v.text };\nconst dangerBtn: CSSProperties = {\n ...tabBtn,\n color: v.red,\n border: `1px solid color-mix(in srgb, ${v.red} 50%, transparent)`,\n};\nconst actionsCol: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 8,\n margin: \"12px 0\",\n};\nconst formCol: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 6,\n maxWidth: 640,\n};\nconst fieldLabel: CSSProperties = {\n fontSize: 13,\n color: v.textDim,\n marginTop: 8,\n};\nconst backdrop: CSSProperties = {\n position: \"fixed\",\n inset: 0,\n background: v.backdrop,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n zIndex: 50,\n};\nconst modal: CSSProperties = {\n position: \"relative\",\n ...panel,\n color: v.text,\n padding: 20,\n width: \"min(640px, 94vw)\",\n maxHeight: \"90vh\",\n overflow: \"auto\",\n};\nconst closeBtn: CSSProperties = {\n position: \"absolute\",\n top: 10,\n right: 10,\n background: \"transparent\",\n color: v.textDim,\n border: 0,\n fontSize: 18,\n cursor: \"pointer\",\n};\nconst detailsHead: CSSProperties = {\n display: \"flex\",\n gap: 14,\n alignItems: \"flex-start\",\n marginRight: 24,\n};\nconst detailsThumb: CSSProperties = {\n ...thumbBox,\n width: 120,\n flex: \"0 0 120px\",\n};\n"
|
|
64
64
|
},
|
|
65
65
|
{
|
|
66
66
|
"path": "module.meta.json",
|
|
67
|
-
"content": "{\n \"id\": \"workshop\",\n \"type\": \"feature\",\n \"summary\": \"Workshop: players and the publisher share content (maps, levels, skins, models) — free, for in-game resources, or unlocked by holding items.\",\n \"description\": \"A ready catalog of user-generated content for ANY game. Players publish what they made (a world, a level, a skin, any file the title allows), set how others get it — free, a price in in-game resources (several options at once, the author receives it minus commission), or 'hold N of this item / currency to unlock' (nothing is charged; while held or once) — and others browse, filter, acquire, open, like, favorite, follow authors and report. The publisher configures content types, formats, limits, commission and moderation in the Workshop section of the title config, curates featured collections and publishes official content from the dashboard. A game plugs its own content in by registering a handler with ctx.content.registerType({ type, label, listLocal, capture, open, modeId }) — the Workshop never imports the game. VoxelCraft registers 'voxelcraft.world'.\",\n \"provides\": [\n \"player-made content catalog (maps, levels, skins, models, any file type the title allows)\",\n \"publish a map or level made in the game\",\n \"sell content for in-game resources with author royalties minus commission\",\n \"free content sharing\",\n \"unlock content by holding an item or currency without spending it\",\n \"likes, favorites and following content creators\",\n \"report content and auto-hide after reports\",\n \"publisher featured collections and official content\",\n \"download and open shared content in the game\"\n ],\n \"tags\": [\n \"workshop\",\n \"ugc\",\n \"user-generated-content\",\n \"maps\",\n \"levels\",\n \"mods\",\n \"sharing\",\n \"marketplace\",\n \"creators\",\n \"social\"\n ],\n \"author\": { \"name\": \"iDos Games\", \"url\": \"https://idosgames.com\" },\n \"version\": \"0.1.
|
|
67
|
+
"content": "{\n \"id\": \"workshop\",\n \"type\": \"feature\",\n \"summary\": \"Workshop: players and the publisher share content (maps, levels, skins, models) — free, for in-game resources, or unlocked by holding items.\",\n \"description\": \"A ready catalog of user-generated content for ANY game. Players publish what they made (a world, a level, a skin, any file the title allows), set how others get it — free, a price in in-game resources (several options at once, the author receives it minus commission), or 'hold N of this item / currency to unlock' (nothing is charged; while held or once) — and others browse, filter, acquire, open, like, favorite, follow authors and report. The publisher configures content types, formats, limits, commission and moderation in the Workshop section of the title config, curates featured collections and publishes official content from the dashboard. A game plugs its own content in by registering a handler with ctx.content.registerType({ type, label, listLocal, capture, open, modeId }) — the Workshop never imports the game. VoxelCraft registers 'voxelcraft.world'.\",\n \"provides\": [\n \"player-made content catalog (maps, levels, skins, models, any file type the title allows)\",\n \"publish a map or level made in the game\",\n \"sell content for in-game resources with author royalties minus commission\",\n \"free content sharing\",\n \"unlock content by holding an item or currency without spending it\",\n \"likes, favorites and following content creators\",\n \"report content and auto-hide after reports\",\n \"publisher featured collections and official content\",\n \"download and open shared content in the game\"\n ],\n \"tags\": [\n \"workshop\",\n \"ugc\",\n \"user-generated-content\",\n \"maps\",\n \"levels\",\n \"mods\",\n \"sharing\",\n \"marketplace\",\n \"creators\",\n \"social\"\n ],\n \"author\": { \"name\": \"iDos Games\", \"url\": \"https://idosgames.com\" },\n \"version\": \"0.1.2\"\n}\n"
|
|
68
68
|
}
|
|
69
69
|
]
|
|
70
70
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idosgames-module-contract",
|
|
3
3
|
"description": "Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk: the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route registration, and the shared controller-box bridge between an imperative scene and React panels. Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg, voxelcraft, mini-game, store, character, workshop…), or asks about defineModule, registerScene/registerPanel/registerRoute, ctx.features (register / open / setBadge / setAvailable), ctx.modes, activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic, ctx.content / ContentTypeHandler (letting the Workshop publish and open the game's content), ctx.multiplayer / registerGame (playing online through the lobby's rooms system), ctx.navigate, the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention.",
|
|
4
|
-
"content": "---\nname: idosgames-module-contract\ndescription: >-\n Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk:\n the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route\n registration, and the shared controller-box bridge between an imperative scene and React panels.\n Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg,\n voxelcraft, mini-game, store, character, workshop…), or asks about defineModule,\n registerScene/registerPanel/registerRoute, ctx.features (register / open / setBadge / setAvailable),\n ctx.modes,\n activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic,\n ctx.content / ContentTypeHandler (letting the Workshop publish and open the game's content),\n ctx.multiplayer / registerGame (playing online through the lobby's rooms system),\n ctx.navigate, the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention.\n---\n\n# The module contract (@idosgames/module-sdk)\n\nA module is a manifest the host installs once. It exports `{camelCase(id)}Module` from its\n`index.ts` (e.g. `board-game` → `boardGameModule`, `idle-rpg` → `idleRpgModule`) — the host seeder\nderives the import name from the id, so this convention is required.\n\n```ts\nimport { defineModule } from \"@idosgames/module-sdk\";\n\nexport const boardGameModule = defineModule({\n id: \"board-game\",\n meta: { name: \"Board Game\", type: \"game\", genre: \"board\", engine: \"three\" }, // engine: three|phaser|dom\n setup(ctx) {\n // ctx.client (shared, authed) · ctx.events (typed cross-module bus) · ctx.sharedUi · ctx.surface\n ctx.registerScene(createScene(box)); // rendered engine scene (optional)\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: RootPanel }); // React UI (optional)\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" }); // a mode → the lobby's \"Play\"\n },\n});\n```\n\nTwo kinds of module plug into the base (`src/base/` — sign-in and the lobby):\n\n- a **game** (`meta.type: \"game\"`) registers a route: the lobby shows a \"Play\" button for it, and\n over it the base draws only a small \"To the lobby\" button;\n- a **system** (`meta.type: \"app\"`) registers a feature — its screen becomes a lobby tab:\n `ctx.features.register({ id, label, icon, group, order, primary?, available?, Screen })`. A tab\n that depends on title data registers with `available: false` and calls\n `ctx.features.setAvailable(id, ok, reason)` once it knows (details: **idosgames-ui-kit**).\n\n`ctx.modes.list()` / `current()` / `subscribe()` — every mode (id, label, icon, module, module type),\nfilled once all modules are installed (read it at render time, not inside `setup()`).\n`ctx.navigate(id)` switches (`\"lobby\"` is the base); called inside `setup()` it picks the mode the app\nstarts in. `ctx.features.open(id, args)` from a game (the lobby not on screen) switches to the lobby\nand opens that tab there.\n\n`ctx.multiplayer` — a game that plays online registers itself (`registerGame({ mode, topology,\nchatMode, maxPlayers, joinInProgress, settings, start(room) })`) and takes the room the lobby's\nrooms system hands it on \"Play\"; it never creates or joins rooms (skill **multiplayer-realtime**).\n\nOptional static field: `sharedUi: { provides?: SharedUiRole[]; requires?: SharedUiRole[] }` — see\n\"Shared chrome\" below.\n\n`meta.type` is `game | app | ai-app`; `meta.engine` is `three | phaser | dom` (`dom` = no renderer —\na pure React/DOM app module). `setup` is called ONCE with `ctx: ModuleContext`.\n\n## EngineScene (the rendered part)\n\nA scene mounts into a bare `HTMLElement` (framework-free — this is why a vanilla Three game like\nvoxelcraft fits). The host's Mode Router drives it; only the active mode runs.\n\n```ts\nconst scene: EngineScene = {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext) {\n controller = new Controller(ctx.host);\n box.set(controller);\n },\n activate() {\n controller?.setRunning(true);\n }, // became the active mode → resume RAF\n suspend() {\n controller?.setRunning(false);\n }, // hidden → stop RAF (invariant: only active ticks)\n destroy() {\n controller?.destroy();\n }, // permanent teardown\n};\n```\n\n`mount` takes a context object (not a bare element) so it can grow — e.g. a host-shared renderer in\nthe composition era — without breaking the contract. A scene MUST stop its RAF on `suspend`.\n\nA module that registers a scene MUST also publish its debug surface — `ctx.exposeToAgent({ state,\nactions, describeActions })` — and must NOT gate controls on Pointer Lock (unavailable in the\npreview's cross-origin iframe). Nothing inside a `<canvas>` is observable from the DOM, so without\nthe surface neither the AI Coder nor a human reviewer can tell what the game is doing. See the\n`idosgames-agent-debug-surface` skill.\n\n### `capture()` — the frame for \"Take a shot and share\"\n\nidosgames.com has a **Shot** button under the game: it asks the game for one frame, puts the\nplatform's branding and a QR code on it and offers to post it. A scene answers through the optional\n`capture?(): Promise<Blob | null>`. The host (`@idosgames/app-shell`) wires the request itself —\na module only has to return a picture of its active scene.\n\n**Implement it in any scene that renders to a canvas.** Without it the host falls back to reading\nthe `<canvas>` from outside, and for WebGL that is a trap: a renderer created WITHOUT\n`preserveDrawingBuffer` (the default, and the right default — the flag costs memory and a copy every\nframe) has already cleared its buffer by the time anyone reads it, so `toBlob()` returns a **fully\nblack** image and reports nothing. The host rejects single-colour frames, so the player gets the\ngame's cover instead of their moment.\n\nRender and read in the SAME tick:\n\n```ts\n// three.js\ncapture() {\n return new Promise((resolve) => {\n renderer.render(scene, camera); // draw now…\n renderer.domElement.toBlob(resolve, \"image/png\"); // …and read before the buffer is cleared\n });\n},\n\n// Phaser — use its own snapshot, it handles the timing\ncapture() {\n return new Promise((resolve) => {\n setTimeout(() => resolve(null), 1500); // no frame came (context lost) → answer \"no\", don't hang\n game.loop.wake(); // a sleeping loop never draws again → snapshot never fires\n game.renderer.snapshot((image) => {\n if (!(image instanceof HTMLImageElement)) return resolve(null);\n const c = document.createElement(\"canvas\");\n c.width = image.naturalWidth || image.width;\n c.height = image.naturalHeight || image.height;\n c.getContext(\"2d\")?.drawImage(image, 0, 0);\n c.toBlob(resolve, \"image/png\");\n });\n });\n},\n```\n\nAlways answer — with a Blob or `null`. A `capture()` that never settles makes the site wait out its\ntimeout before it falls back, and the player stares at a spinner.\n\nReturn `null` when there is nothing worth showing (loading, no scene yet) — the site then uses the\ncover. Only the scene is captured, not the React panels over it: a shot for a feed reads better\nwithout the HUD. DOM-only modules (no canvas) simply omit `capture`. Working references:\n`modules/board-game` and `modules/voxelcraft` (three), `modules/idle-rpg` (Phaser).\n\n## UiPanel (the React part)\n\nPanels are React components the host renders inside its provider stack (client + status already\nprovided). Use `@idosgames/react` hooks (`useIDosGamesClient`, `useUserState`) — do NOT re-create\nproviders.\n\n- `slot`: `hud | sidebar | overlay | modal`.\n- `activeOnly` (default true): show only while this module's mode is active. Set `false` for shared\n chrome that stays across every mode (e.g. a persistent HUD).\n- `overlay`/`sidebar` layers sit between the shared HUD and the nav (the host measures both), so a\n panel positioned `absolute` inside its layer never ends up under them. Things drawn outside the\n layers use `var(--idos-safe-top, 0px)` / `var(--idos-safe-bottom, 0px)`.\n- A `hud` panel's wrapper is `display: contents`: the panel is a direct flex child of the HUD row\n (can `flex: 1`) and must set `pointer-events: auto` only on its controls.\n\n## Shared chrome (`sharedUi`)\n\nRoles: `currency-bar` (virtual-currency balances), `wallet` (crypto wallet entry — `LazyWalletPanel`),\n`status` (the `useStatus()` line), `account` (Log out + player ID), `menu` (the menu of features),\n`modes` (the switch between modes). The base takes `currency-bar`, `wallet`, `account`, `menu` and\n`modes` — its lobby draws them; `status` stays with each game. A module that draws one for every\nmode declares `sharedUi: { provides: [...] }` on the module object — statically, so the host resolves\nowners before any `setup()` and the answer never depends on module order.\n\n**Rule for templates: yield every role you draw yourself.** Read it once in `setup()` and close over\nthe answer (it is fixed for the session):\n\n```ts\nsetup(ctx) {\n const chrome = {\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n balances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n };\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: makeRootPanel(box, chrome) });\n}\n```\n\nHide only the SHARED pieces — genre UI (the board's stage/tile/cycle chips) always stays. Never\nwrap your UI in your own `<StatusProvider>`: the host provides ONE, and a nested one would swallow\nyour messages so the shared status line never sees them. A module that needs a role but does not\ndraw it declares `sharedUi: { requires: [\"currency-bar\"] }`. `ctx.sharedUi.ownerOf(role)` names the\nprovider (or `null`). A full-screen mode (its own bottom tab bar) that shows the player ID and\n\"Log out\" in its own header declares `sharedUi: { drawsOwn: [\"account\"] }` and draws them only while\n`shouldDraw(\"account\")`: the host then hides its floating bottom-left copy while that mode is on\nscreen (it would cover the tab bar) and keeps it in the other modes. `drawsOwn` takes no role —\nwith the base installed the lobby draws the account and the mode hides its copy.\n\n## Events (`ctx.events`)\n\nTyped topic tokens — `defineTopic(\"<your-id>:<event>@1\", shape({ … }))` from\n`@idosgames/module-sdk`; emit only your own namespace; to listen to another module, copy its topic\nand payload descriptor from the catalog into your own `defineTopic` (never import it); declare both\nin `module.meta.json` (`events.emits` / `events.listens`). **Subscribe in `setup()`**, not in a panel\neffect — `activeOnly` panels unmount with their mode and miss events; store what arrives in a small\nstore the panel reads. Full rules and examples: **idosgames-compose-modules** (\"Signals between\nmodules\"). The string overloads (`emit(\"x\", …)`) are deprecated.\n\n## Content types (`ctx.content`) and switching modes (`ctx.navigate`)\n\nA game whose players make things (worlds, levels, skins) registers a **content type handler** so the\nshared Workshop module can publish and open them — the Workshop never imports the game:\n\n```ts\nsetup(ctx) {\n ctx.content.registerType({\n type: \"voxelcraft.world\", // = a key of Workshop.ContentTypes in the title config\n label: \"VoxelCraft world\",\n icon: \"⛏️\",\n modeId: \"voxelcraft\", // route to switch to after open()\n async listLocal() { return saves.map((s) => ({ id: s.id, name: s.name, updatedAt: s.at })); },\n async capture(localId) { // roles/MIME types must fit the title's content type config\n return { files: [{ role: \"main\", contentType: \"application/json\", data: json }],\n thumbnail: { contentType: \"image/webp\", data: webp }, suggestedTitle: name };\n },\n async open(content) { importSave(content.files[0].data); },\n });\n}\n```\n\nHandlers registered in `setup()` are removed with the module; `registerType` returns an unregister\nfunction for anything dynamic. `ctx.navigate(modeId)` switches the host to another module's route\n(the Workshop uses it to jump into the game after `open`); an unknown id is ignored. Details:\n**workshop-system**.\n\n## Bridging scene ↔ panel\n\nThe scene creates its controller at `mount` (needs the canvas), but panels render before that. Share\nit with a one-slot observable and read it with `useSyncExternalStore`:\n\n```ts\nexport function createControllerBox<T>() {\n /* get/set/subscribe */\n}\n// panel: const controller = useSyncExternalStore(box.subscribe, box.get); if (!controller) return <Loading/>;\n```\n\nFor the module's controller React context use the shared factory instead of hand-writing it:\n\n```ts\nexport const [BoardControllerProvider, useBoardController] =\n createControllerContext<BoardController>(\"BoardController\"); // from @idosgames/react\n```\n\n## Layout and boundaries\n\nA module is one folder, `src/modules/<id>/`. It imports only its own files, the game's shared code\nin `src/shared/` and npm packages — never another module's files or host files. Talk to other\nmodules through the shared `ctx.client` (durable state) and `ctx.events` (live signals, typed\ntopics `<module-id>:<event>@<major>` declared in `module.meta.json`). A module shipped in the catalog is fully self-contained — it never uses\n`src/shared/`, so it installs into any game; a game's own module may use it, and its shared code is\ncopied into it when it is published for other creators.\n\n```\nsrc/modules/<id>/\n index.ts export { <camelCaseId>Module } from \"./module\";\n module.ts defineModule({ … })\n module.meta.json manifest: type (template|feature), summary, provides, tags, version, author\n components/ game/ data/ react/ — as needed\n```\n\nEvery module carries its own `module.meta.json` (the `ModuleManifest` shape), including a game's own\nmodules. The full standard — where a new feature goes, file size, documentation — is\n**idosgames-project-structure**.\n\n## Dependencies\n\nDeclare only the module's UNIQUE deps (e.g. `phaser` for idle-rpg, `three` for board/voxel) plus the\nshared baseline (react, @idosgames/*). The platform pins shared libs identically across modules; two\nmodules must not request different versions of one package (the build allowlist rejects it).\n\nStudy a real module via `get_module {id}` (MCP) before writing a new one.\n",
|
|
4
|
+
"content": "---\nname: idosgames-module-contract\ndescription: >-\n Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk:\n the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route\n registration, and the shared controller-box bridge between an imperative scene and React panels.\n Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg,\n voxelcraft, mini-game, store, character, workshop…), or asks about defineModule,\n registerScene/registerPanel/registerRoute, ctx.features (register / open / setBadge / setAvailable),\n ctx.modes,\n activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic,\n ctx.content / ContentTypeHandler (letting the Workshop publish and open the game's content),\n ctx.multiplayer / registerGame (playing online through the lobby's rooms system),\n ctx.navigate, the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention.\n---\n\n# The module contract (@idosgames/module-sdk)\n\nA module is a manifest the host installs once. It exports `{camelCase(id)}Module` from its\n`index.ts` (e.g. `board-game` → `boardGameModule`, `idle-rpg` → `idleRpgModule`) — the host seeder\nderives the import name from the id, so this convention is required.\n\n```ts\nimport { defineModule } from \"@idosgames/module-sdk\";\n\nexport const boardGameModule = defineModule({\n id: \"board-game\",\n meta: { name: \"Board Game\", type: \"game\", genre: \"board\", engine: \"three\" }, // engine: three|phaser|dom\n setup(ctx) {\n // ctx.client (shared, authed) · ctx.events (typed cross-module bus) · ctx.sharedUi · ctx.surface\n ctx.registerScene(createScene(box)); // rendered engine scene (optional)\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: RootPanel }); // React UI (optional)\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" }); // a mode → the lobby's \"Play\"\n },\n});\n```\n\nTwo kinds of module plug into the base (`src/base/` — sign-in and the lobby):\n\n- a **game** (`meta.type: \"game\"`) registers a route: the lobby shows a \"Play\" button for it, and\n over it the base draws only a small \"To the lobby\" button;\n- a **system** (`meta.type: \"app\"`) registers a feature — its screen becomes a lobby tab:\n `ctx.features.register({ id, label, icon, group, order, primary?, available?, Screen })`. A tab\n that depends on title data registers with `available: false` and calls\n `ctx.features.setAvailable(id, ok, reason)` once it knows (details: **idosgames-ui-kit**).\n\n`ctx.modes.list()` / `current()` / `subscribe()` — every mode (id, label, icon, module, module type),\nfilled once all modules are installed (read it at render time, not inside `setup()`).\n`ctx.navigate(id)` switches (`\"lobby\"` is the base); called inside `setup()` it picks the mode the app\nstarts in. `ctx.features.open(id, args)` from a game (the lobby not on screen) switches to the lobby\nand opens that tab there.\n\n`ctx.multiplayer` — a game that plays online registers itself (`registerGame({ mode, topology,\nchatMode, maxPlayers, joinInProgress, settings, start(room) })`) and takes the room the lobby's\nrooms system hands it on \"Play\"; it never creates or joins rooms (skill **multiplayer-realtime**).\n\nOptional static field: `sharedUi: { provides?: SharedUiRole[]; requires?: SharedUiRole[] }` — see\n\"Shared chrome\" below.\n\n`meta.type` is `game | app | ai-app`; `meta.engine` is `three | phaser | dom` (`dom` = no renderer —\na pure React/DOM app module). `setup` is called ONCE with `ctx: ModuleContext`.\n\n## EngineScene (the rendered part)\n\nA scene mounts into a bare `HTMLElement` (framework-free — this is why a vanilla Three game like\nvoxelcraft fits). The host's Mode Router drives it; only the active mode runs.\n\n```ts\nconst scene: EngineScene = {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext) {\n controller = new Controller(ctx.host);\n box.set(controller);\n },\n activate() {\n controller?.setRunning(true);\n }, // became the active mode → resume RAF\n suspend() {\n controller?.setRunning(false);\n }, // hidden → stop RAF (invariant: only active ticks)\n destroy() {\n controller?.destroy();\n }, // permanent teardown\n};\n```\n\n`mount` takes a context object (not a bare element) so it can grow — e.g. a host-shared renderer in\nthe composition era — without breaking the contract. A scene MUST stop its RAF on `suspend`.\n\n### Loading — never a still screen\n\nA scene's FIRST start goes behind the app's loading screen by itself: the host shows it (themed,\n\"Loading «<mode>»…\"), lets it paint, THEN calls `mount` — so heavy synchronous work in `mount`\n(generating terrain, building meshes) never looks like a frozen app. The screen goes away after the\nfirst frame, or — when the scene has more to do — when its optional `ready(): Promise<void>` resolves\n(voxelcraft: the last played world restored from storage). Never draw your own start-up spinner.\n\nLong work AFTER the start goes through `ctx.loading` — the same screen:\n\n- `await ctx.loading.track(fetchWorld(), \"Loading the world…\")` — async work;\n- `await ctx.loading.run(() => buildWorld(seed), \"Building the world…\")` — work that BLOCKS the page:\n the screen is shown and painted first (calling heavy code right after `begin()` freezes the page\n before the screen appears);\n- `const task = ctx.loading.begin(\"…\"); task.progress(0.4, \"Textures…\"); task.done();` — with progress.\n\n### Notifications — `ctx.notifications`\n\nThe app's one system of toasts and alert windows (the UI kit draws them, themed): never draw a banner\nof your own. `ctx.notifications.toast({ level, title, message, icon, image, source, key, actions })`\n— `level`: `info | success | warning | error | reward`; `source` defaults to the module's id (the\ntoast shows that screen's name and a tap opens it). `await ctx.notifications.alert({ level, title,\nmessage, actions: [{ id: \"go\", label: \"Open\", primary: true, openFeature: \"season\" }, { label: \"Later\" }] })`\nresolves with the pressed action's id (`null` — closed). React screens: `useNotify()` from the kit.\n\nA module that registers a scene MUST also publish its debug surface — `ctx.exposeToAgent({ state,\nactions, describeActions })` — and must NOT gate controls on Pointer Lock (unavailable in the\npreview's cross-origin iframe). Nothing inside a `<canvas>` is observable from the DOM, so without\nthe surface neither the AI Coder nor a human reviewer can tell what the game is doing. See the\n`idosgames-agent-debug-surface` skill.\n\n### `capture()` — the frame for \"Take a shot and share\"\n\nidosgames.com has a **Shot** button under the game: it asks the game for one frame, puts the\nplatform's branding and a QR code on it and offers to post it. A scene answers through the optional\n`capture?(): Promise<Blob | null>`. The host (`@idosgames/app-shell`) wires the request itself —\na module only has to return a picture of its active scene.\n\n**Implement it in any scene that renders to a canvas.** Without it the host falls back to reading\nthe `<canvas>` from outside, and for WebGL that is a trap: a renderer created WITHOUT\n`preserveDrawingBuffer` (the default, and the right default — the flag costs memory and a copy every\nframe) has already cleared its buffer by the time anyone reads it, so `toBlob()` returns a **fully\nblack** image and reports nothing. The host rejects single-colour frames, so the player gets the\ngame's cover instead of their moment.\n\nRender and read in the SAME tick:\n\n```ts\n// three.js\ncapture() {\n return new Promise((resolve) => {\n renderer.render(scene, camera); // draw now…\n renderer.domElement.toBlob(resolve, \"image/png\"); // …and read before the buffer is cleared\n });\n},\n\n// Phaser — use its own snapshot, it handles the timing\ncapture() {\n return new Promise((resolve) => {\n setTimeout(() => resolve(null), 1500); // no frame came (context lost) → answer \"no\", don't hang\n game.loop.wake(); // a sleeping loop never draws again → snapshot never fires\n game.renderer.snapshot((image) => {\n if (!(image instanceof HTMLImageElement)) return resolve(null);\n const c = document.createElement(\"canvas\");\n c.width = image.naturalWidth || image.width;\n c.height = image.naturalHeight || image.height;\n c.getContext(\"2d\")?.drawImage(image, 0, 0);\n c.toBlob(resolve, \"image/png\");\n });\n });\n},\n```\n\nAlways answer — with a Blob or `null`. A `capture()` that never settles makes the site wait out its\ntimeout before it falls back, and the player stares at a spinner.\n\nReturn `null` when there is nothing worth showing (loading, no scene yet) — the site then uses the\ncover. Only the scene is captured, not the React panels over it: a shot for a feed reads better\nwithout the HUD. DOM-only modules (no canvas) simply omit `capture`. Working references:\n`modules/board-game` and `modules/voxelcraft` (three), `modules/idle-rpg` (Phaser).\n\n## UiPanel (the React part)\n\nPanels are React components the host renders inside its provider stack (client + status already\nprovided). Use `@idosgames/react` hooks (`useIDosGamesClient`, `useUserState`) — do NOT re-create\nproviders.\n\n- `slot`: `hud | sidebar | overlay | modal`.\n- `activeOnly` (default true): show only while this module's mode is active. Set `false` for shared\n chrome that stays across every mode (e.g. a persistent HUD).\n- `overlay`/`sidebar` layers sit between the shared HUD and the nav (the host measures both), so a\n panel positioned `absolute` inside its layer never ends up under them. Things drawn outside the\n layers use `var(--idos-safe-top, 0px)` / `var(--idos-safe-bottom, 0px)`.\n- A `hud` panel's wrapper is `display: contents`: the panel is a direct flex child of the HUD row\n (can `flex: 1`) and must set `pointer-events: auto` only on its controls.\n\n## Shared chrome (`sharedUi`)\n\nRoles: `currency-bar` (virtual-currency balances), `wallet` (crypto wallet entry — `LazyWalletPanel`),\n`status` (the `useStatus()` line), `account` (Log out + player ID), `menu` (the menu of features),\n`modes` (the switch between modes). The base takes `currency-bar`, `wallet`, `account`, `menu` and\n`modes` — its lobby draws them; `status` stays with each game. A module that draws one for every\nmode declares `sharedUi: { provides: [...] }` on the module object — statically, so the host resolves\nowners before any `setup()` and the answer never depends on module order.\n\n**Rule for templates: yield every role you draw yourself.** Read it once in `setup()` and close over\nthe answer (it is fixed for the session):\n\n```ts\nsetup(ctx) {\n const chrome = {\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n balances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n };\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: makeRootPanel(box, chrome) });\n}\n```\n\nHide only the SHARED pieces — genre UI (the board's stage/tile/cycle chips) always stays. Never\nwrap your UI in your own `<StatusProvider>`: the host provides ONE, and a nested one would swallow\nyour messages so the shared status line never sees them. A module that needs a role but does not\ndraw it declares `sharedUi: { requires: [\"currency-bar\"] }`. `ctx.sharedUi.ownerOf(role)` names the\nprovider (or `null`). A full-screen mode (its own bottom tab bar) that shows the player ID and\n\"Log out\" in its own header declares `sharedUi: { drawsOwn: [\"account\"] }` and draws them only while\n`shouldDraw(\"account\")`: the host then hides its floating bottom-left copy while that mode is on\nscreen (it would cover the tab bar) and keeps it in the other modes. `drawsOwn` takes no role —\nwith the base installed the lobby draws the account and the mode hides its copy.\n\n## Events (`ctx.events`)\n\nTyped topic tokens — `defineTopic(\"<your-id>:<event>@1\", shape({ … }))` from\n`@idosgames/module-sdk`; emit only your own namespace; to listen to another module, copy its topic\nand payload descriptor from the catalog into your own `defineTopic` (never import it); declare both\nin `module.meta.json` (`events.emits` / `events.listens`). **Subscribe in `setup()`**, not in a panel\neffect — `activeOnly` panels unmount with their mode and miss events; store what arrives in a small\nstore the panel reads. Full rules and examples: **idosgames-compose-modules** (\"Signals between\nmodules\"). The string overloads (`emit(\"x\", …)`) are deprecated.\n\n## Content types (`ctx.content`) and switching modes (`ctx.navigate`)\n\nA game whose players make things (worlds, levels, skins) registers a **content type handler** so the\nshared Workshop module can publish and open them — the Workshop never imports the game:\n\n```ts\nsetup(ctx) {\n ctx.content.registerType({\n type: \"voxelcraft.world\", // = a key of Workshop.ContentTypes in the title config\n label: \"VoxelCraft world\",\n icon: \"⛏️\",\n modeId: \"voxelcraft\", // route to switch to after open()\n async listLocal() { return saves.map((s) => ({ id: s.id, name: s.name, updatedAt: s.at })); },\n async capture(localId) { // roles/MIME types must fit the title's content type config\n return { files: [{ role: \"main\", contentType: \"application/json\", data: json }],\n thumbnail: { contentType: \"image/webp\", data: webp }, suggestedTitle: name };\n },\n async open(content) { importSave(content.files[0].data); },\n });\n}\n```\n\nHandlers registered in `setup()` are removed with the module; `registerType` returns an unregister\nfunction for anything dynamic. `ctx.navigate(modeId)` switches the host to another module's route\n(the Workshop uses it to jump into the game after `open`); an unknown id is ignored. Details:\n**workshop-system**.\n\n## Bridging scene ↔ panel\n\nThe scene creates its controller at `mount` (needs the canvas), but panels render before that. Share\nit with a one-slot observable and read it with `useSyncExternalStore`:\n\n```ts\nexport function createControllerBox<T>() {\n /* get/set/subscribe */\n}\n// panel: const controller = useSyncExternalStore(box.subscribe, box.get); if (!controller) return <Loading/>;\n```\n\nFor the module's controller React context use the shared factory instead of hand-writing it:\n\n```ts\nexport const [BoardControllerProvider, useBoardController] =\n createControllerContext<BoardController>(\"BoardController\"); // from @idosgames/react\n```\n\n## Layout and boundaries\n\nA module is one folder, `src/modules/<id>/`. It imports only its own files, the game's shared code\nin `src/shared/` and npm packages — never another module's files or host files. Talk to other\nmodules through the shared `ctx.client` (durable state) and `ctx.events` (live signals, typed\ntopics `<module-id>:<event>@<major>` declared in `module.meta.json`). A module shipped in the catalog is fully self-contained — it never uses\n`src/shared/`, so it installs into any game; a game's own module may use it, and its shared code is\ncopied into it when it is published for other creators.\n\n```\nsrc/modules/<id>/\n index.ts export { <camelCaseId>Module } from \"./module\";\n module.ts defineModule({ … })\n module.meta.json manifest: type (template|feature), summary, provides, tags, version, author\n components/ game/ data/ react/ — as needed\n```\n\nEvery module carries its own `module.meta.json` (the `ModuleManifest` shape), including a game's own\nmodules. The full standard — where a new feature goes, file size, documentation — is\n**idosgames-project-structure**.\n\n## Dependencies\n\nDeclare only the module's UNIQUE deps (e.g. `phaser` for idle-rpg, `three` for board/voxel) plus the\nshared baseline (react, @idosgames/*). The platform pins shared libs identically across modules; two\nmodules must not request different versions of one package (the build allowlist rejects it).\n\nStudy a real module via `get_module {id}` (MCP) before writing a new one.\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|
|
@@ -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([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",
|
|
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: ONLY what a later session must take\n into account and cannot see in the code — why it is designed this way, a non-obvious constraint, a\n decision the user made, a rule for how the system must be changed. Test: \"would a new session\n working on this system go wrong without knowing this?\" If not, write nothing. Ordinary edits (a\n restyle, a bug fix, a text or balance tweak, a routine new screen) get no entry, and it is never a\n log of what was done. Update the system's file when such a fact changes, and give every file ONE\n line in `docs/feature-history/README.md`: `- [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
|
}
|