@idosgames/mcp 0.1.2 → 0.1.4
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 +13 -5
- package/registry/index.json +27 -25
- package/registry/modules/board-game.json +12 -15
- package/registry/modules/idle-rpg.json +14 -17
- package/registry/modules/voxelcraft.json +11 -7
- package/registry/skills/blockchain-system.json +1 -1
- package/registry/skills/cloud-code.json +2 -2
- package/registry/skills/idosgames-agent-debug-surface.json +6 -0
- package/registry/skills/idosgames-getting-started.json +1 -1
- package/registry/skills/idosgames-module-contract.json +1 -1
- package/registry/skills/quest-system.json +2 -2
- package/registry/skills/timed-event-system.json +1 -1
- package/registry/skills/title-custom-data.json +6 -0
- package/registry/skills/title-system.json +2 -2
- package/registry/skills/user-custom-data.json +1 -1
- package/registry/skills/user-profile.json +1 -1
|
@@ -37,10 +37,14 @@
|
|
|
37
37
|
"version": "0.1.0"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@idosgames/module-sdk": "0.1.
|
|
40
|
+
"@idosgames/module-sdk": "0.1.3",
|
|
41
41
|
"three": "0.185.1"
|
|
42
42
|
},
|
|
43
43
|
"files": [
|
|
44
|
+
{
|
|
45
|
+
"path": "agent.ts",
|
|
46
|
+
"content": "import type { ModuleAgentApi } from \"@idosgames/module-sdk\";\nimport { Intent } from \"./core/Intents\";\nimport type { Game } from \"./main\";\n\n// Debug-поверхность VoxelCraft для агента AI-кодера (контракт `ctx.exposeToAgent`).\n//\n// Зачем она нужна именно здесь: агент осматривает работающее приложение, читая DOM, а вся игра —\n// один `<canvas>`. Без этого файла для агента экран пуст, и «персонаж провалился сквозь пол» он\n// не увидит никак.\n//\n// ПРАВИЛО, которое нельзя нарушать: действия идут тем же путём, что и живой ввод — через клавиши\n// `Input` и очередь интентов. Второй реализации ходьбы здесь нет и быть не должно, иначе агент\n// проверял бы не ту игру, в которую играет человек.\n\n/** Потолок удержания клавиши: агент не должен уметь «зажать W» на минуту. */\nconst MAX_HOLD_MS = 5000;\n\n/** Сколько времени на действие, если агент не сказал. */\nconst DEFAULT_HOLD_MS = 400;\n\nconst DIR_KEYS: Record<string, string> = {\n forward: \"KeyW\",\n back: \"KeyS\",\n left: \"KeyA\",\n right: \"KeyD\",\n};\n\nfunction clampMs(value: unknown): number {\n const ms = typeof value === \"number\" && value > 0 ? value : DEFAULT_HOLD_MS;\n return Math.min(ms, MAX_HOLD_MS);\n}\n\nfunction round(value: number, digits = 2): number {\n const k = 10 ** digits;\n return Math.round(value * k) / k;\n}\n\nconst toDegrees = (rad: number): number => round((rad * 180) / Math.PI, 1);\n\n/**\n * @param getGame доступ к текущему экземпляру игры. Именно функция, а не объект: сцена создаёт\n * `Game` в `mount()` и обнуляет в `destroy()`, а поверхность регистрируется один раз на `setup()`.\n */\nexport function createVoxelcraftAgentApi(\n getGame: () => Game | null,\n): ModuleAgentApi {\n /** Игра, готовая принимать команды: существует и не на паузе после действий агента. */\n function live(): Game {\n const game = getGame();\n if (!game) throw new Error(\"VoxelCraft is not mounted yet\");\n // Действие в игре, которая стоит на паузе, молча не сделало бы ничего — сначала берём\n // управление (это же снимает оверлей паузы, как клик игрока).\n game.input.takeControl();\n return game;\n }\n\n /** Удержать клавишу как живой игрок: нажали, подождали, отпустили. */\n function hold(code: string, ms: number): Promise<void> {\n const game = live();\n game.input.keys.add(code);\n return new Promise((resolve) => {\n setTimeout(() => {\n game.input.keys.delete(code);\n resolve();\n }, ms);\n });\n }\n\n /** Удержать кнопку мыши (ломать/ставить обрабатываются по удержанию). */\n function holdButton(button: \"left\" | \"right\", ms: number): Promise<void> {\n const game = live();\n if (button === \"left\") game.input.leftDown = true;\n else game.input.rightDown = true;\n return new Promise((resolve) => {\n setTimeout(() => {\n if (button === \"left\") game.input.leftDown = false;\n else game.input.rightDown = false;\n resolve();\n }, ms);\n });\n }\n\n return {\n state() {\n const game = getGame();\n if (!game) return { mounted: false };\n\n const p = game.player;\n const held = game.inventory.heldItem();\n\n return {\n mounted: true,\n // Идёт ли симуляция. false = игра на паузе (оверлей «Click to play»): в этом состоянии\n // персонаж не двигается вообще, и это НЕ баг движения.\n playing: game.playing,\n inputActive: game.input.active,\n player: {\n pos: { x: round(p.pos.x), y: round(p.pos.y), z: round(p.pos.z) },\n velocity: {\n x: round(p.vel.x),\n y: round(p.vel.y),\n z: round(p.vel.z),\n },\n yawDeg: toDegrees(p.yaw),\n pitchDeg: toDegrees(p.pitch),\n health: p.health,\n food: p.food,\n mode: p.mode,\n flying: p.flying,\n onGround: p.onGround,\n inWater: p.inWater,\n dead: p.dead,\n },\n world: {\n loadedChunks: game.world.chunks.size,\n // Блок под ногами: по нему видно и «стоит на траве», и «провалился в пустоту».\n blockBelow: game.world.getBlock(\n Math.floor(p.pos.x),\n Math.floor(p.pos.y - 0.1),\n Math.floor(p.pos.z),\n ),\n },\n inventory: {\n hotbarIndex: game.inventory.hotbarIndex,\n inHand: held ? { item: held.item, count: held.count } : null,\n },\n };\n },\n\n actions: {\n move: async (args) => {\n const dir = String(args?.dir ?? \"forward\");\n const code = DIR_KEYS[dir];\n if (!code)\n throw new Error(\n `unknown dir '${dir}' — use forward | back | left | right`,\n );\n await hold(code, clampMs(args?.ms));\n },\n\n jump: async (args) => {\n await hold(\"Space\", clampMs(args?.ms ?? 120));\n },\n\n look: (args) => {\n const game = live();\n const dYaw = Number(args?.yawDeg ?? 0) * (Math.PI / 180);\n const dPitch = Number(args?.pitchDeg ?? 0) * (Math.PI / 180);\n // Через штатный метод игрока — те же ограничения по pitch, что и у мыши.\n game.player.look(dYaw, dPitch);\n },\n\n breakBlock: async (args) => {\n // Ломание идёт по удержанию ЛКМ: короткий «клик» не сломает ничего, кроме мгновенных блоков.\n await holdButton(\"left\", clampMs(args?.ms ?? 1200));\n },\n\n placeBlock: async () => {\n await holdButton(\"right\", 120);\n },\n\n selectSlot: (args) => {\n const n = Number(args?.slot ?? 1);\n if (!Number.isInteger(n) || n < 1 || n > 9)\n throw new Error(\"slot must be 1..9\");\n live().intents.push(Intent.SelectSlot, { slot: n - 1 });\n },\n\n toggleMode: () => {\n live().intents.push(Intent.ToggleMode, {});\n },\n },\n\n describeActions: {\n move: \"Walk: { dir: forward|back|left|right, ms?: number } — holds the movement key for ms (default 400, max 5000).\",\n jump: \"Jump: { ms?: number } — taps Space.\",\n look: \"Turn the camera by a DELTA in degrees: { yawDeg?: number, pitchDeg?: number }. Positive pitch looks up.\",\n breakBlock:\n \"Hold left mouse to break the block under the crosshair: { ms?: number } (default 1200 — breaking takes time).\",\n placeBlock:\n \"Tap right mouse to place the block from the selected hotbar slot.\",\n selectSlot: \"Select a hotbar slot: { slot: 1..9 }.\",\n toggleMode: \"Switch between survival and creative.\",\n },\n };\n}\n"
|
|
47
|
+
},
|
|
44
48
|
{
|
|
45
49
|
"path": "config.ts",
|
|
46
50
|
"content": "// Единый конфиг игры. Все тюнинги — здесь, логика магических чисел не содержит.\nexport const CONFIG = {\n chunk: { sx: 16, sy: 64, sz: 16 }, // размер чанка в блоках\n renderDistance: 5, // радиус в чанках\n unloadDistance: 7, // дальше этого — выгрузка\n genBudgetPerFrame: 2, // генераций данных чанков за кадр\n meshBudgetPerFrame: 2, // пересборок мешей за кадр\n\n tps: 20, // тиков симуляции в секунду\n maxCatchupTicks: 4, // защита от «спирали смерти» после сворачивания вкладки\n\n world: {\n seed: 1337,\n waterLevel: 25, // уровень моря (y)\n baseHeight: 31,\n hillAmp: 14, // амплитуда холмов\n },\n\n player: {\n width: 0.6,\n height: 1.8,\n eye: 1.62,\n walkSpeed: 4.3,\n runSpeed: 6.5,\n flySpeed: 11,\n jumpSpeed: 8.2,\n gravity: 24,\n swimSpeed: 2.4,\n swimUp: 4.0,\n waterDrag: 0.6,\n reach: 5, // дистанция взаимодействия с блоками\n breakHealDelay: 1.2, // сек простоя, прежде чем урон блока начнёт заживать\n breakHealRate: 0.6, // сек прогресса, восстанавливаемого за секунду простоя\n maxHealth: 20, // 10 сердечек\n fallSafe: 3.5, // блоков падения без урона\n attackDamage: 1, // урон кулаком\n attackCooldown: 0.35, // сек\n\n // Голод (модель Minecraft: еда + сатурация + накопитель «истощения»)\n maxFood: 20, // 10 «окорочков»\n sprintFood: 6, // ниже — нельзя бежать\n exhaustionPerFood: 4.0, // истощения на -1 очко еды/сатурации\n exhaustIdle: 0.02, // пассивное истощение в секунду (стоя)\n exhaustWalk: 0.04, // истощение на блок ходьбы\n exhaustSprint: 0.08, // истощение на блок бега\n exhaustJump: 0.2, // за прыжок\n exhaustBreak: 0.06, // за сломанный блок\n exhaustAttack: 0.1, // за удар\n exhaustRegen: 6.0, // за восстановленное сердце (регенерация «стоит» еды)\n regenFoodThreshold: 18, // еда >= этого → лечит HP\n regenInterval: 3.5, // сек на +1 HP\n starveInterval: 4.0, // сек на -1 HP при нулевой еде\n eatTime: 1.4, // сек удержания ПКМ, чтобы съесть\n },\n\n drops: {\n magnetRadius: 1.9,\n pickupRadius: 0.6,\n magnetPull: 14,\n gravity: 18,\n bounce: 0.3,\n friction: 0.6,\n maxCount: 60,\n mergeRadius: 0.6,\n scale: 0.25,\n despawnTime: 300, // сек\n },\n\n mobs: {\n targetPopulation: 7, // куриц вокруг игрока\n spawnRadius: [10, 24], // мин/макс дистанция спавна\n despawnRadius: 48,\n },\n\n water: {\n tickEvery: 5, // каждый N-й симтик (20/5 = 4 Гц)\n cellBudget: 400, // макс клеток за водный тик\n maxLevel: 8,\n minFlowLevel: 2, // уровень 2 останавливает растекание (~7 блоков)\n },\n\n dayNight: {\n dayLength: 30,\n nightLength: 30, // секунд\n },\n\n // Задел под этап 2 (мультиплеер «звезда с хостом»). В MVP не используется.\n net: {\n maxPlayers: 8,\n snapshotRateHz: 15, // рассылка позиций unreliable-каналом\n interpBufferMs: 120,\n timeoutMs: 8000,\n protocolVersion: 1,\n },\n\n saveVersion: 1,\n};\n"
|
|
@@ -83,11 +87,11 @@
|
|
|
83
87
|
},
|
|
84
88
|
{
|
|
85
89
|
"path": "main.ts",
|
|
86
|
-
"content": "// Точка сборки: единственное место, где конструируются все системы и\n// внедряются зависимости. Модули общаются через EventBus или переданные ссылки.\n// Тик (20 TPS) — симуляция; кадр — ввод, физика игрока, интерполяция, рендер.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"./config\";\nimport { events } from \"./core/EventBus\";\nimport { GameLoop } from \"./core/GameLoop\";\nimport { IntentQueue } from \"./core/Intents\";\nimport { TextureAtlas } from \"./gfx/TextureAtlas\";\nimport { buildIcons } from \"./registry/Items\";\nimport { ChunkManager } from \"./world/ChunkManager\";\nimport { Player } from \"./player/Player\";\nimport { Input } from \"./player/Input\";\nimport { Interaction } from \"./player/Interaction\";\nimport { ArmView } from \"./player/ArmView\";\nimport { EntityManager } from \"./entities/EntityManager\";\nimport { Inventory } from \"./systems/Inventory\";\nimport { WaterSim } from \"./systems/WaterSim\";\nimport { DayNight } from \"./systems/DayNight\";\nimport { Audio } from \"./systems/Audio\";\nimport { SaveSystem } from \"./systems/Save\";\nimport { HUD } from \"./ui/HUD\";\nimport { InventoryUI } from \"./ui/InventoryUI\";\n\nexport class Game {\n renderer: THREE.WebGLRenderer;\n scene: THREE.Scene;\n camera: THREE.PerspectiveCamera;\n private _resizeObserver: ResizeObserver;\n atlas: TextureAtlas;\n intents: IntentQueue;\n world: ChunkManager;\n player: Player;\n inventory: Inventory;\n entities: EntityManager;\n interaction: Interaction;\n waterSim: WaterSim;\n dayNight: DayNight;\n audio: Audio;\n input: Input;\n armView: ArmView;\n save: SaveSystem;\n hud: HUD;\n invUI: InventoryUI;\n playing: boolean;\n private _time: number;\n loop: GameLoop;\n\n constructor() {\n // --- рендер ---\n const canvas = document.getElementById(\"game\") as HTMLCanvasElement;\n this.renderer = new THREE.WebGLRenderer({ canvas, antialias: false });\n this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n this.scene = new THREE.Scene();\n // Пропорции выставит первый же замер ResizeObserver'а — до него кадров не будет.\n this.camera = new THREE.PerspectiveCamera(\n 75,\n 1,\n 0.1,\n CONFIG.renderDistance * 16 + 60,\n );\n this.camera.rotation.order = \"YXZ\";\n this.scene.add(this.camera); // рука прикреплена к камере\n\n // Размер берём из раскладки самого канваса (CSS растягивает его на вьюпорт), а не из\n // window.innerWidth, и через ResizeObserver, а не событие resize: игра может стартовать\n // в ещё не разложенном контейнере — фоновая вкладка или iframe превью, — где на момент\n // конструктора innerWidth равен нулю. Событие resize там уже не придёт, и канвас навсегда\n // остался бы 0x0: симуляция живая, экран чёрный. ResizeObserver сам отдаёт первый замер,\n // как только раскладка появляется.\n this._resizeObserver = new ResizeObserver(() => this._resize());\n this._resizeObserver.observe(canvas);\n this._resize();\n\n // --- контент: атлас и иконки из одних и тех же процедурных текстур ---\n this.atlas = new TextureAtlas();\n buildIcons(this.atlas);\n\n // --- системы ---\n this.intents = new IntentQueue();\n this.world = new ChunkManager(this.scene, this.atlas, CONFIG.world.seed);\n this.player = new Player(this.world);\n this.inventory = new Inventory();\n this.entities = new EntityManager(\n this.scene,\n this.world,\n this.atlas,\n this.player,\n this.inventory,\n );\n this.interaction = new Interaction(\n this.world,\n this.player,\n this.inventory,\n this.intents,\n this.scene,\n this.atlas,\n this.entities,\n );\n this.waterSim = new WaterSim(this.world);\n this.dayNight = new DayNight(this.scene, this.renderer);\n this.audio = new Audio();\n this.input = new Input(canvas, this.intents);\n this.armView = new ArmView(this.camera, this.atlas);\n this.save = new SaveSystem(this);\n this.hud = new HUD(this);\n this.invUI = new InventoryUI(this);\n\n this.playing = false;\n this._time = 0;\n\n events.on(\"ui:pause\", () => {\n this.playing = false;\n if (this.invUI.open) this.invUI.close();\n this.hud.showOverlay(true);\n });\n events.on(\"ui:resume\", () => {\n this.playing = true;\n this.hud.hideOverlay();\n });\n document.addEventListener(\"keydown\", (e) => {\n if (e.code === \"Escape\" && this.invUI.open) this.invUI.close();\n });\n // редкий случай: лок слетел, оверлея нет — клик по канвасу возвращает захват\n canvas.addEventListener(\"click\", () => {\n if (this.playing && !this.input.
|
|
90
|
+
"content": "// Точка сборки: единственное место, где конструируются все системы и\n// внедряются зависимости. Модули общаются через EventBus или переданные ссылки.\n// Тик (20 TPS) — симуляция; кадр — ввод, физика игрока, интерполяция, рендер.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"./config\";\nimport { events } from \"./core/EventBus\";\nimport { GameLoop } from \"./core/GameLoop\";\nimport { IntentQueue } from \"./core/Intents\";\nimport { TextureAtlas } from \"./gfx/TextureAtlas\";\nimport { buildIcons } from \"./registry/Items\";\nimport { ChunkManager } from \"./world/ChunkManager\";\nimport { Player } from \"./player/Player\";\nimport { Input } from \"./player/Input\";\nimport { Interaction } from \"./player/Interaction\";\nimport { ArmView } from \"./player/ArmView\";\nimport { EntityManager } from \"./entities/EntityManager\";\nimport { Inventory } from \"./systems/Inventory\";\nimport { WaterSim } from \"./systems/WaterSim\";\nimport { DayNight } from \"./systems/DayNight\";\nimport { Audio } from \"./systems/Audio\";\nimport { SaveSystem } from \"./systems/Save\";\nimport { HUD } from \"./ui/HUD\";\nimport { InventoryUI } from \"./ui/InventoryUI\";\n\nexport class Game {\n renderer: THREE.WebGLRenderer;\n scene: THREE.Scene;\n camera: THREE.PerspectiveCamera;\n private _resizeObserver: ResizeObserver;\n atlas: TextureAtlas;\n intents: IntentQueue;\n world: ChunkManager;\n player: Player;\n inventory: Inventory;\n entities: EntityManager;\n interaction: Interaction;\n waterSim: WaterSim;\n dayNight: DayNight;\n audio: Audio;\n input: Input;\n armView: ArmView;\n save: SaveSystem;\n hud: HUD;\n invUI: InventoryUI;\n playing: boolean;\n private _time: number;\n loop: GameLoop;\n\n constructor() {\n // --- рендер ---\n const canvas = document.getElementById(\"game\") as HTMLCanvasElement;\n this.renderer = new THREE.WebGLRenderer({ canvas, antialias: false });\n this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n this.scene = new THREE.Scene();\n // Пропорции выставит первый же замер ResizeObserver'а — до него кадров не будет.\n this.camera = new THREE.PerspectiveCamera(\n 75,\n 1,\n 0.1,\n CONFIG.renderDistance * 16 + 60,\n );\n this.camera.rotation.order = \"YXZ\";\n this.scene.add(this.camera); // рука прикреплена к камере\n\n // Размер берём из раскладки самого канваса (CSS растягивает его на вьюпорт), а не из\n // window.innerWidth, и через ResizeObserver, а не событие resize: игра может стартовать\n // в ещё не разложенном контейнере — фоновая вкладка или iframe превью, — где на момент\n // конструктора innerWidth равен нулю. Событие resize там уже не придёт, и канвас навсегда\n // остался бы 0x0: симуляция живая, экран чёрный. ResizeObserver сам отдаёт первый замер,\n // как только раскладка появляется.\n this._resizeObserver = new ResizeObserver(() => this._resize());\n this._resizeObserver.observe(canvas);\n this._resize();\n\n // --- контент: атлас и иконки из одних и тех же процедурных текстур ---\n this.atlas = new TextureAtlas();\n buildIcons(this.atlas);\n\n // --- системы ---\n this.intents = new IntentQueue();\n this.world = new ChunkManager(this.scene, this.atlas, CONFIG.world.seed);\n this.player = new Player(this.world);\n this.inventory = new Inventory();\n this.entities = new EntityManager(\n this.scene,\n this.world,\n this.atlas,\n this.player,\n this.inventory,\n );\n this.interaction = new Interaction(\n this.world,\n this.player,\n this.inventory,\n this.intents,\n this.scene,\n this.atlas,\n this.entities,\n );\n this.waterSim = new WaterSim(this.world);\n this.dayNight = new DayNight(this.scene, this.renderer);\n this.audio = new Audio();\n this.input = new Input(canvas, this.intents);\n this.armView = new ArmView(this.camera, this.atlas);\n this.save = new SaveSystem(this);\n this.hud = new HUD(this);\n this.invUI = new InventoryUI(this);\n\n this.playing = false;\n this._time = 0;\n\n events.on(\"ui:pause\", () => {\n this.playing = false;\n if (this.invUI.open) this.invUI.close();\n this.hud.showOverlay(true);\n });\n events.on(\"ui:resume\", () => {\n this.playing = true;\n this.hud.hideOverlay();\n });\n document.addEventListener(\"keydown\", (e) => {\n if (e.code === \"Escape\" && this.invUI.open) this.invUI.close();\n });\n // редкий случай: лок слетел, оверлея нет — клик по канвасу возвращает захват\n canvas.addEventListener(\"click\", () => {\n if (this.playing && !this.input.active && !this.input.uiOpen)\n this.input.requestLock();\n });\n\n // --- старт мира ---\n this.warmup(8, 8);\n this.player.spawn();\n\n this.loop = new GameLoop(this.tick.bind(this), this.frame.bind(this));\n this.hud.showOverlay(false);\n this.loop.start();\n }\n\n /** Подгонка рендера под текущую раскладку канваса. Вызывается ResizeObserver'ом. */\n _resize() {\n const canvas = this.renderer.domElement;\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n if (w === 0 || h === 0) return; // ещё не разложено — придёт следующим замером\n\n this.camera.aspect = w / h;\n this.camera.updateProjectionMatrix();\n // updateStyle=false: размерами канваса управляет CSS (#game { position: fixed; inset: 0 }),\n // инлайновый стиль от three конфликтовал бы с ним.\n this.renderer.setSize(w, h, false);\n }\n\n /** Синхронная прогенерация зоны вокруг точки (старт/телепорт/импорт). */\n warmup(x: number, z: number) {\n for (let i = 0; i < 200; i++) {\n this.world.update(x, z);\n const busy =\n this.world.genQueue.length > 0 ||\n [...this.world.chunks.values()].some(\n (c) => c.generated && (c.dirty || c.waterDirty),\n );\n if (!busy) break;\n }\n }\n\n /** Пересоздание мира под новый сид (регенерация/импорт сейва). */\n rebuildWorld(seed: number) {\n this.world.clear();\n this.world = new ChunkManager(this.scene, this.atlas, seed);\n this.player.world = this.world;\n this.entities.world = this.world;\n this.interaction.world = this.world;\n this.waterSim.world = this.world;\n this.waterSim.queue.clear();\n this.interaction.breaking = null;\n this.intents.clear();\n }\n\n /** Полностью новый мир: чистые инвентарь, сущности, время. */\n newWorld(seed: number) {\n this.rebuildWorld(seed);\n this.entities.clear();\n this.inventory.deserialize({\n slots: new Array(36).fill(null),\n hotbarIndex: 0,\n });\n this.dayNight.time = this.dayNight.cycleLength * 0.15;\n this.warmup(8, 8);\n this.player.spawn();\n this.hud.message(\"Новый мир, сид \" + seed, 1800);\n }\n\n startPlay() {\n this.playing = true;\n this.hud.hideOverlay();\n this.audio.unlock();\n this.input.requestLock();\n }\n\n // ---- симуляция (20 TPS) ----\n tick(dt: number, n: number) {\n if (!this.playing) return;\n this.interaction.tick(dt);\n this.player.tickHunger(dt);\n this.entities.tick(dt);\n this.waterSim.tick();\n this.world.tick(); // onTick-блоки (задел автоматизации)\n this.dayNight.tick(dt);\n events.emit(\"tick\", { n });\n }\n\n // ---- кадр ----\n frame(dt: number, alpha: number) {\n this._time += dt;\n\n if (this.playing) {\n this.player.update(dt, this.input);\n this.interaction.frame(this.input);\n this.interaction.frameTimers(dt);\n }\n\n // камера из игрока\n const eye = this.player.eyePos;\n this.camera.position.copy(eye);\n this.camera.rotation.set(this.player.pitch, this.player.yaw, 0);\n\n this.world.update(this.player.pos.x, this.player.pos.z);\n this.entities.updateVisuals(alpha, this._time);\n this.dayNight.frame(this.player.pos, this.player.headInWater);\n\n const walking =\n this.playing &&\n this.player.onGround &&\n (this.input.forward ||\n this.input.back ||\n this.input.left ||\n this.input.right);\n this.armView.frame(dt, this.inventory, walking, this._time);\n\n // анимация поверхности воды: медленный сдвиг repeat-текстуры\n this.atlas.waterTexture.offset.x = this._time * 0.02;\n this.atlas.waterTexture.offset.y = this._time * 0.013;\n\n this.hud.frame();\n this.renderer.render(this.scene, this.camera);\n }\n\n /** Teardown for the host: stop the loop, release the pointer, dispose GPU + observers. Called by\n * the module's EngineScene when the host destroys the stage. (This engine is authored as a\n * standalone app; the host adds this so it can live as a composable module.) */\n destroy() {\n this.loop.stop();\n this.input.releaseLock();\n this._resizeObserver.disconnect();\n this.world.clear();\n this.renderer.dispose();\n this.renderer.forceContextLoss();\n }\n\n /** Pause/resume from the host's Mode Router (only the active mode runs its loop). */\n setRunning(running: boolean) {\n if (running) {\n if (!this.loop.running) this.loop.start();\n } else {\n this.loop.stop();\n this.input.releaseLock();\n this.playing = false;\n }\n }\n}\n"
|
|
87
91
|
},
|
|
88
92
|
{
|
|
89
93
|
"path": "module.ts",
|
|
90
|
-
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport {
|
|
94
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { createVoxelcraft } from \"./scene\";\n\n// The VoxelCraft feature module. It contributes ONLY an engine scene and a nav entry — no React UI\n// panels (the game draws its own DOM HUD). This exercises the scene-only branch of the contract and\n// proves a module need not use React or the SDK at all.\nexport const voxelcraftModule: Module = defineModule({\n id: \"voxelcraft\",\n meta: {\n name: \"VoxelCraft\",\n type: \"game\",\n genre: \"sandbox\",\n engine: \"three\",\n },\n setup(ctx) {\n const { scene, agent } = createVoxelcraft();\n ctx.registerScene(scene);\n ctx.registerRoute({ id: \"voxelcraft\", label: \"Voxel\", icon: \"🧊\" });\n // Глаза и руки агента: у canvas-игры DOM пустой, без этого она для него невидима.\n ctx.exposeToAgent(agent);\n },\n});\n"
|
|
91
95
|
},
|
|
92
96
|
{
|
|
93
97
|
"path": "player/ArmView.ts",
|
|
@@ -95,7 +99,7 @@
|
|
|
95
99
|
},
|
|
96
100
|
{
|
|
97
101
|
"path": "player/Input.ts",
|
|
98
|
-
"content": "// Ввод: Pointer Lock, клавиши, мышь. НИКОГДА не трогает мир напрямую —\n// дискретные действия уходят интентами в очередь, непрерывные состояния\n// (WASD, зажатые кнопки мыши, взгляд) читает Player/Interaction на кадре.\nimport { events } from \"../core/EventBus\";\nimport { Intent, type IntentQueue } from \"../core/Intents\";\n\nexport class Input {\n canvas: HTMLCanvasElement;\n intents: IntentQueue;\n keys: Set<string>;\n leftDown: boolean;\n rightDown: boolean;\n dx: number;\n dy: number;\n locked: boolean;\n uiOpen: boolean;\n _lastSpace: number;\n\n constructor(canvas: HTMLCanvasElement, intents: IntentQueue) {\n this.canvas = canvas;\n this.intents = intents;\n this.keys = new Set(); // KeyboardEvent.code\n this.leftDown = false;\n this.rightDown = false;\n this.dx = 0;\n this.dy = 0; // накопленное движение мыши за кадр\n this.locked = false;\n this.uiOpen = false; // инвентарь открыт — игровой ввод заморожен\n this._lastSpace = 0; // двойной Space → полёт в креативе\n\n document.addEventListener(\"pointerlockchange\", () => {\n this.locked = document.pointerLockElement === canvas;\n if (!this.locked && !this.uiOpen) events.emit(\"ui:pause\");\n if (this.locked) events.emit(\"ui:resume\");\n this.keys.clear();\n this.leftDown = this.rightDown = false;\n });\n\n canvas.addEventListener(\"mousemove\", (e) => {\n if (!this.locked) return;\n this.dx += e.movementX;\n this.dy += e.movementY;\n });\n\n canvas.addEventListener(\"mousedown\", (e) => {\n if (!this.locked) return;\n if (e.button === 0) this.leftDown = true;\n if (e.button === 2) this.rightDown = true;\n });\n canvas.addEventListener(\"mouseup\", (e) => {\n if (e.button === 0) this.leftDown = false;\n if (e.button === 2) this.rightDown = false;\n });\n canvas.addEventListener(\"contextmenu\", (e) => e.preventDefault());\n\n canvas.addEventListener(\n \"wheel\",\n (e) => {\n if (!this.
|
|
102
|
+
"content": "// Ввод: Pointer Lock, клавиши, мышь. НИКОГДА не трогает мир напрямую —\n// дискретные действия уходят интентами в очередь, непрерывные состояния\n// (WASD, зажатые кнопки мыши, взгляд) читает Player/Interaction на кадре.\nimport { events } from \"../core/EventBus\";\nimport { Intent, type IntentQueue } from \"../core/Intents\";\n\nexport class Input {\n canvas: HTMLCanvasElement;\n intents: IntentQueue;\n keys: Set<string>;\n leftDown: boolean;\n rightDown: boolean;\n dx: number;\n dy: number;\n locked: boolean;\n uiOpen: boolean;\n _lastSpace: number;\n\n /**\n * Управление без захвата указателя.\n *\n * Pointer Lock есть не везде: в кросс-доменном iframe (превью AI-кодера, встраивание игры на\n * сайте) браузер его запрещает. Раньше это означало, что играть НЕЛЬЗЯ ВООБЩЕ — весь ввод был\n * закрыт проверкой `locked`, а `locked` без API никогда не становился true: игра шла, а клавиши\n * и мышь молчали.\n *\n * Поэтому здесь тот же режим, но своими силами: клик по канвасу «берёт управление»\n * (`softLocked`), Esc отпускает. Мышь двигает камеру без захвата — курсор при этом виден и\n * может уйти за пределы канваса, зато играть можно.\n */\n softLocked: boolean;\n\n /** Есть ли в этом окружении настоящий Pointer Lock. Если да — мягкий режим не нужен. */\n readonly pointerLockAvailable: boolean;\n\n constructor(canvas: HTMLCanvasElement, intents: IntentQueue) {\n this.canvas = canvas;\n this.intents = intents;\n this.keys = new Set(); // KeyboardEvent.code\n this.leftDown = false;\n this.rightDown = false;\n this.dx = 0;\n this.dy = 0; // накопленное движение мыши за кадр\n this.locked = false;\n this.uiOpen = false; // инвентарь открыт — игровой ввод заморожен\n this._lastSpace = 0; // двойной Space → полёт в креативе\n this.softLocked = false;\n this.pointerLockAvailable =\n typeof canvas.requestPointerLock === \"function\" &&\n typeof document.exitPointerLock === \"function\";\n\n document.addEventListener(\"pointerlockchange\", () => {\n this.locked = document.pointerLockElement === canvas;\n if (!this.locked && !this.uiOpen) events.emit(\"ui:pause\");\n if (this.locked) events.emit(\"ui:resume\");\n this.keys.clear();\n this.leftDown = this.rightDown = false;\n });\n\n canvas.addEventListener(\"mousemove\", (e) => {\n if (!this.active) return;\n // Без захвата курсор реальный: он может уйти с канваса и вернуться, и тогда браузер отдаёт\n // одну гигантскую дельту — камера дёрнулась бы на пол-оборота. Рывки отбрасываем.\n if (\n !this.locked &&\n (Math.abs(e.movementX) > 120 || Math.abs(e.movementY) > 120)\n )\n return;\n this.dx += e.movementX;\n this.dy += e.movementY;\n });\n\n canvas.addEventListener(\"mousedown\", (e) => {\n // Первый клик по канвасу в мягком режиме — это «взять управление», а не удар киркой.\n if (!this.locked && !this.softLocked && !this.uiOpen) {\n this.takeControl();\n return;\n }\n if (!this.active) return;\n if (e.button === 0) this.leftDown = true;\n if (e.button === 2) this.rightDown = true;\n });\n canvas.addEventListener(\"mouseup\", (e) => {\n if (e.button === 0) this.leftDown = false;\n if (e.button === 2) this.rightDown = false;\n });\n canvas.addEventListener(\"contextmenu\", (e) => e.preventDefault());\n\n canvas.addEventListener(\n \"wheel\",\n (e) => {\n if (!this.active) return;\n this.intents.push(Intent.SelectSlot, { delta: Math.sign(e.deltaY) });\n e.preventDefault();\n },\n { passive: false },\n );\n\n document.addEventListener(\"keydown\", (e) => {\n if (e.repeat) return;\n // E работает и без pointer lock (закрыть инвентарь)\n if (e.code === \"KeyE\") {\n events.emit(\"ui:toggleInventory\");\n return;\n }\n // Esc в мягком режиме играет роль выхода из захвата: браузер сам его не отдаст.\n if (e.code === \"Escape\" && this.softLocked) {\n this.releaseControl();\n return;\n }\n if (!this.active) return;\n this.keys.add(e.code);\n if (e.code.startsWith(\"Digit\")) {\n const n = +e.code.slice(5);\n if (n >= 1 && n <= 9)\n this.intents.push(Intent.SelectSlot, { slot: n - 1 });\n }\n if (e.code === \"KeyC\") this.intents.push(Intent.ToggleMode);\n if (e.code === \"Space\") {\n const now = performance.now();\n if (now - this._lastSpace < 300) events.emit(\"input:doubleSpace\");\n this._lastSpace = now;\n }\n });\n document.addEventListener(\"keyup\", (e) => this.keys.delete(e.code));\n }\n\n /** Читает ли игра ввод прямо сейчас: настоящий захват ИЛИ мягкий режим. */\n get active(): boolean {\n return (this.locked || this.softLocked) && !this.uiOpen;\n }\n\n /** Взять управление без Pointer Lock (мягкий режим) и снять паузу. */\n takeControl(): void {\n if (this.softLocked) return;\n this.softLocked = true;\n this.keys.clear();\n this.leftDown = this.rightDown = false;\n this.dx = this.dy = 0;\n events.emit(\"ui:resume\");\n }\n\n /** Отпустить управление в мягком режиме — аналог выхода из захвата по Esc. */\n releaseControl(): void {\n if (!this.softLocked) return;\n this.softLocked = false;\n this.keys.clear();\n this.leftDown = this.rightDown = false;\n if (!this.uiOpen) events.emit(\"ui:pause\");\n }\n\n /** Захват мыши — только из user gesture (клик по оверлею). */\n requestLock(): void {\n // Там, где Pointer Lock запрещён (кросс-доменный iframe: превью AI-кодера, встраивание игры\n // на сайте), играем без него. Раньше здесь был тихий `return` — и игра оставалась\n // неуправляемой навсегда: `locked` без API не становится true никогда.\n if (!this.pointerLockAvailable) {\n this.takeControl();\n return;\n }\n try {\n // Браузер может бросить, если Esc был нажат <1.2 c назад — тихо игнорируем.\n this.canvas.requestPointerLock()?.catch?.(() => {\n // Обещание отклонено (нет user gesture, политика фрейма) — не оставляем игрока без\n // управления, а переходим в мягкий режим.\n this.takeControl();\n });\n } catch {\n this.takeControl();\n }\n }\n releaseLock(): void {\n if (this.locked && typeof document.exitPointerLock === \"function\") {\n document.exitPointerLock();\n }\n this.releaseControl();\n }\n\n /** Забрать накопленную дельту мыши (вызывается раз в кадр). */\n consumeMouse(): { dx: number; dy: number } {\n const d = { dx: this.dx, dy: this.dy };\n this.dx = this.dy = 0;\n return d;\n }\n\n get forward(): boolean {\n return this.keys.has(\"KeyW\");\n }\n get back(): boolean {\n return this.keys.has(\"KeyS\");\n }\n get left(): boolean {\n return this.keys.has(\"KeyA\");\n }\n get right(): boolean {\n return this.keys.has(\"KeyD\");\n }\n get jump(): boolean {\n return this.keys.has(\"Space\");\n }\n get sneakOrDown(): boolean {\n return this.keys.has(\"ShiftLeft\") || this.keys.has(\"ShiftRight\");\n }\n get run(): boolean {\n return this.keys.has(\"ControlLeft\") || this.keys.has(\"ShiftLeft\");\n }\n}\n"
|
|
99
103
|
},
|
|
100
104
|
{
|
|
101
105
|
"path": "player/Interaction.ts",
|
|
@@ -103,7 +107,7 @@
|
|
|
103
107
|
},
|
|
104
108
|
{
|
|
105
109
|
"path": "player/Player.ts",
|
|
106
|
-
"content": "// Игрок: AABB-физика с поосевым разрешением коллизий, плавание, полёт (креатив),\n// здоровье и урон от падения. Физика игрока считается на КАДРЕ (20 Гц для камеры\n// ощущается плохо); в этапе 2 этот же код становится client-side prediction,\n// а у хоста он и есть авторитативная симуляция.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { Input } from \"./Input\";\n\nconst P = CONFIG.player;\n\ntype PlayerMode = \"survival\" | \"creative\";\n\n/** Форма persist-блоба игрока (см. serialize/deserialize). */\ninterface PlayerSaveData {\n pos: number[];\n yaw: number;\n pitch: number;\n health: number;\n food?: number;\n saturation?: number;\n mode: PlayerMode;\n flying: boolean;\n}\n\nexport class Player {\n world: ChunkManager;\n pos: THREE.Vector3;\n vel: THREE.Vector3;\n yaw: number;\n pitch: number;\n onGround: boolean;\n inWater: boolean;\n headInWater: boolean;\n mode: PlayerMode;\n flying: boolean;\n health: number;\n food: number;\n saturation: number;\n exhaustion: number;\n dead: boolean;\n spawnPoint: THREE.Vector3;\n _fallDist: number;\n _stepDist: number;\n _regenTimer: number;\n _starveTimer: number;\n _sprinting = false;\n\n constructor(world: ChunkManager) {\n this.world = world;\n this.pos = new THREE.Vector3(8.5, 40, 8.5); // ноги; уточняется при спавне\n this.vel = new THREE.Vector3();\n this.yaw = 0;\n this.pitch = 0;\n this.onGround = false;\n this.inWater = false;\n this.headInWater = false;\n this.mode = \"survival\"; // 'survival' | 'creative'\n this.flying = false;\n this.health = P.maxHealth;\n // голод: еда [0..maxFood], сатурация [0..food] (буфер, тратится первой),\n // истощение [0..exhaustionPerFood] — накопитель, при переполнении съедает очко\n this.food = P.maxFood;\n this.saturation = 5;\n this.exhaustion = 0;\n this.dead = false;\n this._fallDist = 0;\n this._stepDist = 0;\n this._regenTimer = 0;\n this._starveTimer = 0;\n this.spawnPoint = new THREE.Vector3();\n\n events.on(\"input:doubleSpace\", () => {\n if (this.mode === \"creative\") this.flying = !this.flying;\n });\n }\n\n spawn(): void {\n // ищем сушу по спирали от (8,8): не спавнимся в океане на произвольном сиде\n let sx = 8,\n sz = 8;\n outer: for (let r = 0; r <= 6; r++) {\n for (let dx = -r; dx <= r; dx += Math.max(1, r)) {\n for (let dz = -r; dz <= r; dz += Math.max(1, r)) {\n const x = 8 + dx * 8,\n z = 8 + dz * 8;\n const y = this.world.surfaceY(x, z);\n if (\n y > CONFIG.world.waterLevel &&\n this.world.getBlock(x, y, z) === B.grass\n ) {\n sx = x;\n sz = z;\n break outer;\n }\n }\n }\n }\n const y = this.world.surfaceY(sx, sz);\n this.pos.set(sx + 0.5, y + 1.01, sz + 0.5);\n this.spawnPoint.copy(this.pos);\n this.vel.set(0, 0, 0);\n this.health = P.maxHealth;\n this.food = P.maxFood;\n this.saturation = 5;\n this.exhaustion = 0;\n this.dead = false;\n }\n\n get eyePos(): THREE.Vector3 {\n return new THREE.Vector3(this.pos.x, this.pos.y + P.eye, this.pos.z);\n }\n\n lookDir(): THREE.Vector3 {\n return new THREE.Vector3(\n -Math.sin(this.yaw) * Math.cos(this.pitch),\n Math.sin(this.pitch),\n -Math.cos(this.yaw) * Math.cos(this.pitch),\n );\n }\n\n toggleMode(): void {\n this.mode = this.mode === \"survival\" ? \"creative\" : \"survival\";\n if (this.mode === \"survival\") this.flying = false;\n events.emit(\"modeChanged\", { mode: this.mode });\n }\n\n /** Кадровое обновление: взгляд, ускорения, интеграция, коллизии. */\n update(dt: number, input: Input): void {\n if (this.dead) return;\n // взгляд\n const m = input.consumeMouse();\n this.yaw -= m.dx * 0.0024;\n this.pitch = Math.max(\n -Math.PI / 2 + 0.01,\n Math.min(Math.PI / 2 - 0.01, this.pitch - m.dy * 0.0024),\n );\n\n // среда\n const feet = this.world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + 0.2),\n Math.floor(this.pos.z),\n );\n const head = this.world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + P.eye),\n Math.floor(this.pos.z),\n );\n this.inWater = !!(BLOCKS[feet]?.liquid || BLOCKS[head]?.liquid);\n this.headInWater = !!BLOCKS[head]?.liquid;\n\n // желаемое горизонтальное движение в локальных осях камеры\n let ix = (input.right ? 1 : 0) - (input.left ? 1 : 0);\n let iz = (input.back ? 1 : 0) - (input.forward ? 1 : 0);\n if (input.uiOpen) {\n ix = 0;\n iz = 0;\n }\n const len = Math.hypot(ix, iz) || 1;\n ix /= len;\n iz /= len;\n // перевод ввода в мировые оси: forward = (-sin yaw, -cos yaw), right = (cos yaw, -sin yaw)\n const sin = Math.sin(this.yaw),\n cos = Math.cos(this.yaw);\n const wishX = ix * cos + iz * sin;\n const wishZ = iz * cos - ix * sin;\n\n // бег доступен, пока еды достаточно (как в Minecraft)\n const canSprint = input.run && input.forward && this.food > P.sprintFood;\n this._sprinting = canSprint && !this.flying && !this.inWater;\n const speed = this.flying\n ? P.flySpeed\n : this.inWater\n ? P.swimSpeed\n : canSprint\n ? P.runSpeed\n : P.walkSpeed;\n\n if (this.flying) {\n // полёт: прямое управление скоростью, вертикаль на Space/Shift\n this.vel.x = wishX * speed;\n this.vel.z = wishZ * speed;\n this.vel.y = (input.jump ? speed : 0) + (input.sneakOrDown ? -speed : 0);\n if (input.uiOpen) this.vel.y = 0;\n } else if (this.inWater) {\n const accel = 24 * dt;\n this.vel.x += (wishX * speed - this.vel.x) * Math.min(1, accel);\n this.vel.z += (wishZ * speed - this.vel.z) * Math.min(1, accel);\n this.vel.y -= P.gravity * 0.3 * dt; // ослабленная гравитация\n if (input.jump && !input.uiOpen) this.vel.y = P.swimUp; // гребок вверх\n this.vel.y *= 1 - P.waterDrag * dt; // сопротивление воды\n this._fallDist = 0;\n } else {\n const accel = (this.onGround ? 18 : 4) * dt;\n this.vel.x += (wishX * speed - this.vel.x) * Math.min(1, accel);\n this.vel.z += (wishZ * speed - this.vel.z) * Math.min(1, accel);\n this.vel.y -= P.gravity * dt;\n if (input.jump && this.onGround && !input.uiOpen) {\n this.vel.y = P.jumpSpeed;\n this.addExhaustion(this._sprinting ? P.exhaustJump * 4 : P.exhaustJump);\n }\n }\n\n // интеграция с сабстепами — защита от туннелирования на лагающем кадре\n const steps = Math.max(1, Math.ceil((this.vel.length() * dt) / 0.4));\n const sdt = dt / steps;\n for (let s = 0; s < steps; s++) this._moveStep(sdt);\n\n // шаги (звук) + истощение от ходьбы/бега\n if (this.onGround && !this.inWater) {\n const dist = Math.hypot(this.vel.x, this.vel.z) * dt;\n this._stepDist += dist;\n if (this.mode === \"survival\")\n this.addExhaustion(\n dist * (this._sprinting ? P.exhaustSprint : P.exhaustWalk),\n );\n if (this._stepDist > 2.2) {\n this._stepDist = 0;\n events.emit(\"playerStep\");\n }\n }\n\n if (this.pos.y < -10) this._die(); // выпал из мира\n }\n\n _moveStep(dt: number): void {\n const wasAirborne = !this.onGround && !this.flying && !this.inWater;\n const prevVy = this.vel.y;\n\n // ось Y\n this.pos.y += this.vel.y * dt;\n const hit = this._resolveAxis(1);\n const landed = hit === -1; // упёрлись вниз\n if (landed) {\n if (\n wasAirborne &&\n this._fallDist > P.fallSafe &&\n this.mode === \"survival\"\n )\n this.damage(Math.round(this._fallDist - P.fallSafe), \"fall\");\n this._fallDist = 0;\n this.onGround = true;\n this.vel.y = 0;\n } else if (hit === 1) {\n this.vel.y = 0; // потолок\n } else {\n this.onGround = false;\n if (prevVy < 0) this._fallDist += -prevVy * dt;\n }\n\n // ось X\n this.pos.x += this.vel.x * dt;\n if (this._resolveAxis(0)) this.vel.x = 0;\n // ось Z\n this.pos.z += this.vel.z * dt;\n if (this._resolveAxis(2)) this.vel.z = 0;\n }\n\n /**\n * Разрешение коллизии по одной оси: если AABB пересекает твёрдый воксель,\n * позиция клампится к его грани. Возвращает -1/1 (сторону) или 0.\n */\n _resolveAxis(axis: number): number {\n const half = P.width / 2;\n const minX = this.pos.x - half,\n maxX = this.pos.x + half;\n const minY = this.pos.y,\n maxY = this.pos.y + P.height;\n const minZ = this.pos.z - half,\n maxZ = this.pos.z + half;\n const eps = 0.001;\n let result = 0;\n\n for (let by = Math.floor(minY); by <= Math.floor(maxY - eps); by++) {\n for (let bz = Math.floor(minZ); bz <= Math.floor(maxZ - eps); bz++) {\n for (let bx = Math.floor(minX); bx <= Math.floor(maxX - eps); bx++) {\n if (!this.world.isSolid(bx, by, bz)) continue;\n if (axis === 1) {\n if (this.vel.y <= 0 && minY < by + 1 && maxY > by + 1) {\n /* невозможно по одной оси */\n }\n if (this.vel.y <= 0) {\n this.pos.y = by + 1;\n result = -1;\n } else {\n this.pos.y = by - P.height - eps;\n result = 1;\n }\n } else if (axis === 0) {\n if (this.vel.x > 0) this.pos.x = bx - half - eps;\n else this.pos.x = bx + 1 + half + eps;\n result = 1;\n } else {\n if (this.vel.z > 0) this.pos.z = bz - half - eps;\n else this.pos.z = bz + 1 + half + eps;\n result = 1;\n }\n return result; // после клампа пересечений по этой оси больше нет\n }\n }\n }\n return result;\n }\n\n damage(amount: number, cause: string): void {\n if (this.mode === \"creative\" || this.dead || amount <= 0) return;\n this.health = Math.max(0, this.health - amount);\n events.emit(\"playerHurt\", { amount, health: this.health, cause });\n if (this.health <= 0) this._die();\n }\n\n addExhaustion(n: number): void {\n this.exhaustion += n;\n }\n\n /** Тик голода (симуляция, 20 TPS): истощение → еда, регенерация, голодание. */\n tickHunger(dt: number): void {\n if (this.mode === \"creative\" || this.dead) return;\n this.addExhaustion(P.exhaustIdle * dt); // медленная пассивная трата\n while (this.exhaustion >= P.exhaustionPerFood) {\n this.exhaustion -= P.exhaustionPerFood;\n if (this.saturation > 0)\n this.saturation = Math.max(0, this.saturation - 1);\n else this.food = Math.max(0, this.food - 1);\n }\n\n // сытость лечит; лечение «стоит» истощения\n if (this.food >= P.regenFoodThreshold && this.health < P.maxHealth) {\n this._regenTimer += dt;\n if (this._regenTimer >= P.regenInterval) {\n this._regenTimer = 0;\n this.health = Math.min(P.maxHealth, this.health + 1);\n this.addExhaustion(P.exhaustRegen);\n events.emit(\"playerHeal\", { health: this.health });\n }\n } else this._regenTimer = 0;\n\n // голодание: урон при нулевой еде, но не насмерть (минимум 1 HP)\n if (this.food <= 0) {\n this._starveTimer += dt;\n if (this._starveTimer >= P.starveInterval) {\n this._starveTimer = 0;\n if (this.health > 1) this.damage(1, \"starve\");\n }\n } else this._starveTimer = 0;\n\n events.emit(\"foodChanged\", { food: this.food });\n }\n\n /** Съесть предмет (если это еда и есть куда). @returns {boolean} успех */\n eat(itemName: string): boolean {\n const it = ITEMS[itemName];\n if (!it?.food || this.food >= P.maxFood) return false;\n this.food = Math.min(P.maxFood, this.food + it.food);\n this.saturation = Math.min(\n this.food,\n this.saturation + (it.saturation ?? it.food * 0.6),\n );\n events.emit(\"playerAte\", { item: itemName });\n events.emit(\"foodChanged\", { food: this.food });\n return true;\n }\n\n _die(): void {\n if (this.dead) return;\n this.dead = true;\n events.emit(\"playerDied\");\n // простой респаун через секунду\n setTimeout(() => {\n this.pos.copy(this.spawnPoint);\n this.vel.set(0, 0, 0);\n this.health = P.maxHealth;\n this.food = P.maxFood;\n this.saturation = 5;\n this.exhaustion = 0;\n this.dead = false;\n this._fallDist = 0;\n events.emit(\"playerRespawn\");\n }, 1200);\n }\n\n /** Пересекается ли AABB игрока с блоком (запрет постановки «в себя»). */\n intersectsBlock(bx: number, by: number, bz: number): boolean {\n const half = P.width / 2;\n return (\n bx + 1 > this.pos.x - half &&\n bx < this.pos.x + half &&\n by + 1 > this.pos.y &&\n by < this.pos.y + P.height &&\n bz + 1 > this.pos.z - half &&\n bz < this.pos.z + half\n );\n }\n\n serialize() {\n return {\n pos: this.pos.toArray(),\n yaw: this.yaw,\n pitch: this.pitch,\n health: this.health,\n food: this.food,\n saturation: this.saturation,\n mode: this.mode,\n flying: this.flying,\n };\n }\n deserialize(d: PlayerSaveData): void {\n this.pos.fromArray(d.pos);\n this.yaw = d.yaw;\n this.pitch = d.pitch;\n this.health = d.health;\n this.mode = d.mode;\n this.flying = d.flying;\n this.food = d.food ?? P.maxFood;\n this.saturation = d.saturation ?? 5;\n this.exhaustion = 0;\n this.vel.set(0, 0, 0);\n this.spawnPoint.copy(this.pos);\n }\n}\n"
|
|
110
|
+
"content": "// Игрок: AABB-физика с поосевым разрешением коллизий, плавание, полёт (креатив),\n// здоровье и урон от падения. Физика игрока считается на КАДРЕ (20 Гц для камеры\n// ощущается плохо); в этапе 2 этот же код становится client-side prediction,\n// а у хоста он и есть авторитативная симуляция.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { Input } from \"./Input\";\n\nconst P = CONFIG.player;\n\ntype PlayerMode = \"survival\" | \"creative\";\n\n/** Форма persist-блоба игрока (см. serialize/deserialize). */\ninterface PlayerSaveData {\n pos: number[];\n yaw: number;\n pitch: number;\n health: number;\n food?: number;\n saturation?: number;\n mode: PlayerMode;\n flying: boolean;\n}\n\nexport class Player {\n world: ChunkManager;\n pos: THREE.Vector3;\n vel: THREE.Vector3;\n yaw: number;\n pitch: number;\n onGround: boolean;\n inWater: boolean;\n headInWater: boolean;\n mode: PlayerMode;\n flying: boolean;\n health: number;\n food: number;\n saturation: number;\n exhaustion: number;\n dead: boolean;\n spawnPoint: THREE.Vector3;\n _fallDist: number;\n _stepDist: number;\n _regenTimer: number;\n _starveTimer: number;\n _sprinting = false;\n\n constructor(world: ChunkManager) {\n this.world = world;\n this.pos = new THREE.Vector3(8.5, 40, 8.5); // ноги; уточняется при спавне\n this.vel = new THREE.Vector3();\n this.yaw = 0;\n this.pitch = 0;\n this.onGround = false;\n this.inWater = false;\n this.headInWater = false;\n this.mode = \"survival\"; // 'survival' | 'creative'\n this.flying = false;\n this.health = P.maxHealth;\n // голод: еда [0..maxFood], сатурация [0..food] (буфер, тратится первой),\n // истощение [0..exhaustionPerFood] — накопитель, при переполнении съедает очко\n this.food = P.maxFood;\n this.saturation = 5;\n this.exhaustion = 0;\n this.dead = false;\n this._fallDist = 0;\n this._stepDist = 0;\n this._regenTimer = 0;\n this._starveTimer = 0;\n this.spawnPoint = new THREE.Vector3();\n\n events.on(\"input:doubleSpace\", () => {\n if (this.mode === \"creative\") this.flying = !this.flying;\n });\n }\n\n spawn(): void {\n // ищем сушу по спирали от (8,8): не спавнимся в океане на произвольном сиде\n let sx = 8,\n sz = 8;\n outer: for (let r = 0; r <= 6; r++) {\n for (let dx = -r; dx <= r; dx += Math.max(1, r)) {\n for (let dz = -r; dz <= r; dz += Math.max(1, r)) {\n const x = 8 + dx * 8,\n z = 8 + dz * 8;\n const y = this.world.surfaceY(x, z);\n if (\n y > CONFIG.world.waterLevel &&\n this.world.getBlock(x, y, z) === B.grass\n ) {\n sx = x;\n sz = z;\n break outer;\n }\n }\n }\n }\n const y = this.world.surfaceY(sx, sz);\n this.pos.set(sx + 0.5, y + 1.01, sz + 0.5);\n this.spawnPoint.copy(this.pos);\n this.vel.set(0, 0, 0);\n this.health = P.maxHealth;\n this.food = P.maxFood;\n this.saturation = 5;\n this.exhaustion = 0;\n this.dead = false;\n }\n\n get eyePos(): THREE.Vector3 {\n return new THREE.Vector3(this.pos.x, this.pos.y + P.eye, this.pos.z);\n }\n\n lookDir(): THREE.Vector3 {\n return new THREE.Vector3(\n -Math.sin(this.yaw) * Math.cos(this.pitch),\n Math.sin(this.pitch),\n -Math.cos(this.yaw) * Math.cos(this.pitch),\n );\n }\n\n toggleMode(): void {\n this.mode = this.mode === \"survival\" ? \"creative\" : \"survival\";\n if (this.mode === \"survival\") this.flying = false;\n events.emit(\"modeChanged\", { mode: this.mode });\n }\n\n /**\n * Поворот взгляда в радианах. Отдельным методом, чтобы у поворота был ОДИН источник правды:\n * им пользуется и кадровое обновление по мыши, и агентский API (`agent.ts`) — иначе у агента\n * появилась бы своя копия правил поворота, которая разъехалась бы с настоящей.\n */\n look(dYaw: number, dPitch: number): void {\n this.yaw += dYaw;\n this.pitch = Math.max(\n -Math.PI / 2 + 0.01,\n Math.min(Math.PI / 2 - 0.01, this.pitch + dPitch),\n );\n }\n\n /** Кадровое обновление: взгляд, ускорения, интеграция, коллизии. */\n update(dt: number, input: Input): void {\n if (this.dead) return;\n // взгляд\n const m = input.consumeMouse();\n this.look(-m.dx * 0.0024, -m.dy * 0.0024);\n\n // среда\n const feet = this.world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + 0.2),\n Math.floor(this.pos.z),\n );\n const head = this.world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + P.eye),\n Math.floor(this.pos.z),\n );\n this.inWater = !!(BLOCKS[feet]?.liquid || BLOCKS[head]?.liquid);\n this.headInWater = !!BLOCKS[head]?.liquid;\n\n // желаемое горизонтальное движение в локальных осях камеры\n let ix = (input.right ? 1 : 0) - (input.left ? 1 : 0);\n let iz = (input.back ? 1 : 0) - (input.forward ? 1 : 0);\n if (input.uiOpen) {\n ix = 0;\n iz = 0;\n }\n const len = Math.hypot(ix, iz) || 1;\n ix /= len;\n iz /= len;\n // перевод ввода в мировые оси: forward = (-sin yaw, -cos yaw), right = (cos yaw, -sin yaw)\n const sin = Math.sin(this.yaw),\n cos = Math.cos(this.yaw);\n const wishX = ix * cos + iz * sin;\n const wishZ = iz * cos - ix * sin;\n\n // бег доступен, пока еды достаточно (как в Minecraft)\n const canSprint = input.run && input.forward && this.food > P.sprintFood;\n this._sprinting = canSprint && !this.flying && !this.inWater;\n const speed = this.flying\n ? P.flySpeed\n : this.inWater\n ? P.swimSpeed\n : canSprint\n ? P.runSpeed\n : P.walkSpeed;\n\n if (this.flying) {\n // полёт: прямое управление скоростью, вертикаль на Space/Shift\n this.vel.x = wishX * speed;\n this.vel.z = wishZ * speed;\n this.vel.y = (input.jump ? speed : 0) + (input.sneakOrDown ? -speed : 0);\n if (input.uiOpen) this.vel.y = 0;\n } else if (this.inWater) {\n const accel = 24 * dt;\n this.vel.x += (wishX * speed - this.vel.x) * Math.min(1, accel);\n this.vel.z += (wishZ * speed - this.vel.z) * Math.min(1, accel);\n this.vel.y -= P.gravity * 0.3 * dt; // ослабленная гравитация\n if (input.jump && !input.uiOpen) this.vel.y = P.swimUp; // гребок вверх\n this.vel.y *= 1 - P.waterDrag * dt; // сопротивление воды\n this._fallDist = 0;\n } else {\n const accel = (this.onGround ? 18 : 4) * dt;\n this.vel.x += (wishX * speed - this.vel.x) * Math.min(1, accel);\n this.vel.z += (wishZ * speed - this.vel.z) * Math.min(1, accel);\n this.vel.y -= P.gravity * dt;\n if (input.jump && this.onGround && !input.uiOpen) {\n this.vel.y = P.jumpSpeed;\n this.addExhaustion(this._sprinting ? P.exhaustJump * 4 : P.exhaustJump);\n }\n }\n\n // интеграция с сабстепами — защита от туннелирования на лагающем кадре\n const steps = Math.max(1, Math.ceil((this.vel.length() * dt) / 0.4));\n const sdt = dt / steps;\n for (let s = 0; s < steps; s++) this._moveStep(sdt);\n\n // шаги (звук) + истощение от ходьбы/бега\n if (this.onGround && !this.inWater) {\n const dist = Math.hypot(this.vel.x, this.vel.z) * dt;\n this._stepDist += dist;\n if (this.mode === \"survival\")\n this.addExhaustion(\n dist * (this._sprinting ? P.exhaustSprint : P.exhaustWalk),\n );\n if (this._stepDist > 2.2) {\n this._stepDist = 0;\n events.emit(\"playerStep\");\n }\n }\n\n if (this.pos.y < -10) this._die(); // выпал из мира\n }\n\n _moveStep(dt: number): void {\n const wasAirborne = !this.onGround && !this.flying && !this.inWater;\n const prevVy = this.vel.y;\n\n // ось Y\n this.pos.y += this.vel.y * dt;\n const hit = this._resolveAxis(1);\n const landed = hit === -1; // упёрлись вниз\n if (landed) {\n if (\n wasAirborne &&\n this._fallDist > P.fallSafe &&\n this.mode === \"survival\"\n )\n this.damage(Math.round(this._fallDist - P.fallSafe), \"fall\");\n this._fallDist = 0;\n this.onGround = true;\n this.vel.y = 0;\n } else if (hit === 1) {\n this.vel.y = 0; // потолок\n } else {\n this.onGround = false;\n if (prevVy < 0) this._fallDist += -prevVy * dt;\n }\n\n // ось X\n this.pos.x += this.vel.x * dt;\n if (this._resolveAxis(0)) this.vel.x = 0;\n // ось Z\n this.pos.z += this.vel.z * dt;\n if (this._resolveAxis(2)) this.vel.z = 0;\n }\n\n /**\n * Разрешение коллизии по одной оси: если AABB пересекает твёрдый воксель,\n * позиция клампится к его грани. Возвращает -1/1 (сторону) или 0.\n */\n _resolveAxis(axis: number): number {\n const half = P.width / 2;\n const minX = this.pos.x - half,\n maxX = this.pos.x + half;\n const minY = this.pos.y,\n maxY = this.pos.y + P.height;\n const minZ = this.pos.z - half,\n maxZ = this.pos.z + half;\n const eps = 0.001;\n let result = 0;\n\n for (let by = Math.floor(minY); by <= Math.floor(maxY - eps); by++) {\n for (let bz = Math.floor(minZ); bz <= Math.floor(maxZ - eps); bz++) {\n for (let bx = Math.floor(minX); bx <= Math.floor(maxX - eps); bx++) {\n if (!this.world.isSolid(bx, by, bz)) continue;\n if (axis === 1) {\n if (this.vel.y <= 0 && minY < by + 1 && maxY > by + 1) {\n /* невозможно по одной оси */\n }\n if (this.vel.y <= 0) {\n this.pos.y = by + 1;\n result = -1;\n } else {\n this.pos.y = by - P.height - eps;\n result = 1;\n }\n } else if (axis === 0) {\n if (this.vel.x > 0) this.pos.x = bx - half - eps;\n else this.pos.x = bx + 1 + half + eps;\n result = 1;\n } else {\n if (this.vel.z > 0) this.pos.z = bz - half - eps;\n else this.pos.z = bz + 1 + half + eps;\n result = 1;\n }\n return result; // после клампа пересечений по этой оси больше нет\n }\n }\n }\n return result;\n }\n\n damage(amount: number, cause: string): void {\n if (this.mode === \"creative\" || this.dead || amount <= 0) return;\n this.health = Math.max(0, this.health - amount);\n events.emit(\"playerHurt\", { amount, health: this.health, cause });\n if (this.health <= 0) this._die();\n }\n\n addExhaustion(n: number): void {\n this.exhaustion += n;\n }\n\n /** Тик голода (симуляция, 20 TPS): истощение → еда, регенерация, голодание. */\n tickHunger(dt: number): void {\n if (this.mode === \"creative\" || this.dead) return;\n this.addExhaustion(P.exhaustIdle * dt); // медленная пассивная трата\n while (this.exhaustion >= P.exhaustionPerFood) {\n this.exhaustion -= P.exhaustionPerFood;\n if (this.saturation > 0)\n this.saturation = Math.max(0, this.saturation - 1);\n else this.food = Math.max(0, this.food - 1);\n }\n\n // сытость лечит; лечение «стоит» истощения\n if (this.food >= P.regenFoodThreshold && this.health < P.maxHealth) {\n this._regenTimer += dt;\n if (this._regenTimer >= P.regenInterval) {\n this._regenTimer = 0;\n this.health = Math.min(P.maxHealth, this.health + 1);\n this.addExhaustion(P.exhaustRegen);\n events.emit(\"playerHeal\", { health: this.health });\n }\n } else this._regenTimer = 0;\n\n // голодание: урон при нулевой еде, но не насмерть (минимум 1 HP)\n if (this.food <= 0) {\n this._starveTimer += dt;\n if (this._starveTimer >= P.starveInterval) {\n this._starveTimer = 0;\n if (this.health > 1) this.damage(1, \"starve\");\n }\n } else this._starveTimer = 0;\n\n events.emit(\"foodChanged\", { food: this.food });\n }\n\n /** Съесть предмет (если это еда и есть куда). @returns {boolean} успех */\n eat(itemName: string): boolean {\n const it = ITEMS[itemName];\n if (!it?.food || this.food >= P.maxFood) return false;\n this.food = Math.min(P.maxFood, this.food + it.food);\n this.saturation = Math.min(\n this.food,\n this.saturation + (it.saturation ?? it.food * 0.6),\n );\n events.emit(\"playerAte\", { item: itemName });\n events.emit(\"foodChanged\", { food: this.food });\n return true;\n }\n\n _die(): void {\n if (this.dead) return;\n this.dead = true;\n events.emit(\"playerDied\");\n // простой респаун через секунду\n setTimeout(() => {\n this.pos.copy(this.spawnPoint);\n this.vel.set(0, 0, 0);\n this.health = P.maxHealth;\n this.food = P.maxFood;\n this.saturation = 5;\n this.exhaustion = 0;\n this.dead = false;\n this._fallDist = 0;\n events.emit(\"playerRespawn\");\n }, 1200);\n }\n\n /** Пересекается ли AABB игрока с блоком (запрет постановки «в себя»). */\n intersectsBlock(bx: number, by: number, bz: number): boolean {\n const half = P.width / 2;\n return (\n bx + 1 > this.pos.x - half &&\n bx < this.pos.x + half &&\n by + 1 > this.pos.y &&\n by < this.pos.y + P.height &&\n bz + 1 > this.pos.z - half &&\n bz < this.pos.z + half\n );\n }\n\n serialize() {\n return {\n pos: this.pos.toArray(),\n yaw: this.yaw,\n pitch: this.pitch,\n health: this.health,\n food: this.food,\n saturation: this.saturation,\n mode: this.mode,\n flying: this.flying,\n };\n }\n deserialize(d: PlayerSaveData): void {\n this.pos.fromArray(d.pos);\n this.yaw = d.yaw;\n this.pitch = d.pitch;\n this.health = d.health;\n this.mode = d.mode;\n this.flying = d.flying;\n this.food = d.food ?? P.maxFood;\n this.saturation = d.saturation ?? 5;\n this.exhaustion = 0;\n this.vel.set(0, 0, 0);\n this.spawnPoint.copy(this.pos);\n }\n}\n"
|
|
107
111
|
},
|
|
108
112
|
{
|
|
109
113
|
"path": "registry/Blocks.ts",
|
|
@@ -123,7 +127,7 @@
|
|
|
123
127
|
},
|
|
124
128
|
{
|
|
125
129
|
"path": "scene.ts",
|
|
126
|
-
"content": "import type {
|
|
130
|
+
"content": "import type {\n EngineScene,\n ModuleAgentApi,\n SceneMountContext,\n} from \"@idosgames/module-sdk\";\nimport { createVoxelcraftAgentApi } from \"./agent\";\nimport { Game } 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> run</div>\n <div><b>LMB</b> (hold) break / attack, <b>RMB</b> place block</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=\"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`;\n\n/**\n * Сцена + debug-поверхность для агента одной парой: обе смотрят на ОДИН экземпляр игры, который\n * создаётся в `mount()` и обнуляется в `destroy()`. Поэтому агент получает не объект, а доступ к\n * текущему `game` — иначе после пересоздания сцены он держал бы ссылку на мёртвую игру.\n */\nexport function createVoxelcraft(): {\n scene: EngineScene;\n agent: ModuleAgentApi;\n} {\n let game: Game | null = null;\n const scene = buildScene(\n () => game,\n (next) => {\n game = next;\n },\n );\n return { scene, agent: createVoxelcraftAgentApi(() => game) };\n}\n\nfunction buildScene(\n getGame: () => Game | null,\n setGame: (game: Game | null) => void,\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());\n },\n activate(): void {\n getGame()?.setRunning(true);\n },\n suspend(): void {\n getGame()?.setRunning(false);\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"
|
|
127
131
|
},
|
|
128
132
|
{
|
|
129
133
|
"path": "shims.d.ts",
|
|
@@ -131,7 +135,7 @@
|
|
|
131
135
|
},
|
|
132
136
|
{
|
|
133
137
|
"path": "style.css",
|
|
134
|
-
"content": "/* VoxelCraft HUD/UI. Пиксель-арт: image-rendering: pixelated везде, где иконки. */\n* {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n}\nhtml,\nbody {\n width: 100%;\n height: 100%;\n overflow: hidden;\n background: #
|
|
138
|
+
"content": "/* VoxelCraft HUD/UI. Пиксель-арт: image-rendering: pixelated везде, где иконки. */\n* {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n}\nhtml,\nbody {\n width: 100%;\n height: 100%;\n overflow: hidden;\n background: #063d99;\n}\nbody {\n font-family: \"Segoe UI\", system-ui, sans-serif;\n user-select: none;\n}\n\n#game {\n position: fixed;\n inset: 0;\n width: 100%;\n height: 100%;\n display: block;\n}\n\n.hidden {\n display: none !important;\n}\n\n/* ---------- HUD ---------- */\n#crosshair {\n position: fixed;\n left: 50%;\n top: 50%;\n width: 18px;\n height: 18px;\n transform: translate(-50%, -50%);\n pointer-events: none;\n z-index: 5;\n}\n#crosshair::before,\n#crosshair::after {\n content: \"\";\n position: absolute;\n background: rgba(255, 255, 255, 0.85);\n mix-blend-mode: difference;\n}\n#crosshair::before {\n left: 8px;\n top: 0;\n width: 2px;\n height: 18px;\n}\n#crosshair::after {\n left: 0;\n top: 8px;\n width: 18px;\n height: 2px;\n}\n\n#fps {\n position: fixed;\n top: 8px;\n right: 10px;\n color: #7fff6a;\n z-index: 5;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n#debug {\n position: fixed;\n top: 8px;\n left: 10px;\n color: #ddd;\n z-index: 5;\n font: 11px monospace;\n text-shadow: 1px 1px 0 #000;\n opacity: 0.85;\n}\n#timeIndicator {\n position: fixed;\n top: 26px;\n right: 10px;\n color: #ffe9a0;\n z-index: 5;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n\n#hotbar {\n position: fixed;\n left: 50%;\n bottom: 8px;\n transform: translateX(-50%);\n display: flex;\n gap: 3px;\n z-index: 5;\n padding: 3px;\n background: rgba(0, 0, 0, 0.45);\n border: 2px solid #222;\n border-radius: 4px;\n}\n.slot {\n width: 44px;\n height: 44px;\n position: relative;\n background: rgba(120, 120, 120, 0.35);\n border: 2px solid #555;\n}\n.slot img {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n pointer-events: none;\n}\n.slot .count {\n position: absolute;\n right: 2px;\n bottom: 0;\n color: #fff;\n pointer-events: none;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n#hotbar .slot.selected {\n border-color: #fff;\n background: rgba(200, 200, 200, 0.4);\n}\n\n/* сердечки — слева от центра, шкала голода — справа (как в Minecraft) */\n#hearts {\n position: fixed;\n right: calc(50% + 8px);\n bottom: 62px;\n white-space: nowrap;\n z-index: 5;\n font-size: 18px;\n letter-spacing: 2px;\n text-shadow: 1px 1px 0 #000;\n}\n.heart {\n color: #e03c3c;\n}\n.heart.half {\n color: #e03c3c;\n opacity: 0.55;\n}\n.heart.empty {\n color: #3a3a3a;\n}\n\n#hunger {\n position: fixed;\n left: calc(50% + 8px);\n bottom: 62px;\n white-space: nowrap;\n z-index: 5;\n font-size: 18px;\n letter-spacing: 2px;\n text-shadow: 1px 1px 0 #000;\n direction: rtl; /* пустеет справа налево, как в MC */\n}\n.drumstick {\n color: #c98a3c;\n}\n.drumstick.half {\n color: #c98a3c;\n opacity: 0.55;\n}\n.drumstick.empty {\n color: #3a3a3a;\n}\n\n#hurtFlash {\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 4;\n background: radial-gradient(\n ellipse at center,\n transparent 40%,\n rgba(255, 0, 0, 0.5)\n );\n opacity: 0;\n}\n#hurtFlash.show {\n animation: hurt 0.4s ease-out;\n}\n@keyframes hurt {\n 0% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n}\n\n#message {\n position: fixed;\n left: 50%;\n top: 30%;\n transform: translateX(-50%);\n color: #fff;\n font: bold 18px monospace;\n text-shadow: 2px 2px 0 #000;\n z-index: 6;\n opacity: 0;\n transition: opacity 0.3s;\n pointer-events: none;\n}\n#message.show {\n opacity: 1;\n}\n\n/* ---------- инвентарь ---------- */\n#invScreen {\n position: fixed;\n inset: 0;\n z-index: 10;\n background: rgba(0, 0, 0, 0.5);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.invPanel {\n background: #c6c6c6;\n border: 3px solid #555;\n border-radius: 4px;\n padding: 14px 16px;\n box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);\n}\n.invPanel h3 {\n color: #3a3a3a;\n margin-bottom: 8px;\n font-size: 15px;\n}\n.invPanel .slot {\n background: #8b8b8b;\n border: 2px solid;\n border-color: #373737 #fff #fff #373737;\n}\n.craftRow {\n display: flex;\n align-items: center;\n gap: 12px;\n margin-bottom: 12px;\n}\n#craftGrid {\n display: grid;\n gap: 3px;\n}\n.arrow {\n font-size: 26px;\n color: #3a3a3a;\n}\n.slot.result {\n width: 50px;\n height: 50px;\n}\n#invGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n margin-bottom: 10px;\n}\n#invHotbar {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n}\n\n#cursorStack {\n position: fixed;\n z-index: 20;\n width: 40px;\n height: 40px;\n pointer-events: none;\n transform: translate(-50%, -50%);\n}\n#cursorStack img {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n}\n#cursorStack .count {\n position: absolute;\n right: 0;\n bottom: -2px;\n color: #fff;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n\n/* ---------- оверлей ---------- */\n#overlay {\n position: fixed;\n inset: 0;\n z-index: 30;\n cursor: pointer;\n background: rgba(4, 32, 90, 0.85);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n#overlay .panel {\n text-align: center;\n color: #eee;\n max-width: 560px;\n padding: 20px;\n}\n#overlay h1 {\n font-size: 44px;\n letter-spacing: 2px;\n margin-bottom: 6px;\n color: #9adf6a;\n text-shadow: 3px 3px 0 #2a4d1a;\n}\n#overlayHint {\n font-size: 17px;\n color: #ffd76a;\n margin-bottom: 18px;\n}\n#overlay .controls {\n text-align: left;\n background: rgba(0, 0, 0, 0.35);\n border-radius: 6px;\n padding: 12px 16px;\n font-size: 14px;\n line-height: 1.8;\n margin-bottom: 16px;\n}\n#overlay .controls b {\n color: #9adf6a;\n}\n#overlay .buttons {\n display: flex;\n gap: 10px;\n justify-content: center;\n}\n#overlay button {\n cursor: pointer;\n font-size: 14px;\n padding: 8px 14px;\n border-radius: 4px;\n border: 2px solid #666;\n background: #3a3f4d;\n color: #eee;\n}\n#overlay button:hover {\n background: #4a5163;\n}\n"
|
|
135
139
|
},
|
|
136
140
|
{
|
|
137
141
|
"path": "systems/Audio.ts",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blockchain-system",
|
|
3
3
|
"description": "Bridge in-game assets on-chain in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.blockchain (BlockchainService): load blockchain network/config definitions, load the player's on-chain state (linked wallets, pending withdrawals, KYC, stats), deposit a token or NFT from a wallet into the game, request a token or NFT withdrawal out to a wallet, read on-chain transaction history, retry a still-pending withdrawal's signature, confirm a withdrawal's on-chain tx hash, and donate crypto to the developer or a users' pool. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants crypto wallets, NFT deposits/withdrawals, token bridging, on-chain asset transfers, KYC status, or otherwise touches client.blockchain, BlockchainService, BlockchainDefinitions, UserBlockchainState, DepositTokenResponse, TokenWithdrawalResponse, or NFTWithdrawalResponse — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: blockchain-system\ndescription: >-\n Bridge in-game assets on-chain in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.blockchain (BlockchainService): load blockchain\n network/config definitions, load the player's on-chain state (linked\n wallets, pending withdrawals, KYC, stats), deposit a token or NFT from a\n wallet into the game, request a token or NFT withdrawal out to a wallet,\n read on-chain transaction history, retry a still-pending withdrawal's\n signature, confirm a withdrawal's on-chain tx hash, and donate crypto to\n the developer or a users' pool. Use this whenever the user is working in\n the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants crypto wallets, NFT\n deposits/withdrawals, token bridging, on-chain asset transfers, KYC status,\n or otherwise touches client.blockchain, BlockchainService,\n BlockchainDefinitions, UserBlockchainState, DepositTokenResponse,\n TokenWithdrawalResponse, or NFTWithdrawalResponse — even if they don't name\n the module explicitly.\n---\n\n# Blockchain system (iDosGames TS SDK)\n\nThe Blockchain module bridges in-game assets to and from real wallets on\nsupported chains (EVM and Solana networks). A player can deposit a token or\nNFT they already sent on-chain (crediting their in-game balance/inventory),\nor request a withdrawal that pays an in-game token/NFT out to their wallet\n(debiting their in-game balance/inventory and producing a signature the\nplayer submits on-chain themselves). Everything is **server-authoritative**:\nthe client reports/requests, the backend validates the transaction against\nthe chain, applies rules (network enabled, KYC, account-safety policy), and\nthe SDK mirrors the confirmed result into a local cache your UI reads. You\nnever credit/debit balances yourself.\n\nThis skill is for **using** the production `BlockchainService`, not for\nporting or extending it, and not for signing/broadcasting transactions\nyourself — this SDK reports deposits and requests withdrawals; actually\nsending the on-chain transaction (the deposit transfer, or broadcasting a\nwithdrawal signature) happens with a wallet SDK outside this client.\n\n> **The on-chain half is the `@idosgames/wallet` companion package.** It\n> connects browser & mobile wallets (EVM via wagmi/viem/WalletConnect, Solana\n> via wallet-adapter) and runs the exact RewardPool contract calls, threading\n> them through this service's request → submit → confirm / approve → deposit →\n> report lifecycle. If the user wants to actually connect a wallet and move\n> tokens/NFTs (not just call `client.blockchain.*`), reach for that package —\n> see its README. Everything below documents the server-authoritative\n> `client.blockchain` surface that `@idosgames/wallet` builds on. The wallet\n> package's EVM surface covers both NFT standards: `submitEvmNftWithdrawal` /\n> `depositNftEvm` (+ `erc1155Abi`) for ERC-1155 collections, and\n> `submitEvmNftWithdrawal721` / `depositNftEvm721` (+ `erc721Abi`) for\n> ERC-721 unique-item collections (4-arg `safeTransferFrom`, no `id`/`amount`\n> — the token is always qty 1). `submitEvmTokenWithdrawal`'s `withdrawERC20`\n> call now also threads `sig.BurnAmount` through (see\n> [Burn on withdrawal](#gotchas) below) — the ABI/contract call order changed,\n> so an app pinned to an older `@idosgames/wallet` build will revert on-chain\n> against an updated RewardPool contract.\n\n### Operation category (`game_topup` by default)\n\nThe updated RewardPool contract tags every deposit/withdrawal with a string\n**category** (default `\"game_topup\"`; `\"community_reward\"` is the other known\nvalue, and arbitrary strings are allowed). On a **withdrawal** the server signs\nthe category into the on-chain hash and returns it on the signature payload\n(`EvmSignature.Category` / `.TitleID`); whoever submits the transaction **must\npass the same value on-chain verbatim** or the contract rejects the signature —\n`@idosgames/wallet` does this for you. Pass it as the optional last arg to\n`requestTokenWithdrawal`/`requestNFTWithdrawal` (omit → `\"game_topup\"`). On a\n**deposit** the category is read back from the on-chain transaction, so you\ndon't pass it to `depositToken`/`depositNFT`. It also appears on transaction\ndocuments as `Category`. Import the constants from `@idosgames/core`:\n`BlockchainOperationCategory.GameTopUp` / `.CommunityReward`.\n\n## Mental model: deposits vs. withdrawals\n\n- **Deposit** = the player already sent tokens/an NFT to the platform's pool\n or vault address on-chain. The client then calls `depositToken`/`depositNFT`\n with that transaction's hash so the backend can verify it and credit the\n player in-game. One-shot: the credit happens directly on a successful call.\n- **Withdrawal** = the player wants an in-game token/NFT sent out to their\n wallet. The client calls `requestTokenWithdrawal`/`requestNFTWithdrawal`,\n which **debits in-game immediately** and returns a signed payload\n (`EvmSignature` or `SolanaSignature`) the player's wallet must submit\n on-chain to actually receive the asset. This is a **multi-step, async\n flow** — see [Recipes](#recipes) for the full lifecycle, including what to\n do when the on-chain submission fails.\n\nBoth flows are per-network: every call takes a `networkID` that must match one\nof the title's configured `Networks` (EVM or Solana), each with its own\ndeposit/withdrawal enable flags, contract/vault addresses, and (for NFTs) a\ncollection binding to an item catalog. Withdrawals additionally run through a\nlong chain of server-side gates (balances, per-network minimums, account\nsafety, KYC, daily/monthly compliance limits, a collective title-wide pool\ncap, and platform commission) — see\n[Withdrawal gates](#withdrawal-gates-what-can-reject-a-request) for the full\nlist with verbatim error strings.\n\nFor the full config/state field shapes (network definitions, NFT collection\nbindings, KYC tiers, transaction documents, signature payloads), read\n[references/data-model.md](references/data-model.md). You do **not** need it\nto call the methods — only to drive richer UI (network pickers, KYC gates,\ntransaction history tables).\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst blockchain = client.blockchain; // the BlockchainService\n```\n\nEvery blockchain method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always\nbranch on `result.ok` before touching `result.data`. `reason` is one of\n`\"client\"` (missing/empty required arg — rejected before any network call),\n`\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again inside the\n600ms client-side throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the exact backend message). Withdrawals in particular can be\nrejected by a long chain of server-side gates — see\n[Withdrawal gates](#withdrawal-gates-what-can-reject-a-request) below for the\nfull list with verbatim error strings.\n\n| Method | Purpose | `data` on success |\n| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ----------------------------- |\n| `getDefinitions()` | Load the title's blockchain config: networks, NFT bindings, crypto currencies. | `BlockchainConfigResponse` |\n| `getUserState()` | Load this player's on-chain state: linked wallets, pending withdrawals, KYC, stats, crypto balances. | `UserBlockchainStateResponse` |\n| `depositToken(networkID, transactionHash)` | Report an on-chain token transfer; credits the matching crypto balance. | `DepositTokenResponse` |\n| `depositNFT(networkID, transactionHash)` | Report an on-chain NFT transfer; grants the matching in-game item. | `DepositNFTResponse` |\n| `requestTokenWithdrawal(currencyID, networkID, walletAddress, amount, category?)` | Debit a crypto balance and get a signed payload to withdraw on-chain. | `TokenWithdrawalResponse` |\n| `requestNFTWithdrawal(itemID, networkID, walletAddress, amount, category?, level?, itemInstanceID?)` | Consume an in-game item and get a signed payload to withdraw the NFT on-chain. | `NFTWithdrawalResponse` |\n| `getTransactionHistory(limit?)` | Load recent token + NFT transaction documents (default limit 50). | `TransactionHistoryResponse` |\n| `retryWithdrawal(titleTransactionID)` | Re-issue a fresh signature for a still-`Pending` withdrawal without re-debiting. | `RetryWithdrawalResponse` |\n| `confirmWithdrawal(titleTransactionID, onChainTransactionHash)` | Tell the backend the signed withdrawal was submitted on-chain, with its tx hash. | `ConfirmWithdrawalResponse` |\n| `donateToDeveloper(networkID, transactionHash)` | Report an on-chain transfer as a donation to the developer pool (no personal credit). | `DonationResponse` |\n| `donateToUsersPool(networkID, transactionHash)` | Report an on-chain transfer as a donation to the users' pool (no personal credit). | `DonationResponse` |\n\nAll string args (`networkID`, `transactionHash`, `currencyID`,\n`walletAddress`, `amount`, `itemID`, `titleTransactionID`,\n`onChainTransactionHash`) are required and checked client-side before any\nnetwork call — an empty one short-circuits with `reason: \"client\"`. `amount`\nis a decimal string for token withdrawals and an integer-as-string for NFT\nwithdrawals / not used for deposits (deposit amounts come from the verified\non-chain transaction, not from the client). `getTransactionHistory(limit)`\ndefaults to `50`, is capped at **200** server-side (values above are silently\nclamped, values `<= 0` fall back to 50), and is sent as `Amount` on the wire\n(reused request field, not an actual currency amount).\n\n`requestNFTWithdrawal`'s trailing `level`/`itemInstanceID` are both optional\nand only matter for NFT catalogs with leveled or unique (ERC-721) bindings:\n`level` selects which leveled instance/tokenId to withdraw (omit or `1` →\nbase level, prior behavior); `itemInstanceID` is **required** when the\nitem's NFT binding is ERC-721 — it tells the server exactly which\nunstackable instance to debit and tokenize (preserving its Level/RemainingUses/\nCustomData in the on-chain registry via a separate ItemBridge contract).\nBoth are ignored for stackable/ERC-1155 items.\n\nOn success, most methods **mirror the confirmed change into the cache and\nemit an event** — see the next section for exactly which cache each method\ntouches, since it's not uniform across this module.\n\n## Withdrawal gates (what can reject a request)\n\n`requestTokenWithdrawal` / `requestNFTWithdrawal` run through a long chain of\nserver-side checks, each a `reason: \"server\"` failure with a specific `error`\nstring. Surface the string; don't try to pre-validate all of these\nclient-side — the gate list can change without a client update:\n\n- **Global kill switch** — withdrawals can be turned off platform-wide\n independently of any per-network/per-currency flag: `\"Withdrawals are\ncurrently disabled.\"`\n- **Network / currency / binding disabled** — `\"Withdrawals disabled for this\nnetwork.\"`, `\"Withdrawals are disabled for currency '{id}'.\"`, `\"This\ncurrency cannot be withdrawn in this network.\"`, or (currency under\n maintenance) `\"Currency '{id}' is under maintenance. Try again later.\"`\n- **Per-network minimum** — every currency has a `MinWithdraw` for each\n network it's bound to (`CryptoNetworkBinding.MinWithdraw`, see the\n currency-system skill's data-model for the full shape): `\"Minimum withdraw\nis {MinWithdraw} {currencyID}.\"`\n- **Insufficient balance** — `\"Not enough balance: have {available}\n{currencyID}, need {amount}.\"` (tokens) or `\"Not enough items: have {owned},\nneed {amount}.\"` (NFTs).\n- **Account safety** (`BlockchainAccountSafetyPolicy`, read-only in\n `getDefinitions()`'s `AccountSafety` block) — applies to withdrawals only,\n never deposits: account younger than `MinAccountAgeDays` →\n `\"Account is too fresh. Try again later.\"`; banned account → `\"Account is\nbanned. Contact support.\"`; withdrawing to a wallet address another account\n already used, when `MultiAccountCheckEnabled` +\n `BanOnSharedWithdrawalAddress` are both on, **bans the account on the\n spot** and returns `\"Account banned. Contact support.\"`\n- **Compliance / KYC** (`CryptoCurrencyDefinition.Limits`, per currency) —\n checked in USD-equivalent of the requested amount:\n - Above `Limits.KycRequiredAboveUsd` without `Kyc.Status === \"Verified\"` →\n `\"KYC verification required for withdrawals above {threshold} USD.\"`\n - This UTC calendar day's spend would exceed `Limits.DailyWithdrawUsd`\n (window resets at 00:00 UTC, not a rolling 24h window) →\n `\"Daily withdraw limit exceeded ({spentSoFar} + {thisAmount} >\n{dailyLimit} USD).\"`\n - This UTC calendar month's spend would exceed `Limits.MonthlyWithdrawUsd`\n (window resets 00:00 UTC on the 1st) → `\"Monthly withdraw limit exceeded\n({spentSoFar} + {thisAmount} > {monthlyLimit} USD).\"`\n - Any `Limits` field can be absent/null, which disables that specific\n check for that currency. The daily/monthly counters live server-side on\n `UserCryptoCurrencyState.Compliance` (not exposed as its own client\n method) and reset at UTC day/month boundaries — there is no way to read\n \"USD spent so far today\" from the client ahead of a request; read it off\n a rejection's `error` string instead.\n- **Collective pool cap** — independent of the player's own balance, the\n title's whole player-withdrawable pool for that (network, currency) pair\n can be exhausted: `\"Title users-withdrawable limit reached: available\n{available} {currencyID}, requested {amount}.\"` This is a title-wide\n economic limit, not specific to one player — if you see it, don't retry\n immediately.\n- **Platform commission** — a platform-wide withdrawal commission percent can\n reduce the net payout; if it would consume the entire requested amount,\n the request is rejected outright: `\"Withdrawal amount is fully consumed by\nplatform commission.\"` Otherwise the withdrawal proceeds and\n `NetAmountNative` reflects the amount after commission (see\n [Gotchas](#gotchas)).\n\nNone of these are configurable or visible as a single \"can I withdraw right\nnow\" flag — the practical pattern is: build the request, call it, and render\n`error` on failure. Use `getDefinitions()`'s `AccountSafety` block and the\ncurrency's `Limits` (from `getDefinitions()`'s sibling `CryptoCurrencies` map)\nonly for soft, non-authoritative UI hints (e.g. \"KYC may be required above\n$X\").\n\n## Reading state and reacting to changes\n\n```ts\n// On-chain activity state (only present after getUserState()):\nconst bc = client.data.user.state?.Blockchain;\nbc?.LinkedWallets; // Record<networkID, LinkedWalletInfo>\nbc?.PendingWithdrawals; // PendingWithdrawalRef[] — light refs, not full tx docs\nbc?.Kyc; // UserKycState\nbc?.Stats; // BlockchainStats (deposit/withdrawal counters & volume)\n\n// Crypto balances (decimal-as-string), same cache Currency module reads:\nclient.data.user.getCryptoCurrencyAmount(\"usdt\");\n\n// Definitions (cached after getDefinitions()):\nimport type { BlockchainDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<BlockchainDefinitions>(\"Blockchain\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `blockchain:definitionsLoaded` → `BlockchainConfigResponse`\n- `blockchain:userStateLoaded` → `UserBlockchainStateResponse`\n- `blockchain:tokenDeposited` → `DepositTokenResponse`\n- `blockchain:nftDeposited` → `DepositNFTResponse`\n- `blockchain:tokenWithdrawalRequested` → `TokenWithdrawalResponse`\n- `blockchain:nftWithdrawalRequested` → `NFTWithdrawalResponse`\n- `blockchain:transactionHistoryLoaded` → `TransactionHistoryResponse`\n- `blockchain:withdrawalRetried` → `RetryWithdrawalResponse`\n- `blockchain:withdrawalConfirmed` → `ConfirmWithdrawalResponse`\n- `blockchain:donatedToDeveloper` → `DonationResponse`\n- `blockchain:donatedToUsersPool` → `DonationResponse`\n\n**Cache writes are not uniform across this module — read this carefully:**\n\n- `getUserState()` is the only call that writes `client.data.user.state.Blockchain`\n (`LinkedWallets`, `PendingWithdrawals`, `Kyc`, `Stats`) and fires the coarse\n `user:blockchainUpdated` (+ `user:anyUpdated`).\n- `depositToken` / `requestTokenWithdrawal` patch only the crypto **balance**\n (`InventoryV2.CryptoCurrencies`) via a decimal delta, firing\n `user:inventoryUpdated` (+ `user:anyUpdated`) — **not** `user:blockchainUpdated`.\n- `depositNFT` / `requestNFTWithdrawal` patch inventory (items and/or\n currencies) via the shared `Resources` resource-operation pipeline, firing\n `user:inventoryUpdated` (and `user:virtualCurrencyUpdated` if VC moved) —\n again **not** `user:blockchainUpdated`.\n- `getTransactionHistory`, `retryWithdrawal`, `confirmWithdrawal`,\n `donateToDeveloper`, `donateToUsersPool` only emit their own\n `blockchain:*` event — they don't touch `client.data.user.state` at all.\n\nPractical consequence: after a deposit or withdrawal request, your **balance**\nis fresh in the cache, but `client.data.user.state.Blockchain.PendingWithdrawals`\nand `.Stats` are stale until you call `getUserState()` again. Re-fetch\n`getUserState()` after a withdrawal request/confirm/retry if your UI shows the\npending-withdrawals list or stats.\n\n**`StateDelta` / `Inventory` — the response already carries what changed, if\nyou want to apply it yourself instead of re-fetching.** `DepositTokenResponse`,\n`TokenWithdrawalResponse`, `NFTWithdrawalResponse`, and\n`ConfirmWithdrawalResponse` all carry an optional `StateDelta`\n(`BlockchainStateDelta`): a signed `CryptoBalances` delta per currency\n(`{ AmountDelta, FrozenDelta, UpdatedAt }` — add, don't overwrite), a\n`PendingAdded` ref (this call's newly-added pending withdrawal, if any), and\n`PendingRemovedIDs` (pending withdrawals this call confirmed or lazily\nexpired). `DepositNFTResponse` / `NFTWithdrawalResponse` similarly carry an\n`Inventory` (`InventoryDelta`) for the NFT's `UnstackableItems` instance —\nsame shape/semantics as the character-system module's `Inventory` deltas\n(`ChangedInstances` to upsert, `RemovedInstanceIDs` to drop). This mirrors the\nself-sufficient-response pattern used elsewhere in this SDK (see the\ncharacter-system skill) so a client that wants to reconcile\n`PendingWithdrawals`/balances/instances without another round trip can do so\nstraight from the mutating call's response. **Note:** `BlockchainService`\nitself does not auto-apply `StateDelta`/`Inventory` into\n`client.data.user.state.Blockchain` today — only the crypto **balance**\n(via the existing `AmountNative`-based patch) and item `Resources` are\napplied automatically. If you need `PendingWithdrawals` reconciled without a\nfull `getUserState()` refetch, read `result.data.StateDelta` yourself. Both\nare `null`/absent on an idempotent replay (nothing new to apply).\n\n```ts\nconst off = client.on(\"blockchain:tokenWithdrawalRequested\", (r) => {\n console.log(`Withdrawal ${r.TitleTransactionID} expires at ${r.ExpiresAt}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load config + state on a wallet/blockchain screen\n\n```ts\nawait client.blockchain.getDefinitions();\nawait client.blockchain.getUserState();\n\nconst defs = client.data.config.getSection<BlockchainDefinitions>(\"Blockchain\");\nconst bc = client.data.user.state?.Blockchain;\n\nfor (const [networkID, net] of Object.entries(defs?.Networks ?? {})) {\n if (!net.DepositsEnabled && !net.WithdrawalsEnabled) continue;\n // render a network card; net.NftCollections binds contracts to item catalogs\n}\nbc?.Kyc?.Status; // gate withdrawal UI on KYC if the title requires it\n```\n\n### Deposit a token (player already sent it on-chain)\n\n```ts\nconst res = await client.blockchain.depositToken(\"polygon\", \"0xabc123...\");\nif (!res.ok) return showError(res.error); // e.g. \"Transaction not found on chain.\",\n// \"Not enough confirmations (required 12). Try again in a few minutes.\",\n// \"Transaction hash already used.\"\n\nres.data.CurrencyID; // e.g. \"usdt\"\nres.data.AmountNative; // decimal string credited\n// Balance is already updated in the cache:\nclient.data.user.getCryptoCurrencyAmount(res.data.CurrencyID!);\n```\n\n### Full withdrawal lifecycle: request -> submit on-chain -> confirm, with a retry-after-failure path\n\n```ts\n// 1. Request the withdrawal — debits in-game immediately, returns a signature payload.\nconst req = await client.blockchain.requestTokenWithdrawal(\n \"usdt\",\n \"polygon\",\n \"0xPlayerWallet...\",\n \"25.00\",\n);\nif (!req.ok) return showError(req.error); // e.g. \"Not enough balance: have 10 usdt, need 25.00.\",\n// \"KYC verification required for withdrawals above 1000 USD.\",\n// \"Title users-withdrawable limit reached: available 5 usdt, requested 25.00.\"\n// — see \"Withdrawal gates\" above for the full list.\n\nconst { TitleTransactionID, EvmSignature, ExpiresAt } = req.data;\n// Balance is already debited (gross amount) in the cache.\n\n// 2. Hand EvmSignature (or SolanaSignature on a Solana network) to the\n// player's wallet SDK to submit the on-chain transaction yourself —\n// this SDK does not sign/broadcast. That step can fail (rejected in\n// wallet, gas issue).\n\n// 2a. If on-chain submission failed WHILE the transaction is still Pending\n// (before ExpiresAt), retry — this re-issues a fresh signature WITHOUT\n// debiting again:\nconst retry = await client.blockchain.retryWithdrawal(TitleTransactionID!);\nif (!retry.ok) return showError(retry.error); // e.g. \"Transaction is not in Pending state (current: Abandoned).\"\nconst freshSignature = retry.data.EvmSignature ?? retry.data.SolanaSignature;\n// Submit freshSignature on-chain instead, then continue to step 3.\n//\n// IMPORTANT: retryWithdrawal only works while the transaction is Pending. If\n// ExpiresAt already passed, the backend has lazily moved it to Abandoned and\n// retryWithdrawal will reject it — there is no \"re-request\" for an Abandoned\n// withdrawal (the asset was already debited and is not refunded). The only\n// way to still complete it is confirmWithdrawal with a hash, if the player\n// actually managed to submit the original signature before it was swept —\n// see the Gotchas section.\n\n// 3. Once the wallet actually broadcasts the transaction, tell the backend\n// the resulting on-chain hash so it can verify and close out the withdrawal:\nconst confirm = await client.blockchain.confirmWithdrawal(\n TitleTransactionID!,\n \"0xOnChainTxHash...\",\n);\nif (!confirm.ok) return showError(confirm.error);\nconfirm.data.Status; // e.g. \"Completed\" once the chain confirms it\n\n// 4. Refresh state — request/retry/confirm don't touch Blockchain cache themselves.\nawait client.blockchain.getUserState();\nclient.data.user.state?.Blockchain?.PendingWithdrawals; // should no longer list it once Completed\n```\n\n### KYC-gated withdrawal\n\nThe client never decides whether KYC is required — the backend compares the\nwithdrawal's USD-equivalent against the currency's configured threshold at\nrequest time. Use `Kyc.Status` only to pre-empt an obvious rejection in the\nUI; still branch on the real error:\n\n```ts\nawait client.blockchain.getUserState();\nconst kyc = client.data.user.state?.Blockchain?.Kyc;\n\nif (kyc?.Status !== \"Verified\") {\n // Optional UX nicety: warn before the call for large amounts. This SDK has\n // no startKyc/submitKyc method — verification happens through whatever KYC\n // provider integration the title uses outside this SDK; Kyc here only\n // reflects the result.\n}\n\nconst req = await client.blockchain.requestTokenWithdrawal(\n \"usdt\",\n \"polygon\",\n \"0xPlayerWallet...\",\n \"5000.00\",\n);\nif (!req.ok) {\n if (req.error.startsWith(\"KYC verification required\")) {\n // Route the player to the title's KYC verification flow.\n }\n return showError(req.error);\n}\n```\n\n### Deposit / withdraw an NFT\n\n```ts\n// Deposit: player already transferred the NFT to the vault address on-chain.\nconst dep = await client.blockchain.depositNFT(\"ethereum\", \"0xNftDepositTx...\");\nif (!dep.ok) return showError(dep.error);\ndep.data.ItemID; // the in-game item granted\ndep.data.Resources; // already applied to inventory in the cache\n\n// Withdraw: consumes the in-game item, returns a signature to submit on-chain.\nconst wd = await client.blockchain.requestNFTWithdrawal(\n \"sword-of-embers-inst-1\", // ItemID (per NFTWithdrawalResponse/BlockchainRequest shape)\n \"ethereum\",\n \"0xPlayerWallet...\",\n \"1\",\n);\nif (!wd.ok) return showError(wd.error);\nwd.data.TitleTransactionID; // use with retryWithdrawal / confirmWithdrawal exactly as tokens above\n\n// ERC-721 unique NFT: pass the specific instance to tokenize. level/itemInstanceID\n// are the trailing optional args — see the Methods table above.\nconst wd721 = await client.blockchain.requestNFTWithdrawal(\n \"sword-of-embers\",\n \"ethereum\",\n \"0xPlayerWallet...\",\n \"1\",\n undefined, // category\n 1, // level\n \"sword-of-embers-inst-1\", // ItemInstanceID — required for ERC-721 bindings\n);\n```\n\n### Edge case: not logged in / missing args\n\n```ts\nconst res = await client.blockchain.depositToken(\"\", \"0xabc\");\n// res.ok === false, res.reason === \"client\" — \"NetworkID is required.\" — no network call.\n\nconst res2 = await client.blockchain.getUserState();\n// If called before auth.loginWithDeviceID() (or any auth.* login):\n// res2.ok === false, res2.reason === \"unauthorized\"\n```\n\n### Donate crypto (no personal credit)\n\n```ts\nconst res = await client.blockchain.donateToDeveloper(\n \"polygon\",\n \"0xdonateTx...\",\n);\nif (!res.ok) return showError(res.error);\nres.data.Target; // \"Developer\" — confirms which pool bucket it landed in\n\n// donateToUsersPool is identical in shape, credits the users' pool bucket instead:\nawait client.blockchain.donateToUsersPool(\"polygon\", \"0xdonateTx2...\");\n```\n\n## Gotchas\n\n- **Withdrawal request debits immediately; the on-chain leg is separate and\n can fail.** `requestTokenWithdrawal`/`requestNFTWithdrawal` already took the\n asset from the player before any on-chain transaction exists. If the\n player's wallet fails to submit (rejected, gas issue) **while the\n transaction is still `Pending`**, don't ask them to request again — that\n would debit twice. Use `retryWithdrawal` with the same\n `TitleTransactionID` to get a fresh signature without a new charge. This\n only works before `ExpiresAt` — see the next two points for what happens\n after.\n- **`retryWithdrawal` vs `confirmWithdrawal` are opposite ends of the same\n flow.** Retry re-issues the _signed payload_ before submission (nothing has\n reached the chain yet); confirm reports the _resulting tx hash_ after\n submission (the chain now has it). Calling confirm with a hash from a\n transaction that never actually landed on-chain will simply fail\n server-side verification — don't fabricate a hash to \"force\" completion.\n- **`ExpiresAt` is real, expiry does not refund the player, and — contrary to\n what the name suggests — an expired withdrawal is NOT retryable.** A\n withdrawal signature is time-boxed (`TokenWithdrawalResponse.ExpiresAt` /\n `NFTWithdrawalResponse.ExpiresAt`, driven by\n `BlockchainAccountSafetyPolicy.PendingWithdrawalTtlHours`). Once it passes\n without a submission, the backend lazily transitions the transaction to\n **`Abandoned`** (not `Expired` — that enum value exists but this backend\n path never assigns it) and drops it off `PendingWithdrawals` — but the\n already-debited asset is **not** credited back; this is intentional, not a\n bug. Critically, `retryWithdrawal` requires the transaction to still be\n `Pending` — calling it on an `Abandoned` one fails with `\"Transaction is\nnot in Pending state (current: Abandoned).\"` There is no \"re-request\"\n operation for an abandoned withdrawal.\n- **A withdrawal can still be confirmed after it's `Abandoned`.** If the\n player submits late — after `ExpiresAt` passed and the backend already\n swept it to `Abandoned` — `confirmWithdrawal` still accepts it as long as\n the on-chain transaction verifies (the signature itself doesn't expire\n on-chain, only the title's own bookkeeping window does). Don't treat an\n `Abandoned` transaction as unrecoverable if the player insists they\n submitted it; calling `confirmWithdrawal` with the resulting hash is still\n the right move, and is in fact the _only_ way to close out an\n already-expired-but-actually-submitted withdrawal.\n- **`retryWithdrawal` only works on a `Pending` transaction the caller owns.**\n It fails with `\"Transaction not found.\"` for an unknown or someone else's\n `TitleTransactionID`, `\"Transaction is not in Pending state (current:\n{status}).\"` if it already completed/failed/was abandoned, or\n `\"Signature data not found for this transaction.\"` if there's nothing to\n reissue. A banned account additionally gets `\"Account is banned. Contact\nsupport.\"` on retry (deposits stay allowed for banned accounts; retrying a\n withdrawal does not).\n- **Gross vs. net amounts on token withdrawals.** `AmountNative` is what was\n debited from the player (gross); `NetAmountNative` is what actually gets\n paid out on-chain after a platform commission percentage **and** an\n optional on-chain burn are deducted (`NetAmountNative = AmountNative −\ncommission − BurnAmountNative`). Show the player the net figure they'll\n receive, not the gross debit, to avoid support tickets about a \"missing\"\n amount. NFT withdrawals have no such split — there's no `NetAmountNative`\n on `NFTWithdrawalResponse`.\n- **Burn on withdrawal (EVM-only).** `TokenWithdrawalResponse.BurnAmountNative`\n is the amount burned on-chain (sent to the DEAD address) for this\n withdrawal, driven by the currency's `WithdrawalBurnPercent` (see the\n currency-system skill) — `0` if burn is disabled for that currency or the\n network is Solana. The raw-units counterpart, `WithdrawalSignatureResponse.\nBurnAmount`, is bound into the signed hash and must be passed to the\n contract call verbatim, same as `Amount`/`Nonce` — `@idosgames/wallet`'s\n `submitEvmTokenWithdrawal` does this for you; a client calling\n `withdrawERC20` directly must include it too, or the signature check fails.\n- **`client.data.user.state.Blockchain` goes stale after deposits/withdrawal\n requests.** Only `getUserState()` refreshes `LinkedWallets`,\n `PendingWithdrawals`, `Kyc`, and `Stats`. A deposit/withdrawal call updates\n your _balance_/_inventory_ cache correctly, but if your UI also shows the\n pending-withdrawals list or lifetime stats, re-call `getUserState()`\n afterward (see the withdrawal recipe above).\n- **Deposits are reporting, not sending.** `depositToken`/`depositNFT` don't\n move any asset on-chain — they tell the backend \"verify this transaction\n hash and credit me.\" The actual on-chain transfer to the platform's pool/\n vault address must already have happened via a wallet SDK before you call\n these.\n- **This SDK never signs or broadcasts.** `EvmSignature`/`SolanaSignature`\n payloads are inputs to a wallet SDK/contract call that happens outside\n `@idosgames/core`. Don't look for a \"submit on-chain\" method here — there\n isn't one; `confirmWithdrawal` only reports the result afterward. The\n `@idosgames/wallet` companion package is that outside layer — it submits the\n signature on-chain and calls `confirmWithdrawal` for you.\n- **Donations never touch personal balances.** `donateToDeveloper` /\n `donateToUsersPool` intentionally don't credit the player anything and\n don't touch `client.data.user.state` — they only emit their own\n `blockchain:donated*` event for a confirmation toast/receipt.\n- **Render from the cache, handle the error from the result.** The happy\n path updates the relevant cache slice + emits an event; the failure path\n gives you `reason` + `error`. Use `reason` to decide behavior (retry on\n `\"connection\"`, re-auth on `\"unauthorized\"`, toast the `error` on\n `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field: network definitions, NFT collection bindings, account-safety\npolicy, KYC state, transaction documents, the withdrawal-gate limits, and the\nEVM/Solana withdrawal signature payload shapes. Read it when building network\npickers, a transaction-history table, or KYC/limit-aware withdrawal UI. For\nthe shared `ResourceConsume`/`ResourceGrant`/`ResourceOperation`\ncost-and-reward shapes riding along on `depositNFT`/`requestNFTWithdrawal`,\nand for the full `CryptoCurrencyDefinition` shape (`Limits`, `Networks[].\nMinWithdraw`/`WithdrawFee`, `Permissions`) that the withdrawal gates enforce,\nsee the currency-system skill.\n",
|
|
4
|
+
"content": "---\nname: blockchain-system\ndescription: >-\n Bridge in-game assets on-chain in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.blockchain (BlockchainService): load blockchain\n network/config definitions, load the player's on-chain state (linked\n wallets, pending withdrawals, KYC, stats), deposit a token or NFT from a\n wallet into the game, request a token or NFT withdrawal out to a wallet,\n read on-chain transaction history, retry a still-pending withdrawal's\n signature, confirm a withdrawal's on-chain tx hash, and donate crypto to\n the developer or a users' pool. Use this whenever the user is working in\n the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants crypto wallets, NFT\n deposits/withdrawals, token bridging, on-chain asset transfers, KYC status,\n or otherwise touches client.blockchain, BlockchainService,\n BlockchainDefinitions, UserBlockchainState, DepositTokenResponse,\n TokenWithdrawalResponse, or NFTWithdrawalResponse — even if they don't name\n the module explicitly.\n---\n\n# Blockchain system (iDosGames TS SDK)\n\nThe Blockchain module bridges in-game assets to and from real wallets on\nsupported chains (EVM and Solana networks). A player can deposit a token or\nNFT they already sent on-chain (crediting their in-game balance/inventory),\nor request a withdrawal that pays an in-game token/NFT out to their wallet\n(debiting their in-game balance/inventory and producing a signature the\nplayer submits on-chain themselves). Everything is **server-authoritative**:\nthe client reports/requests, the backend validates the transaction against\nthe chain, applies rules (network enabled, KYC, account-safety policy), and\nthe SDK mirrors the confirmed result into a local cache your UI reads. You\nnever credit/debit balances yourself.\n\nThis skill is for **using** the production `BlockchainService`, not for\nporting or extending it, and not for signing/broadcasting transactions\nyourself — this SDK reports deposits and requests withdrawals; actually\nsending the on-chain transaction (the deposit transfer, or broadcasting a\nwithdrawal signature) happens with a wallet SDK outside this client.\n\n> **The on-chain half is the `@idosgames/wallet` companion package.** It\n> connects browser & mobile wallets (EVM via wagmi/viem/WalletConnect, Solana\n> via wallet-adapter) and runs the exact RewardPool contract calls, threading\n> them through this service's request → submit → confirm / approve → deposit →\n> report lifecycle. If the user wants to actually connect a wallet and move\n> tokens/NFTs (not just call `client.blockchain.*`), reach for that package —\n> see its README. Everything below documents the server-authoritative\n> `client.blockchain` surface that `@idosgames/wallet` builds on. The wallet\n> package's EVM surface covers both NFT standards: `submitEvmNftWithdrawal` /\n> `depositNftEvm` (+ `erc1155Abi`) for ERC-1155 collections, and\n> `submitEvmNftWithdrawal721` / `depositNftEvm721` (+ `erc721Abi`) for\n> ERC-721 unique-item collections (4-arg `safeTransferFrom`, no `id`/`amount`\n> — the token is always qty 1). `submitEvmTokenWithdrawal`'s `withdrawERC20`\n> call now also threads `sig.BurnAmount` through (see\n> [Burn on withdrawal](#gotchas) below) — the ABI/contract call order changed,\n> so an app pinned to an older `@idosgames/wallet` build will revert on-chain\n> against an updated RewardPool contract.\n\n> **Never import `@idosgames/wallet/react` (or `/react/solana`) from a file\n> that loads on startup.** Those subpaths pull in Reown AppKit, and AppKit is\n> deliberately _not_ a dependency of a generated project — the live preview\n> resolves every declared dependency up front and times out on AppKit's tree.\n> A static import therefore blanks the preview before any game code runs\n> (`Could not find dependency: '@reown/appkit-adapter-wagmi'`), while the real\n> build stays green — that mismatch is the signature of this mistake.\n> Two rules keep both working:\n>\n> - **Sign-in button:** import `LazyWalletLogin` / `LazySolanaWalletLogin` from\n> `@idosgames/wallet/react/lazy` (that entry has no AppKit in its graph; it\n> also re-exports the chains as plain objects — never import chains from\n> `wagmi/chains` or `viem/chains`, that barrel breaks the preview too).\n> - **In-game deposit/withdraw panel:** import `LazyWalletPanel` from\n> `@idosgames/wallet/react/lazy` and pass it the authenticated `client` (a\n> prop, like the login button — never a module context). Same lazy contract:\n> AppKit stays out of the startup graph. Don't hand-roll your own\n> `await import(\"./PanelImpl\")` wrapper — `LazyWalletPanel` is that wrapper.\n> Because the wallet config is memoised per WalletConnect project id, the\n> panel reuses the wallet the player connected at sign-in: same store, so it\n> opens already-connected, no second tap and no second modal. This is exactly\n> how the board-game and idle-rpg modules wire their `WalletPanel.tsx`.\n\n### Operation category (`game_topup` by default)\n\nThe updated RewardPool contract tags every deposit/withdrawal with a string\n**category** (default `\"game_topup\"`; `\"community_reward\"` is the other known\nvalue, and arbitrary strings are allowed). On a **withdrawal** the server signs\nthe category into the on-chain hash and returns it on the signature payload\n(`EvmSignature.Category` / `.TitleID`); whoever submits the transaction **must\npass the same value on-chain verbatim** or the contract rejects the signature —\n`@idosgames/wallet` does this for you. Pass it as the optional last arg to\n`requestTokenWithdrawal`/`requestNFTWithdrawal` (omit → `\"game_topup\"`). On a\n**deposit** the category is read back from the on-chain transaction, so you\ndon't pass it to `depositToken`/`depositNFT`. It also appears on transaction\ndocuments as `Category`. Import the constants from `@idosgames/core`:\n`BlockchainOperationCategory.GameTopUp` / `.CommunityReward`.\n\n## Mental model: deposits vs. withdrawals\n\n- **Deposit** = the player already sent tokens/an NFT to the platform's pool\n or vault address on-chain. The client then calls `depositToken`/`depositNFT`\n with that transaction's hash so the backend can verify it and credit the\n player in-game. One-shot: the credit happens directly on a successful call.\n- **Withdrawal** = the player wants an in-game token/NFT sent out to their\n wallet. The client calls `requestTokenWithdrawal`/`requestNFTWithdrawal`,\n which **debits in-game immediately** and returns a signed payload\n (`EvmSignature` or `SolanaSignature`) the player's wallet must submit\n on-chain to actually receive the asset. This is a **multi-step, async\n flow** — see [Recipes](#recipes) for the full lifecycle, including what to\n do when the on-chain submission fails.\n\nBoth flows are per-network: every call takes a `networkID` that must match one\nof the title's configured `Networks` (EVM or Solana), each with its own\ndeposit/withdrawal enable flags, contract/vault addresses, and (for NFTs) a\ncollection binding to an item catalog. Withdrawals additionally run through a\nlong chain of server-side gates (balances, per-network minimums, account\nsafety, KYC, daily/monthly compliance limits, a collective title-wide pool\ncap, and platform commission) — see\n[Withdrawal gates](#withdrawal-gates-what-can-reject-a-request) for the full\nlist with verbatim error strings.\n\nFor the full config/state field shapes (network definitions, NFT collection\nbindings, KYC tiers, transaction documents, signature payloads), read\n[references/data-model.md](references/data-model.md). You do **not** need it\nto call the methods — only to drive richer UI (network pickers, KYC gates,\ntransaction history tables).\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst blockchain = client.blockchain; // the BlockchainService\n```\n\nEvery blockchain method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always\nbranch on `result.ok` before touching `result.data`. `reason` is one of\n`\"client\"` (missing/empty required arg — rejected before any network call),\n`\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again inside the\n600ms client-side throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the exact backend message). Withdrawals in particular can be\nrejected by a long chain of server-side gates — see\n[Withdrawal gates](#withdrawal-gates-what-can-reject-a-request) below for the\nfull list with verbatim error strings.\n\n| Method | Purpose | `data` on success |\n| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ----------------------------- |\n| `getDefinitions()` | Load the title's blockchain config: networks, NFT bindings, crypto currencies. | `BlockchainConfigResponse` |\n| `getUserState()` | Load this player's on-chain state: linked wallets, pending withdrawals, KYC, stats, crypto balances. | `UserBlockchainStateResponse` |\n| `depositToken(networkID, transactionHash)` | Report an on-chain token transfer; credits the matching crypto balance. | `DepositTokenResponse` |\n| `depositNFT(networkID, transactionHash)` | Report an on-chain NFT transfer; grants the matching in-game item. | `DepositNFTResponse` |\n| `requestTokenWithdrawal(currencyID, networkID, walletAddress, amount, category?)` | Debit a crypto balance and get a signed payload to withdraw on-chain. | `TokenWithdrawalResponse` |\n| `requestNFTWithdrawal(itemID, networkID, walletAddress, amount, category?, level?, itemInstanceID?)` | Consume an in-game item and get a signed payload to withdraw the NFT on-chain. | `NFTWithdrawalResponse` |\n| `getTransactionHistory(limit?)` | Load recent token + NFT transaction documents (default limit 50). | `TransactionHistoryResponse` |\n| `retryWithdrawal(titleTransactionID)` | Re-issue a fresh signature for a still-`Pending` withdrawal without re-debiting. | `RetryWithdrawalResponse` |\n| `confirmWithdrawal(titleTransactionID, onChainTransactionHash)` | Tell the backend the signed withdrawal was submitted on-chain, with its tx hash. | `ConfirmWithdrawalResponse` |\n| `donateToDeveloper(networkID, transactionHash)` | Report an on-chain transfer as a donation to the developer pool (no personal credit). | `DonationResponse` |\n| `donateToUsersPool(networkID, transactionHash)` | Report an on-chain transfer as a donation to the users' pool (no personal credit). | `DonationResponse` |\n\nAll string args (`networkID`, `transactionHash`, `currencyID`,\n`walletAddress`, `amount`, `itemID`, `titleTransactionID`,\n`onChainTransactionHash`) are required and checked client-side before any\nnetwork call — an empty one short-circuits with `reason: \"client\"`. `amount`\nis a decimal string for token withdrawals and an integer-as-string for NFT\nwithdrawals / not used for deposits (deposit amounts come from the verified\non-chain transaction, not from the client). `getTransactionHistory(limit)`\ndefaults to `50`, is capped at **200** server-side (values above are silently\nclamped, values `<= 0` fall back to 50), and is sent as `Amount` on the wire\n(reused request field, not an actual currency amount).\n\n`requestNFTWithdrawal`'s trailing `level`/`itemInstanceID` are both optional\nand only matter for NFT catalogs with leveled or unique (ERC-721) bindings:\n`level` selects which leveled instance/tokenId to withdraw (omit or `1` →\nbase level, prior behavior); `itemInstanceID` is **required** when the\nitem's NFT binding is ERC-721 — it tells the server exactly which\nunstackable instance to debit and tokenize (preserving its Level/RemainingUses/\nCustomData in the on-chain registry via a separate ItemBridge contract).\nBoth are ignored for stackable/ERC-1155 items.\n\nOn success, most methods **mirror the confirmed change into the cache and\nemit an event** — see the next section for exactly which cache each method\ntouches, since it's not uniform across this module.\n\n## Withdrawal gates (what can reject a request)\n\n`requestTokenWithdrawal` / `requestNFTWithdrawal` run through a long chain of\nserver-side checks, each a `reason: \"server\"` failure with a specific `error`\nstring. Surface the string; don't try to pre-validate all of these\nclient-side — the gate list can change without a client update:\n\n- **Global kill switch** — withdrawals can be turned off platform-wide\n independently of any per-network/per-currency flag: `\"Withdrawals are\ncurrently disabled.\"`\n- **Network / currency / binding disabled** — `\"Withdrawals disabled for this\nnetwork.\"`, `\"Withdrawals are disabled for currency '{id}'.\"`, `\"This\ncurrency cannot be withdrawn in this network.\"`, or (currency under\n maintenance) `\"Currency '{id}' is under maintenance. Try again later.\"`\n- **Per-network minimum** — every currency has a `MinWithdraw` for each\n network it's bound to (`CryptoNetworkBinding.MinWithdraw`, see the\n currency-system skill's data-model for the full shape): `\"Minimum withdraw\nis {MinWithdraw} {currencyID}.\"`\n- **Insufficient balance** — `\"Not enough balance: have {available}\n{currencyID}, need {amount}.\"` (tokens) or `\"Not enough items: have {owned},\nneed {amount}.\"` (NFTs).\n- **Account safety** (`BlockchainAccountSafetyPolicy`, read-only in\n `getDefinitions()`'s `AccountSafety` block) — applies to withdrawals only,\n never deposits: account younger than `MinAccountAgeDays` →\n `\"Account is too fresh. Try again later.\"`; banned account → `\"Account is\nbanned. Contact support.\"`; withdrawing to a wallet address another account\n already used, when `MultiAccountCheckEnabled` +\n `BanOnSharedWithdrawalAddress` are both on, **bans the account on the\n spot** and returns `\"Account banned. Contact support.\"`\n- **Compliance / KYC** (`CryptoCurrencyDefinition.Limits`, per currency) —\n checked in USD-equivalent of the requested amount:\n - Above `Limits.KycRequiredAboveUsd` without `Kyc.Status === \"Verified\"` →\n `\"KYC verification required for withdrawals above {threshold} USD.\"`\n - This UTC calendar day's spend would exceed `Limits.DailyWithdrawUsd`\n (window resets at 00:00 UTC, not a rolling 24h window) →\n `\"Daily withdraw limit exceeded ({spentSoFar} + {thisAmount} >\n{dailyLimit} USD).\"`\n - This UTC calendar month's spend would exceed `Limits.MonthlyWithdrawUsd`\n (window resets 00:00 UTC on the 1st) → `\"Monthly withdraw limit exceeded\n({spentSoFar} + {thisAmount} > {monthlyLimit} USD).\"`\n - Any `Limits` field can be absent/null, which disables that specific\n check for that currency. The daily/monthly counters live server-side on\n `UserCryptoCurrencyState.Compliance` (not exposed as its own client\n method) and reset at UTC day/month boundaries — there is no way to read\n \"USD spent so far today\" from the client ahead of a request; read it off\n a rejection's `error` string instead.\n- **Collective pool cap** — independent of the player's own balance, the\n title's whole player-withdrawable pool for that (network, currency) pair\n can be exhausted: `\"Title users-withdrawable limit reached: available\n{available} {currencyID}, requested {amount}.\"` This is a title-wide\n economic limit, not specific to one player — if you see it, don't retry\n immediately.\n- **Platform commission** — a platform-wide withdrawal commission percent can\n reduce the net payout; if it would consume the entire requested amount,\n the request is rejected outright: `\"Withdrawal amount is fully consumed by\nplatform commission.\"` Otherwise the withdrawal proceeds and\n `NetAmountNative` reflects the amount after commission (see\n [Gotchas](#gotchas)).\n\nNone of these are configurable or visible as a single \"can I withdraw right\nnow\" flag — the practical pattern is: build the request, call it, and render\n`error` on failure. Use `getDefinitions()`'s `AccountSafety` block and the\ncurrency's `Limits` (from `getDefinitions()`'s sibling `CryptoCurrencies` map)\nonly for soft, non-authoritative UI hints (e.g. \"KYC may be required above\n$X\").\n\n## Reading state and reacting to changes\n\n```ts\n// On-chain activity state (only present after getUserState()):\nconst bc = client.data.user.state?.Blockchain;\nbc?.LinkedWallets; // Record<networkID, LinkedWalletInfo>\nbc?.PendingWithdrawals; // PendingWithdrawalRef[] — light refs, not full tx docs\nbc?.Kyc; // UserKycState\nbc?.Stats; // BlockchainStats (deposit/withdrawal counters & volume)\n\n// Crypto balances (decimal-as-string), same cache Currency module reads:\nclient.data.user.getCryptoCurrencyAmount(\"usdt\");\n\n// Definitions (cached after getDefinitions()):\nimport type { BlockchainDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<BlockchainDefinitions>(\"Blockchain\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `blockchain:definitionsLoaded` → `BlockchainConfigResponse`\n- `blockchain:userStateLoaded` → `UserBlockchainStateResponse`\n- `blockchain:tokenDeposited` → `DepositTokenResponse`\n- `blockchain:nftDeposited` → `DepositNFTResponse`\n- `blockchain:tokenWithdrawalRequested` → `TokenWithdrawalResponse`\n- `blockchain:nftWithdrawalRequested` → `NFTWithdrawalResponse`\n- `blockchain:transactionHistoryLoaded` → `TransactionHistoryResponse`\n- `blockchain:withdrawalRetried` → `RetryWithdrawalResponse`\n- `blockchain:withdrawalConfirmed` → `ConfirmWithdrawalResponse`\n- `blockchain:donatedToDeveloper` → `DonationResponse`\n- `blockchain:donatedToUsersPool` → `DonationResponse`\n\n**Cache writes are not uniform across this module — read this carefully:**\n\n- `getUserState()` is the only call that writes `client.data.user.state.Blockchain`\n (`LinkedWallets`, `PendingWithdrawals`, `Kyc`, `Stats`) and fires the coarse\n `user:blockchainUpdated` (+ `user:anyUpdated`).\n- `depositToken` / `requestTokenWithdrawal` patch only the crypto **balance**\n (`InventoryV2.CryptoCurrencies`) via a decimal delta, firing\n `user:inventoryUpdated` (+ `user:anyUpdated`) — **not** `user:blockchainUpdated`.\n- `depositNFT` / `requestNFTWithdrawal` patch inventory (items and/or\n currencies) via the shared `Resources` resource-operation pipeline, firing\n `user:inventoryUpdated` (and `user:virtualCurrencyUpdated` if VC moved) —\n again **not** `user:blockchainUpdated`.\n- `getTransactionHistory`, `retryWithdrawal`, `confirmWithdrawal`,\n `donateToDeveloper`, `donateToUsersPool` only emit their own\n `blockchain:*` event — they don't touch `client.data.user.state` at all.\n\nPractical consequence: after a deposit or withdrawal request, your **balance**\nis fresh in the cache, but `client.data.user.state.Blockchain.PendingWithdrawals`\nand `.Stats` are stale until you call `getUserState()` again. Re-fetch\n`getUserState()` after a withdrawal request/confirm/retry if your UI shows the\npending-withdrawals list or stats.\n\n**`StateDelta` / `Inventory` — the response already carries what changed, if\nyou want to apply it yourself instead of re-fetching.** `DepositTokenResponse`,\n`TokenWithdrawalResponse`, `NFTWithdrawalResponse`, and\n`ConfirmWithdrawalResponse` all carry an optional `StateDelta`\n(`BlockchainStateDelta`): a signed `CryptoBalances` delta per currency\n(`{ AmountDelta, FrozenDelta, UpdatedAt }` — add, don't overwrite), a\n`PendingAdded` ref (this call's newly-added pending withdrawal, if any), and\n`PendingRemovedIDs` (pending withdrawals this call confirmed or lazily\nexpired). `DepositNFTResponse` / `NFTWithdrawalResponse` similarly carry an\n`Inventory` (`InventoryDelta`) for the NFT's `UnstackableItems` instance —\nsame shape/semantics as the character-system module's `Inventory` deltas\n(`ChangedInstances` to upsert, `RemovedInstanceIDs` to drop). This mirrors the\nself-sufficient-response pattern used elsewhere in this SDK (see the\ncharacter-system skill) so a client that wants to reconcile\n`PendingWithdrawals`/balances/instances without another round trip can do so\nstraight from the mutating call's response. **Note:** `BlockchainService`\nitself does not auto-apply `StateDelta`/`Inventory` into\n`client.data.user.state.Blockchain` today — only the crypto **balance**\n(via the existing `AmountNative`-based patch) and item `Resources` are\napplied automatically. If you need `PendingWithdrawals` reconciled without a\nfull `getUserState()` refetch, read `result.data.StateDelta` yourself. Both\nare `null`/absent on an idempotent replay (nothing new to apply).\n\n```ts\nconst off = client.on(\"blockchain:tokenWithdrawalRequested\", (r) => {\n console.log(`Withdrawal ${r.TitleTransactionID} expires at ${r.ExpiresAt}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load config + state on a wallet/blockchain screen\n\n```ts\nawait client.blockchain.getDefinitions();\nawait client.blockchain.getUserState();\n\nconst defs = client.data.config.getSection<BlockchainDefinitions>(\"Blockchain\");\nconst bc = client.data.user.state?.Blockchain;\n\nfor (const [networkID, net] of Object.entries(defs?.Networks ?? {})) {\n if (!net.DepositsEnabled && !net.WithdrawalsEnabled) continue;\n // render a network card; net.NftCollections binds contracts to item catalogs\n}\nbc?.Kyc?.Status; // gate withdrawal UI on KYC if the title requires it\n```\n\n### Deposit a token (player already sent it on-chain)\n\n```ts\nconst res = await client.blockchain.depositToken(\"polygon\", \"0xabc123...\");\nif (!res.ok) return showError(res.error); // e.g. \"Transaction not found on chain.\",\n// \"Not enough confirmations (required 12). Try again in a few minutes.\",\n// \"Transaction hash already used.\"\n\nres.data.CurrencyID; // e.g. \"usdt\"\nres.data.AmountNative; // decimal string credited\n// Balance is already updated in the cache:\nclient.data.user.getCryptoCurrencyAmount(res.data.CurrencyID!);\n```\n\n### Full withdrawal lifecycle: request -> submit on-chain -> confirm, with a retry-after-failure path\n\n```ts\n// 1. Request the withdrawal — debits in-game immediately, returns a signature payload.\nconst req = await client.blockchain.requestTokenWithdrawal(\n \"usdt\",\n \"polygon\",\n \"0xPlayerWallet...\",\n \"25.00\",\n);\nif (!req.ok) return showError(req.error); // e.g. \"Not enough balance: have 10 usdt, need 25.00.\",\n// \"KYC verification required for withdrawals above 1000 USD.\",\n// \"Title users-withdrawable limit reached: available 5 usdt, requested 25.00.\"\n// — see \"Withdrawal gates\" above for the full list.\n\nconst { TitleTransactionID, EvmSignature, ExpiresAt } = req.data;\n// Balance is already debited (gross amount) in the cache.\n\n// 2. Hand EvmSignature (or SolanaSignature on a Solana network) to the\n// player's wallet SDK to submit the on-chain transaction yourself —\n// this SDK does not sign/broadcast. That step can fail (rejected in\n// wallet, gas issue).\n\n// 2a. If on-chain submission failed WHILE the transaction is still Pending\n// (before ExpiresAt), retry — this re-issues a fresh signature WITHOUT\n// debiting again:\nconst retry = await client.blockchain.retryWithdrawal(TitleTransactionID!);\nif (!retry.ok) return showError(retry.error); // e.g. \"Transaction is not in Pending state (current: Abandoned).\"\nconst freshSignature = retry.data.EvmSignature ?? retry.data.SolanaSignature;\n// Submit freshSignature on-chain instead, then continue to step 3.\n//\n// IMPORTANT: retryWithdrawal only works while the transaction is Pending. If\n// ExpiresAt already passed, the backend has lazily moved it to Abandoned and\n// retryWithdrawal will reject it — there is no \"re-request\" for an Abandoned\n// withdrawal (the asset was already debited and is not refunded). The only\n// way to still complete it is confirmWithdrawal with a hash, if the player\n// actually managed to submit the original signature before it was swept —\n// see the Gotchas section.\n\n// 3. Once the wallet actually broadcasts the transaction, tell the backend\n// the resulting on-chain hash so it can verify and close out the withdrawal:\nconst confirm = await client.blockchain.confirmWithdrawal(\n TitleTransactionID!,\n \"0xOnChainTxHash...\",\n);\nif (!confirm.ok) return showError(confirm.error);\nconfirm.data.Status; // e.g. \"Completed\" once the chain confirms it\n\n// 4. Refresh state — request/retry/confirm don't touch Blockchain cache themselves.\nawait client.blockchain.getUserState();\nclient.data.user.state?.Blockchain?.PendingWithdrawals; // should no longer list it once Completed\n```\n\n### KYC-gated withdrawal\n\nThe client never decides whether KYC is required — the backend compares the\nwithdrawal's USD-equivalent against the currency's configured threshold at\nrequest time. Use `Kyc.Status` only to pre-empt an obvious rejection in the\nUI; still branch on the real error:\n\n```ts\nawait client.blockchain.getUserState();\nconst kyc = client.data.user.state?.Blockchain?.Kyc;\n\nif (kyc?.Status !== \"Verified\") {\n // Optional UX nicety: warn before the call for large amounts. This SDK has\n // no startKyc/submitKyc method — verification happens through whatever KYC\n // provider integration the title uses outside this SDK; Kyc here only\n // reflects the result.\n}\n\nconst req = await client.blockchain.requestTokenWithdrawal(\n \"usdt\",\n \"polygon\",\n \"0xPlayerWallet...\",\n \"5000.00\",\n);\nif (!req.ok) {\n if (req.error.startsWith(\"KYC verification required\")) {\n // Route the player to the title's KYC verification flow.\n }\n return showError(req.error);\n}\n```\n\n### Deposit / withdraw an NFT\n\n```ts\n// Deposit: player already transferred the NFT to the vault address on-chain.\nconst dep = await client.blockchain.depositNFT(\"ethereum\", \"0xNftDepositTx...\");\nif (!dep.ok) return showError(dep.error);\ndep.data.ItemID; // the in-game item granted\ndep.data.Resources; // already applied to inventory in the cache\n\n// Withdraw: consumes the in-game item, returns a signature to submit on-chain.\nconst wd = await client.blockchain.requestNFTWithdrawal(\n \"sword-of-embers-inst-1\", // ItemID (per NFTWithdrawalResponse/BlockchainRequest shape)\n \"ethereum\",\n \"0xPlayerWallet...\",\n \"1\",\n);\nif (!wd.ok) return showError(wd.error);\nwd.data.TitleTransactionID; // use with retryWithdrawal / confirmWithdrawal exactly as tokens above\n\n// ERC-721 unique NFT: pass the specific instance to tokenize. level/itemInstanceID\n// are the trailing optional args — see the Methods table above.\nconst wd721 = await client.blockchain.requestNFTWithdrawal(\n \"sword-of-embers\",\n \"ethereum\",\n \"0xPlayerWallet...\",\n \"1\",\n undefined, // category\n 1, // level\n \"sword-of-embers-inst-1\", // ItemInstanceID — required for ERC-721 bindings\n);\n```\n\n### Edge case: not logged in / missing args\n\n```ts\nconst res = await client.blockchain.depositToken(\"\", \"0xabc\");\n// res.ok === false, res.reason === \"client\" — \"NetworkID is required.\" — no network call.\n\nconst res2 = await client.blockchain.getUserState();\n// If called before auth.loginWithDeviceID() (or any auth.* login):\n// res2.ok === false, res2.reason === \"unauthorized\"\n```\n\n### Donate crypto (no personal credit)\n\n```ts\nconst res = await client.blockchain.donateToDeveloper(\n \"polygon\",\n \"0xdonateTx...\",\n);\nif (!res.ok) return showError(res.error);\nres.data.Target; // \"Developer\" — confirms which pool bucket it landed in\n\n// donateToUsersPool is identical in shape, credits the users' pool bucket instead:\nawait client.blockchain.donateToUsersPool(\"polygon\", \"0xdonateTx2...\");\n```\n\n## Gotchas\n\n- **Withdrawal request debits immediately; the on-chain leg is separate and\n can fail.** `requestTokenWithdrawal`/`requestNFTWithdrawal` already took the\n asset from the player before any on-chain transaction exists. If the\n player's wallet fails to submit (rejected, gas issue) **while the\n transaction is still `Pending`**, don't ask them to request again — that\n would debit twice. Use `retryWithdrawal` with the same\n `TitleTransactionID` to get a fresh signature without a new charge. This\n only works before `ExpiresAt` — see the next two points for what happens\n after.\n- **`retryWithdrawal` vs `confirmWithdrawal` are opposite ends of the same\n flow.** Retry re-issues the _signed payload_ before submission (nothing has\n reached the chain yet); confirm reports the _resulting tx hash_ after\n submission (the chain now has it). Calling confirm with a hash from a\n transaction that never actually landed on-chain will simply fail\n server-side verification — don't fabricate a hash to \"force\" completion.\n- **`ExpiresAt` is real, expiry does not refund the player, and — contrary to\n what the name suggests — an expired withdrawal is NOT retryable.** A\n withdrawal signature is time-boxed (`TokenWithdrawalResponse.ExpiresAt` /\n `NFTWithdrawalResponse.ExpiresAt`, driven by\n `BlockchainAccountSafetyPolicy.PendingWithdrawalTtlHours`). Once it passes\n without a submission, the backend lazily transitions the transaction to\n **`Abandoned`** (not `Expired` — that enum value exists but this backend\n path never assigns it) and drops it off `PendingWithdrawals` — but the\n already-debited asset is **not** credited back; this is intentional, not a\n bug. Critically, `retryWithdrawal` requires the transaction to still be\n `Pending` — calling it on an `Abandoned` one fails with `\"Transaction is\nnot in Pending state (current: Abandoned).\"` There is no \"re-request\"\n operation for an abandoned withdrawal.\n- **A withdrawal can still be confirmed after it's `Abandoned`.** If the\n player submits late — after `ExpiresAt` passed and the backend already\n swept it to `Abandoned` — `confirmWithdrawal` still accepts it as long as\n the on-chain transaction verifies (the signature itself doesn't expire\n on-chain, only the title's own bookkeeping window does). Don't treat an\n `Abandoned` transaction as unrecoverable if the player insists they\n submitted it; calling `confirmWithdrawal` with the resulting hash is still\n the right move, and is in fact the _only_ way to close out an\n already-expired-but-actually-submitted withdrawal.\n- **`retryWithdrawal` only works on a `Pending` transaction the caller owns.**\n It fails with `\"Transaction not found.\"` for an unknown or someone else's\n `TitleTransactionID`, `\"Transaction is not in Pending state (current:\n{status}).\"` if it already completed/failed/was abandoned, or\n `\"Signature data not found for this transaction.\"` if there's nothing to\n reissue. A banned account additionally gets `\"Account is banned. Contact\nsupport.\"` on retry (deposits stay allowed for banned accounts; retrying a\n withdrawal does not).\n- **Gross vs. net amounts on token withdrawals.** `AmountNative` is what was\n debited from the player (gross); `NetAmountNative` is what actually gets\n paid out on-chain after a platform commission percentage **and** an\n optional on-chain burn are deducted (`NetAmountNative = AmountNative −\ncommission − BurnAmountNative`). Show the player the net figure they'll\n receive, not the gross debit, to avoid support tickets about a \"missing\"\n amount. NFT withdrawals have no such split — there's no `NetAmountNative`\n on `NFTWithdrawalResponse`.\n- **Burn on withdrawal (EVM-only).** `TokenWithdrawalResponse.BurnAmountNative`\n is the amount burned on-chain (sent to the DEAD address) for this\n withdrawal, driven by the currency's `WithdrawalBurnPercent` (see the\n currency-system skill) — `0` if burn is disabled for that currency or the\n network is Solana. The raw-units counterpart, `WithdrawalSignatureResponse.\nBurnAmount`, is bound into the signed hash and must be passed to the\n contract call verbatim, same as `Amount`/`Nonce` — `@idosgames/wallet`'s\n `submitEvmTokenWithdrawal` does this for you; a client calling\n `withdrawERC20` directly must include it too, or the signature check fails.\n- **`client.data.user.state.Blockchain` goes stale after deposits/withdrawal\n requests.** Only `getUserState()` refreshes `LinkedWallets`,\n `PendingWithdrawals`, `Kyc`, and `Stats`. A deposit/withdrawal call updates\n your _balance_/_inventory_ cache correctly, but if your UI also shows the\n pending-withdrawals list or lifetime stats, re-call `getUserState()`\n afterward (see the withdrawal recipe above).\n- **Deposits are reporting, not sending.** `depositToken`/`depositNFT` don't\n move any asset on-chain — they tell the backend \"verify this transaction\n hash and credit me.\" The actual on-chain transfer to the platform's pool/\n vault address must already have happened via a wallet SDK before you call\n these.\n- **This SDK never signs or broadcasts.** `EvmSignature`/`SolanaSignature`\n payloads are inputs to a wallet SDK/contract call that happens outside\n `@idosgames/core`. Don't look for a \"submit on-chain\" method here — there\n isn't one; `confirmWithdrawal` only reports the result afterward. The\n `@idosgames/wallet` companion package is that outside layer — it submits the\n signature on-chain and calls `confirmWithdrawal` for you.\n- **Donations never touch personal balances.** `donateToDeveloper` /\n `donateToUsersPool` intentionally don't credit the player anything and\n don't touch `client.data.user.state` — they only emit their own\n `blockchain:donated*` event for a confirmation toast/receipt.\n- **Render from the cache, handle the error from the result.** The happy\n path updates the relevant cache slice + emits an event; the failure path\n gives you `reason` + `error`. Use `reason` to decide behavior (retry on\n `\"connection\"`, re-auth on `\"unauthorized\"`, toast the `error` on\n `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field: network definitions, NFT collection bindings, account-safety\npolicy, KYC state, transaction documents, the withdrawal-gate limits, and the\nEVM/Solana withdrawal signature payload shapes. Read it when building network\npickers, a transaction-history table, or KYC/limit-aware withdrawal UI. For\nthe shared `ResourceConsume`/`ResourceGrant`/`ResourceOperation`\ncost-and-reward shapes riding along on `depositNFT`/`requestNFTWithdrawal`,\nand for the full `CryptoCurrencyDefinition` shape (`Limits`, `Networks[].\nMinWithdraw`/`WithdrawFee`, `Permissions`) that the withdrawal gates enforce,\nsee the currency-system skill.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cloud-code",
|
|
3
|
-
"description": "
|
|
4
|
-
"content": "---\nname: cloud-code\ndescription: >-\n Call custom server-side game logic on the iDosGames TypeScript SDK\n (@idosgames/core) via client.cloudCode (CloudCodeService): execute a\n title-defined cloud script by name with an arbitrary JSON args payload and\n get back its arbitrary JSON result. Use this whenever the user wants to run\n custom/bespoke server logic, a \"cloud script\", \"cloud function\", \"server\n callable\", crafting/trading/matchmaking logic not covered by a dedicated SDK\n module, or otherwise touches client.cloudCode, CloudCodeService, or\n ExecuteCloudCodeResponse — even if they don't name the module explicitly.\n---\n\n# Cloud Code (iDosGames TS SDK)\n\nCloudCodeService is the **escape hatch**: a generic way to run a title-defined\nserver-side script (a \"handler\") and get back whatever JSON that script\nreturns. Use it when a feature doesn't have a dedicated SDK module (Character,\nItem, Economy, etc.) — e.g. bespoke crafting rules, custom matchmaking, an\nadmin action, anything that's easier to write once as server logic than to\ncompose from generic client calls. If a dedicated module already covers what\nyou need, prefer that module — it gives you typed request/response shapes and\ncache integration; Cloud Code gives you neither.\n\nThis skill is for **using** production Cloud Code, not for writing the scripts\nthemselves (that's title/server-side configuration — a JavaScript revision\ndeployed and administered outside this SDK, out of scope here).\n\n## Mental model\n\nThere is exactly one client-facing action: `execute`. You pass a **function\nname** (a handler defined in the title's deployed script's `handlers` object)\nand an optional **arbitrary JSON payload**; the server runs it in a sandboxed\nJS engine and returns an arbitrary JSON result plus execution metadata (logs,\ntiming, error info). The SDK has no idea what a given script's args or result\nlook like — **you** know your title's script contract, so you type the\npayload and result yourself (see Gotchas). There's no \"config vs state\" split\nhere like other modules — Cloud Code has no persistent per-player data model\nof its own; it's pure request/response.\n\nScript failure is a **first-class outcome, not a network error**: if the\nscript throws, times out, is rate-limited, or the handler doesn't exist, the\ncall still comes back `{ ok: true, data }` with `data.Error` populated and\n`data.FunctionResult` empty. Only infrastructure problems (not logged in, bad\nlocal args, connection issues, backend down) surface as `{ ok: false }`.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst cloudCode = client.cloudCode; // the CloudCodeService\n```\n\nRequires an authenticated session — without one, `execute` returns\n`{ ok: false, reason: \"unauthorized\" }` rather than making a request.\n\n## Methods\n\n`execute` returns `Promise<OperationResult<ExecuteCloudCodeResponse>>`: either\n`{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data` — and then check `data.Error` before\ntrusting `data.FunctionResult` (see below). `reason` is one of `\"client\"`\n(empty/whitespace-only function name), `\"unauthorized\"`, `\"throttled\"` (fired\nthe same call again inside the throttle window), `\"connection\"` (transient,\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"`\n(infrastructure-level rejection — `error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| ---------------------------------------------------------------------------------- | ------------------------------------------------- | -------------------------- |\n| `execute(functionName, functionParameter?, revisionSelection?, specificRevision?)` | Run a title-defined cloud script handler by name. | `ExecuteCloudCodeResponse` |\n\nParameters:\n\n- `functionName` — the handler name inside the deployed script's `handlers`\n object. Case-sensitive; trimmed before sending. Client-side, only\n empty/whitespace is rejected (`reason: \"client\"`). Server-side, the backend\n additionally rejects (as a script-level `InvalidFieldName` error, not an\n `OperationResult` failure) names containing `.`, `$`, whitespace, or control\n characters, or longer than 128 characters — these are illegal as MongoDB\n field names since the name can end up in audit/log paths.\n- `functionParameter?` — any `JsonValue` (object, array, string, number,\n boolean, or null) passed as the handler's first argument. Omit if the script\n needs no input. If it's an object (at any nesting depth), none of its keys\n may contain `.` or `$` — the backend rejects such payloads with a\n script-level `InvalidFieldName` error before the script ever runs.\n- `revisionSelection?` — `\"Live\"` (default when omitted), `\"Latest\"`, or\n `\"Specific\"`. Lets you target a non-live revision for testing.\n- `specificRevision?` — the revision number to run; only used when\n `revisionSelection` is `\"Specific\"`.\n\n`ExecuteCloudCodeResponse` shape:\n\n| Field | Meaning |\n| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |\n| `FunctionName` | Echo of the handler that ran. |\n| `Revision` | Which revision actually executed. |\n| `FunctionResult` | The script's return value — arbitrary JSON, `null` if it returned nothing or on error. |\n| `FunctionResultTooLarge` | `true` if the result was dropped for exceeding the title's result-size limit (`FunctionResult` is `null` in that case). |\n| `Logs` | Array of `{ Level, Message?, Data? }` entries from `log.debug/info/warn/error` calls inside the script. |\n| `LogsTooLarge` | `true` if logs were truncated for exceeding the title's log-size limit. |\n| `ExecutionTimeSeconds` | Server-side wall-clock execution duration. |\n| `APIRequestsIssued` | Count of server API calls the script made internally (e.g. reading user data) — counts toward a per-execution cap. |\n| `Error` | `{ Error: CloudCodeErrorCode, Message?, StackTrace? }`, present only when the script failed or never ran; `null`/absent on success. |\n\n`CloudCodeErrorCode` values: `None`, `Disabled`, `NoActiveRevision`,\n`RevisionNotFound`, `InvalidFieldName`, `RateLimited`, `HandlerNotFound`,\n`HandlerDisabled`, `Timeout`, `StatementCountExceeded`, `StackOverflow`,\n`ApiCallLimitExceeded`, `JavaScriptException`, `ExecutionError` — stable, safe\nto switch on for retry/UX logic (e.g. treat `RateLimited`/`Timeout` as\nretryable, others as not).\n\nOn success, the SDK emits an event — it does **not** write anything into\n`client.data`, since the result shape is script-specific and there's no\ngeneric cache slot for it. If your script mutates player state (grants\ncurrency, items, etc. via server-side APIs), re-fetch that state through its\nowning module afterward — Cloud Code itself won't refresh your local cache.\n\n## Events\n\nSubscribe with `client.on(...)`; returns an unsubscribe fn.\n\n- `cloudCode:executed` → `ExecuteCloudCodeResponse` — fired whenever `execute` returns `{ ok: true }`, regardless of whether the script itself succeeded (check `data.Error` inside the handler).\n\n```ts\nconst off = client.on(\"cloudCode:executed\", (r) => {\n if (r.Error) console.warn(\"script failed:\", r.Error.Error, r.Error.Message);\n});\n// later: off();\n```\n\n## Recipes\n\n### Call a script and handle both failure layers\n\n```ts\ninterface GrantBonusArgs {\n reason: string;\n}\ninterface GrantBonusResult {\n granted: number;\n}\n\nconst args: GrantBonusArgs = { reason: \"daily\" };\nconst result = await client.cloudCode.execute(\"grantLoginBonus\", args);\nif (!result.ok) return showError(result.error ?? result.reason); // infra-level failure\n\nif (result.data.Error) {\n return showError(result.data.Error.Message ?? result.data.Error.Error); // script-level failure\n}\n\nconst payload = result.data.FunctionResult as GrantBonusResult; // your contract — cast/validate it yourself\nconsole.log(`granted ${payload.granted}`);\n```\n\n### Fire-and-forget script with no input\n\n```ts\nconst result = await client.cloudCode.execute(\"resetDailyQuests\");\nif (!result.ok || result.data.Error) {\n console.warn(\"resetDailyQuests failed\", result.error ?? result.data.Error);\n}\n```\n\n### Test against a specific revision before it goes live\n\n```ts\nconst result = await client.cloudCode.execute(\n \"computeMatchReward\",\n { matchID },\n \"Specific\",\n 42, // revision number\n);\n```\n\n### Surface script logs during development\n\n```ts\nconst result = await client.cloudCode.execute(\"debugScript\", { x: 1 });\nif (result.ok) {\n for (const log of result.data.Logs ?? []) {\n console.log(`[${log.Level}]`, log.Message, log.Data);\n }\n}\n```\n\nLogs only come back at all if the title has logs enabled for clients; on\ntitles that don't, `Logs` is always an empty array even though the script did\nlog server-side — don't treat an empty array as proof the script logged\nnothing.\n\n### Chain a cloud-code call with a resource refresh\n\n```ts\nconst res = await client.cloudCode.execute(\"craftSpecialItem\", { recipeID });\nif (!res.ok || res.data.Error) return showError(res.error ?? res.data.Error);\n\n// The script granted items/currency server-side — Cloud Code didn't touch the\n// cache, so pull the owning module's state to see the new balance/inventory.\nawait client.user.getClientState(); // or the specific module's getter, e.g. client.item...\n```\n\n## Gotchas\n\n- **Two failure layers, don't conflate them.** `result.ok === false` means the\n call itself failed (auth, bad args, connection) — the script never ran or\n its outcome is unknown. `result.ok === true && result.data.Error` means the\n call succeeded but the _script_ failed (threw, timed out, disabled,\n unknown/undeclared handler, rate-limited) — always check both before\n trusting `FunctionResult`.\n- **Unknown handler is a script-level error, not a client-side check.** The\n SDK never validates that `functionName` refers to a real handler — that's\n entirely server-side. Depending on the title's config you can get\n `HandlerNotFound` either because the name isn't in the title's declared\n handler whitelist, or because the deployed script simply never defined\n `handlers[functionName]`; both look the same to the caller. A handler can\n also be individually killed by an admin, which comes back as\n `HandlerDisabled`.\n- **A hard 10-second ceiling always applies.** Whatever timeout the title/\n revision configures, the backend clamps every single execution to a 10\n second wall-clock budget; past that you get `Timeout` no matter what. Don't\n design a script-based feature around long-running work.\n- **Rate limiting can hit independently of the generic per-endpoint throttle.**\n Beyond the SDK's own ~600ms client-side throttle per call and the\n transport's per-user rate limit, the title can configure CloudCode-specific\n limits at three levels — whole title, this user, or this user+handler pair.\n Any of them tripping comes back as `data.Error.Error === \"RateLimited\"`\n (an in-band script-level outcome, `result.ok` is still `true`), with\n `data.Error.Message` naming which layer triggered it — treat it as\n retryable-after-a-delay, not a hard failure.\n- **No client-side validation of script logic.** The SDK only validates that\n `functionName` is non-empty and that you're logged in. Argument shape,\n business rules, and error handling are entirely up to the script — a\n malformed `functionParameter` will fail server-side (`JavaScriptException`\n or similar), not client-side.\n- **Type the payload and result yourself.** `functionParameter` is `JsonValue`\n and `FunctionResult` is `JsonValue | null` — the SDK has no schema for your\n title's specific scripts. Define your own request/response interfaces per\n handler (as in the recipes above) and cast/validate after the call.\n- **Cloud Code doesn't touch `client.data`.** Unlike feature modules, a\n successful `execute` doesn't mirror anything into the cache. If the script\n changed player-facing state, re-fetch it via the owning module (e.g. call\n the Economy/Item/Character module's getter) so the UI reflects it.\n- **`Logs`/`FunctionResult` can be silently dropped.** Both are subject to a\n title-configured byte-size ceiling; check `LogsTooLarge` /\n `FunctionResultTooLarge` before assuming absence means the script produced\n nothing. Whether `Logs` is populated at all (even under the size limit) also\n depends on a title setting — some titles never reveal script logs to\n clients.\n- **Keys in your JSON payload can't contain `.` or `$`.** This is a MongoDB\n field-name restriction the backend enforces recursively on\n `functionParameter` (and on whatever the script returns) — a payload with a\n dotted or `$`-prefixed key fails with `InvalidFieldName` before the script\n even starts. Stick to plain alphanumeric/underscore keys.\n- **Prefer a dedicated module when one exists.** Cloud Code has no typed\n contract, no cache integration, and no per-feature event — reach for it only\n when the feature genuinely isn't covered elsewhere.\n",
|
|
3
|
+
"description": "",
|
|
4
|
+
"content": "---\r\nname: cloud-code\r\ndescription: >-\r\n Write and call custom server-side game logic on the iDosGames platform:\r\n author a CloudCode handler (sandboxed JavaScript with a server.* API) and\r\n invoke it from the game via client.cloudCode (CloudCodeService) with an\r\n arbitrary JSON payload. Use this whenever the user wants bespoke server\r\n logic, a \"cloud script\", \"cloud function\" or \"server callable\", logic that\r\n must be authoritative (granting rewards, validating a reported result,\r\n anti-cheat), a write to protected player data (UserCustomData ReadOnly /\r\n Internal buckets) or to shared title state (TitleCustomData Runtime scope),\r\n or otherwise touches client.cloudCode, CloudCodeService, handlers,\r\n server.SetUserCustomData, server.IncrementTitleCustomData,\r\n server.HttpRequest or ExecuteCloudCodeResponse — even if they don't name the\r\n module explicitly. Also covers integrating a title with a third-party service\r\n (calling an external API with a stored API key, webhooks out, payment or\r\n analytics providers) and the {{secret:NAME}} / {{var:NAME}} placeholders.\r\n---\r\n\r\n# Cloud Code (iDosGames TS SDK)\r\n\r\nCloud Code runs **your** JavaScript on the platform's servers. A script defines\r\nnamed handlers; the game calls one by name and gets back whatever JSON it\r\nreturns.\r\n\r\nTwo distinct reasons to reach for it:\r\n\r\n1. **Authority.** Client code that \"grants\" a reward, \"validates\" a score or\r\n \"unlocks\" a level is a suggestion — the player owns the browser. Logic whose\r\n outcome must be trusted belongs in a handler.\r\n2. **Protected data.** The `ReadOnly`/`Internal` buckets of a player's\r\n UserCustomData and the `Runtime` scope of the title's data store have no\r\n client write path at all. A handler is the only way to write them.\r\n\r\nIf a dedicated module already covers what you need (currency, item, store,\r\nquest, character, leaderboard…), prefer that module — it gives you typed\r\nrequest/response shapes and cache integration; Cloud Code gives you neither.\r\n\r\nPublishing a script is a platform operation, not an SDK one: the AI Coder does\r\nit with its `SaveCloudCode` tool, an external agent with the backend MCP's\r\n`publish_cloud_code`, and a publisher from the dashboard. Publishing **replaces\r\nthe whole revision** — read the current source first (`GetCloudCode` with\r\n`include_code`, or `get_cloud_code`) and send it back with your handler added,\r\nor you silently delete every handler the game still calls.\r\n\r\n## Mental model\r\n\r\nThere is exactly one client-facing action: `execute`. You pass a **function\r\nname** (a handler defined in the title's deployed script's `handlers` object)\r\nand an optional **arbitrary JSON payload**; the server runs it in a sandboxed\r\nJS engine and returns an arbitrary JSON result plus execution metadata (logs,\r\ntiming, error info). The SDK has no idea what a given script's args or result\r\nlook like — **you** know your title's script contract, so you type the\r\npayload and result yourself (see Gotchas). There's no \"config vs state\" split\r\nhere like other modules — Cloud Code has no persistent per-player data model\r\nof its own; it's pure request/response.\r\n\r\nScript failure is a **first-class outcome, not a network error**: if the\r\nscript throws, times out, is rate-limited, or the handler doesn't exist, the\r\ncall still comes back `{ ok: true, data }` with `data.Error` populated and\r\n`data.FunctionResult` empty. Only infrastructure problems (not logged in, bad\r\nlocal args, connection issues, backend down) surface as `{ ok: false }`.\r\n\r\n## Writing a handler\r\n\r\nA revision is one plain JavaScript file that fills the global `handlers` object.\r\nNo imports, no modules, no `async`/`await`, no `fetch` — everything you can touch\r\nis on `server.*` and `log.*`, and every call is synchronous. Outbound network\r\naccess exists but only through `server.HttpRequest`, and only to hosts the\r\npublisher allow-listed (see _Calling another service_).\r\n\r\n```js\r\nhandlers.claimDailyBonus = function (args, context) {\r\n // context: { UserID, FunctionName, Revision, InvokedAt }\r\n var data = server.GetUserCustomData();\r\n if (!data.Success) throw new Error(data.Error);\r\n\r\n var last = data.Data.ReadOnly[\"daily_claimed_at\"];\r\n var today = new Date().toISOString().slice(0, 10);\r\n if (last && last.Value === today)\r\n return { granted: false, reason: \"already_claimed\" };\r\n\r\n var write = server.SetUserCustomData(\"ReadOnly\", \"daily_claimed_at\", today);\r\n if (!write.Success) throw new Error(write.Error);\r\n\r\n server.IncrementTitleCustomData(\"Public\", \"daily_claims_total\", 1);\r\n log.Info(\"daily bonus granted\", { user: context.UserID });\r\n return { granted: true };\r\n};\r\n```\r\n\r\n### The `server.*` API\r\n\r\nEvery call returns `{ Success, Error, Data }` — **check `Success`**; a rejected\r\nwrite (limit hit, wrong bucket, version conflict) is a normal result, not a\r\nthrow. Each call also counts against the per-execution API budget, so batch.\r\n\r\n| Call | What it does |\r\n| ----------------------------------------------------------------- | ------------------------------------------------------------------ |\r\n| `server.ReadUserData([\"InventoryV2\", \"Premium\", …])` | Read whitelisted sections of the caller's player document. |\r\n| `server.GetTitleConfig(\"Currency\", \"Item\", …)` | Read the title's configuration sections. |\r\n| `server.GetUserCustomData()` | All four buckets of the caller, **including `Internal`**. |\r\n| `server.GetPublicUserCustomDataOf(userId)` | Another player's `Public` bucket. |\r\n| `server.SetUserCustomData(bucket, key, value)` | Write any bucket — this is the protected-data write. |\r\n| `server.DeleteUserCustomData(bucket, key)` | Delete a key from any bucket (idempotent). |\r\n| `server.BatchSetUserCustomData([{Bucket, KeyID, Value}, …])` | Atomic multi-key write (all-or-nothing). |\r\n| `server.BatchDeleteUserCustomData([{Bucket, KeyID}, …])` | Atomic multi-key delete. |\r\n| `server.GetTitleCustomData()` | Title store: both scopes, both buckets. |\r\n| `server.SetTitleCustomData(bucket, key, value, expectedVersion?)` | Write the title's `Runtime` scope; pass a version for CAS. |\r\n| `server.IncrementTitleCustomData(bucket, key, delta)` | Atomic counter on shared data — use this, never read-modify-write. |\r\n| `server.DeleteTitleCustomData(bucket, key)` | Delete a `Runtime` key. |\r\n| `server.BatchSetTitleCustomData` / `BatchDeleteTitleCustomData` | Atomic multi-key variants for the title store. |\r\n| `server.GetIntegrationVariable(name)` | Read a non-secret integration setting (base URL, account id). |\r\n| `server.HttpRequest({ Method, Url, Headers, Body, ContentType })` | Call an external API — the only way out of the sandbox. |\r\n| `server.AddQuestProgress(metricID, value)` | Advance quest objectives configured with `Source: \"ServerApi\"`. |\r\n\r\n`server.AddQuestProgress` is the only way to move a `ServerApi` objective —\r\nneither the client nor the dashboard can touch those. Use it when only the server\r\nknows the fact (anti-cheat verdict, match result, an external system confirming\r\nvia `server.HttpRequest`). Unlike the client's `addQuestProgress` it neither bans\r\nnor clamps on `MaxProgressPerCall`: the script is written by the title owner, so\r\nthe value is trusted. It still clamps to the objective's `TargetValue`. Quests\r\nwhose objectives use `ClientApi` or `SystemEvent` are unreachable from here.\r\n\r\n`log.Debug/Info/Warning/Error(message, data?)` records a line the publisher sees\r\n(and, if the title reveals logs, the client too). It costs no API budget.\r\n\r\nNotes that bite:\r\n\r\n- Bucket and scope names are **case-sensitive strings**: `\"Private\"`,\r\n `\"Public\"`, `\"ReadOnly\"`, `\"Internal\"` for player data; `\"Public\"`,\r\n `\"Private\"` for title data. Anything else comes back as an error result.\r\n- Title writes always land in the `Runtime` scope — the `Static` scope is\r\n authored configuration and a script cannot touch it.\r\n- Shared counters must go through `IncrementTitleCustomData` (or\r\n `SetTitleCustomData` with `expectedVersion` from the record you read).\r\n Read-then-write from two concurrent calls silently loses one of them.\r\n- `throw` inside a handler is fine — it reaches the caller as a script-level\r\n error with your message, which is usually what you want for \"not allowed\".\r\n\r\n### Calling another service\r\n\r\nA handler can call a third-party API. The credential never appears in your code:\r\nyou reference it by placeholder and the platform substitutes it after your script\r\nhas run, immediately before the request leaves.\r\n\r\n```js\r\nhandlers.notifyDiscord = function (args, context) {\r\n var res = server.HttpRequest({\r\n Method: \"POST\",\r\n Url: \"https://discord.com/api/webhooks/{{var:DISCORD_WEBHOOK_PATH}}\",\r\n Headers: { Authorization: \"Bearer {{secret:DISCORD_TOKEN}}\" },\r\n Body: JSON.stringify({ content: \"Player \" + context.UserID + \" won!\" }),\r\n });\r\n if (!res.Success) throw new Error(res.Error); // network/policy failure\r\n if (!res.Data.Ok) return { sent: false, status: res.Data.Status };\r\n return { sent: true };\r\n};\r\n```\r\n\r\n- `{{secret:NAME}}` — an API key or token. **You can never read its value**, in\r\n any tool or any call; there is no `GetSecret`. That is deliberate: a value in\r\n JS could be returned to the player or logged by accident.\r\n- `{{var:NAME}}` — a non-secret setting. Also readable with\r\n `server.GetIntegrationVariable(name)` when you need it as a value.\r\n- `res.Data` is `{ Status, Ok, Body, BodyTooLarge, ContentType }`. `Body` is a\r\n string — parse it yourself; anything matching a substituted secret is replaced\r\n with `***` before you see it.\r\n\r\nWhat the platform enforces, and what you cannot work around from a script:\r\n\r\n- **Only allow-listed hosts.** The publisher lists them per title; there is no\r\n allow-all. An unlisted host fails with a clear message — surface it rather than\r\n retrying.\r\n- **https only** (unless the title explicitly allows plain http), **no\r\n redirects**, and no requests to private/loopback addresses.\r\n- **Per-execution request cap** (3 by default) and a **response size cap** — an\r\n oversized body is dropped, not truncated, with `BodyTooLarge: true`.\r\n- The whole call still lives inside the 10-second execution budget, so one slow\r\n integration can starve everything after it.\r\n\r\nIf the credential or the host you need does not exist yet, say exactly what has\r\nto be added in the title's **Integrations** settings — you cannot add either one.\r\n\r\n### Limits you are designing against\r\n\r\n10 seconds of wall-clock per call (hard, whatever the title configures), a cap\r\non statements and recursion depth, a cap on `server.*` calls per execution, and\r\nbyte ceilings on the returned result and the logs. Handlers are short decisions,\r\nnot jobs.\r\n\r\n## Setup\r\n\r\n```ts\r\nimport { createIDosGamesClient } from \"@idosgames/core\";\r\n\r\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\r\nawait client.auth.loginWithDeviceID(); // or any auth.* method\r\n\r\nconst cloudCode = client.cloudCode; // the CloudCodeService\r\n```\r\n\r\nRequires an authenticated session — without one, `execute` returns\r\n`{ ok: false, reason: \"unauthorized\" }` rather than making a request.\r\n\r\n## Methods\r\n\r\n`execute` returns `Promise<OperationResult<ExecuteCloudCodeResponse>>`: either\r\n`{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\r\n`result.ok` before touching `result.data` — and then check `data.Error` before\r\ntrusting `data.FunctionResult` (see below). `reason` is one of `\"client\"`\r\n(empty/whitespace-only function name), `\"unauthorized\"`, `\"throttled\"` (fired\r\nthe same call again inside the throttle window), `\"connection\"` (transient,\r\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"`\r\n(infrastructure-level rejection — `error` carries the human-readable reason).\r\n\r\n| Method | Purpose | `data` on success |\r\n| ---------------------------------------------------------------------------------- | ------------------------------------------------- | -------------------------- |\r\n| `execute(functionName, functionParameter?, revisionSelection?, specificRevision?)` | Run a title-defined cloud script handler by name. | `ExecuteCloudCodeResponse` |\r\n\r\nParameters:\r\n\r\n- `functionName` — the handler name inside the deployed script's `handlers`\r\n object. Case-sensitive; trimmed before sending. Client-side, only\r\n empty/whitespace is rejected (`reason: \"client\"`). Server-side, the backend\r\n additionally rejects (as a script-level `InvalidFieldName` error, not an\r\n `OperationResult` failure) names containing `.`, `$`, whitespace, or control\r\n characters, or longer than 128 characters — these are illegal as MongoDB\r\n field names since the name can end up in audit/log paths.\r\n- `functionParameter?` — any `JsonValue` (object, array, string, number,\r\n boolean, or null) passed as the handler's first argument. Omit if the script\r\n needs no input. If it's an object (at any nesting depth), none of its keys\r\n may contain `.` or `$` — the backend rejects such payloads with a\r\n script-level `InvalidFieldName` error before the script ever runs.\r\n- `revisionSelection?` — `\"Live\"` (default when omitted), `\"Latest\"`, or\r\n `\"Specific\"`. Lets you target a non-live revision for testing.\r\n- `specificRevision?` — the revision number to run; only used when\r\n `revisionSelection` is `\"Specific\"`.\r\n\r\n`ExecuteCloudCodeResponse` shape:\r\n\r\n| Field | Meaning |\r\n| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |\r\n| `FunctionName` | Echo of the handler that ran. |\r\n| `Revision` | Which revision actually executed. |\r\n| `FunctionResult` | The script's return value — arbitrary JSON, `null` if it returned nothing or on error. |\r\n| `FunctionResultTooLarge` | `true` if the result was dropped for exceeding the title's result-size limit (`FunctionResult` is `null` in that case). |\r\n| `Logs` | Array of `{ Level, Message?, Data? }` entries from `log.debug/info/warn/error` calls inside the script. |\r\n| `LogsTooLarge` | `true` if logs were truncated for exceeding the title's log-size limit. |\r\n| `ExecutionTimeSeconds` | Server-side wall-clock execution duration. |\r\n| `APIRequestsIssued` | Count of server API calls the script made internally (e.g. reading user data) — counts toward a per-execution cap. |\r\n| `Error` | `{ Error: CloudCodeErrorCode, Message?, StackTrace? }`, present only when the script failed or never ran; `null`/absent on success. |\r\n\r\n`CloudCodeErrorCode` values: `None`, `Disabled`, `NoActiveRevision`,\r\n`RevisionNotFound`, `InvalidFieldName`, `RateLimited`, `HandlerNotFound`,\r\n`HandlerDisabled`, `Timeout`, `StatementCountExceeded`, `StackOverflow`,\r\n`ApiCallLimitExceeded`, `JavaScriptException`, `ExecutionError` — stable, safe\r\nto switch on for retry/UX logic (e.g. treat `RateLimited`/`Timeout` as\r\nretryable, others as not).\r\n\r\nOn success, the SDK emits an event — it does **not** write anything into\r\n`client.data`, since the result shape is script-specific and there's no\r\ngeneric cache slot for it. If your script mutates player state (grants\r\ncurrency, items, etc. via server-side APIs), re-fetch that state through its\r\nowning module afterward — Cloud Code itself won't refresh your local cache.\r\n\r\n## Events\r\n\r\nSubscribe with `client.on(...)`; returns an unsubscribe fn.\r\n\r\n- `cloudCode:executed` → `ExecuteCloudCodeResponse` — fired whenever `execute` returns `{ ok: true }`, regardless of whether the script itself succeeded (check `data.Error` inside the handler).\r\n\r\n```ts\r\nconst off = client.on(\"cloudCode:executed\", (r) => {\r\n if (r.Error) console.warn(\"script failed:\", r.Error.Error, r.Error.Message);\r\n});\r\n// later: off();\r\n```\r\n\r\n## Recipes\r\n\r\n### Call a script and handle both failure layers\r\n\r\n```ts\r\ninterface GrantBonusArgs {\r\n reason: string;\r\n}\r\ninterface GrantBonusResult {\r\n granted: number;\r\n}\r\n\r\nconst args: GrantBonusArgs = { reason: \"daily\" };\r\nconst result = await client.cloudCode.execute(\"grantLoginBonus\", args);\r\nif (!result.ok) return showError(result.error ?? result.reason); // infra-level failure\r\n\r\nif (result.data.Error) {\r\n return showError(result.data.Error.Message ?? result.data.Error.Error); // script-level failure\r\n}\r\n\r\nconst payload = result.data.FunctionResult as GrantBonusResult; // your contract — cast/validate it yourself\r\nconsole.log(`granted ${payload.granted}`);\r\n```\r\n\r\n### Fire-and-forget script with no input\r\n\r\n```ts\r\nconst result = await client.cloudCode.execute(\"resetDailyQuests\");\r\nif (!result.ok || result.data.Error) {\r\n console.warn(\"resetDailyQuests failed\", result.error ?? result.data.Error);\r\n}\r\n```\r\n\r\n### Test against a specific revision before it goes live\r\n\r\n```ts\r\nconst result = await client.cloudCode.execute(\r\n \"computeMatchReward\",\r\n { matchID },\r\n \"Specific\",\r\n 42, // revision number\r\n);\r\n```\r\n\r\n### Surface script logs during development\r\n\r\n```ts\r\nconst result = await client.cloudCode.execute(\"debugScript\", { x: 1 });\r\nif (result.ok) {\r\n for (const log of result.data.Logs ?? []) {\r\n console.log(`[${log.Level}]`, log.Message, log.Data);\r\n }\r\n}\r\n```\r\n\r\nLogs only come back at all if the title has logs enabled for clients; on\r\ntitles that don't, `Logs` is always an empty array even though the script did\r\nlog server-side — don't treat an empty array as proof the script logged\r\nnothing.\r\n\r\n### Chain a cloud-code call with a resource refresh\r\n\r\n```ts\r\nconst res = await client.cloudCode.execute(\"craftSpecialItem\", { recipeID });\r\nif (!res.ok || res.data.Error) return showError(res.error ?? res.data.Error);\r\n\r\n// The script granted items/currency server-side — Cloud Code didn't touch the\r\n// cache, so pull the owning module's state to see the new balance/inventory.\r\nawait client.user.getClientState(); // or the specific module's getter, e.g. client.item...\r\n```\r\n\r\n## Gotchas\r\n\r\n- **Two failure layers, don't conflate them.** `result.ok === false` means the\r\n call itself failed (auth, bad args, connection) — the script never ran or\r\n its outcome is unknown. `result.ok === true && result.data.Error` means the\r\n call succeeded but the _script_ failed (threw, timed out, disabled,\r\n unknown/undeclared handler, rate-limited) — always check both before\r\n trusting `FunctionResult`.\r\n- **Unknown handler is a script-level error, not a client-side check.** The\r\n SDK never validates that `functionName` refers to a real handler — that's\r\n entirely server-side. Depending on the title's config you can get\r\n `HandlerNotFound` either because the name isn't in the title's declared\r\n handler whitelist, or because the deployed script simply never defined\r\n `handlers[functionName]`; both look the same to the caller. A handler can\r\n also be individually killed by an admin, which comes back as\r\n `HandlerDisabled`.\r\n- **A hard 10-second ceiling always applies.** Whatever timeout the title/\r\n revision configures, the backend clamps every single execution to a 10\r\n second wall-clock budget; past that you get `Timeout` no matter what. Don't\r\n design a script-based feature around long-running work.\r\n- **Rate limiting can hit independently of the generic per-endpoint throttle.**\r\n Beyond the SDK's own ~600ms client-side throttle per call and the\r\n transport's per-user rate limit, the title can configure CloudCode-specific\r\n limits at three levels — whole title, this user, or this user+handler pair.\r\n Any of them tripping comes back as `data.Error.Error === \"RateLimited\"`\r\n (an in-band script-level outcome, `result.ok` is still `true`), with\r\n `data.Error.Message` naming which layer triggered it — treat it as\r\n retryable-after-a-delay, not a hard failure.\r\n- **No client-side validation of script logic.** The SDK only validates that\r\n `functionName` is non-empty and that you're logged in. Argument shape,\r\n business rules, and error handling are entirely up to the script — a\r\n malformed `functionParameter` will fail server-side (`JavaScriptException`\r\n or similar), not client-side.\r\n- **Type the payload and result yourself.** `functionParameter` is `JsonValue`\r\n and `FunctionResult` is `JsonValue | null` — the SDK has no schema for your\r\n title's specific scripts. Define your own request/response interfaces per\r\n handler (as in the recipes above) and cast/validate after the call.\r\n- **Cloud Code doesn't touch `client.data`.** Unlike feature modules, a\r\n successful `execute` doesn't mirror anything into the cache. If the script\r\n changed player-facing state, re-fetch it via the owning module (e.g. call\r\n the Economy/Item/Character module's getter) so the UI reflects it.\r\n- **`Logs`/`FunctionResult` can be silently dropped.** Both are subject to a\r\n title-configured byte-size ceiling; check `LogsTooLarge` /\r\n `FunctionResultTooLarge` before assuming absence means the script produced\r\n nothing. Whether `Logs` is populated at all (even under the size limit) also\r\n depends on a title setting — some titles never reveal script logs to\r\n clients.\r\n- **Keys in your JSON payload can't contain `.` or `$`.** This is a MongoDB\r\n field-name restriction the backend enforces recursively on\r\n `functionParameter` (and on whatever the script returns) — a payload with a\r\n dotted or `$`-prefixed key fails with `InvalidFieldName` before the script\r\n even starts. Stick to plain alphanumeric/underscore keys.\r\n- **Never put a third-party key in game code.** The project ships to the\r\n player's browser; a key there is a public key. The call belongs in a handler,\r\n and the key belongs in the title's integration store.\r\n- **Treat an integration's response as untrusted.** Check `Status`, don't echo\r\n the whole body back to the player, and never write an unvalidated field\r\n straight into player data.\r\n- **Prefer a dedicated module when one exists.** Cloud Code has no typed\r\n contract, no cache integration, and no per-feature event — reach for it only\r\n when the feature genuinely isn't covered elsewhere.\r\n- **Publishing replaces everything.** A revision is the whole script: publish\r\n one containing only your new handler and every other handler stops existing,\r\n with the game getting `HandlerNotFound` at runtime and nothing failing at\r\n build time. Always read the live source first and extend it.\r\n- **The handler whitelist is separate from the code.** A title can declare the\r\n handlers it allows; a function that exists in the script but not in that list\r\n is rejected with `HandlerNotFound`. When you add a handler to a title that\r\n uses a whitelist, add it to the list in the same publish.\r\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|