@idosgames/mcp 0.1.33 → 0.1.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +12 -1
  2. package/dist/cli.js +40 -3
  3. package/package.json +5 -5
  4. package/registry/host.json +1 -1
  5. package/registry/index.json +43 -43
  6. package/registry/modules/ai.json +1 -1
  7. package/registry/modules/board-game.json +93 -17
  8. package/registry/modules/boosts.json +1 -1
  9. package/registry/modules/character.json +20 -12
  10. package/registry/modules/chat.json +1 -1
  11. package/registry/modules/collections.json +1 -1
  12. package/registry/modules/coop-events.json +1 -1
  13. package/registry/modules/craft.json +1 -1
  14. package/registry/modules/deals.json +1 -1
  15. package/registry/modules/events.json +1 -1
  16. package/registry/modules/friends.json +1 -1
  17. package/registry/modules/idle-rpg.json +1 -1
  18. package/registry/modules/inventory.json +1 -1
  19. package/registry/modules/invite.json +1 -1
  20. package/registry/modules/leaderboard.json +1 -1
  21. package/registry/modules/lootboxes.json +3 -3
  22. package/registry/modules/mailbox.json +1 -1
  23. package/registry/modules/marketplace.json +1 -1
  24. package/registry/modules/mini-game.json +1 -1
  25. package/registry/modules/multiplayer.json +5 -5
  26. package/registry/modules/premium.json +1 -1
  27. package/registry/modules/profile.json +1 -1
  28. package/registry/modules/pvp.json +1 -1
  29. package/registry/modules/quests.json +1 -1
  30. package/registry/modules/rewards.json +1 -1
  31. package/registry/modules/season.json +1 -1
  32. package/registry/modules/store.json +1 -1
  33. package/registry/modules/tutorials.json +1 -1
  34. package/registry/modules/voxelcraft.json +11 -11
  35. package/registry/modules/workshop.json +1 -1
@@ -81,16 +81,16 @@
81
81
  "name": "iDos Games",
82
82
  "url": "https://idosgames.com"
83
83
  },
84
- "version": "0.6.0"
84
+ "version": "0.6.1"
85
85
  },
86
86
  "dependencies": {
87
87
  "@idosgames/core": "0.21.6",
88
88
  "@idosgames/module-sdk": "0.11.0",
89
- "@idosgames/react": "0.5.0",
89
+ "@idosgames/react": "0.5.1",
90
90
  "react": "19.2.7",
91
91
  "three": "0.185.1"
92
92
  },
93
- "contentHash": "bfd8ce2406bd36ea05e1bb4f26d83752e8a3b295ad27690eb59c368bebad9051",
93
+ "contentHash": "d1116beca55d0f654ff1c0592bdd55b063bfff6ebae41ba7419fa606cd5890de",
94
94
  "files": [
95
95
  {
96
96
  "path": "agent.ts",
@@ -246,7 +246,7 @@
246
246
  },
247
247
  {
248
248
  "path": "core/GameLoop.ts",
249
- "content": "// Игровой цикл: rAF-рендер + аккумулятор фиксированного тика (20 TPS).\n// Симуляция (мир, мобы, вода, интенты) — на тике; всё, что касается глаза и руки\n// (камера, физика игрока, интерполяция, рендер) — на кадре.\nimport { CONFIG } from \"../config\";\n\nexport class GameLoop {\n onTick: (dt: number, n: number) => void;\n onFrame: (dt: number, alpha: number) => void;\n tickDt: number;\n acc: number;\n tickN: number;\n last: number;\n running: boolean;\n fps: number;\n /**\n * Сколько кадров отработано. Агент ждёт по нему, что нажатие увидел хотя бы один кадр: при\n * страховочном таймере (200 мс) короткий «клик» иначе проскакивал между кадрами.\n */\n frameN = 0;\n private _frames: number;\n private _fpsTime: number;\n /** id запланированного кадра: stop() обязан его отменить, см. там. */\n private _raf = 0;\n /**\n * Таймер шага для скрытой страницы. В фоне (свёрнутое превью AI-кодера, фоновая вкладка) браузер\n * не зовёт requestAnimationFrame вовсе — симуляция вставала, и действия агента (команды, ходьба)\n * висели до возврата на вкладку. Скрытая страница шагает таймером; браузер его душит до ~1 Гц,\n * но тики (maxCatchupTicks за шаг) идут.\n */\n private _timer: ReturnType<typeof setTimeout> | undefined;\n /**\n * Сетевая игра: мир общий, и скрытая вкладка хоста или владельца измерения не может считать его\n * раз в секунду (браузер душит таймеры фоновой страницы до ~1 Гц, а тиков за шаг не больше\n * maxCatchupTicks — мир у ВСЕХ шёл бы вчетверо медленнее). Таймеры выделенного воркера так не\n * душатся: в фоне шаг задаёт его сообщение раз в 50 мс. Подключает Game, пока идёт сетевая игра.\n */\n backgroundTicker: (() => boolean) | null = null;\n private _worker: Worker | null = null;\n private _workerUrl: string | null = null;\n private _workerStep: (() => void) | null = null;\n\n /**\n * @param onTick — фиксированный шаг, dt = 1/TPS\n * @param onFrame — alpha = доля тика для интерполяции\n */\n constructor(\n onTick: (dt: number, n: number) => void,\n onFrame: (dt: number, alpha: number) => void,\n ) {\n this.onTick = onTick;\n this.onFrame = onFrame;\n this.tickDt = 1 / CONFIG.tps;\n this.acc = 0;\n this.tickN = 0;\n this.last = 0;\n this.running = false;\n // FPS: скользящее окно за секунду\n this.fps = 0;\n this._frames = 0;\n this._fpsTime = 0;\n }\n\n start() {\n this.running = true;\n this.last = performance.now();\n const step = (now: number) => {\n if (!this.running) return;\n let dt = (now - this.last) / 1000;\n this.last = now;\n if (dt > 0.25) dt = 0.25; // возврат из фона — не наращивать гигантский долг\n\n this.acc += dt;\n let ticks = 0;\n while (this.acc >= this.tickDt && ticks < CONFIG.maxCatchupTicks) {\n this.onTick(this.tickDt, this.tickN++);\n this.acc -= this.tickDt;\n ticks++;\n }\n if (ticks === CONFIG.maxCatchupTicks) this.acc = 0; // сбросить долг\n\n this._frames++;\n this.frameN++;\n this._fpsTime += dt;\n if (this._fpsTime >= 0.5) {\n this.fps = Math.round(this._frames / this._fpsTime);\n this._frames = 0;\n this._fpsTime = 0;\n }\n\n this.onFrame(Math.min(dt, 0.05), this.acc / this.tickDt);\n schedule();\n };\n const schedule = () => {\n if (typeof document !== \"undefined\" && document.hidden) {\n if (\n this.backgroundTicker?.() &&\n this._startWorker(() => step(performance.now()))\n )\n return;\n this._timer = setTimeout(() => step(performance.now()), 50);\n return;\n }\n this._workerStep = null;\n // Видимая, но неотрисовываемая страница (панель превью за другой вкладкой, iframe вне\n // экрана) не зовёт rAF и при document.hidden === false. Страховочный таймер шагает сам,\n // если кадр не пришёл за 200 мс; кто сработал первым — шагает, второй отменяется.\n this._raf = requestAnimationFrame((now) => {\n clearTimeout(this._timer);\n step(now);\n });\n this._timer = setTimeout(() => {\n cancelAnimationFrame(this._raf);\n step(performance.now());\n }, 200);\n };\n schedule();\n }\n\n stop() {\n this.running = false;\n // Уже запланированный кадр отменяем явно: если start() придёт раньше, чем он сработает\n // (suspend+activate в одном кадре), он увидел бы running=true и продолжил бы СВОЮ цепочку\n // рядом с новой — симуляция пошла бы вдвое быстрее.\n cancelAnimationFrame(this._raf);\n clearTimeout(this._timer);\n this._workerStep = null;\n this._worker?.terminate();\n this._worker = null;\n if (this._workerUrl) URL.revokeObjectURL(this._workerUrl);\n this._workerUrl = null;\n }\n\n /**\n * Следующий шаг скрытой страницы — по сообщению воркера. Воркер тикает всегда, пока жив; шаг\n * берёт только один ожидающий (`_workerStep`), поэтому двойной цепочки не бывает. false —\n * воркер не завести (CSP без blob:, нет Worker) — остаётся обычный таймер.\n */\n private _startWorker(next: () => void): boolean {\n try {\n if (!this._worker) {\n const src = \"setInterval(function(){postMessage(0)},50)\";\n this._workerUrl = URL.createObjectURL(\n new Blob([src], { type: \"text/javascript\" }),\n );\n this._worker = new Worker(this._workerUrl);\n this._worker.onmessage = () => {\n const fn = this._workerStep;\n this._workerStep = null;\n fn?.();\n };\n }\n this._workerStep = next;\n return true;\n } catch {\n return false;\n }\n }\n}\n"
249
+ "content": "// Игровой цикл: rAF-рендер + аккумулятор фиксированного тика (20 TPS).\n// Симуляция (мир, мобы, вода, интенты) — на тике; всё, что касается глаза и руки\n// (камера, физика игрока, интерполяция, рендер) — на кадре.\nimport { CONFIG } from \"../config\";\n\nexport class GameLoop {\n onTick: (dt: number, n: number) => void;\n onFrame: (dt: number, alpha: number) => void;\n tickDt: number;\n acc: number;\n tickN: number;\n last: number;\n running: boolean;\n fps: number;\n /**\n * Сколько кадров отработано. Агент ждёт по нему, что нажатие увидел хотя бы один кадр: при\n * страховочном таймере (200 мс) короткий «клик» иначе проскакивал между кадрами.\n */\n frameN = 0;\n private _frames: number;\n private _fpsTime: number;\n /** id запланированного кадра: stop() обязан его отменить, см. там. */\n private _raf = 0;\n /**\n * Таймер шага для скрытой страницы. В фоне (свёрнутое превью AI-кодера, фоновая вкладка) браузер\n * не зовёт requestAnimationFrame вовсе — симуляция вставала, и действия агента (команды, ходьба)\n * висели до возврата на вкладку. Скрытая страница шагает таймером; браузер его душит до ~1 Гц,\n * но тики (maxCatchupTicks за шаг) идут.\n */\n private _timer: ReturnType<typeof setTimeout> | undefined;\n /**\n * Сетевая игра: мир общий, и скрытая вкладка хоста или владельца измерения не может считать его\n * раз в секунду (браузер душит таймеры фоновой страницы до ~1 Гц, а тиков за шаг не больше\n * maxCatchupTicks — мир у ВСЕХ шёл бы вчетверо медленнее). Таймеры выделенного воркера так не\n * душатся: в фоне шаг задаёт его сообщение раз в 50 мс. Подключает Game, пока идёт сетевая игра.\n */\n backgroundTicker: (() => boolean) | null = null;\n private _worker: Worker | null = null;\n private _workerUrl: string | null = null;\n private _workerStep: (() => void) | null = null;\n\n /**\n * @param onTick — фиксированный шаг, dt = 1/TPS\n * @param onFrame — alpha = доля тика для интерполяции\n */\n constructor(\n onTick: (dt: number, n: number) => void,\n onFrame: (dt: number, alpha: number) => void,\n ) {\n this.onTick = onTick;\n this.onFrame = onFrame;\n this.tickDt = 1 / CONFIG.tps;\n this.acc = 0;\n this.tickN = 0;\n this.last = 0;\n this.running = false;\n // FPS: скользящее окно за секунду\n this.fps = 0;\n this._frames = 0;\n this._fpsTime = 0;\n }\n\n start() {\n this.running = true;\n this.last = performance.now();\n const step = (now: number) => {\n if (!this.running) return;\n let dt = (now - this.last) / 1000;\n this.last = now;\n if (dt > 0.25) dt = 0.25; // возврат из фона — не наращивать гигантский долг\n\n this.acc += dt;\n let ticks = 0;\n while (this.acc >= this.tickDt && ticks < CONFIG.maxCatchupTicks) {\n try {\n this.onTick(this.tickDt, this.tickN++);\n } catch (e) {\n this._report(e);\n }\n this.acc -= this.tickDt;\n ticks++;\n }\n if (ticks === CONFIG.maxCatchupTicks) this.acc = 0; // сбросить долг\n\n this._frames++;\n this.frameN++;\n this._fpsTime += dt;\n if (this._fpsTime >= 0.5) {\n this.fps = Math.round(this._frames / this._fpsTime);\n this._frames = 0;\n this._fpsTime = 0;\n }\n\n try {\n this.onFrame(Math.min(dt, 0.05), this.acc / this.tickDt);\n } catch (e) {\n this._report(e);\n }\n schedule();\n };\n const schedule = () => {\n if (typeof document !== \"undefined\" && document.hidden) {\n if (\n this.backgroundTicker?.() &&\n this._startWorker(() => step(performance.now()))\n )\n return;\n this._timer = setTimeout(() => step(performance.now()), 50);\n return;\n }\n this._workerStep = null;\n // Видимая, но неотрисовываемая страница (панель превью за другой вкладкой, iframe вне\n // экрана) не зовёт rAF и при document.hidden === false. Страховочный таймер шагает сам,\n // если кадр не пришёл за 200 мс; кто сработал первым — шагает, второй отменяется.\n this._raf = requestAnimationFrame((now) => {\n clearTimeout(this._timer);\n step(now);\n });\n this._timer = setTimeout(() => {\n cancelAnimationFrame(this._raf);\n step(performance.now());\n }, 200);\n };\n schedule();\n }\n\n /**\n * Ошибка тика или кадра. Раньше она вылетала из step до schedule() — следующего кадра не было\n * НИКОГДА: игра застывала намертво (в сетевой игре — у хоста, и мир вставал у всех). Теперь цикл\n * идёт дальше; одна и та же ошибка пишется в консоль один раз, а не 20 раз в секунду.\n */\n private _report(e: unknown): void {\n this.errors++;\n const key = e instanceof Error ? (e.stack ?? e.message) : String(e);\n if (this._reported.has(key)) return;\n this._reported.add(key);\n console.error(\"[voxelcraft] game loop error\", e);\n }\n\n /** Сколько тиков/кадров упало с ошибкой (для отладки и тестов). */\n errors = 0;\n private readonly _reported = new Set<string>();\n\n stop() {\n this.running = false;\n // Уже запланированный кадр отменяем явно: если start() придёт раньше, чем он сработает\n // (suspend+activate в одном кадре), он увидел бы running=true и продолжил бы СВОЮ цепочку\n // рядом с новой — симуляция пошла бы вдвое быстрее.\n cancelAnimationFrame(this._raf);\n clearTimeout(this._timer);\n this._workerStep = null;\n this._worker?.terminate();\n this._worker = null;\n if (this._workerUrl) URL.revokeObjectURL(this._workerUrl);\n this._workerUrl = null;\n }\n\n /**\n * Следующий шаг скрытой страницы — по сообщению воркера. Воркер тикает всегда, пока жив; шаг\n * берёт только один ожидающий (`_workerStep`), поэтому двойной цепочки не бывает. false —\n * воркер не завести (CSP без blob:, нет Worker) — остаётся обычный таймер.\n */\n private _startWorker(next: () => void): boolean {\n try {\n if (!this._worker) {\n const src = \"setInterval(function(){postMessage(0)},50)\";\n this._workerUrl = URL.createObjectURL(\n new Blob([src], { type: \"text/javascript\" }),\n );\n this._worker = new Worker(this._workerUrl);\n this._worker.onmessage = () => {\n const fn = this._workerStep;\n this._workerStep = null;\n fn?.();\n };\n }\n this._workerStep = next;\n return true;\n } catch {\n return false;\n }\n }\n}\n"
250
250
  },
251
251
  {
252
252
  "path": "core/Intents.ts",
@@ -290,7 +290,7 @@
290
290
  },
291
291
  {
292
292
  "path": "entities/EntityManager.ts",
293
- "content": "// Менеджер сущностей: мобы, дроп-предметы, сферы опыта, стрелы. Тик — симуляция (20 TPS),\n// updateVisuals — интерполяция для рендера. Перепись (census) держит население мобов вокруг игрока\n// по правилам Minecraft: мирные — на траве при свете, враждебные — в темноте (ночью и в пещерах).\n// Игрок хранится отдельно (Player), но интерфейс сущностей совместим — в этапе 2 удалённые игроки\n// станут ещё одним kind'ом.\nimport * as THREE from \"three\";\nimport { events } from \"../core/EventBus\";\nimport { B } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport { MOBS } from \"../registry/Mobs\";\nimport { frogFor } from \"../registry/mobs/frog\";\nimport { rayBox } from \"../physics/rayBox\";\nimport { Mob } from \"./Mob\";\nimport { ItemDrop } from \"./ItemDrop\";\nimport { Arrow } from \"./Arrow\";\nimport { PrimedTnt } from \"./PrimedTnt\";\nimport { Fireball, type FireballKind } from \"./Fireball\";\nimport { RimEye } from \"./RimEye\";\nimport { Minecart, type CartVariant } from \"./Minecart\";\nimport { smokedHive } from \"../world/Hive\";\nimport type { BlockEntity } from \"../world/BlockEntities\";\nimport { Boat } from \"./Boat\";\nimport { FishingHook } from \"./FishingHook\";\nimport { REP_CURE, REP_TRADE } from \"../systems/Reputation\";\nimport type { MobCtx } from \"./MobBrain\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { Player } from \"../player/Player\";\nimport type { Inventory } from \"../systems/Inventory\";\nimport type { ItemStack, MobDef } from \"../types\";\nimport {\n netSnapshot,\n createFromSnap,\n netSpawnFromRemote,\n netApplySpawn,\n rebase,\n netApplyPose,\n netPose,\n netRemove,\n replicaTick,\n netLocalChanges,\n netPatch,\n netKill,\n netRide,\n netDrive,\n netReleaseDriver,\n release,\n} from \"./manager/net\";\nimport { serialize, deserialize, loadOne } from \"./manager/persistence\";\nimport { census, pickType, trySpawn, canSpawn } from \"./manager/census\";\nimport {\n villagerJobs,\n villagerGolem,\n tickCuring,\n convertToZombie,\n reprofess,\n villageAnger,\n addRep,\n} from \"./manager/villagers\";\nimport { hurt, headHit, explode, mobDied, angerGroup } from \"./manager/combat\";\nimport {\n markRaiders,\n summonAtAltar,\n buildIronGolem,\n buildSnowGolem,\n spawnFangs,\n spawnWanderingTrader,\n spawnPatrol,\n layEggs,\n spawnBee,\n spawnMinecart,\n spawnBoat,\n castHook,\n playerHook,\n spawnDrop,\n takeDrop,\n ejectItem,\n shootArrow,\n spawnMob,\n spawnXp,\n spawnArrow,\n spawnFireball,\n spawnRimEye,\n spawnTnt,\n} from \"./manager/spawners\";\nimport { ctx } from \"./manager/mobContext\";\nimport type {\n Entity,\n EntitySave,\n Actor,\n NetEntitySnap,\n NetEntityAction,\n} from \"./manager/types\";\nimport { INFESTED_IDS, randInt } from \"./manager/shared\";\nimport { cartBox, cartItems, cartWork, raycastRide } from \"./manager/vehicles\";\nimport { updateVisuals, beam } from \"./manager/visuals\";\n\nexport type {\n Actor,\n Entity,\n NetEntityAction,\n NetEntitySnap,\n} from \"./manager/types\";\n\nexport class EntityManager {\n /**\n * Сетевая игра: другие игроки в этом измерении (у владельца измерения). Мобы выбирают ближайшего,\n * предмет подбирает ближайший, взрыв бьёт всех — у удалённого игрока урон уходит ему по сети.\n */\n others: () => Actor[] = () => [];\n /**\n * Реплика (net/sim): сущности живут у владельца измерения, здесь только их образ — без тика,\n * без спавна по событиям. То, что создаёт ДЕЙСТВИЕ этого игрока (бросил предмет, выстрелил),\n * уходит владельцу через `onLocalSpawn`, а удар и взрыв — через `onRemoteAction`.\n */\n replica = false;\n onLocalSpawn: ((snap: NetEntitySnap) => void) | null = null;\n onRemoteAction: ((a: NetEntityAction) => void) | null = null;\n /** Реплика: куда сущность должна прийти по последнему снимку владельца (сглаживание). */\n readonly _netTargets = new Map<\n number,\n { x: number; y: number; z: number; yaw: number }\n >();\n /** Идёт применение сетевого снимка: спавн регистрируется здесь, а не уходит владельцу. */\n _netApplying = false;\n /** Сетевая игра: слот своего игрока (поводок держит конкретный игрок); null — одиночная игра. */\n localSlot: number | null = null;\n /** Реплика: состояние взаимодействия образов по последнему слову владельца (см. interactState). */\n readonly _netBase = new Map<number, string>();\n /** Реплика: сущность, в которой сидит свой игрок, — её ведёт он сам, позы владельца не слушает. */\n drivenId: number | null = null;\n /** Владелец: сущности, которые ведут игроки-реплики (не тикают здесь): id → слот и время позы. */\n readonly _netDriven = new Map<number, { slot: number; t: number }>();\n scene: THREE.Scene;\n world: ChunkManager;\n atlas: TextureAtlas;\n player: Player;\n inventory: Inventory;\n entities: Map<number, Entity>;\n _nextId: number;\n _spawnTimer: number;\n /** На сколько уровней ночь гасит небесный свет и день ли сейчас — задаёт Game из DayNight. */\n skyDarken = 0;\n isDay = true;\n /** Идёт дождь (Game из systems/Weather). */\n raining = false;\n /** Игрок не спал три ночи — прилетают фантомы (Game.daysAwake). */\n insomnia = false;\n /** Секунд до следующего патруля мародёров. */\n _patrolT = 300 + Math.random() * 300;\n\n constructor(\n scene: THREE.Scene,\n world: ChunkManager,\n atlas: TextureAtlas,\n player: Player,\n inventory: Inventory,\n ) {\n this.scene = scene;\n this.world = world;\n this.atlas = atlas;\n this.player = player;\n this.inventory = inventory;\n this.entities = new Map();\n this._nextId = 1;\n this._spawnTimer = 0;\n // TNT, подожжённый огнём (поведение огня не знает про сущности — просит событием)\n events.on(\n \"tntPrime\",\n (d: { x: number; y: number; z: number; fuse?: number }) =>\n this.spawnTnt(d.x + 0.5, d.y, d.z + 0.5, d.fuse ?? 4),\n );\n // вагонетку поставили на рельс (предмет minecart — world/behaviours/rails)\n events.on(\n \"spawnMinecart\",\n (d: { x: number; y: number; z: number; variant?: CartVariant }) =>\n this.spawnMinecart(d.x + 0.5, d.y, d.z + 0.5, d.variant),\n );\n events.on(\n \"spawnBoat\",\n (d: { x: number; y: number; z: number; wood: string }) =>\n this.spawnBoat(d.x + 0.5, d.y, d.z + 0.5, d.wood),\n );\n // из заражённого блока вылезает чешуйница (registry/blocks/infested)\n events.on(\n \"blockBreak\",\n (d: { x: number; y: number; z: number; id: number }) => {\n if (INFESTED_IDS.has(d.id) && MOBS.silverfish)\n this.spawnMob(\"silverfish\", d.x + 0.5, d.y, d.z + 0.5);\n },\n );\n // мёд собрали — пчёлы в 16 блоках от улья злятся\n events.on(\"hiveHarvest\", (d: { x: number; y: number; z: number }) => {\n if (smokedHive(this.world, d.x, d.y, d.z)) return; // дым костра под ульем их успокаивает\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n e.def.bee &&\n !e.dead &&\n e.pos.distanceTo(new THREE.Vector3(d.x, d.y, d.z)) < 16\n )\n e.aggro = true;\n });\n // черепашье яйцо вылупилось (world/behaviours/ocean); выросший черепашонок роняет щиток\n events.on(\"turtleHatch\", (d: { x: number; y: number; z: number }) => {\n if (MOBS.turtle) this.spawnMob(\"turtle\", d.x + 0.5, d.y, d.z + 0.5, true);\n });\n events.on(\"mobGrew\", ({ entity }: { entity: Mob }) => {\n const item = entity.def.growDrop;\n if (item && ITEMS[item])\n this.spawnDrop(entity.pos.x, entity.pos.y + 0.3, entity.pos.z, item, 1);\n });\n // яйцо нюхача треснуло — детёныш нюхача\n events.on(\"snifferHatch\", (d: { x: number; y: number; z: number }) => {\n if (MOBS.sniffer)\n this.spawnMob(\"sniffer\", d.x + 0.5, d.y, d.z + 0.5, true);\n });\n // лягушачья икра — 2–6 головастиков в воду под ней; головастик вырос — лягушка по биому\n events.on(\"frogspawnHatch\", (d: { x: number; y: number; z: number }) => {\n if (!MOBS.tadpole) return;\n for (let i = randInt(2, 6); i > 0; i--)\n this.spawnMob(\n \"tadpole\",\n d.x + 0.3 + Math.random() * 0.4,\n d.y - 0.8,\n d.z + 0.3 + Math.random() * 0.4,\n );\n });\n events.on(\"mobMature\", ({ entity }: { entity: Mob }) => {\n const into =\n entity.def.growsInto === \"frog\"\n ? frogFor(this.world.generator.biomeOf?.(entity.pos.x, entity.pos.z))\n : entity.def.growsInto;\n if (!into || !MOBS[into] || entity.dead) return;\n this.spawnMob(into, entity.pos.x, entity.pos.y, entity.pos.z);\n entity.dead = true; // hp > 0 — без дропа: превратился\n });\n // третий пепельный череп на Т из песка душ — Пепельный владыка (entities/AshLord)\n events.on(\n \"blockPlace\",\n (d: { x: number; y: number; z: number; id: number }) => {\n if (d.id === B.ash_skull) this.summonAtAltar(d.x, d.y, d.z);\n if (d.id === B.pumpkin)\n // тыква достраивает снеговика или железного голема (systems/Golem)\n if (!this.buildSnowGolem(d.x, d.y, d.z))\n this.buildIronGolem(d.x, d.y, d.z);\n },\n );\n // ручные волки: на того, кого бьёт игрок, и на того, кто бьёт игрока\n events.on(\"playerHitMob\", ({ entity }: { entity: Mob }) => {\n this.guard(entity);\n this.villageAnger(entity);\n });\n // деревня помнит добро: торговля и вылеченный зомби-житель поднимают репутацию\n events.on(\"trade\", () => this._addRep(REP_TRADE));\n events.on(\"villagerCured\", () => this._addRep(REP_CURE));\n // колокол: налётчики вокруг светятся, как в Minecraft\n events.on(\"bellRing\", (p: { x: number; y: number; z: number }) =>\n this.markRaiders(p.x, p.y, p.z),\n );\n events.on(\"mobAttack\", ({ entity }: { entity: Mob }) => this.guard(entity));\n }\n\n /**\n * Единственная точка регистрации новой сущности. У реплики (net/sim) сущность, созданная\n * действием игрока, здесь НЕ живёт: после того как вызвавший её донастроит (скорость брошенного\n * предмета, эффект стрелы — это делается сразу после spawn*), её снимок уходит владельцу\n * измерения, а тот разошлёт настоящую. Исключения живут у реплики сами: поплавок (рыбалка —\n * своя) и жемчуг Предела (переносит бросившего, а не владельца измерения).\n */\n _register<T extends Entity>(e: T, ...objects: THREE.Object3D[]): T {\n const ownLocal =\n e.kind === \"hook\" || (e.kind === \"fireball\" && e.ballKind === \"pearl\");\n if (this.replica && !this._netApplying && !ownLocal) {\n queueMicrotask(() => {\n const snap = this.netSnapshot(e);\n if (snap) this.onLocalSpawn?.(snap);\n });\n return e;\n }\n this.entities.set(e.id, e);\n if (objects.length) this.scene.add(...objects);\n return e;\n }\n\n /** Убрать всех враждебных мобов (переход на мирную сложность — systems/Difficulty). */\n clearHostiles(): void {\n for (const e of this.entities.values())\n if (e.kind === \"mob\" && e.def.category === \"hostile\" && !e.def.boss)\n e.dead = true; // hp > 0 — исчезают без дропа, как при удалении игрока\n }\n\n /** Сдвинуть репутацию игрока в деревне и сказать об этом HUD. */\n _addRep(by: number): void {\n addRep(this, by);\n }\n\n /**\n * Ударили жителя: репутация падает, а железные големы рядом бросаются на обидчика — как в\n * Minecraft, где деревня помнит зло (systems/Reputation).\n */\n villageAnger(target: Mob): void {\n villageAnger(this, target);\n }\n\n /** Звон колокола: налётчики в 32 блоках светятся 3 секунды (видно сквозь стены). */\n markRaiders(x: number, y: number, z: number): void {\n markRaiders(this, x, y, z);\n }\n\n /** Череп в (x,y,z) достроил алтарь — блоки исчезают, встаёт владыка и набирает силу. */\n summonAtAltar(x: number, y: number, z: number): Mob | null {\n return summonAtAltar(this, x, y, z);\n }\n\n /** Железный голем: тыква на фигуре из четырёх железных блоков — блоки исчезают, встаёт голем. */\n buildIronGolem(x: number, y: number, z: number): Mob | null {\n return buildIronGolem(this, x, y, z);\n }\n\n /** Снеговик: тыква на двух блоках снега — блоки исчезают, на их месте снеговик. */\n buildSnowGolem(x: number, y: number, z: number): Mob | null {\n return buildSnowGolem(this, x, y, z);\n }\n\n /** Клыки ворожея: 16 штук по прямой от from к to через 1.25 блока, каждый позже предыдущего. */\n spawnFangs(from: THREE.Vector3, to: THREE.Vector3, ownerId: number): void {\n spawnFangs(this, from, to, ownerId);\n }\n\n /** До следующей попытки прихода странствующего торговца, сек. */\n _traderT = 1200;\n /** Перезарядка вызова железного голема деревней (systems/Golem). */\n _golemT = 0;\n\n /** Странствующий торговец с двумя ламами на поводке — на суше в 16–32 блоках от игрока. */\n spawnWanderingTrader(): Mob | null {\n return spawnWanderingTrader(this);\n }\n\n /** Патруль мародёров: 2–5 во главе с капитаном на суше в 24–48 блоках от игрока. */\n spawnPatrol(): number {\n return spawnPatrol(this);\n }\n\n /** Черепаха после размножения откладывает 1–4 яйца на песок рядом. */\n layEggs(m: Mob): number {\n return layEggs(this, m);\n }\n\n /** Вода в квадрате r вокруг клетки (на её высоте и выше на блок) — пляж, а не пустыня. */\n /** Есть ли блок id в кубе радиуса r (аксолотль — мох пышных пещер рядом с озером). */\n _blockNear(\n x: number,\n y: number,\n z: number,\n id: number | undefined,\n r: number,\n ): boolean {\n if (id === undefined) return false;\n for (let dy = -r; dy <= r; dy++)\n for (let dx = -r; dx <= r; dx++)\n for (let dz = -r; dz <= r; dz++)\n if (this.world.getBlock(x + dx, y + dy, z + dz) === id) return true;\n return false;\n }\n\n _waterNear(x: number, y: number, z: number, r: number): boolean {\n for (let dx = -r; dx <= r; dx++)\n for (let dz = -r; dz <= r; dz++)\n for (let dy = 0; dy <= 1; dy++)\n if (this.world.getBlock(x + dx, y + dy, z + dz) === B.water)\n return true;\n return false;\n }\n\n /** Выпустить пчелу из улья (x,y,z) в свободную соседнюю клетку; false — места нет. */\n spawnBee(x: number, y: number, z: number): boolean {\n return spawnBee(this, x, y, z);\n }\n\n /** Ручные волки рядом с игроком (24 блока) бросаются на `target`. */\n guard(target: Mob): void {\n if (target.tamed || target.dead) return;\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n e !== target &&\n e.tamed &&\n e.def.guards &&\n !e.sitting &&\n !e.dead &&\n e.pos.distanceTo(this.player.pos) < 24\n )\n e.targetId = target.id;\n }\n\n /** Груз вагонетки как контейнер (воронка под рельсом и в вагонетку, экран сундука). */\n /** Груз вагонетки или вьюк моба — как контейнер для экрана сундука. */\n cartBox(c: Minecart | Mob): BlockEntity {\n return cartBox(this, c);\n }\n\n /** Вагонетка с грузом в клетке (x, y, z) — как контейнер; null — нет такой. */\n cartItems(x: number, y: number, z: number): BlockEntity | null {\n return cartItems(this, x, y, z);\n }\n\n /**\n * Работа вагонетки за тик: динамит взрывается, запитанные активирующие рельсы высаживают седока и\n * запирают воронку; вагонетка с воронкой берёт из контейнера над собой или лежащие рядом предметы.\n */\n _cartWork(c: Minecart, dt: number): void {\n cartWork(this, c, dt);\n }\n\n spawnMinecart(\n x: number,\n y: number,\n z: number,\n variant: CartVariant = \"plain\",\n ): Minecart {\n return spawnMinecart(this, x, y, z, variant);\n }\n\n spawnBoat(x: number, y: number, z: number, wood: string): Boat {\n return spawnBoat(this, x, y, z, wood);\n }\n\n /** Забросить поплавок игрока (прежний, если был, исчезает). */\n castHook(pos: THREE.Vector3, vel: THREE.Vector3): FishingHook {\n return castHook(this, pos, vel);\n }\n\n /** Поплавок игрока в мире или null. */\n playerHook(): FishingHook | null {\n return playerHook(this);\n }\n\n /** Луч по транспорту (вагонетки, лодки): ближайший и расстояние до него. */\n raycastRide(\n origin: THREE.Vector3,\n dir: THREE.Vector3,\n maxDist: number,\n ): { cart: Minecart | Boat; t: number } | null {\n return raycastRide(this, origin, dir, maxDist);\n }\n\n spawnDrop(\n x: number,\n y: number,\n z: number,\n item: string,\n count: number,\n dmg?: number,\n tag?: Record<string, unknown>,\n ): ItemDrop {\n return spawnDrop(this, x, y, z, item, count, dmg, tag);\n }\n\n /** Воронка: забрать из лежащего в коробке дропа один предмет, если accept его взял. */\n takeDrop(\n x0: number,\n y0: number,\n z0: number,\n x1: number,\n y1: number,\n z1: number,\n accept: (s: ItemStack) => boolean,\n ): boolean {\n return takeDrop(this, x0, y0, z0, x1, y1, z1, accept);\n }\n\n /** Выбрасыватель: стак со скоростью, поднять можно не сразу. */\n ejectItem(\n x: number,\n y: number,\n z: number,\n vx: number,\n vy: number,\n vz: number,\n s: ItemStack,\n ): void {\n ejectItem(this, x, y, z, vx, vy, vz, s);\n }\n\n /** Раздатчик: стрела без стрелка; отравленная несёт свой эффект (systems/TippedArrow). */\n shootArrow(\n x: number,\n y: number,\n z: number,\n vx: number,\n vy: number,\n vz: number,\n item = \"arrow\",\n ): void {\n shootArrow(this, x, y, z, vx, vy, vz, item);\n }\n\n spawnMob(type: string, x: number, y: number, z: number, baby = false): Mob {\n return spawnMob(this, type, x, y, z, baby);\n }\n\n /** Опыт сферами (крупные номиналы, как в Minecraft), не больше 10 сфер. */\n spawnXp(x: number, y: number, z: number, amount: number): void {\n spawnXp(this, x, y, z, amount);\n }\n\n spawnArrow(\n pos: THREE.Vector3,\n vel: THREE.Vector3,\n damage: number,\n fromPlayer: boolean,\n shooterId: number,\n ): Arrow {\n return spawnArrow(this, pos, vel, damage, fromPlayer, shooterId);\n }\n\n /** Огненный шар моба Пекла. */\n spawnFireball(\n kind: FireballKind,\n pos: THREE.Vector3,\n vel: THREE.Vector3,\n damage: number,\n shooterId: number,\n ): Fireball {\n return spawnFireball(this, kind, pos, vel, damage, shooterId);\n }\n\n /** Брошенное Око Предела — летит к цели (цитадели). */\n spawnRimEye(from: THREE.Vector3, target: { x: number; z: number }): RimEye {\n return spawnRimEye(this, from, target);\n }\n\n /** Ударили моба со стайной злостью — злятся все такие же в 32 блоках. */\n _angerGroup(m: Mob): void {\n angerGroup(this, m);\n }\n\n /** Взведённая взрывчатка (огниво, огонь, соседний взрыв). */\n spawnTnt(x: number, y: number, z: number, fuse: number): PrimedTnt {\n return spawnTnt(this, x, y, z, fuse);\n }\n\n /**\n * Удар игрока по мобу. `attacker` — кто бил (сетевая игра: удалённый игрок; по умолчанию свой).\n * У реплики (net/sim) удар уходит владельцу измерения — моб живёт там.\n */\n hurt(\n entityId: number,\n damage: number,\n fromPos: THREE.Vector3,\n attacker: Player = this.player,\n ): void {\n hurt(this, entityId, damage, fromPos, attacker);\n }\n\n /** Луч взгляда игрока проходит у головы дракона (перед центром, на половине ширины). */\n _headHit(m: Mob, who: Player = this.player): boolean {\n return headHit(this, m, who);\n }\n\n /** Взрыв силы power: блоки (дроп с шансом 1/сила), урон и отброс игроку, мобам и предметам. */\n explode(x: number, y: number, z: number, power: number): void {\n explode(this, x, y, z, power);\n }\n\n _ctx(): MobCtx {\n return ctx(this);\n }\n\n /** Все игроки этого измерения: свой и удалённые (сетевая игра). */\n actors(): Actor[] {\n const own: Actor = { player: this.player, inventory: this.inventory };\n const rest = this.others();\n return rest.length ? [own, ...rest] : [own];\n }\n\n /**\n * Ближайший к точке игрок: мобы целятся в него, предмет летит к нему. Живые впереди мёртвых —\n * иначе моб «застревал» бы на трупе, пока рядом бегает живой.\n */\n nearestActor(pos: { x: number; y: number; z: number }): Actor {\n const list = this.actors();\n if (list.length === 1) return list[0]!;\n let best = list[0]!,\n bd = Infinity;\n for (const a of list) {\n const p = a.player.pos;\n const d =\n (p.x - pos.x) ** 2 +\n (p.y - pos.y) ** 2 +\n (p.z - pos.z) ** 2 +\n (a.player.dead ? 1e12 : 0);\n if (d < bd) {\n bd = d;\n best = a;\n }\n }\n return best;\n }\n\n /**\n * Игрок для сущности: моба на поводке ведёт ТОТ, кто держит поводок (у двух игроков рядом моб\n * иначе шёл бы к ближайшему); остальным — ближайший.\n */\n private _actorFor(e: Entity): Actor {\n if (\n e.kind === \"mob\" &&\n e.leashed &&\n !e.leashPost &&\n e.leashMobId === null &&\n e.leashSlot !== null\n ) {\n if (e.leashSlot === this.localSlot)\n return { player: this.player, inventory: this.inventory };\n const held = this.others().find(\n (o) => (o.player as Player & { slot?: number }).slot === e.leashSlot,\n );\n if (held) return held;\n }\n return this.nearestActor(e.pos);\n }\n\n tick(dt: number): void {\n if (this.replica) {\n this._replicaTick(dt);\n return;\n }\n const ctx = this._ctx();\n const ownHeld = ctx.heldItem;\n const multi = this.others().length > 0;\n const mobs = [...this.entities.values()].filter(\n (e): e is Mob => e.kind === \"mob\",\n );\n const now = performance.now();\n for (const e of this.entities.values()) {\n // её ведёт игрок-реплика: позу шлёт он (netDrive); замолчал 3 с — снова наша\n const drv = this._netDriven.get(e.id);\n if (drv && !e.dead) {\n if (now - drv.t < 3000) continue;\n this._release(e.id);\n }\n const a = multi\n ? this._actorFor(e)\n : { player: this.player, inventory: this.inventory };\n if (e.kind === \"drop\") e.tick(dt, this.world, a.player, a.inventory);\n else if (e.kind === \"mob\") {\n if (multi) {\n ctx.player = a.player;\n ctx.heldItem =\n a.player === this.player\n ? ownHeld\n : (a.inventory.heldItem()?.item ?? null);\n }\n e.tick(dt, ctx);\n if (e.angerCall) this._angerGroup(e);\n } else if (e.kind === \"xp\") e.tick(dt, this.world, a.player);\n else if (e.kind === \"tnt\")\n e.tick(dt, this.world, (x, y, z, p) => this.explode(x, y, z, p));\n else if (e.kind === \"fireball\")\n e.tick(\n dt,\n this.world,\n a.player,\n (x, y, z, p) => this.explode(x, y, z, p),\n mobs,\n );\n else if (e.kind === \"minecart\" || e.kind === \"boat\") {\n e.tick(dt, this.world);\n if (e.kind === \"minecart\") this._cartWork(e, dt);\n } else if (e.kind === \"hook\")\n e.tick(\n dt,\n this.world,\n this.player.pos,\n this.raining,\n this.inventory.heldItem()?.item === \"fishing_rod\",\n );\n else if (e.kind === \"rimeye\") {\n // долетело: в 80% падает предметом, иначе разбивается\n if (e.tick(dt)) {\n if (Math.random() < 0.8)\n this.spawnDrop(e.pos.x, e.pos.y, e.pos.z, \"rim_eye\", 1);\n else\n events.emit(\"eyeShatter\", { x: e.pos.x, y: e.pos.y, z: e.pos.z });\n }\n } else e.tick(dt, this.world, a.player, mobs, a.inventory);\n if (e.dead) {\n if (e.kind === \"mob\" && e.hp <= 0) this._mobDied(e);\n e.dispose(this.scene);\n this.entities.delete(e.id);\n }\n }\n this._census(dt);\n }\n\n /** Смерть моба: дроп по таблице (у детёнышей нет) и опыт, если бил игрок. */\n _mobDied(m: Mob): void {\n mobDied(this, m);\n }\n\n /**\n * Профессия от рабочего блока (systems/Jobs), раз в секунду: безработный житель занимает свободный\n * блок рядом и становится мастером; у кого рабочий блок сломали — теряет профессию, если ни разу\n * не торговал. Житель из деревни рабочего места не имеет и профессию не теряет, как в Minecraft.\n */\n _villagerJobs(): void {\n villagerJobs(this);\n }\n\n /**\n * Деревня зовёт голема (systems/Golem): рядом с жителями враждебные (ночная осада) — раз в\n * GOLEM_COOLDOWN секунд встаёт один железный голем. Так деревня отвечает на осаду сама.\n */\n _villagerGolem(): void {\n villagerGolem(this);\n }\n\n /** Лечение зомби-жителя (systems/Zombify): раз в секунду, на нуле — обратно в свою профессию. */\n _tickCuring(): void {\n tickCuring(this);\n }\n\n /**\n * Житель, убитый зомби, встаёт зомби-жителем и помнит профессию (systems/Zombify).\n * Зовёт мозг зомби через MobCtx.convert.\n */\n convertToZombie(v: Mob): Mob {\n return convertToZombie(this, v);\n }\n\n /** Тот же житель на том же месте, но другим типом моба: типы жителей — по профессии. */\n _reprofess(v: Mob, type: string, site: [number, number, number] | null): Mob {\n return reprofess(this, v, type, site);\n }\n\n /** Перепись: враждебные вдали исчезают, население добирается попытками спавна. */\n _census(dt: number): void {\n census(this, dt);\n }\n\n _pickType(light: \"bright\" | \"dark\"): MobDef | null {\n return pickType(this, light);\n }\n\n _trySpawn(\n light: \"bright\" | \"dark\",\n p: THREE.Vector3 = this.player.pos,\n ): number {\n return trySpawn(this, light, p);\n }\n\n /** Место для спавна: пол непрозрачный, над ним воздух на рост, свет по правилу вида. */\n _canSpawn(def: MobDef, x: number, y: number, z: number): boolean {\n return canSpawn(this, def, x, y, z);\n }\n\n /** Луч по AABB мобов (slab-тест) — для атаки и кормления. Возвращает ближайшего. */\n raycast(\n origin: THREE.Vector3,\n dir: THREE.Vector3,\n maxDist: number,\n ): Mob | null {\n let best: Mob | null = null,\n bestT = maxDist;\n for (const e of this.entities.values()) {\n if (e.kind !== \"mob\" || e.dead || e.rider) continue; // лошадь под игроком лучу не мешает\n const w = e.width / 2;\n const t = rayBox(\n origin,\n dir,\n bestT,\n { x: e.pos.x - w, y: e.pos.y, z: e.pos.z - w },\n { x: e.pos.x + w, y: e.pos.y + e.height, z: e.pos.z + w },\n );\n if (t !== null && t < bestT) {\n bestT = t;\n best = e;\n }\n }\n return best;\n }\n\n updateVisuals(alpha: number, time: number): void {\n updateVisuals(this, alpha, time);\n }\n\n /** Луч от лечащего Кристалла Предела к дракону. */\n beamLine: THREE.Line | null = null;\n /** Движущихся сущностей в области теней на прошлом кадре (Game решает, перерисовывать ли тени). */\n shadowCasters = 0;\n\n _beam(): void {\n beam(this);\n }\n\n clear(): void {\n this._netBase.clear();\n this._netDriven.clear();\n this.drivenId = null;\n // игрок сидел в вагонетке/лодке этого мира — ссаживаем без телепорта: иначе Player.update\n // каждый кадр ставил бы его к выброшенной сущности (после портала — в координаты прошлого\n // измерения), а «присесть» высаживало бы туда же\n const ride = this.player?.riding;\n if (ride) {\n ride.rider = false;\n this.player.riding = null;\n }\n // выброшенные сущности — мёртвые для всех, кто держит на них ссылку (экран торговли с жителем,\n // груз вагонетки, поводок): иначе после портала экран жил бы с призраком из прошлого измерения\n for (const e of this.entities.values()) {\n e.dead = true;\n e.dispose(this.scene);\n }\n this.entities.clear();\n if (this.beamLine) this.beamLine.visible = false;\n }\n\n serialize() {\n return serialize(this);\n }\n\n deserialize(list?: EntitySave[]): void {\n deserialize(this, list);\n }\n\n /** Одна сохранённая сущность → живая (сейв, Мастерская, сетевой снимок). null — запись негодна. */\n _loadOne(d: EntitySave): Entity | null {\n return loadOne(this, d);\n }\n\n /**\n * Сущность → снимок для другого игрока. null — вид в сети не живёт (поплавок и клыки — локальные\n * и короткие, Око Предела — чистая анимация у бросившего).\n */\n netSnapshot(e: Entity): NetEntitySnap | null {\n return netSnapshot(this, e);\n }\n\n /** Снимок → живая сущность этого менеджера (id — свой). null — снимок негоден. */\n _createFromSnap(s: NetEntitySnap): Entity | null {\n return createFromSnap(this, s);\n }\n\n /**\n * Владелец измерения: реплика попросила создать сущность (бросила предмет, выстрелила). Создаётся\n * по обычным правилам — с id этого менеджера, и разойдётся всем обычным снимком.\n */\n netSpawnFromRemote(s: NetEntitySnap): Entity | null {\n return netSpawnFromRemote(this, s);\n }\n\n /** Реплика: сущность владельца с ЕГО id (повтор того же id — замена: сменился вид). */\n netApplySpawn(id: number, s: NetEntitySnap): void {\n netApplySpawn(this, id, s);\n }\n\n /** Реплика: запомнить состояние образа как «слово владельца» (изменения от него — не наши). */\n _rebase(e: Entity): void {\n rebase(this, e);\n }\n\n /**\n * Реплика: поза по снимку владельца. Сущность идёт к ней сглаженно (_replicaTick), а флаги вида\n * применяются сразу. Незнакомый id — пропуск: снимок появления придёт надёжным каналом.\n */\n netApplyPose(\n id: number,\n x: number,\n y: number,\n z: number,\n yaw: number,\n flags: number,\n extra: number,\n ): void {\n netApplyPose(this, id, x, y, z, yaw, flags, extra);\n }\n\n /** Поза сущности для снимка: флаги вида (см. netApplyPose) и «лишний» байт (фитиль/раскрытие). */\n netPose(e: Entity): { yaw: number; flags: number; extra: number } {\n return netPose(this, e);\n }\n\n /** Реплика: сущность ушла у владельца (умерла, подобрана, вышла из зоны видимости). */\n netRemove(id: number): void {\n netRemove(this, id);\n }\n\n /**\n * Тик реплики: никакой симуляции — только ход к позе из последнего снимка (снимки приходят 10 раз\n * в секунду, тик — 20: без сглаживания мобы двигались бы рывками). Свои локальные сущности\n * (поплавок, жемчуг) тикают по-настоящему.\n */\n _replicaTick(dt: number): void {\n replicaTick(this, dt);\n }\n\n /**\n * Реплика: что игрок поменял в сущностях своими действиями с прошлого тика — только изменившиеся\n * поля. Зовётся в конце тика (NetGame.afterTick): между изменением и сравнением снимки владельца\n * прийти не могут, так что всё отличие от «слова владельца» — наше.\n */\n netLocalChanges(): NetEntityAction[] {\n return netLocalChanges(this);\n }\n\n /** Владелец: правка сущности игроком-реплики (см. interactState). `slot` — кто правил. */\n netPatch(id: number, f: Record<string, unknown>, slot?: number): void {\n netPatch(this, id, f, slot);\n }\n\n /** Владелец: образ сущности погиб у реплики — с дропом (убили) или без (исчезла, превратилась). */\n netKill(id: number, drops: boolean): void {\n netKill(this, id, drops);\n }\n\n /**\n * Владелец: игрок-реплика сел (on) или слез. Пока сидит, сущность здесь не тикает — её ведёт он.\n * @returns false — место занято (сел кто-то другой, или сидит свой игрок)\n */\n netRide(id: number, slot: number, on: boolean): boolean {\n return netRide(this, id, slot, on);\n }\n\n /** Владелец: поза сущности, которую ведёт реплика (чужая поза той же сущности — мимо). */\n netDrive(\n id: number,\n slot: number,\n pos: readonly number[],\n vel: readonly number[],\n yaw: number,\n ): void {\n netDrive(this, id, slot, pos, vel, yaw);\n }\n\n /** Владелец: игрок ушёл (из измерения, из игры) — всё, что он вёл, снова симулируется здесь. */\n netReleaseDriver(slot: number): void {\n netReleaseDriver(this, slot);\n }\n\n _release(id: number): void {\n release(this, id);\n }\n}\n"
293
+ "content": "// Менеджер сущностей: мобы, дроп-предметы, сферы опыта, стрелы. Тик — симуляция (20 TPS),\n// updateVisuals — интерполяция для рендера. Перепись (census) держит население мобов вокруг игрока\n// по правилам Minecraft: мирные — на траве при свете, враждебные — в темноте (ночью и в пещерах).\n// Игрок хранится отдельно (Player), но интерфейс сущностей совместим — в этапе 2 удалённые игроки\n// станут ещё одним kind'ом.\nimport * as THREE from \"three\";\nimport { events } from \"../core/EventBus\";\nimport { B } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport { MOBS } from \"../registry/Mobs\";\nimport { frogFor } from \"../registry/mobs/frog\";\nimport { rayBox } from \"../physics/rayBox\";\nimport { Mob } from \"./Mob\";\nimport { ItemDrop } from \"./ItemDrop\";\nimport { Arrow } from \"./Arrow\";\nimport { PrimedTnt } from \"./PrimedTnt\";\nimport { Fireball, type FireballKind } from \"./Fireball\";\nimport { RimEye } from \"./RimEye\";\nimport { Minecart, type CartVariant } from \"./Minecart\";\nimport { smokedHive } from \"../world/Hive\";\nimport type { BlockEntity } from \"../world/BlockEntities\";\nimport { Boat } from \"./Boat\";\nimport { FishingHook } from \"./FishingHook\";\nimport { REP_CURE, REP_TRADE } from \"../systems/Reputation\";\nimport type { MobCtx } from \"./MobBrain\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { Player } from \"../player/Player\";\nimport type { Inventory } from \"../systems/Inventory\";\nimport type { ItemStack, MobDef } from \"../types\";\nimport {\n netSnapshot,\n createFromSnap,\n netSpawnFromRemote,\n netApplySpawn,\n rebase,\n netApplyPose,\n netPose,\n netRemove,\n replicaTick,\n netLocalChanges,\n netPatch,\n netKill,\n netRide,\n netDrive,\n netReleaseDriver,\n release,\n} from \"./manager/net\";\nimport { serialize, deserialize, loadOne } from \"./manager/persistence\";\nimport { census, pickType, trySpawn, canSpawn } from \"./manager/census\";\nimport {\n villagerJobs,\n villagerGolem,\n tickCuring,\n convertToZombie,\n reprofess,\n villageAnger,\n addRep,\n} from \"./manager/villagers\";\nimport { hurt, headHit, explode, mobDied, angerGroup } from \"./manager/combat\";\nimport {\n markRaiders,\n summonAtAltar,\n buildIronGolem,\n buildSnowGolem,\n spawnFangs,\n spawnWanderingTrader,\n spawnPatrol,\n layEggs,\n spawnBee,\n spawnMinecart,\n spawnBoat,\n castHook,\n playerHook,\n spawnDrop,\n takeDrop,\n ejectItem,\n shootArrow,\n spawnMob,\n spawnXp,\n spawnArrow,\n spawnFireball,\n spawnRimEye,\n spawnTnt,\n} from \"./manager/spawners\";\nimport { ctx } from \"./manager/mobContext\";\nimport type {\n Entity,\n EntitySave,\n Actor,\n NetEntitySnap,\n NetEntityAction,\n} from \"./manager/types\";\nimport { INFESTED_IDS, randInt } from \"./manager/shared\";\nimport { cartBox, cartItems, cartWork, raycastRide } from \"./manager/vehicles\";\nimport { updateVisuals, beam } from \"./manager/visuals\";\n\n/** Ошибки тика сущностей — в консоль по одному разу на вид и текст, а не 20 раз в секунду. */\nconst reportedEntityErrors = new Set<string>();\nfunction reportEntityError(kind: string, err: unknown): void {\n const key = kind + \":\" + (err instanceof Error ? err.message : String(err));\n if (reportedEntityErrors.has(key)) return;\n reportedEntityErrors.add(key);\n console.error(`[voxelcraft] entity tick error (${kind})`, err);\n}\n\nexport type {\n Actor,\n Entity,\n NetEntityAction,\n NetEntitySnap,\n} from \"./manager/types\";\n\nexport class EntityManager {\n /**\n * Сетевая игра: другие игроки в этом измерении (у владельца измерения). Мобы выбирают ближайшего,\n * предмет подбирает ближайший, взрыв бьёт всех — у удалённого игрока урон уходит ему по сети.\n */\n others: () => Actor[] = () => [];\n /**\n * Реплика (net/sim): сущности живут у владельца измерения, здесь только их образ — без тика,\n * без спавна по событиям. То, что создаёт ДЕЙСТВИЕ этого игрока (бросил предмет, выстрелил),\n * уходит владельцу через `onLocalSpawn`, а удар и взрыв — через `onRemoteAction`.\n */\n replica = false;\n onLocalSpawn: ((snap: NetEntitySnap) => void) | null = null;\n onRemoteAction: ((a: NetEntityAction) => void) | null = null;\n /** Реплика: куда сущность должна прийти по последнему снимку владельца (сглаживание). */\n readonly _netTargets = new Map<\n number,\n { x: number; y: number; z: number; yaw: number }\n >();\n /** Идёт применение сетевого снимка: спавн регистрируется здесь, а не уходит владельцу. */\n _netApplying = false;\n /** Сетевая игра: слот своего игрока (поводок держит конкретный игрок); null — одиночная игра. */\n localSlot: number | null = null;\n /** Реплика: состояние взаимодействия образов по последнему слову владельца (см. interactState). */\n readonly _netBase = new Map<number, string>();\n /** Реплика: сущность, в которой сидит свой игрок, — её ведёт он сам, позы владельца не слушает. */\n drivenId: number | null = null;\n /** Владелец: сущности, которые ведут игроки-реплики (не тикают здесь): id → слот и время позы. */\n readonly _netDriven = new Map<number, { slot: number; t: number }>();\n scene: THREE.Scene;\n world: ChunkManager;\n atlas: TextureAtlas;\n player: Player;\n inventory: Inventory;\n entities: Map<number, Entity>;\n _nextId: number;\n _spawnTimer: number;\n /** На сколько уровней ночь гасит небесный свет и день ли сейчас — задаёт Game из DayNight. */\n skyDarken = 0;\n isDay = true;\n /** Идёт дождь (Game из systems/Weather). */\n raining = false;\n /** Игрок не спал три ночи — прилетают фантомы (Game.daysAwake). */\n insomnia = false;\n /** Секунд до следующего патруля мародёров. */\n _patrolT = 300 + Math.random() * 300;\n\n constructor(\n scene: THREE.Scene,\n world: ChunkManager,\n atlas: TextureAtlas,\n player: Player,\n inventory: Inventory,\n ) {\n this.scene = scene;\n this.world = world;\n this.atlas = atlas;\n this.player = player;\n this.inventory = inventory;\n this.entities = new Map();\n this._nextId = 1;\n this._spawnTimer = 0;\n // TNT, подожжённый огнём (поведение огня не знает про сущности — просит событием)\n events.on(\n \"tntPrime\",\n (d: { x: number; y: number; z: number; fuse?: number }) =>\n this.spawnTnt(d.x + 0.5, d.y, d.z + 0.5, d.fuse ?? 4),\n );\n // вагонетку поставили на рельс (предмет minecart — world/behaviours/rails)\n events.on(\n \"spawnMinecart\",\n (d: { x: number; y: number; z: number; variant?: CartVariant }) =>\n this.spawnMinecart(d.x + 0.5, d.y, d.z + 0.5, d.variant),\n );\n events.on(\n \"spawnBoat\",\n (d: { x: number; y: number; z: number; wood: string }) =>\n this.spawnBoat(d.x + 0.5, d.y, d.z + 0.5, d.wood),\n );\n // из заражённого блока вылезает чешуйница (registry/blocks/infested)\n events.on(\n \"blockBreak\",\n (d: { x: number; y: number; z: number; id: number }) => {\n if (INFESTED_IDS.has(d.id) && MOBS.silverfish)\n this.spawnMob(\"silverfish\", d.x + 0.5, d.y, d.z + 0.5);\n },\n );\n // мёд собрали — пчёлы в 16 блоках от улья злятся\n events.on(\"hiveHarvest\", (d: { x: number; y: number; z: number }) => {\n if (smokedHive(this.world, d.x, d.y, d.z)) return; // дым костра под ульем их успокаивает\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n e.def.bee &&\n !e.dead &&\n e.pos.distanceTo(new THREE.Vector3(d.x, d.y, d.z)) < 16\n )\n e.aggro = true;\n });\n // черепашье яйцо вылупилось (world/behaviours/ocean); выросший черепашонок роняет щиток\n events.on(\"turtleHatch\", (d: { x: number; y: number; z: number }) => {\n if (MOBS.turtle) this.spawnMob(\"turtle\", d.x + 0.5, d.y, d.z + 0.5, true);\n });\n events.on(\"mobGrew\", ({ entity }: { entity: Mob }) => {\n const item = entity.def.growDrop;\n if (item && ITEMS[item])\n this.spawnDrop(entity.pos.x, entity.pos.y + 0.3, entity.pos.z, item, 1);\n });\n // яйцо нюхача треснуло — детёныш нюхача\n events.on(\"snifferHatch\", (d: { x: number; y: number; z: number }) => {\n if (MOBS.sniffer)\n this.spawnMob(\"sniffer\", d.x + 0.5, d.y, d.z + 0.5, true);\n });\n // лягушачья икра — 2–6 головастиков в воду под ней; головастик вырос — лягушка по биому\n events.on(\"frogspawnHatch\", (d: { x: number; y: number; z: number }) => {\n if (!MOBS.tadpole) return;\n for (let i = randInt(2, 6); i > 0; i--)\n this.spawnMob(\n \"tadpole\",\n d.x + 0.3 + Math.random() * 0.4,\n d.y - 0.8,\n d.z + 0.3 + Math.random() * 0.4,\n );\n });\n events.on(\"mobMature\", ({ entity }: { entity: Mob }) => {\n const into =\n entity.def.growsInto === \"frog\"\n ? frogFor(this.world.generator.biomeOf?.(entity.pos.x, entity.pos.z))\n : entity.def.growsInto;\n if (!into || !MOBS[into] || entity.dead) return;\n this.spawnMob(into, entity.pos.x, entity.pos.y, entity.pos.z);\n entity.dead = true; // hp > 0 — без дропа: превратился\n });\n // третий пепельный череп на Т из песка душ — Пепельный владыка (entities/AshLord)\n events.on(\n \"blockPlace\",\n (d: { x: number; y: number; z: number; id: number }) => {\n if (d.id === B.ash_skull) this.summonAtAltar(d.x, d.y, d.z);\n if (d.id === B.pumpkin)\n // тыква достраивает снеговика или железного голема (systems/Golem)\n if (!this.buildSnowGolem(d.x, d.y, d.z))\n this.buildIronGolem(d.x, d.y, d.z);\n },\n );\n // ручные волки: на того, кого бьёт игрок, и на того, кто бьёт игрока\n events.on(\"playerHitMob\", ({ entity }: { entity: Mob }) => {\n this.guard(entity);\n this.villageAnger(entity);\n });\n // деревня помнит добро: торговля и вылеченный зомби-житель поднимают репутацию\n events.on(\"trade\", () => this._addRep(REP_TRADE));\n events.on(\"villagerCured\", () => this._addRep(REP_CURE));\n // колокол: налётчики вокруг светятся, как в Minecraft\n events.on(\"bellRing\", (p: { x: number; y: number; z: number }) =>\n this.markRaiders(p.x, p.y, p.z),\n );\n events.on(\"mobAttack\", ({ entity }: { entity: Mob }) => this.guard(entity));\n }\n\n /**\n * Единственная точка регистрации новой сущности. У реплики (net/sim) сущность, созданная\n * действием игрока, здесь НЕ живёт: после того как вызвавший её донастроит (скорость брошенного\n * предмета, эффект стрелы — это делается сразу после spawn*), её снимок уходит владельцу\n * измерения, а тот разошлёт настоящую. Исключения живут у реплики сами: поплавок (рыбалка —\n * своя) и жемчуг Предела (переносит бросившего, а не владельца измерения).\n */\n _register<T extends Entity>(e: T, ...objects: THREE.Object3D[]): T {\n const ownLocal =\n e.kind === \"hook\" || (e.kind === \"fireball\" && e.ballKind === \"pearl\");\n if (this.replica && !this._netApplying && !ownLocal) {\n queueMicrotask(() => {\n const snap = this.netSnapshot(e);\n if (snap) this.onLocalSpawn?.(snap);\n });\n return e;\n }\n this.entities.set(e.id, e);\n if (objects.length) this.scene.add(...objects);\n return e;\n }\n\n /** Убрать всех враждебных мобов (переход на мирную сложность — systems/Difficulty). */\n clearHostiles(): void {\n for (const e of this.entities.values())\n if (e.kind === \"mob\" && e.def.category === \"hostile\" && !e.def.boss)\n e.dead = true; // hp > 0 — исчезают без дропа, как при удалении игрока\n }\n\n /** Сдвинуть репутацию игрока в деревне и сказать об этом HUD. */\n _addRep(by: number): void {\n addRep(this, by);\n }\n\n /**\n * Ударили жителя: репутация падает, а железные големы рядом бросаются на обидчика — как в\n * Minecraft, где деревня помнит зло (systems/Reputation).\n */\n villageAnger(target: Mob): void {\n villageAnger(this, target);\n }\n\n /** Звон колокола: налётчики в 32 блоках светятся 3 секунды (видно сквозь стены). */\n markRaiders(x: number, y: number, z: number): void {\n markRaiders(this, x, y, z);\n }\n\n /** Череп в (x,y,z) достроил алтарь — блоки исчезают, встаёт владыка и набирает силу. */\n summonAtAltar(x: number, y: number, z: number): Mob | null {\n return summonAtAltar(this, x, y, z);\n }\n\n /** Железный голем: тыква на фигуре из четырёх железных блоков — блоки исчезают, встаёт голем. */\n buildIronGolem(x: number, y: number, z: number): Mob | null {\n return buildIronGolem(this, x, y, z);\n }\n\n /** Снеговик: тыква на двух блоках снега — блоки исчезают, на их месте снеговик. */\n buildSnowGolem(x: number, y: number, z: number): Mob | null {\n return buildSnowGolem(this, x, y, z);\n }\n\n /** Клыки ворожея: 16 штук по прямой от from к to через 1.25 блока, каждый позже предыдущего. */\n spawnFangs(from: THREE.Vector3, to: THREE.Vector3, ownerId: number): void {\n spawnFangs(this, from, to, ownerId);\n }\n\n /** До следующей попытки прихода странствующего торговца, сек. */\n _traderT = 1200;\n /** Перезарядка вызова железного голема деревней (systems/Golem). */\n _golemT = 0;\n\n /** Странствующий торговец с двумя ламами на поводке — на суше в 16–32 блоках от игрока. */\n spawnWanderingTrader(): Mob | null {\n return spawnWanderingTrader(this);\n }\n\n /** Патруль мародёров: 2–5 во главе с капитаном на суше в 24–48 блоках от игрока. */\n spawnPatrol(): number {\n return spawnPatrol(this);\n }\n\n /** Черепаха после размножения откладывает 1–4 яйца на песок рядом. */\n layEggs(m: Mob): number {\n return layEggs(this, m);\n }\n\n /** Вода в квадрате r вокруг клетки (на её высоте и выше на блок) — пляж, а не пустыня. */\n /** Есть ли блок id в кубе радиуса r (аксолотль — мох пышных пещер рядом с озером). */\n _blockNear(\n x: number,\n y: number,\n z: number,\n id: number | undefined,\n r: number,\n ): boolean {\n if (id === undefined) return false;\n for (let dy = -r; dy <= r; dy++)\n for (let dx = -r; dx <= r; dx++)\n for (let dz = -r; dz <= r; dz++)\n if (this.world.getBlock(x + dx, y + dy, z + dz) === id) return true;\n return false;\n }\n\n _waterNear(x: number, y: number, z: number, r: number): boolean {\n for (let dx = -r; dx <= r; dx++)\n for (let dz = -r; dz <= r; dz++)\n for (let dy = 0; dy <= 1; dy++)\n if (this.world.getBlock(x + dx, y + dy, z + dz) === B.water)\n return true;\n return false;\n }\n\n /** Выпустить пчелу из улья (x,y,z) в свободную соседнюю клетку; false — места нет. */\n spawnBee(x: number, y: number, z: number): boolean {\n return spawnBee(this, x, y, z);\n }\n\n /** Ручные волки рядом с игроком (24 блока) бросаются на `target`. */\n guard(target: Mob): void {\n if (target.tamed || target.dead) return;\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n e !== target &&\n e.tamed &&\n e.def.guards &&\n !e.sitting &&\n !e.dead &&\n e.pos.distanceTo(this.player.pos) < 24\n )\n e.targetId = target.id;\n }\n\n /** Груз вагонетки как контейнер (воронка под рельсом и в вагонетку, экран сундука). */\n /** Груз вагонетки или вьюк моба — как контейнер для экрана сундука. */\n cartBox(c: Minecart | Mob): BlockEntity {\n return cartBox(this, c);\n }\n\n /** Вагонетка с грузом в клетке (x, y, z) — как контейнер; null — нет такой. */\n cartItems(x: number, y: number, z: number): BlockEntity | null {\n return cartItems(this, x, y, z);\n }\n\n /**\n * Работа вагонетки за тик: динамит взрывается, запитанные активирующие рельсы высаживают седока и\n * запирают воронку; вагонетка с воронкой берёт из контейнера над собой или лежащие рядом предметы.\n */\n _cartWork(c: Minecart, dt: number): void {\n cartWork(this, c, dt);\n }\n\n spawnMinecart(\n x: number,\n y: number,\n z: number,\n variant: CartVariant = \"plain\",\n ): Minecart {\n return spawnMinecart(this, x, y, z, variant);\n }\n\n spawnBoat(x: number, y: number, z: number, wood: string): Boat {\n return spawnBoat(this, x, y, z, wood);\n }\n\n /** Забросить поплавок игрока (прежний, если был, исчезает). */\n castHook(pos: THREE.Vector3, vel: THREE.Vector3): FishingHook {\n return castHook(this, pos, vel);\n }\n\n /** Поплавок игрока в мире или null. */\n playerHook(): FishingHook | null {\n return playerHook(this);\n }\n\n /** Луч по транспорту (вагонетки, лодки): ближайший и расстояние до него. */\n raycastRide(\n origin: THREE.Vector3,\n dir: THREE.Vector3,\n maxDist: number,\n ): { cart: Minecart | Boat; t: number } | null {\n return raycastRide(this, origin, dir, maxDist);\n }\n\n spawnDrop(\n x: number,\n y: number,\n z: number,\n item: string,\n count: number,\n dmg?: number,\n tag?: Record<string, unknown>,\n ): ItemDrop {\n return spawnDrop(this, x, y, z, item, count, dmg, tag);\n }\n\n /** Воронка: забрать из лежащего в коробке дропа один предмет, если accept его взял. */\n takeDrop(\n x0: number,\n y0: number,\n z0: number,\n x1: number,\n y1: number,\n z1: number,\n accept: (s: ItemStack) => boolean,\n ): boolean {\n return takeDrop(this, x0, y0, z0, x1, y1, z1, accept);\n }\n\n /** Выбрасыватель: стак со скоростью, поднять можно не сразу. */\n ejectItem(\n x: number,\n y: number,\n z: number,\n vx: number,\n vy: number,\n vz: number,\n s: ItemStack,\n ): void {\n ejectItem(this, x, y, z, vx, vy, vz, s);\n }\n\n /** Раздатчик: стрела без стрелка; отравленная несёт свой эффект (systems/TippedArrow). */\n shootArrow(\n x: number,\n y: number,\n z: number,\n vx: number,\n vy: number,\n vz: number,\n item = \"arrow\",\n ): void {\n shootArrow(this, x, y, z, vx, vy, vz, item);\n }\n\n spawnMob(type: string, x: number, y: number, z: number, baby = false): Mob {\n return spawnMob(this, type, x, y, z, baby);\n }\n\n /** Опыт сферами (крупные номиналы, как в Minecraft), не больше 10 сфер. */\n spawnXp(x: number, y: number, z: number, amount: number): void {\n spawnXp(this, x, y, z, amount);\n }\n\n spawnArrow(\n pos: THREE.Vector3,\n vel: THREE.Vector3,\n damage: number,\n fromPlayer: boolean,\n shooterId: number,\n ): Arrow {\n return spawnArrow(this, pos, vel, damage, fromPlayer, shooterId);\n }\n\n /** Огненный шар моба Пекла. */\n spawnFireball(\n kind: FireballKind,\n pos: THREE.Vector3,\n vel: THREE.Vector3,\n damage: number,\n shooterId: number,\n ): Fireball {\n return spawnFireball(this, kind, pos, vel, damage, shooterId);\n }\n\n /** Брошенное Око Предела — летит к цели (цитадели). */\n spawnRimEye(from: THREE.Vector3, target: { x: number; z: number }): RimEye {\n return spawnRimEye(this, from, target);\n }\n\n /** Ударили моба со стайной злостью — злятся все такие же в 32 блоках. */\n _angerGroup(m: Mob): void {\n angerGroup(this, m);\n }\n\n /** Взведённая взрывчатка (огниво, огонь, соседний взрыв). */\n spawnTnt(x: number, y: number, z: number, fuse: number): PrimedTnt {\n return spawnTnt(this, x, y, z, fuse);\n }\n\n /**\n * Удар игрока по мобу. `attacker` — кто бил (сетевая игра: удалённый игрок; по умолчанию свой).\n * У реплики (net/sim) удар уходит владельцу измерения — моб живёт там.\n */\n hurt(\n entityId: number,\n damage: number,\n fromPos: THREE.Vector3,\n attacker: Player = this.player,\n ): void {\n hurt(this, entityId, damage, fromPos, attacker);\n }\n\n /** Луч взгляда игрока проходит у головы дракона (перед центром, на половине ширины). */\n _headHit(m: Mob, who: Player = this.player): boolean {\n return headHit(this, m, who);\n }\n\n /** Взрыв силы power: блоки (дроп с шансом 1/сила), урон и отброс игроку, мобам и предметам. */\n explode(x: number, y: number, z: number, power: number): void {\n explode(this, x, y, z, power);\n }\n\n _ctx(): MobCtx {\n return ctx(this);\n }\n\n /** Все игроки этого измерения: свой и удалённые (сетевая игра). */\n actors(): Actor[] {\n const own: Actor = { player: this.player, inventory: this.inventory };\n const rest = this.others();\n return rest.length ? [own, ...rest] : [own];\n }\n\n /**\n * Ближайший к точке игрок: мобы целятся в него, предмет летит к нему. Живые впереди мёртвых —\n * иначе моб «застревал» бы на трупе, пока рядом бегает живой.\n */\n nearestActor(pos: { x: number; y: number; z: number }): Actor {\n const list = this.actors();\n if (list.length === 1) return list[0]!;\n let best = list[0]!,\n bd = Infinity;\n for (const a of list) {\n const p = a.player.pos;\n const d =\n (p.x - pos.x) ** 2 +\n (p.y - pos.y) ** 2 +\n (p.z - pos.z) ** 2 +\n (a.player.dead ? 1e12 : 0);\n if (d < bd) {\n bd = d;\n best = a;\n }\n }\n return best;\n }\n\n /**\n * Игрок для сущности: моба на поводке ведёт ТОТ, кто держит поводок (у двух игроков рядом моб\n * иначе шёл бы к ближайшему); остальным — ближайший.\n */\n private _actorFor(e: Entity): Actor {\n if (\n e.kind === \"mob\" &&\n e.leashed &&\n !e.leashPost &&\n e.leashMobId === null &&\n e.leashSlot !== null\n ) {\n if (e.leashSlot === this.localSlot)\n return { player: this.player, inventory: this.inventory };\n const held = this.others().find(\n (o) => (o.player as Player & { slot?: number }).slot === e.leashSlot,\n );\n if (held) return held;\n }\n return this.nearestActor(e.pos);\n }\n\n tick(dt: number): void {\n if (this.replica) {\n this._replicaTick(dt);\n return;\n }\n const ctx = this._ctx();\n const ownHeld = ctx.heldItem;\n const multi = this.others().length > 0;\n const mobs = [...this.entities.values()].filter(\n (e): e is Mob => e.kind === \"mob\",\n );\n const now = performance.now();\n for (const e of this.entities.values()) {\n // её ведёт игрок-реплика: позу шлёт он (netDrive); замолчал 3 с — снова наша\n const drv = this._netDriven.get(e.id);\n if (drv && !e.dead) {\n if (now - drv.t < 3000) continue;\n this._release(e.id);\n }\n // одна сломанная сущность не останавливает остальные (раньше все после неё в Map стояли)\n try {\n this._tickOne(e, dt, ctx, ownHeld, multi, mobs);\n } catch (err) {\n reportEntityError(e.kind, err);\n }\n if (e.dead) {\n if (e.kind === \"mob\" && e.hp <= 0) this._mobDied(e);\n e.dispose(this.scene);\n this.entities.delete(e.id);\n }\n }\n this._census(dt);\n }\n\n private _tickOne(\n e: Entity,\n dt: number,\n ctx: MobCtx,\n ownHeld: MobCtx[\"heldItem\"],\n multi: boolean,\n mobs: Mob[],\n ): void {\n const a = multi\n ? this._actorFor(e)\n : { player: this.player, inventory: this.inventory };\n if (e.kind === \"drop\") e.tick(dt, this.world, a.player, a.inventory);\n else if (e.kind === \"mob\") {\n if (multi) {\n ctx.player = a.player;\n ctx.heldItem =\n a.player === this.player\n ? ownHeld\n : (a.inventory.heldItem()?.item ?? null);\n }\n e.tick(dt, ctx);\n if (e.angerCall) this._angerGroup(e);\n } else if (e.kind === \"xp\") e.tick(dt, this.world, a.player);\n else if (e.kind === \"tnt\")\n e.tick(dt, this.world, (x, y, z, p) => this.explode(x, y, z, p));\n else if (e.kind === \"fireball\")\n e.tick(\n dt,\n this.world,\n a.player,\n (x, y, z, p) => this.explode(x, y, z, p),\n mobs,\n );\n else if (e.kind === \"minecart\" || e.kind === \"boat\") {\n e.tick(dt, this.world);\n if (e.kind === \"minecart\") this._cartWork(e, dt);\n } else if (e.kind === \"hook\")\n e.tick(\n dt,\n this.world,\n this.player.pos,\n this.raining,\n this.inventory.heldItem()?.item === \"fishing_rod\",\n );\n else if (e.kind === \"rimeye\") {\n // долетело: в 80% падает предметом, иначе разбивается\n if (e.tick(dt)) {\n if (Math.random() < 0.8)\n this.spawnDrop(e.pos.x, e.pos.y, e.pos.z, \"rim_eye\", 1);\n else events.emit(\"eyeShatter\", { x: e.pos.x, y: e.pos.y, z: e.pos.z });\n }\n } else e.tick(dt, this.world, a.player, mobs, a.inventory);\n }\n\n /** Смерть моба: дроп по таблице (у детёнышей нет) и опыт, если бил игрок. */\n _mobDied(m: Mob): void {\n mobDied(this, m);\n }\n\n /**\n * Профессия от рабочего блока (systems/Jobs), раз в секунду: безработный житель занимает свободный\n * блок рядом и становится мастером; у кого рабочий блок сломали — теряет профессию, если ни разу\n * не торговал. Житель из деревни рабочего места не имеет и профессию не теряет, как в Minecraft.\n */\n _villagerJobs(): void {\n villagerJobs(this);\n }\n\n /**\n * Деревня зовёт голема (systems/Golem): рядом с жителями враждебные (ночная осада) — раз в\n * GOLEM_COOLDOWN секунд встаёт один железный голем. Так деревня отвечает на осаду сама.\n */\n _villagerGolem(): void {\n villagerGolem(this);\n }\n\n /** Лечение зомби-жителя (systems/Zombify): раз в секунду, на нуле — обратно в свою профессию. */\n _tickCuring(): void {\n tickCuring(this);\n }\n\n /**\n * Житель, убитый зомби, встаёт зомби-жителем и помнит профессию (systems/Zombify).\n * Зовёт мозг зомби через MobCtx.convert.\n */\n convertToZombie(v: Mob): Mob {\n return convertToZombie(this, v);\n }\n\n /** Тот же житель на том же месте, но другим типом моба: типы жителей — по профессии. */\n _reprofess(v: Mob, type: string, site: [number, number, number] | null): Mob {\n return reprofess(this, v, type, site);\n }\n\n /** Перепись: враждебные вдали исчезают, население добирается попытками спавна. */\n _census(dt: number): void {\n census(this, dt);\n }\n\n _pickType(light: \"bright\" | \"dark\"): MobDef | null {\n return pickType(this, light);\n }\n\n _trySpawn(\n light: \"bright\" | \"dark\",\n p: THREE.Vector3 = this.player.pos,\n ): number {\n return trySpawn(this, light, p);\n }\n\n /** Место для спавна: пол непрозрачный, над ним воздух на рост, свет по правилу вида. */\n _canSpawn(def: MobDef, x: number, y: number, z: number): boolean {\n return canSpawn(this, def, x, y, z);\n }\n\n /** Луч по AABB мобов (slab-тест) — для атаки и кормления. Возвращает ближайшего. */\n raycast(\n origin: THREE.Vector3,\n dir: THREE.Vector3,\n maxDist: number,\n ): Mob | null {\n let best: Mob | null = null,\n bestT = maxDist;\n for (const e of this.entities.values()) {\n if (e.kind !== \"mob\" || e.dead || e.rider) continue; // лошадь под игроком лучу не мешает\n const w = e.width / 2;\n const t = rayBox(\n origin,\n dir,\n bestT,\n { x: e.pos.x - w, y: e.pos.y, z: e.pos.z - w },\n { x: e.pos.x + w, y: e.pos.y + e.height, z: e.pos.z + w },\n );\n if (t !== null && t < bestT) {\n bestT = t;\n best = e;\n }\n }\n return best;\n }\n\n updateVisuals(alpha: number, time: number): void {\n updateVisuals(this, alpha, time);\n }\n\n /** Луч от лечащего Кристалла Предела к дракону. */\n beamLine: THREE.Line | null = null;\n /** Движущихся сущностей в области теней на прошлом кадре (Game решает, перерисовывать ли тени). */\n shadowCasters = 0;\n\n _beam(): void {\n beam(this);\n }\n\n clear(): void {\n this._netBase.clear();\n this._netDriven.clear();\n this.drivenId = null;\n // игрок сидел в вагонетке/лодке этого мира — ссаживаем без телепорта: иначе Player.update\n // каждый кадр ставил бы его к выброшенной сущности (после портала — в координаты прошлого\n // измерения), а «присесть» высаживало бы туда же\n const ride = this.player?.riding;\n if (ride) {\n ride.rider = false;\n this.player.riding = null;\n }\n // выброшенные сущности — мёртвые для всех, кто держит на них ссылку (экран торговли с жителем,\n // груз вагонетки, поводок): иначе после портала экран жил бы с призраком из прошлого измерения\n for (const e of this.entities.values()) {\n e.dead = true;\n e.dispose(this.scene);\n }\n this.entities.clear();\n if (this.beamLine) this.beamLine.visible = false;\n }\n\n serialize() {\n return serialize(this);\n }\n\n deserialize(list?: EntitySave[]): void {\n deserialize(this, list);\n }\n\n /** Одна сохранённая сущность → живая (сейв, Мастерская, сетевой снимок). null — запись негодна. */\n _loadOne(d: EntitySave): Entity | null {\n return loadOne(this, d);\n }\n\n /**\n * Сущность → снимок для другого игрока. null — вид в сети не живёт (поплавок и клыки — локальные\n * и короткие, Око Предела — чистая анимация у бросившего).\n */\n netSnapshot(e: Entity): NetEntitySnap | null {\n return netSnapshot(this, e);\n }\n\n /** Снимок → живая сущность этого менеджера (id — свой). null — снимок негоден. */\n _createFromSnap(s: NetEntitySnap): Entity | null {\n return createFromSnap(this, s);\n }\n\n /**\n * Владелец измерения: реплика попросила создать сущность (бросила предмет, выстрелила). Создаётся\n * по обычным правилам — с id этого менеджера, и разойдётся всем обычным снимком.\n */\n netSpawnFromRemote(s: NetEntitySnap): Entity | null {\n return netSpawnFromRemote(this, s);\n }\n\n /** Реплика: сущность владельца с ЕГО id (повтор того же id — замена: сменился вид). */\n netApplySpawn(id: number, s: NetEntitySnap): void {\n netApplySpawn(this, id, s);\n }\n\n /** Реплика: запомнить состояние образа как «слово владельца» (изменения от него — не наши). */\n _rebase(e: Entity): void {\n rebase(this, e);\n }\n\n /**\n * Реплика: поза по снимку владельца. Сущность идёт к ней сглаженно (_replicaTick), а флаги вида\n * применяются сразу. Незнакомый id — пропуск: снимок появления придёт надёжным каналом.\n */\n netApplyPose(\n id: number,\n x: number,\n y: number,\n z: number,\n yaw: number,\n flags: number,\n extra: number,\n ): void {\n netApplyPose(this, id, x, y, z, yaw, flags, extra);\n }\n\n /** Поза сущности для снимка: флаги вида (см. netApplyPose) и «лишний» байт (фитиль/раскрытие). */\n netPose(e: Entity): { yaw: number; flags: number; extra: number } {\n return netPose(this, e);\n }\n\n /** Реплика: сущность ушла у владельца (умерла, подобрана, вышла из зоны видимости). */\n netRemove(id: number): void {\n netRemove(this, id);\n }\n\n /**\n * Тик реплики: никакой симуляции — только ход к позе из последнего снимка (снимки приходят 10 раз\n * в секунду, тик — 20: без сглаживания мобы двигались бы рывками). Свои локальные сущности\n * (поплавок, жемчуг) тикают по-настоящему.\n */\n _replicaTick(dt: number): void {\n replicaTick(this, dt);\n }\n\n /**\n * Реплика: что игрок поменял в сущностях своими действиями с прошлого тика — только изменившиеся\n * поля. Зовётся в конце тика (NetGame.afterTick): между изменением и сравнением снимки владельца\n * прийти не могут, так что всё отличие от «слова владельца» — наше.\n */\n netLocalChanges(): NetEntityAction[] {\n return netLocalChanges(this);\n }\n\n /** Владелец: правка сущности игроком-реплики (см. interactState). `slot` — кто правил. */\n netPatch(id: number, f: Record<string, unknown>, slot?: number): void {\n netPatch(this, id, f, slot);\n }\n\n /** Владелец: образ сущности погиб у реплики — с дропом (убили) или без (исчезла, превратилась). */\n netKill(id: number, drops: boolean): void {\n netKill(this, id, drops);\n }\n\n /**\n * Владелец: игрок-реплика сел (on) или слез. Пока сидит, сущность здесь не тикает — её ведёт он.\n * @returns false — место занято (сел кто-то другой, или сидит свой игрок)\n */\n netRide(id: number, slot: number, on: boolean): boolean {\n return netRide(this, id, slot, on);\n }\n\n /** Владелец: поза сущности, которую ведёт реплика (чужая поза той же сущности — мимо). */\n netDrive(\n id: number,\n slot: number,\n pos: readonly number[],\n vel: readonly number[],\n yaw: number,\n ): void {\n netDrive(this, id, slot, pos, vel, yaw);\n }\n\n /** Владелец: игрок ушёл (из измерения, из игры) — всё, что он вёл, снова симулируется здесь. */\n netReleaseDriver(slot: number): void {\n netReleaseDriver(this, slot);\n }\n\n _release(id: number): void {\n release(this, id);\n }\n}\n"
294
294
  },
295
295
  {
296
296
  "path": "entities/Fang.ts",
@@ -866,7 +866,7 @@
866
866
  },
867
867
  {
868
868
  "path": "net/game/handlers.ts",
869
- "content": "import { parseCharacterSpec } from \"../../character/CharacterSpec\";\nimport { characterOf, registerCharacter } from \"../../character/characters\";\nimport {\n parseItemSkin,\n type WearSlot,\n} from \"../../character/items/ItemSkinSpec\";\nimport { registerSkin } from \"../../character/items/skins\";\nimport { isEmote } from \"../../character/anim/emotes\";\nimport { emotionFromText, isEmotion } from \"../../character/anim/emotions\";\nimport * as THREE from \"three\";\nimport { events } from \"../../core/EventBus\";\nimport { rules } from \"../../systems/Difficulty\";\nimport { cleanPin } from \"../../systems/WorldAtlas\";\nimport type { NetEntityAction } from \"../../entities/EntityManager\";\nimport { BLOCKS } from \"../../registry/Blocks\";\nimport { ITEMS } from \"../../registry/Items\";\nimport type { DimensionId } from \"../../world/Dimensions\";\nimport { sim } from \"../sim\";\nimport { Avatar, RemotePlayer } from \"../RemotePlayer\";\nimport { decodeJson, encodeJson, encodeJsonSync } from \"../codec\";\nimport {\n DIMS,\n M,\n PROTOCOL,\n buildHash,\n decodeBlocks,\n decodeEntPoses,\n decodeEntRemove,\n decodePose,\n encodeBlocks,\n withPoseSlot,\n type BeMsg,\n type BlockDelta,\n type DimEnter,\n type DimOwn,\n type EntActionMsg,\n type EntSpawnMsg,\n type Give,\n type Hello,\n type Hit,\n type PlayerMeta,\n type EmoteMsg,\n type LookMsg,\n type PinMsg,\n type PortalMsg,\n type Pose,\n type Roster,\n type Shadow,\n type Welcome,\n type WorldState,\n} from \"../protocol\";\nimport type { Peer } from \"./types\";\nimport { HELLO_RETRY_MS } from \"./shared\";\nimport type { NetGame } from \"../NetGame\";\nimport { tr } from \"../../i18n\";\n\nconst BUILD = buildHash(\n BLOCKS.map((b) => b.name),\n Object.keys(ITEMS),\n);\n\nexport async function onMessage(\n ng: NetGame,\n from: string,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n): Promise<void> {\n if (ng.closed || data.length === 0) return;\n const type = data[0]!;\n // Гость до снимка мира: всё, кроме самого снимка, — в отложенные. Правки хоста легли бы на ЕГО\n // одиночный мир (и ушли бы в «Мои миры»), а выбросить их — значит отстать от снимка навсегда.\n if (!ng.welcomed && type !== M.WELCOME && type !== M.REJECT) {\n if (channel === \"rel\") ng.stash.push([from, data]);\n return;\n }\n try {\n if (channel === \"fast\") return onFast(ng, from, type, data);\n switch (type) {\n case M.HELLO:\n if (ng.isHost) await onHello(ng, from, data);\n return;\n case M.WELCOME: {\n const w = await decodeJson<Welcome>(data);\n if (w && ng.welcomeWait) ng.welcomeWait(w);\n return;\n }\n case M.REJECT: {\n const r = await decodeJson<{ reason: string }>(data);\n ng.welcomeWait?.(\n r?.reason ??\n tr({ en: \"The host refused entry\", ru: \"Хост отказал во входе\" }),\n );\n return;\n }\n case M.ROSTER: {\n const r = await decodeJson<Roster>(data);\n if (r && !ng.isHost) ng.applyRoster(r);\n return;\n }\n case M.PMETA:\n return onMeta(ng, from, data);\n case M.BLOCKS:\n case M.EDITS:\n return onBlocks(ng, from, type, data);\n case M.ENT_SPAWN:\n case M.ENT_REMOVE:\n return onEntities(ng, from, type, data);\n case M.ENT_ACTION:\n return onEntAction(ng, from, data);\n case M.BE:\n return onBe(ng, from, data);\n case M.LOCK:\n return ng.onLock(from, data);\n case M.REPLY:\n return ng.onReply(from, data);\n case M.PORTAL:\n return onPortal(ng, from, data);\n case M.PIN:\n return onPin(ng, from, data);\n case M.BE_LIST: {\n const m = await decodeJson<{ dim: DimensionId; list: BeMsg[] }>(data);\n if (m && m.dim === ng.game.dimension && !sim.authority)\n for (const b of m.list) applyBe(ng, b);\n return;\n }\n case M.WORLD: {\n const w = await decodeJson<WorldState>(data);\n if (w && !ng.isHost) {\n ng.game.applyNetWorldState(w);\n // мирная сложность: враждебные исчезают и в измерении, которое считаю я\n if (sim.authority && !rules().hostiles)\n ng.game.entities.clearHostiles();\n }\n return;\n }\n case M.CHAT:\n return onChat(ng, from, data);\n case M.EMOTE:\n return onEmote(ng, from, data);\n case M.LOOK:\n return onLook(ng, from, data);\n case M.HIT:\n case M.GIVE:\n return onToPlayer(ng, from, type, data);\n case M.PSTATE: {\n const s = await decodeJson<{ state: unknown }>(data);\n const r = ng.records.get(from);\n if (s && r && ng.isHost) {\n r.state = s.state;\n ng.game.mergeBeyond((s.state as { beyond?: unknown } | null)?.beyond);\n }\n return;\n }\n case M.DIM_ENTER:\n if (ng.isHost) {\n const m = await decodeJson<DimEnter>(data);\n if (m) ng.onDimEnter(from, m);\n }\n return;\n case M.DIM_OWN: {\n const m = await decodeJson<DimOwn>(data);\n if (m && m.dim === ng.game.dimension) ng.becomeOwner(m.save);\n return;\n }\n case M.DIM_RESYNC:\n if (sim.authority) ng.bc.reset();\n return;\n case M.SHADOW:\n ng.shadow = await decodeJson<Shadow>(data);\n return;\n case M.COMMAND: {\n const m = await decodeJson<{ text: string }>(data);\n if (m && ng.isHost && ng.mayCheat(from)) ng.game.commands.run(m.text);\n return;\n }\n }\n } catch (e) {\n console.warn(\"[net] bad message\", type, e);\n }\n}\n\nexport function onFast(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): void {\n if (type === M.POSE) {\n if (ng.isHost && from !== ng.selfId) {\n const r = ng.records.get(from);\n if (!r) return;\n const stamped = withPoseSlot(data, r.slot);\n const pose = decodePose(stamped);\n if (r.dim !== pose.dim) r.dim = pose.dim;\n ng.toAll(\"fast\", stamped, from);\n applyPose(ng, pose);\n return;\n }\n if (!ng.isHost) applyPose(ng, decodePose(data));\n return;\n }\n if (type === M.ENT_POSE) {\n const { dim } = decodeEntPoses(data);\n if (ng.isHost && from !== ng.selfId) ng.toDim(dim, \"fast\", data, from);\n if (dim === ng.game.dimension && !sim.authority && from !== ng.selfId)\n for (const p of decodeEntPoses(data).list)\n ng.game.entities.netApplyPose(\n p.id,\n p.x,\n p.y,\n p.z,\n p.yaw,\n p.flags,\n p.extra,\n );\n }\n}\n\nexport function sendHello(ng: NetGame): void {\n const hello: Hello = { v: PROTOCOL, build: BUILD, name: ng.name };\n ng.toHost(\"rel\", encodeJsonSync(M.HELLO, hello));\n}\n\nexport async function onHello(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const h = await decodeJson<Hello>(data);\n if (!h) return;\n if (h.v !== PROTOCOL || h.build !== BUILD) {\n await ng.send(\n from,\n \"rel\",\n encodeJsonSync(M.REJECT, {\n reason: tr({\n en: \"Game versions differ — reload the page\",\n ru: \"Версии игры отличаются — обновите страницу\",\n }),\n }),\n );\n return;\n }\n const g = ng.game;\n let r = ng.records.get(from);\n // Гость повторяет Hello, пока снимок не дошёл (HELLO_RETRY_MS): только что отправленный снимок\n // ещё в пути — второй такой же не нужен.\n if (r?.welcomedAt && Date.now() - r.welcomedAt < HELLO_RETRY_MS * 2) return;\n if (!r) {\n const saved = g.netPlayers.get(from) ?? null;\n const savedDim = (saved as { dim?: DimensionId } | null)?.dim;\n r = {\n slot: ng.nextSlot++,\n name: (h.name || tr({ en: \"Player\", ru: \"Игрок\" })).slice(0, 24),\n dim: savedDim && DIMS.includes(savedDim) ? savedDim : \"overworld\",\n op: ng.ops.has(from),\n state: saved,\n };\n ng.records.set(from, r);\n }\n const spawnPos = (r.state as { pos?: [number, number, number] } | null)?.pos;\n const sp = g.player.spawnPoint;\n const welcome: Welcome = {\n v: PROTOCOL,\n you: r.slot,\n world: g.netWorldMeta(),\n diffs: ng.diffs.all(),\n spawn: {\n dim: r.dim,\n pos:\n Array.isArray(spawnPos) && spawnPos.length === 3\n ? spawnPos\n : [sp.x, sp.y, sp.z],\n },\n state: r.state,\n roster: ng.roster(),\n };\n r.welcomedAt = Date.now();\n await ng.send(from, \"rel\", await encodeJson(M.WELCOME, welcome));\n ng.broadcastRoster();\n ng.enterDim(from, r.dim);\n}\n\nexport function applyPose(ng: NetGame, p: Pose): void {\n if (p.slot === ng.slot) return;\n const peer = ng.peers.get(p.slot);\n if (!peer) return;\n // ушёл из измерения — то, что он вёл, снова считаю я\n if (peer.dim === ng.game.dimension && p.dim !== peer.dim && sim.authority)\n ng.game.entities.netReleaseDriver(peer.slot);\n peer.pose = p;\n peer.dim = p.dim;\n const here = p.dim === ng.game.dimension;\n if (here) {\n if (!peer.avatar) {\n peer.avatar = new Avatar(peer.name, ng.game.atlas);\n ng.game.scene.add(peer.avatar.group);\n applyPeerLook(peer);\n }\n peer.avatar.held = peer.held;\n peer.avatar.sleeping = peer.sleeping;\n peer.avatar.heldSkin = peer.heldSkin ?? null;\n peer.avatar.target(p);\n if (sim.authority) {\n if (!peer.remote)\n peer.remote = new RemotePlayer(ng.game.world, p.slot, peer.userId, ng);\n peer.remote.world = ng.game.world;\n peer.remote.held = peer.held;\n peer.remote.applyPose(p);\n }\n } else dropPeerView(ng, peer);\n}\n\nexport function dropPeerView(ng: NetGame, peer: Peer): void {\n if (peer.avatar) {\n peer.avatar.dispose(ng.game.scene);\n peer.avatar = null;\n }\n peer.remote = null;\n}\n\nexport async function onMeta(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<PlayerMeta>(data);\n if (!m) return;\n if (ng.isHost && from !== ng.selfId) {\n const r = ng.records.get(from);\n if (!r) return;\n m.slot = r.slot;\n r.name = (m.name || r.name).slice(0, 24);\n ng.toAll(\"rel\", encodeJsonSync(M.PMETA, m), from);\n ng.hostSleepCheck(from, m);\n }\n const peer = m.slot !== undefined ? ng.peers.get(m.slot) : undefined;\n if (!peer) return;\n peer.held = m.held;\n peer.look = typeof m.look === \"string\" ? m.look : null;\n peer.outfit = m.outfit && typeof m.outfit === \"object\" ? m.outfit : {};\n peer.heldSkin = typeof m.heldSkin === \"string\" ? m.heldSkin : null;\n applyPeerLook(peer);\n if (peer.avatar) peer.avatar.riding = !!m.riding;\n peer.mode = m.mode;\n peer.sleeping = !!(m as { sleeping?: boolean }).sleeping;\n if (m.name && m.name !== peer.name) {\n peer.name = m.name;\n peer.avatar?.setName(m.name);\n }\n}\n\nexport function onBlocks(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): void {\n const { dim, list } = decodeBlocks(data);\n if (ng.isHost && from !== ng.selfId) {\n if (type === M.BLOCKS) {\n // от владельца измерения — всем; дифы хоста для передачи хоста и новых гостей\n if (ng.owners.get(dim) !== from) return;\n ng.diffs.applyAll(dim, list);\n ng.toAll(\"rel\", data, from);\n } else {\n const owner = ng.owners.get(dim);\n if (!owner) return;\n if (owner === from) {\n // правки, сделанные ещё репликой, от того, кто уже стал владельцем (вошёл первым и сразу\n // строил портал): у него они есть — остальным от его имени\n ng.diffs.applyAll(dim, list);\n for (const b of encodeBlocks(M.BLOCKS, dim, list))\n ng.toAll(\"rel\", b, from);\n return;\n }\n if (owner !== ng.selfId) {\n void ng.send(owner, \"rel\", data);\n return;\n }\n }\n } else if (ng.isHost && type === M.EDITS) {\n // хост сам — реплика (измерение считает гость): его правки — владельцу, иначе терялись\n const owner = ng.owners.get(dim);\n if (owner && owner !== ng.selfId) {\n void ng.send(owner, \"rel\", data);\n return;\n }\n }\n if (type === M.EDITS) {\n // я владелец: правка реплики — как своя (каскады посчитаются здесь и разойдутся BLOCKS)\n if (!sim.authority || dim !== ng.game.dimension) return;\n applyBlocks(ng, list, true);\n return;\n }\n if (from === ng.selfId) return;\n if (!ng.isHost) ng.diffs.applyAll(dim, list);\n if (dim === ng.game.dimension && !sim.authority) applyBlocks(ng, list, false);\n}\n\nexport function applyBlocks(\n ng: NetGame,\n list: readonly BlockDelta[],\n asOwner: boolean,\n): void {\n const w = ng.game.world;\n ng.applyingRemote = !asOwner;\n try {\n for (const b of list) {\n if (b.id < 0 || b.id >= BLOCKS.length) continue;\n w.applyRemoteBlock(b.x, b.y, b.z, b.id, b.meta);\n }\n } finally {\n ng.applyingRemote = false;\n }\n}\n\nexport async function onEntities(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): Promise<void> {\n if (type === M.ENT_SPAWN) {\n const m = await decodeJson<EntSpawnMsg>(data);\n if (!m) return;\n if (ng.isHost && from !== ng.selfId) ng.toDim(m.dim, \"rel\", data, from);\n if (m.dim === ng.game.dimension && !sim.authority && from !== ng.selfId)\n for (const [id, snap] of m.list) ng.game.entities.netApplySpawn(id, snap);\n return;\n }\n const { dim, ids } = decodeEntRemove(data);\n if (ng.isHost && from !== ng.selfId) ng.toDim(dim, \"rel\", data, from);\n if (dim === ng.game.dimension && !sim.authority && from !== ng.selfId)\n for (const id of ids) ng.game.entities.netRemove(id);\n}\n\nexport async function onEntAction(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<EntActionMsg>(data);\n if (!m) return;\n if (ng.isHost) {\n // и от самого хоста, когда он реплика: раньше такие действия до владельца не доходили\n m.from = from === ng.selfId ? ng.slot : ng.records.get(from)?.slot;\n const owner = ng.owners.get(m.dim);\n if (owner && owner !== ng.selfId) {\n void ng.send(owner, \"rel\", encodeJsonSync(M.ENT_ACTION, m));\n return;\n }\n }\n if (!sim.authority || m.dim !== ng.game.dimension) return;\n const em = ng.game.entities;\n const a = m.action;\n if (a.a === \"spawn\") em.netSpawnFromRemote(a.snap);\n else if (a.a === \"hurt\") {\n const attacker = m.from !== undefined ? ng.peers.get(m.from)?.remote : null;\n em.hurt(\n a.id,\n a.damage,\n new THREE.Vector3(a.from[0], a.from[1], a.from[2]),\n attacker ?? undefined,\n );\n } else if (a.a === \"explode\") em.explode(a.x, a.y, a.z, a.power);\n else if (a.a === \"patch\") em.netPatch(a.id, a.fields ?? {}, m.from);\n else if (a.a === \"kill\") em.netKill(a.id, !!a.drops);\n else if (a.a === \"ride\") {\n if (m.from !== undefined && !em.netRide(a.id, m.from, !!a.on))\n ng.replyTo({ to: m.from, kind: \"ride\", id: a.id, ok: false });\n } else if (a.a === \"drive\") {\n if (m.from !== undefined && Array.isArray(a.pos) && Array.isArray(a.vel))\n em.netDrive(a.id, m.from, a.pos, a.vel, Number(a.yaw));\n }\n}\n\nexport function entAction(ng: NetGame, action: NetEntityAction): void {\n ng.toHost(\n \"rel\",\n encodeJsonSync(M.ENT_ACTION, {\n dim: ng.game.dimension,\n action,\n } satisfies EntActionMsg),\n );\n}\n\nexport async function onPortal(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n if (from === ng.selfId) return;\n const m = await decodeJson<PortalMsg>(data);\n if (\n !m ||\n !DIMS.includes(m.dim) ||\n !Array.isArray(m.p) ||\n m.p.length !== 3 ||\n !m.p.every(Number.isFinite)\n )\n return;\n if (ng.isHost) ng.toAll(\"rel\", data, from);\n ng.game.applyNetPortal(m.dim, m.p, !!m.add);\n}\n\n/**\n * Метка карты. Хост проверяет право (менять и удалять — автор или хост), ставит автора новой метке\n * и рассылает остальным; гость верит хосту.\n */\nexport async function onPin(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n if (from === ng.selfId) return;\n const m = await decodeJson<PinMsg>(data);\n if (!m) return;\n const map = ng.game.worldMap;\n if (!ng.isHost) {\n map.applyRemote(\n m.op === \"remove\"\n ? { op: \"remove\", id: Number(m.id) }\n : { op: \"set\", pin: cleanPin(m.pin)! },\n );\n return;\n }\n const id = m.op === \"remove\" ? Number(m.id) : cleanPin(m.pin)?.id;\n if (id === undefined || !Number.isInteger(id)) return;\n const old = map.pins.find((p) => p.id === id);\n if (old && old.by !== from) return; // чужая метка (или хоста)\n if (m.op === \"remove\") {\n if (map.applyRemote({ op: \"remove\", id }))\n ng.toAll(\"rel\", encodeJsonSync(M.PIN, m), from);\n return;\n }\n const pin = cleanPin(m.pin);\n if (!pin) return;\n pin.by = from;\n pin.byName = ng.records.get(from)?.name ?? pin.byName;\n if (map.applyRemote({ op: \"set\", pin }))\n ng.toAll(\n \"rel\",\n encodeJsonSync(M.PIN, { op: \"set\", pin } satisfies PinMsg),\n from,\n );\n}\n\nexport async function onBe(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<BeMsg>(data);\n if (!m) return;\n const owner = ng.owners.get(m.dim);\n if (ng.isHost && from !== ng.selfId) {\n if (owner === from) {\n storeBe(ng, m);\n ng.toDim(m.dim, \"rel\", data, from);\n } else if (owner && owner !== ng.selfId) {\n void ng.send(owner, \"rel\", data);\n return;\n }\n } else if (ng.isHost && owner && owner !== ng.selfId) {\n // хост сам — реплика: его правка сундука — владельцу (раньше оставалась только у него)\n void ng.send(owner, \"rel\", data);\n return;\n }\n if (m.dim !== ng.game.dimension || from === ng.selfId) return;\n if (sim.authority) {\n // правка реплики (открытый сундук): принять — diff разошлёт её всем, включая приславшего\n ng.game.blockEntities.applyRemote(m.x, m.y, m.z, m.be);\n } else applyBe(ng, m);\n}\n\nexport function applyBe(ng: NetGame, m: BeMsg): void {\n ng.game.blockEntities.applyRemote(m.x, m.y, m.z, m.be);\n ng.replicaBe.markBe(m.be, m.x + \",\" + m.y + \",\" + m.z);\n}\n\nexport function storeBe(ng: NetGame, m: BeMsg): void {\n let s = ng.beStore.get(m.dim);\n if (!s) ng.beStore.set(m.dim, (s = new Map()));\n const k = m.x + \",\" + m.y + \",\" + m.z;\n if (m.be) s.set(k, m.be);\n else s.delete(k);\n}\n\nexport async function onToPlayer(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<Hit & Give>(data);\n if (!m) return;\n if (ng.isHost && m.to !== ng.slot) {\n const to = ng.userOfSlot(m.to);\n if (to && to !== from) void ng.send(to, \"rel\", data);\n return;\n }\n if (m.to !== ng.slot) return;\n const g = ng.game;\n if (type === M.HIT) {\n if (m.effect)\n g.player.addEffect(\n m.effect.id as Parameters<typeof g.player.addEffect>[0],\n m.effect.seconds,\n m.effect.level,\n );\n if (m.amount > 0) g.player.damage(m.amount, m.cause);\n if (m.knock)\n g.player.vel.set(\n g.player.vel.x + m.knock[0],\n Math.max(g.player.vel.y, m.knock[1]),\n g.player.vel.z + m.knock[2],\n );\n } else {\n if (m.xp) g.player.addXp(m.xp);\n if (m.stack && ITEMS[m.stack.item]) {\n const left = g.inventory.addStack(m.stack);\n if (left < m.stack.count)\n events.emit(\"itemPickup\", {\n item: m.stack.item,\n count: m.stack.count - left,\n });\n if (left > 0) {\n const p = g.player.pos;\n g.entities.spawnDrop(\n p.x,\n p.y + 0.5,\n p.z,\n m.stack.item,\n left,\n m.stack.dmg,\n m.stack.tag,\n );\n }\n }\n }\n}\n\nexport async function onChat(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<{ from?: number; text: string }>(data);\n if (!m || from === ng.selfId) return;\n if (ng.isHost) {\n m.from = ng.records.get(from)?.slot;\n ng.toAll(\"rel\", encodeJsonSync(M.CHAT, m), from);\n }\n const who = m.from !== undefined ? (ng.peers.get(m.from)?.name ?? \"?\") : \"?\";\n ng.game.chat.print(`<${who}> ${String(m.text).slice(0, 256)}`);\n // смайлик в сообщении — эмоция на лице его персонажа\n const feel = emotionFromText(String(m.text));\n if (feel && m.from !== undefined)\n ng.peers.get(m.from)?.avatar?.actor.setEmotion(feel, 4);\n}\n\n/** Облик другого игрока на его аватаре: персонаж (если рецепт уже дошёл) и надетое. */\nexport function applyPeerLook(peer: Peer): void {\n const a = peer.avatar;\n if (!a) return;\n const spec = characterOf(peer.look);\n if (spec) a.setSpec(spec);\n a.actor.setOutfit((peer.outfit ?? {}) as Partial<Record<WearSlot, string>>);\n}\n\n/** Рецепты облика другого игрока: запомнить (персонажа и предметы) и надеть на его аватар. */\nexport async function onLook(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<LookMsg>(data);\n if (!m || from === ng.selfId) return;\n if (ng.isHost) {\n m.slot = ng.records.get(from)?.slot;\n ng.toAll(\"rel\", await encodeJson(M.LOOK, m), from);\n }\n const c = parseCharacterSpec(m.character);\n if (c.ok) registerCharacter(c.spec);\n if (m.skins && typeof m.skins === \"object\")\n for (const raw of Object.values(m.skins).slice(0, 8)) {\n const s = parseItemSkin(raw);\n if (s.ok) registerSkin(s.spec);\n }\n const peer = m.slot !== undefined ? ng.peers.get(m.slot) : undefined;\n if (peer) applyPeerLook(peer);\n}\n\n/** Жест или эмоция другого игрока: хост ставит слот отправителя и рассылает. */\nexport async function onEmote(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<EmoteMsg>(data);\n if (!m || from === ng.selfId) return;\n if (ng.isHost) {\n m.slot = ng.records.get(from)?.slot;\n ng.toAll(\"rel\", encodeJsonSync(M.EMOTE, m), from);\n }\n const actor =\n m.slot !== undefined ? ng.peers.get(m.slot)?.avatar?.actor : undefined;\n if (!actor) return;\n if (isEmote(m.emote)) actor.playEmote(m.emote);\n if (isEmotion(m.emotion))\n actor.setEmotion(\n m.emotion,\n Math.min(30, Math.max(0, Number(m.seconds) || 4)),\n );\n}\n"
869
+ "content": "import { parseCharacterSpec } from \"../../character/CharacterSpec\";\nimport { characterOf, registerCharacter } from \"../../character/characters\";\nimport {\n parseItemSkin,\n type WearSlot,\n} from \"../../character/items/ItemSkinSpec\";\nimport { registerSkin } from \"../../character/items/skins\";\nimport { isEmote } from \"../../character/anim/emotes\";\nimport { emotionFromText, isEmotion } from \"../../character/anim/emotions\";\nimport * as THREE from \"three\";\nimport { events } from \"../../core/EventBus\";\nimport { rules } from \"../../systems/Difficulty\";\nimport { cleanPin } from \"../../systems/WorldAtlas\";\nimport type { NetEntityAction } from \"../../entities/EntityManager\";\nimport { BLOCKS } from \"../../registry/Blocks\";\nimport { ITEMS } from \"../../registry/Items\";\nimport type { DimensionId } from \"../../world/Dimensions\";\nimport { sim } from \"../sim\";\nimport { Avatar, RemotePlayer } from \"../RemotePlayer\";\nimport { decodeJson, encodeJson, encodeJsonSync } from \"../codec\";\nimport {\n DIMS,\n M,\n PROTOCOL,\n buildHash,\n decodeBlocks,\n decodeEntPoses,\n decodeEntRemove,\n decodePose,\n encodeBlocks,\n withPoseSlot,\n type BeMsg,\n type BlockDelta,\n type DimEnter,\n type DimOwn,\n type EntActionMsg,\n type EntSpawnMsg,\n type Give,\n type Hello,\n type Hit,\n type PlayerMeta,\n type EmoteMsg,\n type LookMsg,\n type PinMsg,\n type PortalMsg,\n type Pose,\n type Roster,\n type Shadow,\n type Welcome,\n type WorldState,\n} from \"../protocol\";\nimport type { Peer } from \"./types\";\nimport { HELLO_RETRY_MS } from \"./shared\";\nimport type { NetGame } from \"../NetGame\";\nimport { tr } from \"../../i18n\";\n\nconst BUILD = buildHash(\n BLOCKS.map((b) => b.name),\n Object.keys(ITEMS),\n);\n\nexport async function onMessage(\n ng: NetGame,\n from: string,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n): Promise<void> {\n if (ng.closed || data.length === 0) return;\n const type = data[0]!;\n // Гость до снимка мира: всё, кроме самого снимка, — в отложенные. Правки хоста легли бы на ЕГО\n // одиночный мир (и ушли бы в «Мои миры»), а выбросить их — значит отстать от снимка навсегда.\n if (!ng.welcomed && type !== M.WELCOME && type !== M.REJECT) {\n if (channel === \"rel\") ng.stash.push([from, data]);\n return;\n }\n try {\n if (channel === \"fast\") return onFast(ng, from, type, data);\n switch (type) {\n case M.HELLO:\n if (ng.isHost) await onHello(ng, from, data);\n return;\n case M.WELCOME: {\n const w = await decodeJson<Welcome>(data);\n if (w && ng.welcomeWait) ng.welcomeWait(w);\n return;\n }\n case M.REJECT: {\n const r = await decodeJson<{ reason: string }>(data);\n ng.welcomeWait?.(\n r?.reason ??\n tr({ en: \"The host refused entry\", ru: \"Хост отказал во входе\" }),\n );\n return;\n }\n case M.ROSTER: {\n const r = await decodeJson<Roster>(data);\n if (r && !ng.isHost) ng.applyRoster(r);\n return;\n }\n case M.PMETA:\n return onMeta(ng, from, data);\n case M.BLOCKS:\n case M.EDITS:\n return onBlocks(ng, from, type, data);\n case M.ENT_SPAWN:\n case M.ENT_REMOVE:\n return onEntities(ng, from, type, data);\n case M.ENT_ACTION:\n return onEntAction(ng, from, data);\n case M.BE:\n return onBe(ng, from, data);\n case M.LOCK:\n return ng.onLock(from, data);\n case M.REPLY:\n return ng.onReply(from, data);\n case M.PORTAL:\n return onPortal(ng, from, data);\n case M.PIN:\n return onPin(ng, from, data);\n case M.BE_LIST: {\n const m = await decodeJson<{ dim: DimensionId; list: BeMsg[] }>(data);\n if (m && m.dim === ng.game.dimension && !sim.authority)\n for (const b of m.list) applyBe(ng, b);\n return;\n }\n case M.WORLD: {\n const w = await decodeJson<WorldState>(data);\n if (w && !ng.isHost) {\n ng.game.applyNetWorldState(w);\n // мирная сложность: враждебные исчезают и в измерении, которое считаю я\n if (sim.authority && !rules().hostiles)\n ng.game.entities.clearHostiles();\n }\n return;\n }\n case M.CHAT:\n return onChat(ng, from, data);\n case M.EMOTE:\n return onEmote(ng, from, data);\n case M.LOOK:\n return onLook(ng, from, data);\n case M.HIT:\n case M.GIVE:\n return onToPlayer(ng, from, type, data);\n case M.PSTATE: {\n const s = await decodeJson<{ state: unknown }>(data);\n const r = ng.records.get(from);\n if (s && r && ng.isHost) {\n r.state = s.state;\n ng.game.mergeBeyond((s.state as { beyond?: unknown } | null)?.beyond);\n }\n return;\n }\n case M.DIM_ENTER:\n if (ng.isHost) {\n const m = await decodeJson<DimEnter>(data);\n if (m) ng.onDimEnter(from, m);\n }\n return;\n case M.DIM_OWN: {\n const m = await decodeJson<DimOwn>(data);\n if (m && m.dim === ng.game.dimension) ng.becomeOwner(m.save);\n return;\n }\n case M.DIM_RESYNC:\n if (sim.authority) ng.bc.reset();\n return;\n case M.SHADOW:\n ng.shadow = await decodeJson<Shadow>(data);\n return;\n case M.COMMAND: {\n const m = await decodeJson<{ text: string }>(data);\n if (m && ng.isHost && ng.mayCheat(from)) ng.game.commands.run(m.text);\n return;\n }\n }\n } catch (e) {\n console.warn(\"[net] bad message\", type, e);\n }\n}\n\nexport function onFast(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): void {\n if (type === M.POSE) {\n if (ng.isHost && from !== ng.selfId) {\n const r = ng.records.get(from);\n if (!r) return;\n const stamped = withPoseSlot(data, r.slot);\n const pose = decodePose(stamped);\n if (r.dim !== pose.dim) r.dim = pose.dim;\n ng.toAll(\"fast\", stamped, from);\n applyPose(ng, pose);\n return;\n }\n if (!ng.isHost) applyPose(ng, decodePose(data));\n return;\n }\n if (type === M.ENT_POSE) {\n const { dim } = decodeEntPoses(data);\n if (ng.isHost && from !== ng.selfId) ng.toDim(dim, \"fast\", data, from);\n if (dim === ng.game.dimension && !sim.authority && from !== ng.selfId)\n for (const p of decodeEntPoses(data).list)\n ng.game.entities.netApplyPose(\n p.id,\n p.x,\n p.y,\n p.z,\n p.yaw,\n p.flags,\n p.extra,\n );\n }\n}\n\nexport function sendHello(ng: NetGame): void {\n const hello: Hello = { v: PROTOCOL, build: BUILD, name: ng.name };\n ng.toHost(\"rel\", encodeJsonSync(M.HELLO, hello));\n}\n\nexport async function onHello(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const h = await decodeJson<Hello>(data);\n if (!h) return;\n if (h.v !== PROTOCOL || h.build !== BUILD) {\n await ng.send(\n from,\n \"rel\",\n encodeJsonSync(M.REJECT, {\n reason: tr({\n en: \"Game versions differ — reload the page\",\n ru: \"Версии игры отличаются — обновите страницу\",\n }),\n }),\n );\n return;\n }\n const g = ng.game;\n let r = ng.records.get(from);\n // Гость повторяет Hello, пока снимок не дошёл (HELLO_RETRY_MS): только что отправленный снимок\n // ещё в пути — второй такой же не нужен.\n if (r?.welcomedAt && Date.now() - r.welcomedAt < HELLO_RETRY_MS * 2) return;\n if (!r) {\n const saved = g.netPlayers.get(from) ?? null;\n const savedDim = (saved as { dim?: DimensionId } | null)?.dim;\n r = {\n slot: ng.nextSlot++,\n name: (h.name || tr({ en: \"Player\", ru: \"Игрок\" })).slice(0, 24),\n dim: savedDim && DIMS.includes(savedDim) ? savedDim : \"overworld\",\n op: ng.ops.has(from),\n state: saved,\n };\n ng.records.set(from, r);\n }\n const spawnPos = (r.state as { pos?: [number, number, number] } | null)?.pos;\n const sp = g.player.spawnPoint;\n const welcome: Welcome = {\n v: PROTOCOL,\n you: r.slot,\n world: g.netWorldMeta(),\n diffs: ng.diffs.all(),\n spawn: {\n dim: r.dim,\n pos:\n Array.isArray(spawnPos) && spawnPos.length === 3\n ? spawnPos\n : [sp.x, sp.y, sp.z],\n },\n state: r.state,\n roster: ng.roster(),\n };\n r.welcomedAt = Date.now();\n await ng.send(from, \"rel\", await encodeJson(M.WELCOME, welcome));\n ng.broadcastRoster();\n ng.enterDim(from, r.dim);\n}\n\nexport function applyPose(ng: NetGame, p: Pose): void {\n if (p.slot === ng.slot) return;\n const peer = ng.peers.get(p.slot);\n if (!peer) return;\n // ушёл из измерения — то, что он вёл, снова считаю я\n if (peer.dim === ng.game.dimension && p.dim !== peer.dim && sim.authority)\n ng.game.entities.netReleaseDriver(peer.slot);\n peer.pose = p;\n peer.dim = p.dim;\n const here = p.dim === ng.game.dimension;\n if (here) {\n if (!peer.avatar) {\n peer.avatar = new Avatar(peer.name, ng.game.atlas);\n ng.game.scene.add(peer.avatar.group);\n applyPeerLook(peer);\n }\n peer.avatar.held = peer.held;\n peer.avatar.sleeping = peer.sleeping;\n peer.avatar.heldSkin = peer.heldSkin ?? null;\n peer.avatar.target(p);\n if (sim.authority) {\n if (!peer.remote)\n peer.remote = new RemotePlayer(ng.game.world, p.slot, peer.userId, ng);\n peer.remote.world = ng.game.world;\n peer.remote.held = peer.held;\n peer.remote.gold = !!peer.gold;\n peer.remote.applyPose(p);\n }\n } else dropPeerView(ng, peer);\n}\n\nexport function dropPeerView(ng: NetGame, peer: Peer): void {\n if (peer.avatar) {\n peer.avatar.dispose(ng.game.scene);\n peer.avatar = null;\n }\n peer.remote = null;\n}\n\nexport async function onMeta(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<PlayerMeta>(data);\n if (!m) return;\n if (ng.isHost && from !== ng.selfId) {\n const r = ng.records.get(from);\n if (!r) return;\n m.slot = r.slot;\n r.name = (m.name || r.name).slice(0, 24);\n ng.toAll(\"rel\", encodeJsonSync(M.PMETA, m), from);\n ng.hostSleepCheck(from, m);\n }\n const peer = m.slot !== undefined ? ng.peers.get(m.slot) : undefined;\n if (!peer) return;\n peer.held = m.held;\n peer.look = typeof m.look === \"string\" ? m.look : null;\n peer.outfit = m.outfit && typeof m.outfit === \"object\" ? m.outfit : {};\n peer.heldSkin = typeof m.heldSkin === \"string\" ? m.heldSkin : null;\n peer.gold = m.gold === true;\n if (peer.remote) peer.remote.gold = peer.gold;\n applyPeerLook(peer);\n if (peer.avatar) peer.avatar.riding = !!m.riding;\n peer.mode = m.mode;\n peer.sleeping = !!(m as { sleeping?: boolean }).sleeping;\n if (m.name && m.name !== peer.name) {\n peer.name = m.name;\n peer.avatar?.setName(m.name);\n }\n}\n\nexport function onBlocks(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): void {\n const { dim, list } = decodeBlocks(data);\n if (ng.isHost && from !== ng.selfId) {\n if (type === M.BLOCKS) {\n // от владельца измерения — всем; дифы хоста для передачи хоста и новых гостей\n if (ng.owners.get(dim) !== from) return;\n ng.diffs.applyAll(dim, list);\n ng.toAll(\"rel\", data, from);\n } else {\n const owner = ng.owners.get(dim);\n if (!owner) return;\n if (owner === from) {\n // правки, сделанные ещё репликой, от того, кто уже стал владельцем (вошёл первым и сразу\n // строил портал): у него они есть — остальным от его имени\n ng.diffs.applyAll(dim, list);\n for (const b of encodeBlocks(M.BLOCKS, dim, list))\n ng.toAll(\"rel\", b, from);\n return;\n }\n if (owner !== ng.selfId) {\n void ng.send(owner, \"rel\", data);\n return;\n }\n }\n } else if (ng.isHost && type === M.EDITS) {\n // хост сам — реплика (измерение считает гость): его правки — владельцу, иначе терялись\n const owner = ng.owners.get(dim);\n if (owner && owner !== ng.selfId) {\n void ng.send(owner, \"rel\", data);\n return;\n }\n }\n if (type === M.EDITS) {\n // я владелец: правка реплики — как своя (каскады посчитаются здесь и разойдутся BLOCKS)\n if (!sim.authority || dim !== ng.game.dimension) return;\n applyBlocks(ng, list, true);\n return;\n }\n if (from === ng.selfId) return;\n if (!ng.isHost) ng.diffs.applyAll(dim, list);\n if (dim === ng.game.dimension && !sim.authority) applyBlocks(ng, list, false);\n}\n\nexport function applyBlocks(\n ng: NetGame,\n list: readonly BlockDelta[],\n asOwner: boolean,\n): void {\n const w = ng.game.world;\n ng.applyingRemote = !asOwner;\n try {\n for (const b of list) {\n if (b.id < 0 || b.id >= BLOCKS.length) continue;\n w.applyRemoteBlock(b.x, b.y, b.z, b.id, b.meta);\n }\n } finally {\n ng.applyingRemote = false;\n }\n}\n\nexport async function onEntities(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): Promise<void> {\n if (type === M.ENT_SPAWN) {\n const m = await decodeJson<EntSpawnMsg>(data);\n if (!m) return;\n if (ng.isHost && from !== ng.selfId) ng.toDim(m.dim, \"rel\", data, from);\n if (m.dim === ng.game.dimension && !sim.authority && from !== ng.selfId)\n for (const [id, snap] of m.list) ng.game.entities.netApplySpawn(id, snap);\n return;\n }\n const { dim, ids } = decodeEntRemove(data);\n if (ng.isHost && from !== ng.selfId) ng.toDim(dim, \"rel\", data, from);\n if (dim === ng.game.dimension && !sim.authority && from !== ng.selfId)\n for (const id of ids) ng.game.entities.netRemove(id);\n}\n\nexport async function onEntAction(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<EntActionMsg>(data);\n if (!m) return;\n if (ng.isHost) {\n // и от самого хоста, когда он реплика: раньше такие действия до владельца не доходили\n m.from = from === ng.selfId ? ng.slot : ng.records.get(from)?.slot;\n const owner = ng.owners.get(m.dim);\n if (owner && owner !== ng.selfId) {\n void ng.send(owner, \"rel\", encodeJsonSync(M.ENT_ACTION, m));\n return;\n }\n }\n if (!sim.authority || m.dim !== ng.game.dimension) return;\n const em = ng.game.entities;\n const a = m.action;\n if (a.a === \"spawn\") em.netSpawnFromRemote(a.snap);\n else if (a.a === \"hurt\") {\n const attacker = m.from !== undefined ? ng.peers.get(m.from)?.remote : null;\n em.hurt(\n a.id,\n a.damage,\n new THREE.Vector3(a.from[0], a.from[1], a.from[2]),\n attacker ?? undefined,\n );\n } else if (a.a === \"explode\") em.explode(a.x, a.y, a.z, a.power);\n else if (a.a === \"patch\") em.netPatch(a.id, a.fields ?? {}, m.from);\n else if (a.a === \"kill\") em.netKill(a.id, !!a.drops);\n else if (a.a === \"ride\") {\n if (m.from !== undefined && !em.netRide(a.id, m.from, !!a.on))\n ng.replyTo({ to: m.from, kind: \"ride\", id: a.id, ok: false });\n } else if (a.a === \"drive\") {\n if (m.from !== undefined && Array.isArray(a.pos) && Array.isArray(a.vel))\n em.netDrive(a.id, m.from, a.pos, a.vel, Number(a.yaw));\n }\n}\n\nexport function entAction(ng: NetGame, action: NetEntityAction): void {\n ng.toHost(\n \"rel\",\n encodeJsonSync(M.ENT_ACTION, {\n dim: ng.game.dimension,\n action,\n } satisfies EntActionMsg),\n );\n}\n\nexport async function onPortal(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n if (from === ng.selfId) return;\n const m = await decodeJson<PortalMsg>(data);\n if (\n !m ||\n !DIMS.includes(m.dim) ||\n !Array.isArray(m.p) ||\n m.p.length !== 3 ||\n !m.p.every(Number.isFinite)\n )\n return;\n if (ng.isHost) ng.toAll(\"rel\", data, from);\n ng.game.applyNetPortal(m.dim, m.p, !!m.add);\n}\n\n/**\n * Метка карты. Хост проверяет право (менять и удалять — автор или хост), ставит автора новой метке\n * и рассылает остальным; гость верит хосту.\n */\nexport async function onPin(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n if (from === ng.selfId) return;\n const m = await decodeJson<PinMsg>(data);\n if (!m) return;\n const map = ng.game.worldMap;\n if (!ng.isHost) {\n map.applyRemote(\n m.op === \"remove\"\n ? { op: \"remove\", id: Number(m.id) }\n : { op: \"set\", pin: cleanPin(m.pin)! },\n );\n return;\n }\n const id = m.op === \"remove\" ? Number(m.id) : cleanPin(m.pin)?.id;\n if (id === undefined || !Number.isInteger(id)) return;\n const old = map.pins.find((p) => p.id === id);\n if (old && old.by !== from) return; // чужая метка (или хоста)\n if (m.op === \"remove\") {\n if (map.applyRemote({ op: \"remove\", id }))\n ng.toAll(\"rel\", encodeJsonSync(M.PIN, m), from);\n return;\n }\n const pin = cleanPin(m.pin);\n if (!pin) return;\n pin.by = from;\n pin.byName = ng.records.get(from)?.name ?? pin.byName;\n if (map.applyRemote({ op: \"set\", pin }))\n ng.toAll(\n \"rel\",\n encodeJsonSync(M.PIN, { op: \"set\", pin } satisfies PinMsg),\n from,\n );\n}\n\nexport async function onBe(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<BeMsg>(data);\n if (!m) return;\n const owner = ng.owners.get(m.dim);\n if (ng.isHost && from !== ng.selfId) {\n if (owner === from) {\n storeBe(ng, m);\n ng.toDim(m.dim, \"rel\", data, from);\n } else if (owner && owner !== ng.selfId) {\n void ng.send(owner, \"rel\", data);\n return;\n }\n } else if (ng.isHost && owner && owner !== ng.selfId) {\n // хост сам — реплика: его правка сундука — владельцу (раньше оставалась только у него)\n void ng.send(owner, \"rel\", data);\n return;\n }\n if (m.dim !== ng.game.dimension || from === ng.selfId) return;\n if (sim.authority) {\n // правка реплики (открытый сундук): принять — diff разошлёт её всем, включая приславшего\n ng.game.blockEntities.applyRemote(m.x, m.y, m.z, m.be);\n } else applyBe(ng, m);\n}\n\nexport function applyBe(ng: NetGame, m: BeMsg): void {\n ng.game.blockEntities.applyRemote(m.x, m.y, m.z, m.be);\n ng.replicaBe.markBe(m.be, m.x + \",\" + m.y + \",\" + m.z);\n}\n\nexport function storeBe(ng: NetGame, m: BeMsg): void {\n let s = ng.beStore.get(m.dim);\n if (!s) ng.beStore.set(m.dim, (s = new Map()));\n const k = m.x + \",\" + m.y + \",\" + m.z;\n if (m.be) s.set(k, m.be);\n else s.delete(k);\n}\n\nexport async function onToPlayer(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<Hit & Give>(data);\n if (!m) return;\n if (ng.isHost && m.to !== ng.slot) {\n const to = ng.userOfSlot(m.to);\n if (to && to !== from) void ng.send(to, \"rel\", data);\n return;\n }\n if (m.to !== ng.slot) return;\n const g = ng.game;\n if (type === M.HIT) {\n if (m.effect)\n g.player.addEffect(\n m.effect.id as Parameters<typeof g.player.addEffect>[0],\n m.effect.seconds,\n m.effect.level,\n );\n if (m.amount > 0) g.player.damage(m.amount, m.cause);\n if (m.knock)\n g.player.vel.set(\n g.player.vel.x + m.knock[0],\n Math.max(g.player.vel.y, m.knock[1]),\n g.player.vel.z + m.knock[2],\n );\n } else {\n if (m.xp) g.player.addXp(m.xp);\n if (m.stack && ITEMS[m.stack.item]) {\n const left = g.inventory.addStack(m.stack);\n if (left < m.stack.count)\n events.emit(\"itemPickup\", {\n item: m.stack.item,\n count: m.stack.count - left,\n });\n if (left > 0) {\n const p = g.player.pos;\n g.entities.spawnDrop(\n p.x,\n p.y + 0.5,\n p.z,\n m.stack.item,\n left,\n m.stack.dmg,\n m.stack.tag,\n );\n }\n }\n }\n}\n\nexport async function onChat(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<{ from?: number; text: string }>(data);\n if (!m || from === ng.selfId) return;\n if (ng.isHost) {\n m.from = ng.records.get(from)?.slot;\n ng.toAll(\"rel\", encodeJsonSync(M.CHAT, m), from);\n }\n const who = m.from !== undefined ? (ng.peers.get(m.from)?.name ?? \"?\") : \"?\";\n ng.game.chat.print(`<${who}> ${String(m.text).slice(0, 256)}`);\n // смайлик в сообщении — эмоция на лице его персонажа\n const feel = emotionFromText(String(m.text));\n if (feel && m.from !== undefined)\n ng.peers.get(m.from)?.avatar?.actor.setEmotion(feel, 4);\n}\n\n/** Облик другого игрока на его аватаре: персонаж (если рецепт уже дошёл) и надетое. */\nexport function applyPeerLook(peer: Peer): void {\n const a = peer.avatar;\n if (!a) return;\n const spec = characterOf(peer.look);\n if (spec) a.setSpec(spec);\n a.actor.setOutfit((peer.outfit ?? {}) as Partial<Record<WearSlot, string>>);\n}\n\n/** Рецепты облика другого игрока: запомнить (персонажа и предметы) и надеть на его аватар. */\nexport async function onLook(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<LookMsg>(data);\n if (!m || from === ng.selfId) return;\n if (ng.isHost) {\n m.slot = ng.records.get(from)?.slot;\n ng.toAll(\"rel\", await encodeJson(M.LOOK, m), from);\n }\n const c = parseCharacterSpec(m.character);\n if (c.ok) registerCharacter(c.spec);\n if (m.skins && typeof m.skins === \"object\")\n for (const raw of Object.values(m.skins).slice(0, 8)) {\n const s = parseItemSkin(raw);\n if (s.ok) registerSkin(s.spec);\n }\n const peer = m.slot !== undefined ? ng.peers.get(m.slot) : undefined;\n if (peer) applyPeerLook(peer);\n}\n\n/** Жест или эмоция другого игрока: хост ставит слот отправителя и рассылает. */\nexport async function onEmote(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<EmoteMsg>(data);\n if (!m || from === ng.selfId) return;\n if (ng.isHost) {\n m.slot = ng.records.get(from)?.slot;\n ng.toAll(\"rel\", encodeJsonSync(M.EMOTE, m), from);\n }\n const actor =\n m.slot !== undefined ? ng.peers.get(m.slot)?.avatar?.actor : undefined;\n if (!actor) return;\n if (isEmote(m.emote)) actor.playEmote(m.emote);\n if (isEmotion(m.emotion))\n actor.setEmotion(\n m.emotion,\n Math.min(30, Math.max(0, Number(m.seconds) || 4)),\n );\n}\n"
870
870
  },
871
871
  {
872
872
  "path": "net/game/hostMigration.ts",
@@ -886,11 +886,11 @@
886
886
  },
887
887
  {
888
888
  "path": "net/game/tick.ts",
889
- "content": "import { specHash } from \"../../character/CharacterSpec\";\nimport { skinOf, skinSpecs, stackSkinHash } from \"../../character/items/skins\";\nimport type { DimensionId } from \"../../world/Dimensions\";\nimport { sim } from \"../sim\";\nimport { encodeJson, encodeJsonSync } from \"../codec\";\nimport {\n M,\n POSE_CREATIVE,\n POSE_DEAD,\n POSE_FLY,\n POSE_GLIDE,\n POSE_SNEAK,\n POSE_SWIM,\n POSE_SWING,\n POSE_SPRINT,\n encodeBlocks,\n encodeEntPoses,\n encodeEntRemove,\n encodePose,\n type BeMsg,\n type EntSpawnMsg,\n type PlayerMeta,\n type LookMsg,\n} from \"../protocol\";\nimport type { NetGame } from \"../NetGame\";\n\nexport function afterTick(ng: NetGame): void {\n if (ng.closed) return;\n // Сессию закрыли в обход событий (страховка к \"left\"): дальше играть не на чем.\n if (ng.session.closed) return ng.end(null);\n const g = ng.game;\n ng.tickN++;\n const dim = g.dimension;\n\n if (ng.pending.length) {\n const list = ng.pending;\n ng.pending = [];\n const type = sim.authority ? M.BLOCKS : M.EDITS;\n for (const msg of encodeBlocks(type, dim, list))\n if (ng.isHost && type === M.BLOCKS) ng.toAll(\"rel\", msg);\n else ng.toHost(\"rel\", msg);\n }\n\n if (!sim.authority) {\n // свои действия над сущностями (остриг, оседлал, назвал, поторговал) — владельцу\n for (const a of g.entities.netLocalChanges()) ng.entAction(a);\n syncRide(ng);\n }\n\n if (sim.authority) {\n // толчки удалённых игроков (взрыв, удар моба) — им\n for (const peer of ng.peers.values()) {\n const imp = peer.remote?.takeImpulse();\n if (imp && peer.remote) ng.hit(peer.slot, 0, \"knock\", imp);\n }\n if (ng.tickN % 2 === 0 && hasOthersIn(ng, dim)) {\n const d = ng.bc.diffEntities(g.entities);\n // синхронно: появление обязано уйти раньше удаления следующего тика\n if (d.spawns.length)\n ownerSend(\n ng,\n \"rel\",\n encodeJsonSync(M.ENT_SPAWN, {\n dim,\n list: d.spawns,\n } satisfies EntSpawnMsg),\n );\n for (const b of encodeEntPoses(dim, d.poses)) ownerSend(ng, \"fast\", b);\n if (d.removes.length)\n ownerSend(ng, \"rel\", encodeEntRemove(dim, d.removes));\n }\n if (ng.tickN % 10 === 0)\n for (const { be, key } of ng.bc.diffBlockEntities(g.blockEntities)) {\n const [x, y, z] = key.split(\",\").map(Number) as [\n number,\n number,\n number,\n ];\n const msg: BeMsg = { dim, x, y, z, be };\n if (ng.isHost) ng.storeBe(msg);\n if (hasOthersIn(ng, dim))\n ownerSend(ng, \"rel\", encodeJsonSync(M.BE, msg));\n }\n } else if (ng.tickN % 10 === 0) {\n // реплика: своё изменение блочной сущности (переложил в сундуке) — владельцу\n for (const { be, key } of ng.replicaBe.diffBlockEntities(g.blockEntities)) {\n const [x, y, z] = key.split(\",\").map(Number) as [number, number, number];\n ng.toHost(\n \"rel\",\n encodeJsonSync(M.BE, { dim, x, y, z, be } satisfies BeMsg),\n );\n }\n }\n\n // своя поза — каждый тик; метаданные — по изменению; сейв игрока — раз в 10 с\n sendPose(ng);\n sendMetaIfChanged(ng);\n void sendLookIfChanged(ng);\n if (ng.tickN % 200 === 0 && !ng.isHost) ng.sendPlayerState();\n\n if (ng.isHost) {\n if (ng.tickN % 100 === 0)\n ng.toAll(\"rel\", encodeJsonSync(M.WORLD, g.netWorldState()));\n if (ng.tickN % 200 === 0) ng.pickSuccessorAndShadow();\n } else ng.watchHost();\n}\n\nexport function syncRide(ng: NetGame): void {\n const g = ng.game;\n const em = g.entities;\n const r = g.player.riding as unknown as { id?: number } | null;\n const id =\n r && typeof r.id === \"number\" && (em.entities.get(r.id) as unknown) === r\n ? r.id\n : null;\n if (id !== ng.driving) {\n if (ng.driving !== null)\n ng.entAction({ a: \"ride\", id: ng.driving, on: false });\n if (id !== null) ng.entAction({ a: \"ride\", id, on: true });\n ng.driving = id;\n em.drivenId = id;\n }\n if (id === null || ng.tickN % 2 !== 0) return;\n const e = em.entities.get(id);\n if (!e) return;\n const vel = \"vel\" in e ? e.vel : null;\n ng.entAction({\n a: \"drive\",\n id,\n pos: [e.pos.x, e.pos.y, e.pos.z],\n vel: vel ? [vel.x, vel.y, vel.z] : [0, 0, 0],\n yaw: e.kind === \"mob\" ? e.yaw : 0,\n });\n}\n\nexport function ownerSend(\n ng: NetGame,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n): void {\n if (ng.isHost) ng.toDim(ng.game.dimension, channel, data, ng.selfId);\n else ng.toHost(channel, data);\n}\n\nexport function hasOthersIn(ng: NetGame, dim: DimensionId): boolean {\n for (const p of ng.peers.values()) if (p.dim === dim) return true;\n return false;\n}\n\nexport function sendPose(ng: NetGame): void {\n const g = ng.game;\n const p = g.player;\n const flags =\n (p._sneaking ? POSE_SNEAK : 0) |\n (p.dead ? POSE_DEAD : 0) |\n (p.flying ? POSE_FLY : 0) |\n (p.gliding ? POSE_GLIDE : 0) |\n (p.inWater ? POSE_SWIM : 0) |\n (g.armView._swing > 0 ? POSE_SWING : 0) |\n (p.mode === \"creative\" ? POSE_CREATIVE : 0) |\n (p._sprinting ? POSE_SPRINT : 0);\n const msg = encodePose({\n slot: ng.slot,\n dim: g.dimension,\n x: p.pos.x,\n y: p.pos.y,\n z: p.pos.z,\n yaw: p.yaw,\n pitch: p.pitch,\n flags,\n health: p.health,\n });\n if (ng.isHost) ng.toAll(\"fast\", msg);\n else ng.toHost(\"fast\", msg);\n}\n\nexport function sendMetaIfChanged(ng: NetGame): void {\n const g = ng.game;\n const meta: PlayerMeta & { sleeping: boolean } = {\n slot: ng.slot,\n held: g.inventory.heldItem()?.item ?? null,\n mode: g.player.mode,\n name: ng.name,\n sleeping: g.netSleeping,\n riding: !!g.player.riding,\n look: specHash(g.character.spec),\n outfit: { ...g.character.outfit },\n heldSkin: stackSkinHash(g.inventory.heldItem()),\n };\n const j = JSON.stringify(meta);\n if (j === ng.lastMeta) return;\n ng.lastMeta = j;\n if (ng.isHost) {\n ng.toAll(\"rel\", encodeJsonSync(M.PMETA, meta));\n hostSleepCheck(ng, ng.selfId, meta);\n } else ng.toHost(\"rel\", encodeJsonSync(M.PMETA, meta));\n}\n\n/** Облик сменился (или вошёл новый игрок) — разослать рецепт персонажа и облики предметов. */\nexport async function sendLookIfChanged(ng: NetGame): Promise<void> {\n const g = ng.game;\n const hashes = [\n ...Object.values(g.character.outfit),\n stackSkinHash(g.inventory.heldItem()),\n ].filter((h): h is string => !!h && !!skinOf(h));\n const key = specHash(g.character.spec) + \"|\" + hashes.sort().join(\",\");\n if (key === ng.lastLook) return;\n ng.lastLook = key;\n const msg: LookMsg = {\n slot: ng.slot,\n character: g.character.spec,\n skins: skinSpecs(hashes),\n };\n const data = await encodeJson(M.LOOK, msg);\n if (ng.isHost) ng.toAll(\"rel\", data);\n else ng.toHost(\"rel\", data);\n}\n\nexport function hostSleepCheck(\n ng: NetGame,\n _who: string,\n _m: PlayerMeta,\n): void {\n const g = ng.game;\n const inOverworld = [...ng.records.entries()].filter(\n ([, r]) => r.dim === \"overworld\",\n );\n if (!inOverworld.length) return;\n const allAsleep = inOverworld.every(([uid]) =>\n uid === ng.selfId\n ? g.netSleeping\n : ([...ng.peers.values()].find((p) => p.userId === uid)?.sleeping ??\n false),\n );\n if (allAsleep) {\n g.skipNight();\n ng.toAll(\"rel\", encodeJsonSync(M.WORLD, g.netWorldState()));\n }\n}\n"
889
+ "content": "import { specHash } from \"../../character/CharacterSpec\";\nimport { skinOf, skinSpecs, stackSkinHash } from \"../../character/items/skins\";\nimport type { DimensionId } from \"../../world/Dimensions\";\nimport { sim } from \"../sim\";\nimport { encodeJson, encodeJsonSync } from \"../codec\";\nimport {\n M,\n POSE_CREATIVE,\n POSE_DEAD,\n POSE_FLY,\n POSE_GLIDE,\n POSE_SNEAK,\n POSE_SWIM,\n POSE_SWING,\n POSE_SPRINT,\n encodeBlocks,\n encodeEntPoses,\n encodeEntRemove,\n encodePose,\n type BeMsg,\n type EntSpawnMsg,\n type PlayerMeta,\n type LookMsg,\n} from \"../protocol\";\nimport type { NetGame } from \"../NetGame\";\n\nexport function afterTick(ng: NetGame): void {\n if (ng.closed) return;\n // Сессию закрыли в обход событий (страховка к \"left\"): дальше играть не на чем.\n if (ng.session.closed) return ng.end(null);\n const g = ng.game;\n ng.tickN++;\n const dim = g.dimension;\n\n if (ng.pending.length) {\n const list = ng.pending;\n ng.pending = [];\n const type = sim.authority ? M.BLOCKS : M.EDITS;\n for (const msg of encodeBlocks(type, dim, list))\n if (ng.isHost && type === M.BLOCKS) ng.toAll(\"rel\", msg);\n else ng.toHost(\"rel\", msg);\n }\n\n if (!sim.authority) {\n // свои действия над сущностями (остриг, оседлал, назвал, поторговал) — владельцу\n for (const a of g.entities.netLocalChanges()) ng.entAction(a);\n syncRide(ng);\n }\n\n if (sim.authority) {\n // толчки удалённых игроков (взрыв, удар моба) — им\n for (const peer of ng.peers.values()) {\n const imp = peer.remote?.takeImpulse();\n if (imp && peer.remote) ng.hit(peer.slot, 0, \"knock\", imp);\n }\n if (ng.tickN % 2 === 0 && hasOthersIn(ng, dim)) {\n const d = ng.bc.diffEntities(g.entities);\n // синхронно: появление обязано уйти раньше удаления следующего тика\n if (d.spawns.length)\n ownerSend(\n ng,\n \"rel\",\n encodeJsonSync(M.ENT_SPAWN, {\n dim,\n list: d.spawns,\n } satisfies EntSpawnMsg),\n );\n for (const b of encodeEntPoses(dim, d.poses)) ownerSend(ng, \"fast\", b);\n if (d.removes.length)\n ownerSend(ng, \"rel\", encodeEntRemove(dim, d.removes));\n }\n if (ng.tickN % 10 === 0)\n for (const { be, key } of ng.bc.diffBlockEntities(g.blockEntities)) {\n const [x, y, z] = key.split(\",\").map(Number) as [\n number,\n number,\n number,\n ];\n const msg: BeMsg = { dim, x, y, z, be };\n if (ng.isHost) ng.storeBe(msg);\n if (hasOthersIn(ng, dim))\n ownerSend(ng, \"rel\", encodeJsonSync(M.BE, msg));\n }\n } else if (ng.tickN % 10 === 0) {\n // реплика: своё изменение блочной сущности (переложил в сундуке) — владельцу\n for (const { be, key } of ng.replicaBe.diffBlockEntities(g.blockEntities)) {\n const [x, y, z] = key.split(\",\").map(Number) as [number, number, number];\n ng.toHost(\n \"rel\",\n encodeJsonSync(M.BE, { dim, x, y, z, be } satisfies BeMsg),\n );\n }\n }\n\n // своя поза — каждый тик; метаданные — по изменению; сейв игрока — раз в 10 с\n sendPose(ng);\n sendMetaIfChanged(ng);\n void sendLookIfChanged(ng);\n if (ng.tickN % 200 === 0 && !ng.isHost) ng.sendPlayerState();\n\n if (ng.isHost) {\n if (ng.tickN % 100 === 0)\n ng.toAll(\"rel\", encodeJsonSync(M.WORLD, g.netWorldState()));\n if (ng.tickN % 200 === 0) ng.pickSuccessorAndShadow();\n } else ng.watchHost();\n}\n\nexport function syncRide(ng: NetGame): void {\n const g = ng.game;\n const em = g.entities;\n const r = g.player.riding as unknown as { id?: number } | null;\n const id =\n r && typeof r.id === \"number\" && (em.entities.get(r.id) as unknown) === r\n ? r.id\n : null;\n if (id !== ng.driving) {\n if (ng.driving !== null)\n ng.entAction({ a: \"ride\", id: ng.driving, on: false });\n if (id !== null) ng.entAction({ a: \"ride\", id, on: true });\n ng.driving = id;\n em.drivenId = id;\n }\n if (id === null || ng.tickN % 2 !== 0) return;\n const e = em.entities.get(id);\n if (!e) return;\n const vel = \"vel\" in e ? e.vel : null;\n ng.entAction({\n a: \"drive\",\n id,\n pos: [e.pos.x, e.pos.y, e.pos.z],\n vel: vel ? [vel.x, vel.y, vel.z] : [0, 0, 0],\n yaw: e.kind === \"mob\" ? e.yaw : 0,\n });\n}\n\nexport function ownerSend(\n ng: NetGame,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n): void {\n if (ng.isHost) ng.toDim(ng.game.dimension, channel, data, ng.selfId);\n else ng.toHost(channel, data);\n}\n\nexport function hasOthersIn(ng: NetGame, dim: DimensionId): boolean {\n for (const p of ng.peers.values()) if (p.dim === dim) return true;\n return false;\n}\n\nexport function sendPose(ng: NetGame): void {\n const g = ng.game;\n const p = g.player;\n const flags =\n (p._sneaking ? POSE_SNEAK : 0) |\n (p.dead ? POSE_DEAD : 0) |\n (p.flying ? POSE_FLY : 0) |\n (p.gliding ? POSE_GLIDE : 0) |\n (p.inWater ? POSE_SWIM : 0) |\n (g.armView._swing > 0 ? POSE_SWING : 0) |\n (p.mode === \"creative\" ? POSE_CREATIVE : 0) |\n (p._sprinting ? POSE_SPRINT : 0);\n const msg = encodePose({\n slot: ng.slot,\n dim: g.dimension,\n x: p.pos.x,\n y: p.pos.y,\n z: p.pos.z,\n yaw: p.yaw,\n pitch: p.pitch,\n flags,\n health: p.health,\n });\n if (ng.isHost) ng.toAll(\"fast\", msg);\n else ng.toHost(\"fast\", msg);\n}\n\nexport function sendMetaIfChanged(ng: NetGame): void {\n const g = ng.game;\n const meta: PlayerMeta & { sleeping: boolean } = {\n slot: ng.slot,\n held: g.inventory.heldItem()?.item ?? null,\n mode: g.player.mode,\n name: ng.name,\n sleeping: g.netSleeping,\n riding: !!g.player.riding,\n look: specHash(g.character.spec),\n outfit: { ...g.character.outfit },\n heldSkin: stackSkinHash(g.inventory.heldItem()),\n gold: !!g.player.goldArmor?.(),\n };\n const j = JSON.stringify(meta);\n if (j === ng.lastMeta) return;\n ng.lastMeta = j;\n if (ng.isHost) {\n ng.toAll(\"rel\", encodeJsonSync(M.PMETA, meta));\n hostSleepCheck(ng, ng.selfId, meta);\n } else ng.toHost(\"rel\", encodeJsonSync(M.PMETA, meta));\n}\n\n/** Облик сменился (или вошёл новый игрок) — разослать рецепт персонажа и облики предметов. */\nexport async function sendLookIfChanged(ng: NetGame): Promise<void> {\n const g = ng.game;\n const hashes = [\n ...Object.values(g.character.outfit),\n stackSkinHash(g.inventory.heldItem()),\n ].filter((h): h is string => !!h && !!skinOf(h));\n const key = specHash(g.character.spec) + \"|\" + hashes.sort().join(\",\");\n if (key === ng.lastLook) return;\n ng.lastLook = key;\n const msg: LookMsg = {\n slot: ng.slot,\n character: g.character.spec,\n skins: skinSpecs(hashes),\n };\n const data = await encodeJson(M.LOOK, msg);\n if (ng.isHost) ng.toAll(\"rel\", data);\n else ng.toHost(\"rel\", data);\n}\n\nexport function hostSleepCheck(\n ng: NetGame,\n _who: string,\n _m: PlayerMeta,\n): void {\n const g = ng.game;\n const inOverworld = [...ng.records.entries()].filter(\n ([, r]) => r.dim === \"overworld\",\n );\n if (!inOverworld.length) return;\n const allAsleep = inOverworld.every(([uid]) =>\n uid === ng.selfId\n ? g.netSleeping\n : ([...ng.peers.values()].find((p) => p.userId === uid)?.sleeping ??\n false),\n );\n if (allAsleep) {\n g.skipNight();\n ng.toAll(\"rel\", encodeJsonSync(M.WORLD, g.netWorldState()));\n }\n}\n"
890
890
  },
891
891
  {
892
892
  "path": "net/game/types.ts",
893
- "content": "import type { RealtimeSession as Session } from \"@idosgames/core/realtime\";\nimport type { DimensionId } from \"../../world/Dimensions\";\nimport { Avatar, RemotePlayer } from \"../RemotePlayer\";\nimport type { Pose } from \"../protocol\";\n\n/**\n * Комната, которую отдаёт лобби (`MultiplayerRoom` контракта модулей — здесь только нужные поля, чтобы\n * сетевой код игры не зависел от контракта).\n */\nexport interface NetRoom {\n readonly session: Session;\n readonly isHost: boolean;\n readonly lateJoin: boolean;\n readonly settings: Readonly<Record<string, string | boolean>>;\n leave(): Promise<void>;\n}\n\n/** Всё, что знаем о другом игроке. */\nexport interface Peer {\n slot: number;\n userId: string;\n name: string;\n dim: DimensionId;\n op: boolean;\n pose: Pose | null;\n held: string | null;\n mode: \"survival\" | \"creative\";\n sleeping: boolean;\n avatar: Avatar | null;\n remote: RemotePlayer | null;\n /** Облик (PlayerMeta): хэш персонажа, надетое, облик предмета в руке. */\n look?: string | null;\n outfit?: Record<string, string>;\n heldSkin?: string | null;\n}\n\n/** Хост: сейв игрока (его инвентарь авторитетен у него — здесь копия для мира хоста). */\nexport interface HostRecord {\n slot: number;\n name: string;\n dim: DimensionId;\n op: boolean;\n state: unknown | null;\n /** Когда хост последний раз отправил этому игроку снимок мира (повторный Hello — см. onHello). */\n welcomedAt?: number;\n}\n"
893
+ "content": "import type { RealtimeSession as Session } from \"@idosgames/core/realtime\";\nimport type { DimensionId } from \"../../world/Dimensions\";\nimport { Avatar, RemotePlayer } from \"../RemotePlayer\";\nimport type { Pose } from \"../protocol\";\n\n/**\n * Комната, которую отдаёт лобби (`MultiplayerRoom` контракта модулей — здесь только нужные поля, чтобы\n * сетевой код игры не зависел от контракта).\n */\nexport interface NetRoom {\n readonly session: Session;\n readonly isHost: boolean;\n readonly lateJoin: boolean;\n readonly settings: Readonly<Record<string, string | boolean>>;\n leave(): Promise<void>;\n}\n\n/** Всё, что знаем о другом игроке. */\nexport interface Peer {\n slot: number;\n userId: string;\n name: string;\n dim: DimensionId;\n op: boolean;\n pose: Pose | null;\n held: string | null;\n mode: \"survival\" | \"creative\";\n sleeping: boolean;\n avatar: Avatar | null;\n remote: RemotePlayer | null;\n /** Облик (PlayerMeta): хэш персонажа, надетое, облик предмета в руке. */\n look?: string | null;\n outfit?: Record<string, string>;\n heldSkin?: string | null;\n /** Надета золотая вещь (PlayerMeta.gold). */\n gold?: boolean;\n}\n\n/** Хост: сейв игрока (его инвентарь авторитетен у него — здесь копия для мира хоста). */\nexport interface HostRecord {\n slot: number;\n name: string;\n dim: DimensionId;\n op: boolean;\n state: unknown | null;\n /** Когда хост последний раз отправил этому игроку снимок мира (повторный Hello — см. onHello). */\n welcomedAt?: number;\n}\n"
894
894
  },
895
895
  {
896
896
  "path": "net/NetGame.ts",
@@ -902,11 +902,11 @@
902
902
  },
903
903
  {
904
904
  "path": "net/protocol.ts",
905
- "content": "// Протокол мультиплеера VoxelCraft поверх `@idosgames/core/realtime` (звезда: всё идёт через хоста).\n//\n// Роли:\n// • ХОСТ — координатор: принимает игроков, ведёт список (roster), назначает владельцев измерений,\n// хранит замороженные измерения и состояния игроков (для сейва и передачи хоста), пересылает.\n// • ВЛАДЕЛЕЦ ИЗМЕРЕНИЯ — считает мир своего измерения (net/sim): хост, если он там, иначе первый\n// вошедший игрок. Рассылает изменения блоков, сущности, блочные сущности.\n// • РЕПЛИКА — любой другой игрок в измерении: шлёт свои правки мира и действия с сущностями.\n//\n// Игрок и инвентарь — у КАЖДОГО свои (решение владельца 27.09.2026, вариант А): игрок сам считает\n// себя (физику, голод, смерть, инвентарь), хост и владельцы измерений его только видят и «выдают»\n// ему подобранное, урон и отброс.\n\nimport { ByteReader, ByteWriter } from \"./codec\";\nimport type { DimensionId } from \"../world/Dimensions\";\nimport type { DimensionSave, ItemStack, WorldSource } from \"../types\";\nimport type { NetEntityAction, NetEntitySnap } from \"../entities/EntityManager\";\nimport type { BlockEntity } from \"../world/BlockEntities\";\n\n/** Версия протокола: разные версии не играют вместе (Hello → «обновите страницу»). */\nexport const PROTOCOL = 2;\n\nexport const M = {\n // ---- надёжный канал (rel)\n HELLO: 1, // гость → хост: JSON Hello\n WELCOME: 2, // хост → гость: JSON Welcome (сжатый)\n REJECT: 3, // хост → гость: JSON { reason }\n ROSTER: 4, // хост → все: JSON Roster\n PMETA: 6, // игрок → хост → все: JSON PlayerMeta (предмет в руке, режим, имя)\n BLOCKS: 7, // владелец → хост → все: двоичные изменения блоков\n EDITS: 8, // реплика → хост → владелец: двоичные правки\n ENT_SPAWN: 9, // владелец → хост → игроки измерения: JSON { dim, list: [id, snap][] }\n ENT_REMOVE: 11, // владелец → …: двоичный список id\n ENT_ACTION: 12, // реплика → хост → владелец: JSON { dim, from, action }\n BE: 13, // владелец/реплика ↔ хост ↔ все: JSON { dim, x, y, z, be }\n WORLD: 15, // хост → все: JSON WorldState (время, погода, сложность, Предел)\n CHAT: 16, // любой → хост → все: JSON { from?, text }\n HIT: 17, // владелец → хост → игрок: JSON Hit (урон, отброс, эффект)\n GIVE: 18, // владелец → хост → игрок: JSON Give (предмет, опыт)\n PSTATE: 19, // игрок → хост: JSON { state } — сейв игрока для мира хоста и передачи хоста\n DIM_ENTER: 20, // игрок → хост: JSON { dim, handoff? } — сменил измерение\n DIM_OWN: 21, // хост → игрок: JSON { dim, save } — теперь ты владелец измерения\n DIM_RESYNC: 22, // хост → владелец: JSON { dim } — пришли всем полный снимок сущностей\n SHADOW: 24, // хост → преемник: JSON Shadow (сжатый) — теневая копия мира\n COMMAND: 25, // игрок → хост: JSON { text } — команда мира (для операторов)\n BE_LIST: 26, // хост → игрок: JSON { dim, list } — блочные сущности измерения при входе\n LOCK: 27, // игрок → хост → владелец: JSON LockMsg — занять / отпустить контейнер\n REPLY: 28, // владелец → хост → игрок: JSON ReplyMsg — ответ на LOCK, отказ сесть\n PORTAL: 29, // игрок → хост → все: JSON PortalMsg — реестр порталов\n EMOTE: 30, // игрок → хост → все: JSON EmoteMsg — жест и/или эмоция персонажа\n LOOK: 31, // игрок → хост → все: JSON LookMsg (сжатый) — рецепт персонажа и облики предметов\n PIN: 32, // игрок → хост → все: JSON PinMsg — метка карты (поставить, изменить, удалить)\n // ---- быстрый канал (fast)\n POSE: 5, // игрок → хост → все: двоичная поза\n ENT_POSE: 10, // владелец → …: двоичные позы сущностей\n} as const;\n\n// ============================================================ JSON-сообщения\n\nexport interface Hello {\n v: number;\n /** Хэш набора блоков и предметов: разные сборки игры не совместимы. */\n build: string;\n name: string;\n}\n\nexport interface WorldMeta {\n source: WorldSource;\n palette: string[];\n time: unknown;\n weather: unknown;\n difficulty: string;\n beyond: unknown;\n portals: unknown;\n /** Метки карты мира (systems/WorldAtlas MapPin[]). Нет у старого хоста. */\n pins?: unknown;\n daysAwake: number;\n /** Имя мира у хоста — переходит к новому хосту вместе с миром. Нет у старого хоста. */\n name?: string;\n}\n\nexport interface RosterEntry {\n slot: number;\n userId: string;\n name: string;\n dim: DimensionId;\n op: boolean;\n}\n\nexport interface Roster {\n hostId: string;\n players: RosterEntry[];\n /** Владелец каждого «живого» измерения. */\n owners: Partial<Record<DimensionId, string>>;\n /** Настройки комнаты («Открыть для сети»). */\n settings: RoomSettings;\n}\n\nexport interface RoomSettings {\n /** Режим, в котором входят гости. */\n guestMode: \"survival\" | \"creative\";\n /** Команды всем, а не только операторам. */\n cheatsForAll: boolean;\n}\n\nexport interface Welcome {\n v: number;\n you: number;\n world: WorldMeta;\n /** Изменённые блоки ВСЕХ измерений: у гостя полная копия — на ней держится и передача хоста. */\n diffs: Partial<Record<DimensionId, DimensionSave[\"chunks\"]>>;\n /** Где появиться и в каком измерении (у вернувшегося — там, где был). */\n spawn: { dim: DimensionId; pos: [number, number, number] };\n /** Сохранённое состояние этого игрока в мире хоста (вернулся в тот же мир). */\n state: unknown | null;\n roster: Roster;\n}\n\nexport interface PlayerMeta {\n slot?: number;\n held: string | null;\n mode: \"survival\" | \"creative\";\n name: string;\n /** Едет (лошадь, вагонетка, лодка) — поза седока у других. */\n riding?: boolean;\n /** Хэш рецепта персонажа; сам рецепт — сообщением LOOK. */\n look?: string;\n /** Надетое: слот → хэш облика. */\n outfit?: Record<string, string>;\n /** Облик предмета в руке (tag.skin). */\n heldSkin?: string | null;\n}\n\n/**\n * Облик игрока целиком: рецепт персонажа и облики надетого и того, что в руке. Шлётся при смене и\n * заново, когда в игру вошёл новый игрок (у него этих рецептов ещё нет).\n */\nexport interface LookMsg {\n slot?: number;\n character: unknown;\n skins: Record<string, unknown>;\n}\n\n/** Жест и/или эмоция персонажа (character/anim): слот ставит хост. */\nexport interface EmoteMsg {\n slot?: number;\n emote?: string;\n emotion?: string;\n seconds?: number;\n}\n\nexport interface WorldState {\n time: unknown;\n weather: unknown;\n difficulty: string;\n beyond: unknown;\n daysAwake: number;\n}\n\nexport interface Hit {\n to: number;\n amount: number;\n cause: string;\n /** Толчок (изменение скорости), который насчитал владелец измерения. */\n knock?: [number, number, number];\n effect?: { id: string; seconds: number; level: number };\n}\n\nexport interface Give {\n to: number;\n stack?: ItemStack;\n xp?: number;\n}\n\nexport interface EntSpawnMsg {\n dim: DimensionId;\n list: [number, NetEntitySnap][];\n}\n\nexport interface EntActionMsg {\n dim: DimensionId;\n from?: number;\n action: NetEntityAction;\n}\n\n/**\n * Контейнер (сундук, печь, вьюк, вагонетка с грузом, житель при торговле) открыт — занят этим\n * игроком, пока он не закроет экран: двое, перекладывающие одно и то же, размножали бы предметы.\n * key: \"b:x,y,z\" — блочная сущность, \"e:id\" — сущность.\n */\nexport interface LockMsg {\n dim: DimensionId;\n from?: number;\n key: string;\n open: boolean;\n}\n\nexport interface ReplyMsg {\n to: number;\n kind: \"lock\" | \"ride\";\n key?: string;\n id?: number;\n ok: boolean;\n /** Кто занял (имя) — для подсказки. */\n by?: string;\n /** Блочная сущность от владельца — открыть её, а не свою (добыча постройки катится у него). */\n be?: BlockEntity | null;\n}\n\n/** Метка карты: автора (`pin.by`) ставит хост — менять и удалять может автор или хост. */\nexport type PinMsg = { op: \"set\"; pin: unknown } | { op: \"remove\"; id: number };\n\nexport interface PortalMsg {\n dim: DimensionId;\n p: [number, number, number];\n add: boolean;\n}\n\nexport interface BeMsg {\n dim: DimensionId;\n x: number;\n y: number;\n z: number;\n be: BlockEntity | null;\n}\n\nexport interface DimEnter {\n dim: DimensionId;\n /** Уходящий владелец отдаёт своё измерение: сущности и блочные сущности. */\n handoff?: { dim: DimensionId; save: DimensionSave };\n}\n\nexport interface DimOwn {\n dim: DimensionId;\n save: DimensionSave;\n}\n\nexport interface Shadow {\n world: WorldMeta;\n /** Замороженные и живые измерения хоста — с сущностями. */\n dims: Partial<Record<DimensionId, DimensionSave>>;\n players: Record<string, unknown>;\n settings: RoomSettings;\n ops: string[];\n}\n\n// ============================================================ двоичные сообщения\n\nexport const DIMS: readonly DimensionId[] = [\"overworld\", \"inferno\", \"beyond\"];\nexport const dimCode = (d: DimensionId): number => Math.max(0, DIMS.indexOf(d));\nexport const dimOf = (n: number): DimensionId => DIMS[n] ?? \"overworld\";\n\n/** Поза игрока. Флаги — вид аватара. */\nexport interface Pose {\n slot: number;\n dim: DimensionId;\n x: number;\n y: number;\n z: number;\n yaw: number;\n pitch: number;\n flags: number;\n health: number;\n}\n\nexport const POSE_SNEAK = 1,\n POSE_DEAD = 2,\n POSE_FLY = 4,\n POSE_GLIDE = 8,\n POSE_SWIM = 16,\n POSE_SWING = 32,\n POSE_CREATIVE = 64,\n /** Бежит (анимация бега у других). */\n POSE_SPRINT = 128;\n\nexport function encodePose(p: Pose): Uint8Array {\n return new ByteWriter(24)\n .u8(M.POSE)\n .u8(p.slot)\n .u8(dimCode(p.dim))\n .f32(p.x)\n .f32(p.y)\n .f32(p.z)\n .angle(p.yaw)\n .angle(p.pitch)\n .u8(p.flags)\n .u8(Math.max(0, Math.min(255, Math.round(p.health * 10))))\n .finish();\n}\n\nexport function decodePose(msg: Uint8Array): Pose {\n const r = new ByteReader(msg.subarray(1));\n return {\n slot: r.u8(),\n dim: dimOf(r.u8()),\n x: r.f32(),\n y: r.f32(),\n z: r.f32(),\n yaw: r.angle(),\n pitch: r.angle(),\n flags: r.u8(),\n health: r.u8() / 10,\n };\n}\n\n/** Переписать слот позы (хост пересылает позу гостя остальным под его слотом). */\nexport function withPoseSlot(msg: Uint8Array, slot: number): Uint8Array {\n const out = msg.slice();\n out[1] = slot;\n return out;\n}\n\n/** Изменение блока: мировые координаты, id и мета. */\nexport interface BlockDelta {\n x: number;\n y: number;\n z: number;\n id: number;\n meta: number;\n}\n\n/** BLOCKS / EDITS: [тип][dim][n u16] n × [x i32][y u16][z i32][id u16][meta u8] (13 байт). */\nexport function encodeBlocks(\n type: number,\n dim: DimensionId,\n list: readonly BlockDelta[],\n): Uint8Array[] {\n const out: Uint8Array[] = [];\n // одно сообщение ≤ ~16 КБ, но и без того держим пачки небольшими: 1000 правок = 13 КБ\n for (let i = 0; i < list.length; i += 1000) {\n const part = list.slice(i, i + 1000);\n const w = new ByteWriter(4 + part.length * 13)\n .u8(type)\n .u8(dimCode(dim))\n .u16(part.length);\n for (const d of part) w.i32(d.x).u16(d.y).i32(d.z).u16(d.id).u8(d.meta);\n out.push(w.finish());\n }\n return out;\n}\n\nexport function decodeBlocks(msg: Uint8Array): {\n dim: DimensionId;\n list: BlockDelta[];\n} {\n const r = new ByteReader(msg.subarray(1));\n const dim = dimOf(r.u8());\n const n = r.u16();\n const list: BlockDelta[] = [];\n for (let i = 0; i < n; i++)\n list.push({\n x: r.i32(),\n y: r.u16(),\n z: r.i32(),\n id: r.u16(),\n meta: r.u8(),\n });\n return { dim, list };\n}\n\n/** Поза сущности (владелец → игроки измерения). */\nexport interface EntPose {\n id: number;\n x: number;\n y: number;\n z: number;\n yaw: number;\n flags: number;\n extra: number;\n}\n\n/** ENT_POSE: [тип][dim][n u16] n × [id u32][x f32][y f32][z f32][yaw i16][flags u8][extra u8] (20 байт). */\nexport function encodeEntPoses(\n dim: DimensionId,\n list: readonly EntPose[],\n): Uint8Array[] {\n const out: Uint8Array[] = [];\n // быстрый канал не режет сообщения — держим каждое заметно меньше 16 КБ\n for (let i = 0; i < list.length; i += 600) {\n const part = list.slice(i, i + 600);\n const w = new ByteWriter(4 + part.length * 20)\n .u8(M.ENT_POSE)\n .u8(dimCode(dim))\n .u16(part.length);\n for (const p of part)\n w.u32(p.id)\n .f32(p.x)\n .f32(p.y)\n .f32(p.z)\n .angle(p.yaw)\n .u8(p.flags)\n .u8(p.extra);\n out.push(w.finish());\n }\n return out;\n}\n\nexport function decodeEntPoses(msg: Uint8Array): {\n dim: DimensionId;\n list: EntPose[];\n} {\n const r = new ByteReader(msg.subarray(1));\n const dim = dimOf(r.u8());\n const n = r.u16();\n const list: EntPose[] = [];\n for (let i = 0; i < n; i++)\n list.push({\n id: r.u32(),\n x: r.f32(),\n y: r.f32(),\n z: r.f32(),\n yaw: r.angle(),\n flags: r.u8(),\n extra: r.u8(),\n });\n return { dim, list };\n}\n\n/** ENT_REMOVE: [тип][dim][n u16] n × [id u32]. */\nexport function encodeEntRemove(\n dim: DimensionId,\n ids: readonly number[],\n): Uint8Array {\n const w = new ByteWriter(4 + ids.length * 4)\n .u8(M.ENT_REMOVE)\n .u8(dimCode(dim))\n .u16(ids.length);\n for (const id of ids) w.u32(id);\n return w.finish();\n}\n\nexport function decodeEntRemove(msg: Uint8Array): {\n dim: DimensionId;\n ids: number[];\n} {\n const r = new ByteReader(msg.subarray(1));\n const dim = dimOf(r.u8());\n const n = r.u16();\n const ids: number[] = [];\n for (let i = 0; i < n; i++) ids.push(r.u32());\n return { dim, ids };\n}\n\n/**\n * Сборка игры: хэш имён блоков и предметов по порядку. У двух игроков с разными версиями id блоков\n * разошлись бы — один увидел бы на месте сундука печь.\n */\nexport function buildHash(\n blockNames: readonly string[],\n itemNames: readonly string[],\n): string {\n let h = 0x811c9dc5;\n for (const s of [...blockNames, \"|\", ...itemNames])\n for (let i = 0; i < s.length; i++) {\n h ^= s.charCodeAt(i);\n h = Math.imul(h, 0x01000193) >>> 0;\n }\n return h.toString(16);\n}\n"
905
+ "content": "// Протокол мультиплеера VoxelCraft поверх `@idosgames/core/realtime` (звезда: всё идёт через хоста).\n//\n// Роли:\n// • ХОСТ — координатор: принимает игроков, ведёт список (roster), назначает владельцев измерений,\n// хранит замороженные измерения и состояния игроков (для сейва и передачи хоста), пересылает.\n// • ВЛАДЕЛЕЦ ИЗМЕРЕНИЯ — считает мир своего измерения (net/sim): хост, если он там, иначе первый\n// вошедший игрок. Рассылает изменения блоков, сущности, блочные сущности.\n// • РЕПЛИКА — любой другой игрок в измерении: шлёт свои правки мира и действия с сущностями.\n//\n// Игрок и инвентарь — у КАЖДОГО свои (решение владельца 27.09.2026, вариант А): игрок сам считает\n// себя (физику, голод, смерть, инвентарь), хост и владельцы измерений его только видят и «выдают»\n// ему подобранное, урон и отброс.\n\nimport { ByteReader, ByteWriter } from \"./codec\";\nimport type { DimensionId } from \"../world/Dimensions\";\nimport type { DimensionSave, ItemStack, WorldSource } from \"../types\";\nimport type { NetEntityAction, NetEntitySnap } from \"../entities/EntityManager\";\nimport type { BlockEntity } from \"../world/BlockEntities\";\n\n/** Версия протокола: разные версии не играют вместе (Hello → «обновите страницу»). */\nexport const PROTOCOL = 2;\n\nexport const M = {\n // ---- надёжный канал (rel)\n HELLO: 1, // гость → хост: JSON Hello\n WELCOME: 2, // хост → гость: JSON Welcome (сжатый)\n REJECT: 3, // хост → гость: JSON { reason }\n ROSTER: 4, // хост → все: JSON Roster\n PMETA: 6, // игрок → хост → все: JSON PlayerMeta (предмет в руке, режим, имя)\n BLOCKS: 7, // владелец → хост → все: двоичные изменения блоков\n EDITS: 8, // реплика → хост → владелец: двоичные правки\n ENT_SPAWN: 9, // владелец → хост → игроки измерения: JSON { dim, list: [id, snap][] }\n ENT_REMOVE: 11, // владелец → …: двоичный список id\n ENT_ACTION: 12, // реплика → хост → владелец: JSON { dim, from, action }\n BE: 13, // владелец/реплика ↔ хост ↔ все: JSON { dim, x, y, z, be }\n WORLD: 15, // хост → все: JSON WorldState (время, погода, сложность, Предел)\n CHAT: 16, // любой → хост → все: JSON { from?, text }\n HIT: 17, // владелец → хост → игрок: JSON Hit (урон, отброс, эффект)\n GIVE: 18, // владелец → хост → игрок: JSON Give (предмет, опыт)\n PSTATE: 19, // игрок → хост: JSON { state } — сейв игрока для мира хоста и передачи хоста\n DIM_ENTER: 20, // игрок → хост: JSON { dim, handoff? } — сменил измерение\n DIM_OWN: 21, // хост → игрок: JSON { dim, save } — теперь ты владелец измерения\n DIM_RESYNC: 22, // хост → владелец: JSON { dim } — пришли всем полный снимок сущностей\n SHADOW: 24, // хост → преемник: JSON Shadow (сжатый) — теневая копия мира\n COMMAND: 25, // игрок → хост: JSON { text } — команда мира (для операторов)\n BE_LIST: 26, // хост → игрок: JSON { dim, list } — блочные сущности измерения при входе\n LOCK: 27, // игрок → хост → владелец: JSON LockMsg — занять / отпустить контейнер\n REPLY: 28, // владелец → хост → игрок: JSON ReplyMsg — ответ на LOCK, отказ сесть\n PORTAL: 29, // игрок → хост → все: JSON PortalMsg — реестр порталов\n EMOTE: 30, // игрок → хост → все: JSON EmoteMsg — жест и/или эмоция персонажа\n LOOK: 31, // игрок → хост → все: JSON LookMsg (сжатый) — рецепт персонажа и облики предметов\n PIN: 32, // игрок → хост → все: JSON PinMsg — метка карты (поставить, изменить, удалить)\n // ---- быстрый канал (fast)\n POSE: 5, // игрок → хост → все: двоичная поза\n ENT_POSE: 10, // владелец → …: двоичные позы сущностей\n} as const;\n\n// ============================================================ JSON-сообщения\n\nexport interface Hello {\n v: number;\n /** Хэш набора блоков и предметов: разные сборки игры не совместимы. */\n build: string;\n name: string;\n}\n\nexport interface WorldMeta {\n source: WorldSource;\n palette: string[];\n time: unknown;\n weather: unknown;\n difficulty: string;\n beyond: unknown;\n portals: unknown;\n /** Метки карты мира (systems/WorldAtlas MapPin[]). Нет у старого хоста. */\n pins?: unknown;\n daysAwake: number;\n /** Имя мира у хоста — переходит к новому хосту вместе с миром. Нет у старого хоста. */\n name?: string;\n}\n\nexport interface RosterEntry {\n slot: number;\n userId: string;\n name: string;\n dim: DimensionId;\n op: boolean;\n}\n\nexport interface Roster {\n hostId: string;\n players: RosterEntry[];\n /** Владелец каждого «живого» измерения. */\n owners: Partial<Record<DimensionId, string>>;\n /** Настройки комнаты («Открыть для сети»). */\n settings: RoomSettings;\n}\n\nexport interface RoomSettings {\n /** Режим, в котором входят гости. */\n guestMode: \"survival\" | \"creative\";\n /** Команды всем, а не только операторам. */\n cheatsForAll: boolean;\n}\n\nexport interface Welcome {\n v: number;\n you: number;\n world: WorldMeta;\n /** Изменённые блоки ВСЕХ измерений: у гостя полная копия — на ней держится и передача хоста. */\n diffs: Partial<Record<DimensionId, DimensionSave[\"chunks\"]>>;\n /** Где появиться и в каком измерении (у вернувшегося — там, где был). */\n spawn: { dim: DimensionId; pos: [number, number, number] };\n /** Сохранённое состояние этого игрока в мире хоста (вернулся в тот же мир). */\n state: unknown | null;\n roster: Roster;\n}\n\nexport interface PlayerMeta {\n slot?: number;\n held: string | null;\n mode: \"survival\" | \"creative\";\n name: string;\n /** Едет (лошадь, вагонетка, лодка) — поза седока у других. */\n riding?: boolean;\n /** Хэш рецепта персонажа; сам рецепт — сообщением LOOK. */\n look?: string;\n /** Надетое: слот → хэш облика. */\n outfit?: Record<string, string>;\n /** Облик предмета в руке (tag.skin). */\n heldSkin?: string | null;\n /** Надета золотая вещь: мобы считаются у владельца измерения, свинорыл смотрит на это. */\n gold?: boolean;\n}\n\n/**\n * Облик игрока целиком: рецепт персонажа и облики надетого и того, что в руке. Шлётся при смене и\n * заново, когда в игру вошёл новый игрок (у него этих рецептов ещё нет).\n */\nexport interface LookMsg {\n slot?: number;\n character: unknown;\n skins: Record<string, unknown>;\n}\n\n/** Жест и/или эмоция персонажа (character/anim): слот ставит хост. */\nexport interface EmoteMsg {\n slot?: number;\n emote?: string;\n emotion?: string;\n seconds?: number;\n}\n\nexport interface WorldState {\n time: unknown;\n weather: unknown;\n difficulty: string;\n beyond: unknown;\n daysAwake: number;\n}\n\nexport interface Hit {\n to: number;\n amount: number;\n cause: string;\n /** Толчок (изменение скорости), который насчитал владелец измерения. */\n knock?: [number, number, number];\n effect?: { id: string; seconds: number; level: number };\n}\n\nexport interface Give {\n to: number;\n stack?: ItemStack;\n xp?: number;\n}\n\nexport interface EntSpawnMsg {\n dim: DimensionId;\n list: [number, NetEntitySnap][];\n}\n\nexport interface EntActionMsg {\n dim: DimensionId;\n from?: number;\n action: NetEntityAction;\n}\n\n/**\n * Контейнер (сундук, печь, вьюк, вагонетка с грузом, житель при торговле) открыт — занят этим\n * игроком, пока он не закроет экран: двое, перекладывающие одно и то же, размножали бы предметы.\n * key: \"b:x,y,z\" — блочная сущность, \"e:id\" — сущность.\n */\nexport interface LockMsg {\n dim: DimensionId;\n from?: number;\n key: string;\n open: boolean;\n}\n\nexport interface ReplyMsg {\n to: number;\n kind: \"lock\" | \"ride\";\n key?: string;\n id?: number;\n ok: boolean;\n /** Кто занял (имя) — для подсказки. */\n by?: string;\n /** Блочная сущность от владельца — открыть её, а не свою (добыча постройки катится у него). */\n be?: BlockEntity | null;\n}\n\n/** Метка карты: автора (`pin.by`) ставит хост — менять и удалять может автор или хост. */\nexport type PinMsg = { op: \"set\"; pin: unknown } | { op: \"remove\"; id: number };\n\nexport interface PortalMsg {\n dim: DimensionId;\n p: [number, number, number];\n add: boolean;\n}\n\nexport interface BeMsg {\n dim: DimensionId;\n x: number;\n y: number;\n z: number;\n be: BlockEntity | null;\n}\n\nexport interface DimEnter {\n dim: DimensionId;\n /** Уходящий владелец отдаёт своё измерение: сущности и блочные сущности. */\n handoff?: { dim: DimensionId; save: DimensionSave };\n}\n\nexport interface DimOwn {\n dim: DimensionId;\n save: DimensionSave;\n}\n\nexport interface Shadow {\n world: WorldMeta;\n /** Замороженные и живые измерения хоста — с сущностями. */\n dims: Partial<Record<DimensionId, DimensionSave>>;\n players: Record<string, unknown>;\n settings: RoomSettings;\n ops: string[];\n}\n\n// ============================================================ двоичные сообщения\n\nexport const DIMS: readonly DimensionId[] = [\"overworld\", \"inferno\", \"beyond\"];\nexport const dimCode = (d: DimensionId): number => Math.max(0, DIMS.indexOf(d));\nexport const dimOf = (n: number): DimensionId => DIMS[n] ?? \"overworld\";\n\n/** Поза игрока. Флаги — вид аватара. */\nexport interface Pose {\n slot: number;\n dim: DimensionId;\n x: number;\n y: number;\n z: number;\n yaw: number;\n pitch: number;\n flags: number;\n health: number;\n}\n\nexport const POSE_SNEAK = 1,\n POSE_DEAD = 2,\n POSE_FLY = 4,\n POSE_GLIDE = 8,\n POSE_SWIM = 16,\n POSE_SWING = 32,\n POSE_CREATIVE = 64,\n /** Бежит (анимация бега у других). */\n POSE_SPRINT = 128;\n\nexport function encodePose(p: Pose): Uint8Array {\n return new ByteWriter(24)\n .u8(M.POSE)\n .u8(p.slot)\n .u8(dimCode(p.dim))\n .f32(p.x)\n .f32(p.y)\n .f32(p.z)\n .angle(p.yaw)\n .angle(p.pitch)\n .u8(p.flags)\n .u8(Math.max(0, Math.min(255, Math.round(p.health * 10))))\n .finish();\n}\n\nexport function decodePose(msg: Uint8Array): Pose {\n const r = new ByteReader(msg.subarray(1));\n return {\n slot: r.u8(),\n dim: dimOf(r.u8()),\n x: r.f32(),\n y: r.f32(),\n z: r.f32(),\n yaw: r.angle(),\n pitch: r.angle(),\n flags: r.u8(),\n health: r.u8() / 10,\n };\n}\n\n/** Переписать слот позы (хост пересылает позу гостя остальным под его слотом). */\nexport function withPoseSlot(msg: Uint8Array, slot: number): Uint8Array {\n const out = msg.slice();\n out[1] = slot;\n return out;\n}\n\n/** Изменение блока: мировые координаты, id и мета. */\nexport interface BlockDelta {\n x: number;\n y: number;\n z: number;\n id: number;\n meta: number;\n}\n\n/** BLOCKS / EDITS: [тип][dim][n u16] n × [x i32][y u16][z i32][id u16][meta u8] (13 байт). */\nexport function encodeBlocks(\n type: number,\n dim: DimensionId,\n list: readonly BlockDelta[],\n): Uint8Array[] {\n const out: Uint8Array[] = [];\n // одно сообщение ≤ ~16 КБ, но и без того держим пачки небольшими: 1000 правок = 13 КБ\n for (let i = 0; i < list.length; i += 1000) {\n const part = list.slice(i, i + 1000);\n const w = new ByteWriter(4 + part.length * 13)\n .u8(type)\n .u8(dimCode(dim))\n .u16(part.length);\n for (const d of part) w.i32(d.x).u16(d.y).i32(d.z).u16(d.id).u8(d.meta);\n out.push(w.finish());\n }\n return out;\n}\n\nexport function decodeBlocks(msg: Uint8Array): {\n dim: DimensionId;\n list: BlockDelta[];\n} {\n const r = new ByteReader(msg.subarray(1));\n const dim = dimOf(r.u8());\n const n = r.u16();\n const list: BlockDelta[] = [];\n for (let i = 0; i < n; i++)\n list.push({\n x: r.i32(),\n y: r.u16(),\n z: r.i32(),\n id: r.u16(),\n meta: r.u8(),\n });\n return { dim, list };\n}\n\n/** Поза сущности (владелец → игроки измерения). */\nexport interface EntPose {\n id: number;\n x: number;\n y: number;\n z: number;\n yaw: number;\n flags: number;\n extra: number;\n}\n\n/** ENT_POSE: [тип][dim][n u16] n × [id u32][x f32][y f32][z f32][yaw i16][flags u8][extra u8] (20 байт). */\nexport function encodeEntPoses(\n dim: DimensionId,\n list: readonly EntPose[],\n): Uint8Array[] {\n const out: Uint8Array[] = [];\n // быстрый канал не режет сообщения — держим каждое заметно меньше 16 КБ\n for (let i = 0; i < list.length; i += 600) {\n const part = list.slice(i, i + 600);\n const w = new ByteWriter(4 + part.length * 20)\n .u8(M.ENT_POSE)\n .u8(dimCode(dim))\n .u16(part.length);\n for (const p of part)\n w.u32(p.id)\n .f32(p.x)\n .f32(p.y)\n .f32(p.z)\n .angle(p.yaw)\n .u8(p.flags)\n .u8(p.extra);\n out.push(w.finish());\n }\n return out;\n}\n\nexport function decodeEntPoses(msg: Uint8Array): {\n dim: DimensionId;\n list: EntPose[];\n} {\n const r = new ByteReader(msg.subarray(1));\n const dim = dimOf(r.u8());\n const n = r.u16();\n const list: EntPose[] = [];\n for (let i = 0; i < n; i++)\n list.push({\n id: r.u32(),\n x: r.f32(),\n y: r.f32(),\n z: r.f32(),\n yaw: r.angle(),\n flags: r.u8(),\n extra: r.u8(),\n });\n return { dim, list };\n}\n\n/** ENT_REMOVE: [тип][dim][n u16] n × [id u32]. */\nexport function encodeEntRemove(\n dim: DimensionId,\n ids: readonly number[],\n): Uint8Array {\n const w = new ByteWriter(4 + ids.length * 4)\n .u8(M.ENT_REMOVE)\n .u8(dimCode(dim))\n .u16(ids.length);\n for (const id of ids) w.u32(id);\n return w.finish();\n}\n\nexport function decodeEntRemove(msg: Uint8Array): {\n dim: DimensionId;\n ids: number[];\n} {\n const r = new ByteReader(msg.subarray(1));\n const dim = dimOf(r.u8());\n const n = r.u16();\n const ids: number[] = [];\n for (let i = 0; i < n; i++) ids.push(r.u32());\n return { dim, ids };\n}\n\n/**\n * Сборка игры: хэш имён блоков и предметов по порядку. У двух игроков с разными версиями id блоков\n * разошлись бы — один увидел бы на месте сундука печь.\n */\nexport function buildHash(\n blockNames: readonly string[],\n itemNames: readonly string[],\n): string {\n let h = 0x811c9dc5;\n for (const s of [...blockNames, \"|\", ...itemNames])\n for (let i = 0; i < s.length; i++) {\n h ^= s.charCodeAt(i);\n h = Math.imul(h, 0x01000193) >>> 0;\n }\n return h.toString(16);\n}\n"
906
906
  },
907
907
  {
908
908
  "path": "net/RemotePlayer.ts",
909
- "content": "// Другой игрок сетевой игры — у ВЛАДЕЛЬЦА измерения. Мобы, стрелы, взрывы и подбор работают с ним\n// как с обычным Player (EntityManager.others), но состояние игрока — у самого игрока (вариант А):\n// урон, отброс, эффекты и опыт не применяются здесь, а уходят ему по сети; подобранный предмет\n// «выдаётся» ему, а не кладётся в чужой инвентарь.\nimport { EMOTION_EVENTS, type Emotion } from \"../character/anim/emotions\";\nimport * as THREE from \"three\";\nimport { Player } from \"../player/Player\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { Inventory } from \"../systems/Inventory\";\nimport type { EffectId, ItemStack } from \"../types\";\nimport {\n POSE_CREATIVE,\n POSE_DEAD,\n POSE_FLY,\n POSE_GLIDE,\n POSE_SNEAK,\n POSE_SPRINT,\n POSE_SWIM,\n POSE_SWING,\n type Pose,\n} from \"./protocol\";\nimport { CharacterActor } from \"../character/CharacterActor\";\nimport type { CharacterSpec } from \"../character/CharacterSpec\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\n\n/** Куда уходит то, что с удалённым игроком случилось у владельца измерения. */\nexport interface RemoteSink {\n hit(\n slot: number,\n amount: number,\n cause: string,\n knock?: [number, number, number],\n effect?: { id: string; seconds: number; level: number },\n ): void;\n give(slot: number, stack: ItemStack | null, xp?: number): void;\n}\n\nexport class RemotePlayer extends Player {\n readonly slot: number;\n readonly userId: string;\n /** Предмет в руке (PlayerMeta) — для мобов, которые идут за едой в руке. */\n held: string | null = null;\n /** Скорость из последней позы: всё, что к ней добавили за тик (толчки), уходит игроку. */\n private readonly velBase = new THREE.Vector3();\n\n constructor(\n world: ChunkManager,\n slot: number,\n userId: string,\n private readonly sink: RemoteSink,\n ) {\n super(world);\n this.slot = slot;\n this.userId = userId;\n }\n\n applyPose(p: Pose): void {\n this.pos.set(p.x, p.y, p.z);\n this.yaw = p.yaw;\n this.pitch = p.pitch;\n this.dead = (p.flags & POSE_DEAD) !== 0;\n this._sneaking = (p.flags & POSE_SNEAK) !== 0;\n this.mode = p.flags & POSE_CREATIVE ? \"creative\" : \"survival\";\n this.health = p.health;\n this.vel.set(0, 0, 0);\n this.velBase.set(0, 0, 0);\n }\n\n /** Урон — игроку: он сам решит про щит, броню, тотем и смерть. */\n override damage(amount: number, cause: string): void {\n if (this.dead || this.mode === \"creative\" || amount <= 0) return;\n this.sink.hit(this.slot, amount, cause);\n }\n\n override knock(dx: number, dz: number, strength: number): void {\n const len = Math.hypot(dx, dz) || 1;\n this.vel.x += (dx / len) * strength;\n this.vel.z += (dz / len) * strength;\n this.vel.y = Math.max(this.vel.y, 4);\n }\n\n override addEffect(id: EffectId, seconds: number, level = 1): void {\n this.sink.hit(this.slot, 0, \"effect\", undefined, { id, seconds, level });\n }\n\n override addXp(amount: number): void {\n if (amount > 0) this.sink.give(this.slot, null, amount);\n }\n\n /** Щит решает сам игрок (у него инвентарь): здесь не блокирует ничего. */\n override shieldBlocks(): boolean {\n return false;\n }\n\n /**\n * Толчок, накопленный за тик (knock, взрыв добавили к vel): отдать игроку и обнулить. Зовёт\n * NetGame после тика сущностей.\n */\n takeImpulse(): [number, number, number] | null {\n const dx = this.vel.x - this.velBase.x,\n dy = this.vel.y - this.velBase.y,\n dz = this.vel.z - this.velBase.z;\n this.vel.copy(this.velBase);\n if (dx * dx + dy * dy + dz * dz < 0.01) return null;\n return [dx, dy, dz];\n }\n}\n\n/**\n * Инвентарь удалённого игрока у владельца измерения: подобранное уходит игроку. Считается, что\n * место есть — если нет, игрок сам выбросит лишнее у себя (его инвентарь авторитетен).\n */\nexport function remoteInventory(p: RemotePlayer, sink: RemoteSink): Inventory {\n const inv = {\n add(item: string, count: number): number {\n sink.give(p.slot, { item, count });\n return 0;\n },\n addStack(s: ItemStack): number {\n sink.give(p.slot, { ...s });\n return 0;\n },\n heldItem(): ItemStack | null {\n return p.held ? { item: p.held, count: 1 } : null;\n },\n slots: [] as (ItemStack | null)[],\n armor: [] as (ItemStack | null)[],\n };\n return inv as unknown as Inventory;\n}\n\n// ============================================================ аватар\n\n/**\n * Другой игрок на экране: его персонаж (рецепт по сети, пока не пришёл — «Путник»), анимации по\n * позам — шаг, бег, присед, плавание, полёт, планер, замах, смерть, — жесты и эмоции из сети,\n * предмет в руке. Между позами (15 в секунду) — плавное движение к новой.\n */\nexport class Avatar {\n readonly actor: CharacterActor;\n private readonly from = new THREE.Vector3();\n private readonly to = new THREE.Vector3();\n private readonly pos = new THREE.Vector3();\n private yawFrom = 0;\n private yawTo = 0;\n private pitch = 0;\n private t = 1;\n private flags = 0;\n private speed = 0;\n private vy = 0;\n private placed = false;\n private mood: Emotion = \"neutral\";\n private health = 20;\n /** Предмет в руке и сон/езда — из PlayerMeta (NetGame ставит). */\n held: string | null = null;\n heldSkin: string | null = null;\n sleeping = false;\n riding = false;\n\n constructor(\n name: string,\n atlas: TextureAtlas | null = null,\n spec?: CharacterSpec,\n ) {\n this.actor = new CharacterActor(spec, { name, atlas });\n }\n\n get group(): THREE.Group {\n return this.actor.group;\n }\n\n setName(name: string): void {\n this.actor.view.setName(name);\n }\n\n setSpec(spec: CharacterSpec): void {\n this.actor.setSpec(spec);\n }\n\n /** Новая поза из сети: от текущего положения к новому за ~один интервал поз. */\n target(p: Pose): void {\n this.from.copy(this.pos);\n this.to.set(p.x, p.y, p.z);\n this.yawFrom = this.yawTo;\n this.yawTo = p.yaw;\n this.pitch = p.pitch;\n // замах — по фронту флага (флаг держится, пока у игрока идёт анимация замаха)\n if (p.flags & POSE_SWING && !(this.flags & POSE_SWING))\n this.actor.trigger(\"swing\");\n this.flags = p.flags;\n // настроение по здоровью — как у своего персонажа (game/playerCharacter)\n const mood: Emotion =\n p.flags & (POSE_DEAD | POSE_CREATIVE)\n ? \"neutral\"\n : p.health <= 4\n ? EMOTION_EVENTS.nearDeath.emotion\n : p.health <= 8\n ? EMOTION_EVENTS.lowHealth.emotion\n : \"neutral\";\n if (mood !== this.mood) {\n this.mood = mood;\n this.actor.setEmotion(mood);\n }\n if (p.health < this.health) this.actor.trigger(\"hurt\");\n this.health = p.health;\n // первая поза или телепорт — без плавания через полкарты\n if (!this.placed || this.from.distanceToSquared(this.to) > 64) {\n this.from.copy(this.to);\n this.pos.copy(this.to);\n this.yawFrom = p.yaw;\n this.placed = true;\n }\n this.t = 0;\n }\n\n frame(dt: number, bright?: number): void {\n const prevX = this.pos.x,\n prevY = this.pos.y,\n prevZ = this.pos.z;\n this.t = Math.min(1, this.t + dt / 0.07);\n this.pos.lerpVectors(this.from, this.to, this.t);\n let dy = this.yawTo - this.yawFrom;\n while (dy > Math.PI) dy -= Math.PI * 2;\n while (dy < -Math.PI) dy += Math.PI * 2;\n const yaw = this.yawFrom + dy * this.t;\n if (dt > 0) {\n // скорость по пройденному — сглаженно, иначе шаг дёргается между позами\n const v = Math.hypot(this.pos.x - prevX, this.pos.z - prevZ) / dt;\n this.speed += (v - this.speed) * Math.min(1, dt * 12);\n this.vy += ((this.pos.y - prevY) / dt - this.vy) * Math.min(1, dt * 12);\n }\n const f = this.flags;\n const flying = (f & POSE_FLY) !== 0;\n this.actor.update(dt, {\n x: this.pos.x,\n y: this.pos.y,\n z: this.pos.z,\n yaw,\n pitch: this.pitch,\n speed: this.speed,\n vy: this.vy,\n onGround: !flying && Math.abs(this.vy) < 0.5,\n inWater: (f & POSE_SWIM) !== 0,\n flying,\n gliding: (f & POSE_GLIDE) !== 0,\n sneaking: (f & POSE_SNEAK) !== 0,\n sprinting: (f & POSE_SPRINT) !== 0,\n dead: (f & POSE_DEAD) !== 0,\n sleeping: this.sleeping,\n riding: this.riding,\n held: this.held,\n heldSkin: this.heldSkin,\n ...(bright !== undefined ? { bright } : {}),\n });\n }\n\n dispose(scene: THREE.Object3D): void {\n scene.remove(this.actor.group);\n this.actor.dispose();\n }\n}\n"
909
+ "content": "// Другой игрок сетевой игры — у ВЛАДЕЛЬЦА измерения. Мобы, стрелы, взрывы и подбор работают с ним\n// как с обычным Player (EntityManager.others), но состояние игрока — у самого игрока (вариант А):\n// урон, отброс, эффекты и опыт не применяются здесь, а уходят ему по сети; подобранный предмет\n// «выдаётся» ему, а не кладётся в чужой инвентарь.\nimport { EMOTION_EVENTS, type Emotion } from \"../character/anim/emotions\";\nimport * as THREE from \"three\";\nimport { Player } from \"../player/Player\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { Inventory } from \"../systems/Inventory\";\nimport type { EffectId, ItemStack } from \"../types\";\nimport {\n POSE_CREATIVE,\n POSE_DEAD,\n POSE_FLY,\n POSE_GLIDE,\n POSE_SNEAK,\n POSE_SPRINT,\n POSE_SWIM,\n POSE_SWING,\n type Pose,\n} from \"./protocol\";\nimport { CharacterActor } from \"../character/CharacterActor\";\nimport type { CharacterSpec } from \"../character/CharacterSpec\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\n\n/** Куда уходит то, что с удалённым игроком случилось у владельца измерения. */\nexport interface RemoteSink {\n hit(\n slot: number,\n amount: number,\n cause: string,\n knock?: [number, number, number],\n effect?: { id: string; seconds: number; level: number },\n ): void;\n give(slot: number, stack: ItemStack | null, xp?: number): void;\n}\n\nexport class RemotePlayer extends Player {\n readonly slot: number;\n readonly userId: string;\n /** Предмет в руке (PlayerMeta) — для мобов, которые идут за едой в руке. */\n held: string | null = null;\n /** Надета золотая вещь (PlayerMeta.gold) — свинорыл не нападает, как на своего игрока. */\n gold = false;\n override goldArmor = (): boolean => this.gold;\n /** Скорость из последней позы: всё, что к ней добавили за тик (толчки), уходит игроку. */\n private readonly velBase = new THREE.Vector3();\n\n constructor(\n world: ChunkManager,\n slot: number,\n userId: string,\n private readonly sink: RemoteSink,\n ) {\n super(world);\n this.slot = slot;\n this.userId = userId;\n }\n\n applyPose(p: Pose): void {\n this.pos.set(p.x, p.y, p.z);\n this.yaw = p.yaw;\n this.pitch = p.pitch;\n this.dead = (p.flags & POSE_DEAD) !== 0;\n this._sneaking = (p.flags & POSE_SNEAK) !== 0;\n this.mode = p.flags & POSE_CREATIVE ? \"creative\" : \"survival\";\n this.health = p.health;\n this.vel.set(0, 0, 0);\n this.velBase.set(0, 0, 0);\n }\n\n /** Урон — игроку: он сам решит про щит, броню, тотем и смерть. */\n override damage(amount: number, cause: string): void {\n if (this.dead || this.mode === \"creative\" || amount <= 0) return;\n this.sink.hit(this.slot, amount, cause);\n }\n\n override knock(dx: number, dz: number, strength: number): void {\n const len = Math.hypot(dx, dz) || 1;\n this.vel.x += (dx / len) * strength;\n this.vel.z += (dz / len) * strength;\n this.vel.y = Math.max(this.vel.y, 4);\n }\n\n override addEffect(id: EffectId, seconds: number, level = 1): void {\n this.sink.hit(this.slot, 0, \"effect\", undefined, { id, seconds, level });\n }\n\n override addXp(amount: number): void {\n if (amount > 0) this.sink.give(this.slot, null, amount);\n }\n\n /** Щит решает сам игрок (у него инвентарь): здесь не блокирует ничего. */\n override shieldBlocks(): boolean {\n return false;\n }\n\n /**\n * Толчок, накопленный за тик (knock, взрыв добавили к vel): отдать игроку и обнулить. Зовёт\n * NetGame после тика сущностей.\n */\n takeImpulse(): [number, number, number] | null {\n const dx = this.vel.x - this.velBase.x,\n dy = this.vel.y - this.velBase.y,\n dz = this.vel.z - this.velBase.z;\n this.vel.copy(this.velBase);\n if (dx * dx + dy * dy + dz * dz < 0.01) return null;\n return [dx, dy, dz];\n }\n}\n\n/**\n * Инвентарь удалённого игрока у владельца измерения: подобранное уходит игроку. Считается, что\n * место есть — если нет, игрок сам выбросит лишнее у себя (его инвентарь авторитетен).\n */\nexport function remoteInventory(p: RemotePlayer, sink: RemoteSink): Inventory {\n const inv = {\n add(item: string, count: number): number {\n sink.give(p.slot, { item, count });\n return 0;\n },\n addStack(s: ItemStack): number {\n sink.give(p.slot, { ...s });\n return 0;\n },\n heldItem(): ItemStack | null {\n return p.held ? { item: p.held, count: 1 } : null;\n },\n // магнит предмета (ItemDrop) спрашивает место: без метода цикл сущностей хозяина падал целиком\n hasRoom(): boolean {\n return true;\n },\n slots: [] as (ItemStack | null)[],\n armor: [] as (ItemStack | null)[],\n };\n return inv as unknown as Inventory;\n}\n\n// ============================================================ аватар\n\n/**\n * Другой игрок на экране: его персонаж (рецепт по сети, пока не пришёл — «Путник»), анимации по\n * позам — шаг, бег, присед, плавание, полёт, планер, замах, смерть, — жесты и эмоции из сети,\n * предмет в руке. Между позами (15 в секунду) — плавное движение к новой.\n */\nexport class Avatar {\n readonly actor: CharacterActor;\n private readonly from = new THREE.Vector3();\n private readonly to = new THREE.Vector3();\n private readonly pos = new THREE.Vector3();\n private yawFrom = 0;\n private yawTo = 0;\n private pitch = 0;\n private t = 1;\n private flags = 0;\n private speed = 0;\n private vy = 0;\n private placed = false;\n private mood: Emotion = \"neutral\";\n private health = 20;\n /** Предмет в руке и сон/езда — из PlayerMeta (NetGame ставит). */\n held: string | null = null;\n heldSkin: string | null = null;\n sleeping = false;\n riding = false;\n\n constructor(\n name: string,\n atlas: TextureAtlas | null = null,\n spec?: CharacterSpec,\n ) {\n this.actor = new CharacterActor(spec, { name, atlas });\n }\n\n get group(): THREE.Group {\n return this.actor.group;\n }\n\n setName(name: string): void {\n this.actor.view.setName(name);\n }\n\n setSpec(spec: CharacterSpec): void {\n this.actor.setSpec(spec);\n }\n\n /** Новая поза из сети: от текущего положения к новому за ~один интервал поз. */\n target(p: Pose): void {\n this.from.copy(this.pos);\n this.to.set(p.x, p.y, p.z);\n this.yawFrom = this.yawTo;\n this.yawTo = p.yaw;\n this.pitch = p.pitch;\n // замах — по фронту флага (флаг держится, пока у игрока идёт анимация замаха)\n if (p.flags & POSE_SWING && !(this.flags & POSE_SWING))\n this.actor.trigger(\"swing\");\n this.flags = p.flags;\n // настроение по здоровью — как у своего персонажа (game/playerCharacter)\n const mood: Emotion =\n p.flags & (POSE_DEAD | POSE_CREATIVE)\n ? \"neutral\"\n : p.health <= 4\n ? EMOTION_EVENTS.nearDeath.emotion\n : p.health <= 8\n ? EMOTION_EVENTS.lowHealth.emotion\n : \"neutral\";\n if (mood !== this.mood) {\n this.mood = mood;\n this.actor.setEmotion(mood);\n }\n if (p.health < this.health) this.actor.trigger(\"hurt\");\n this.health = p.health;\n // первая поза или телепорт — без плавания через полкарты\n if (!this.placed || this.from.distanceToSquared(this.to) > 64) {\n this.from.copy(this.to);\n this.pos.copy(this.to);\n this.yawFrom = p.yaw;\n this.placed = true;\n }\n this.t = 0;\n }\n\n frame(dt: number, bright?: number): void {\n const prevX = this.pos.x,\n prevY = this.pos.y,\n prevZ = this.pos.z;\n this.t = Math.min(1, this.t + dt / 0.07);\n this.pos.lerpVectors(this.from, this.to, this.t);\n let dy = this.yawTo - this.yawFrom;\n while (dy > Math.PI) dy -= Math.PI * 2;\n while (dy < -Math.PI) dy += Math.PI * 2;\n const yaw = this.yawFrom + dy * this.t;\n if (dt > 0) {\n // скорость по пройденному — сглаженно, иначе шаг дёргается между позами\n const v = Math.hypot(this.pos.x - prevX, this.pos.z - prevZ) / dt;\n this.speed += (v - this.speed) * Math.min(1, dt * 12);\n this.vy += ((this.pos.y - prevY) / dt - this.vy) * Math.min(1, dt * 12);\n }\n const f = this.flags;\n const flying = (f & POSE_FLY) !== 0;\n this.actor.update(dt, {\n x: this.pos.x,\n y: this.pos.y,\n z: this.pos.z,\n yaw,\n pitch: this.pitch,\n speed: this.speed,\n vy: this.vy,\n onGround: !flying && Math.abs(this.vy) < 0.5,\n inWater: (f & POSE_SWIM) !== 0,\n flying,\n gliding: (f & POSE_GLIDE) !== 0,\n sneaking: (f & POSE_SNEAK) !== 0,\n sprinting: (f & POSE_SPRINT) !== 0,\n dead: (f & POSE_DEAD) !== 0,\n sleeping: this.sleeping,\n riding: this.riding,\n held: this.held,\n heldSkin: this.heldSkin,\n ...(bright !== undefined ? { bright } : {}),\n });\n }\n\n dispose(scene: THREE.Object3D): void {\n scene.remove(this.actor.group);\n this.actor.dispose();\n }\n}\n"
910
910
  },
911
911
  {
912
912
  "path": "net/sim.ts",
@@ -2410,7 +2410,7 @@
2410
2410
  },
2411
2411
  {
2412
2412
  "path": "module.meta.json",
2413
- "content": "{\n \"id\": \"voxelcraft\",\n \"type\": \"template\",\n \"uses\": [\"multiplayer\", \"workshop\"],\n \"summary\": \"Minecraft-like voxel survival game: mine, craft, build, farm and fight from the first night to the dragon — with worlds generated from a picture + description (with or without AI).\",\n \"description\": \"A first-person voxel game rendered in Three.js that follows Minecraft's mechanics 1:1 under its own names (every player-facing name lives in one table, so a commercial game can re-skin it; Minecraft names are accepted as aliases). Survival and creative modes: ores and tool tiers, crafting, smelting, armor, hunger, air and drowning, XP, enchanting, anvils, smithing, potions and beacons; farming, animals, taming and riding, fishing, boats and minecarts; villages with trading villagers, raids, and dozens of hostile and neutral mobs. Three dimensions — the overworld (biomes from deserts and jungles to the deep dark, caves, ocean temples, mansions, trial chambers, archaeology), the Inferno (Nether-like: fortresses, its own mobs and biomes) and the Beyond (End-like: citadel portal, the dragon boss, outer islands and cities, wings). Redstone (wire, torches, repeaters, comparators, pistons, observers, hoppers, dispensers, rails), weather, day/night, block light, fluids, achievements, generated music, a chat console with commands, and touch controls. The lobby's 'Worlds' tab turns a reference picture and a text description into a world — locally in the browser (the picture read as a top-down map or as a mood, the text by keywords) or with InApp AI (client.ai, title feature \\\"voxel-world\\\") — via a compact WorldSpec recipe. Worlds autosave to 'My worlds'. The agent sees and drives the game through its debug surface (state, lookingAt, console commands). Covers any 'Minecraft clone', survival / crafting / mining game, block-building sandbox or 'generate a world from a picture' request; by far the largest feature module. Co-op multiplayer for up to 8 players through the lobby's rooms (the multiplayer module, ctx.multiplayer): the lobby's home shows 'With friends' next to the game's 'Play' and a 'Multiplayer' tab, and the pause menu's 'Play with friends' opens 'Create a room' there (public or by invite, room settings 'guest game mode' and 'commands for everyone'); the host's 'Play' takes the whole room into the host's world, late joiners included; in game the host grants operator, kicks and bans. Peer-to-peer over WebRTC (the SDK transport @idosgames/core/realtime, rooms and signaling on the engine's Multiplayer module; TURN relay from the engine when configured): each live dimension is simulated by one player and players may be in different dimensions at once; everyone's own inventory and progress stay theirs; when the host leaves or its tab dies the world moves to another player without stopping. Needs the title's Multiplayer section enabled (Host topology). Characters: the player's own blocky character (third-person view on F5, the first-person arm and an inventory doll from the same recipe) is a CharacterSpec recipe — skeleton template (humanoid, quadruped, winged, blob), boxes on bones, palette and pixel-art faces with expressions — animated by a layered animator (locomotion state graph, actions, emotes, emotions with faces and posture, secondary motion of tails and capes). The lobby's 'Character' and 'Items' tabs turn reference pictures (front, side, back) and a description into a character or an item look — with InApp AI (title features \\\"voxel-character\\\" and \\\"voxel-item\\\") or without it (colours from the picture); item looks re-skin an existing item (stats stay) or are cosmetics worn on the body (hats, capes, wings). Characters also become NPCs and custom mobs with preset behaviours; looks, emotes and emotions sync over the network; characters and items publish to the Workshop. Not included yet: AI dialogue for NPCs and a dedicated server.\",\n \"provides\": [\n \"first-person voxel world: mine, place and break blocks\",\n \"survival and creative modes (health, hunger, air, XP, death and respawn)\",\n \"crafting, smelting, tool and armor tiers\",\n \"enchanting, anvil, smithing, potions, beacon\",\n \"farming, animals, taming and riding, fishing, boats\",\n \"villages, trading villagers, raids\",\n \"dozens of hostile and neutral mobs, bosses (dragon, ash lord)\",\n \"three dimensions: overworld, Inferno (Nether-like), Beyond (End-like)\",\n \"biomes, caves and structures (temples, mansions, fortresses, trial chambers, ruins)\",\n \"redstone: wire, repeaters, comparators, pistons, observers, hoppers, rails and minecarts\",\n \"weather, day/night cycle, block light and fluid simulation\",\n \"achievements and procedurally generated music\",\n \"chat console with commands (/give, /tp, /locate, /summon, …)\",\n \"touch controls for mobile\",\n \"world generation from a picture + description (WorldSpec recipe)\",\n \"AI world generation via client.ai (feature \\\"voxel-world\\\")\",\n \"AI builds objects from pictures out of coloured blocks (a plane, a car… — models of shapes)\",\n \"AI recreates landscapes from pictures (terrain sketch, rivers, biomes)\",\n \"co-op multiplayer up to 8 players (P2P over WebRTC) through the lobby's rooms (ctx.multiplayer), late joiners included\",\n \"open the world to the network: invite code or public list, guest game mode, operators, kick and ban\",\n \"players in different dimensions at once; host migration without stopping the world\",\n \"autosave and 'My worlds' (IndexedDB)\",\n \"player character from a recipe: third-person view (F5), first-person arm and inventory doll\",\n \"character animation: state graph (walk, run, sneak, swim, fly, glide, ride, sleep, death), actions, emotes, emotions with faces\",\n \"AI character from pictures (front/side/back) + description via client.ai (feature \\\"voxel-character\\\"), or colours from a picture without AI\",\n \"AI item looks via client.ai (feature \\\"voxel-item\\\"): new look of an existing item, or cosmetics (hats, capes, wings)\",\n \"NPCs and custom mobs from a character with preset behaviours (npc, passive, neutral, hostile, pet)\",\n \"emote wheel (G), /emote, /feel, chat smileys; synced over the network\",\n \"characters and item looks in the Workshop (voxelcraft.character, voxelcraft.item)\",\n \"quest book (J): a survival line that doubles as the tutorial — chapters, quests with parents, hints that get more precise when the player is stuck, an arrow to the nearest target, recipes as pictures, situational tips; English and Russian\",\n \"pluggable quest sources (QuestSource) — the base for server quests; new task kinds as registry entries\",\n \"lives inside the SDK lobby, Fortnite-style: the player's character on a pedestal in a block diorama on the lobby's home (ctx.lobby hero) with Edit / Emote, the world card and Play\",\n \"lobby tabs on the UI kit next to every system: Worlds (new world from pictures + description, My worlds), Character (studio), Items (looks and wearables), Locker\",\n \"\\\"To the lobby\\\" in the pause menu returns to the SDK lobby; the world chosen there opens on Play\",\n \"own player-facing names for a re-skinnable commercial game (Minecraft names as aliases)\",\n \"3D block rendering (Three.js)\"\n ],\n \"tags\": [\n \"voxel\",\n \"sandbox\",\n \"survival\",\n \"crafting\",\n \"minecraft\",\n \"3d\",\n \"first-person\",\n \"open-world\",\n \"redstone\",\n \"mobile\",\n \"three\",\n \"ai\",\n \"world-generation\",\n \"character\",\n \"avatar\",\n \"animation\",\n \"multiplayer\",\n \"co-op\"\n ],\n \"media\": {\n \"image\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/cover.png\",\n \"video\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/demo.mp4\"\n },\n \"demoUrl\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/demo/\",\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.6.0\"\n}\n"
2413
+ "content": "{\n \"id\": \"voxelcraft\",\n \"type\": \"template\",\n \"uses\": [\"multiplayer\", \"workshop\"],\n \"summary\": \"Minecraft-like voxel survival game: mine, craft, build, farm and fight from the first night to the dragon — with worlds generated from a picture + description (with or without AI).\",\n \"description\": \"A first-person voxel game rendered in Three.js that follows Minecraft's mechanics 1:1 under its own names (every player-facing name lives in one table, so a commercial game can re-skin it; Minecraft names are accepted as aliases). Survival and creative modes: ores and tool tiers, crafting, smelting, armor, hunger, air and drowning, XP, enchanting, anvils, smithing, potions and beacons; farming, animals, taming and riding, fishing, boats and minecarts; villages with trading villagers, raids, and dozens of hostile and neutral mobs. Three dimensions — the overworld (biomes from deserts and jungles to the deep dark, caves, ocean temples, mansions, trial chambers, archaeology), the Inferno (Nether-like: fortresses, its own mobs and biomes) and the Beyond (End-like: citadel portal, the dragon boss, outer islands and cities, wings). Redstone (wire, torches, repeaters, comparators, pistons, observers, hoppers, dispensers, rails), weather, day/night, block light, fluids, achievements, generated music, a chat console with commands, and touch controls. The lobby's 'Worlds' tab turns a reference picture and a text description into a world — locally in the browser (the picture read as a top-down map or as a mood, the text by keywords) or with InApp AI (client.ai, title feature \\\"voxel-world\\\") — via a compact WorldSpec recipe. Worlds autosave to 'My worlds'. The agent sees and drives the game through its debug surface (state, lookingAt, console commands). Covers any 'Minecraft clone', survival / crafting / mining game, block-building sandbox or 'generate a world from a picture' request; by far the largest feature module. Co-op multiplayer for up to 8 players through the lobby's rooms (the multiplayer module, ctx.multiplayer): the lobby's home shows 'With friends' next to the game's 'Play' and a 'Multiplayer' tab, and the pause menu's 'Play with friends' opens 'Create a room' there (public or by invite, room settings 'guest game mode' and 'commands for everyone'); the host's 'Play' takes the whole room into the host's world, late joiners included; in game the host grants operator, kicks and bans. Peer-to-peer over WebRTC (the SDK transport @idosgames/core/realtime, rooms and signaling on the engine's Multiplayer module; TURN relay from the engine when configured): each live dimension is simulated by one player and players may be in different dimensions at once; everyone's own inventory and progress stay theirs; when the host leaves or its tab dies the world moves to another player without stopping. Needs the title's Multiplayer section enabled (Host topology). Characters: the player's own blocky character (third-person view on F5, the first-person arm and an inventory doll from the same recipe) is a CharacterSpec recipe — skeleton template (humanoid, quadruped, winged, blob), boxes on bones, palette and pixel-art faces with expressions — animated by a layered animator (locomotion state graph, actions, emotes, emotions with faces and posture, secondary motion of tails and capes). The lobby's 'Character' and 'Items' tabs turn reference pictures (front, side, back) and a description into a character or an item look — with InApp AI (title features \\\"voxel-character\\\" and \\\"voxel-item\\\") or without it (colours from the picture); item looks re-skin an existing item (stats stay) or are cosmetics worn on the body (hats, capes, wings). Characters also become NPCs and custom mobs with preset behaviours; looks, emotes and emotions sync over the network; characters and items publish to the Workshop. Not included yet: AI dialogue for NPCs and a dedicated server.\",\n \"provides\": [\n \"first-person voxel world: mine, place and break blocks\",\n \"survival and creative modes (health, hunger, air, XP, death and respawn)\",\n \"crafting, smelting, tool and armor tiers\",\n \"enchanting, anvil, smithing, potions, beacon\",\n \"farming, animals, taming and riding, fishing, boats\",\n \"villages, trading villagers, raids\",\n \"dozens of hostile and neutral mobs, bosses (dragon, ash lord)\",\n \"three dimensions: overworld, Inferno (Nether-like), Beyond (End-like)\",\n \"biomes, caves and structures (temples, mansions, fortresses, trial chambers, ruins)\",\n \"redstone: wire, repeaters, comparators, pistons, observers, hoppers, rails and minecarts\",\n \"weather, day/night cycle, block light and fluid simulation\",\n \"achievements and procedurally generated music\",\n \"chat console with commands (/give, /tp, /locate, /summon, …)\",\n \"touch controls for mobile\",\n \"world generation from a picture + description (WorldSpec recipe)\",\n \"AI world generation via client.ai (feature \\\"voxel-world\\\")\",\n \"AI builds objects from pictures out of coloured blocks (a plane, a car… — models of shapes)\",\n \"AI recreates landscapes from pictures (terrain sketch, rivers, biomes)\",\n \"co-op multiplayer up to 8 players (P2P over WebRTC) through the lobby's rooms (ctx.multiplayer), late joiners included\",\n \"open the world to the network: invite code or public list, guest game mode, operators, kick and ban\",\n \"players in different dimensions at once; host migration without stopping the world\",\n \"autosave and 'My worlds' (IndexedDB)\",\n \"player character from a recipe: third-person view (F5), first-person arm and inventory doll\",\n \"character animation: state graph (walk, run, sneak, swim, fly, glide, ride, sleep, death), actions, emotes, emotions with faces\",\n \"AI character from pictures (front/side/back) + description via client.ai (feature \\\"voxel-character\\\"), or colours from a picture without AI\",\n \"AI item looks via client.ai (feature \\\"voxel-item\\\"): new look of an existing item, or cosmetics (hats, capes, wings)\",\n \"NPCs and custom mobs from a character with preset behaviours (npc, passive, neutral, hostile, pet)\",\n \"emote wheel (G), /emote, /feel, chat smileys; synced over the network\",\n \"characters and item looks in the Workshop (voxelcraft.character, voxelcraft.item)\",\n \"quest book (J): a survival line that doubles as the tutorial — chapters, quests with parents, hints that get more precise when the player is stuck, an arrow to the nearest target, recipes as pictures, situational tips; English and Russian\",\n \"pluggable quest sources (QuestSource) — the base for server quests; new task kinds as registry entries\",\n \"lives inside the SDK lobby, Fortnite-style: the player's character on a pedestal in a block diorama on the lobby's home (ctx.lobby hero) with Edit / Emote, the world card and Play\",\n \"lobby tabs on the UI kit next to every system: Worlds (new world from pictures + description, My worlds), Character (studio), Items (looks and wearables), Locker\",\n \"\\\"To the lobby\\\" in the pause menu returns to the SDK lobby; the world chosen there opens on Play\",\n \"own player-facing names for a re-skinnable commercial game (Minecraft names as aliases)\",\n \"3D block rendering (Three.js)\"\n ],\n \"tags\": [\n \"voxel\",\n \"sandbox\",\n \"survival\",\n \"crafting\",\n \"minecraft\",\n \"3d\",\n \"first-person\",\n \"open-world\",\n \"redstone\",\n \"mobile\",\n \"three\",\n \"ai\",\n \"world-generation\",\n \"character\",\n \"avatar\",\n \"animation\",\n \"multiplayer\",\n \"co-op\"\n ],\n \"media\": {\n \"image\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/cover.png\",\n \"video\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/demo.mp4\"\n },\n \"demoUrl\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/demo/\",\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.6.1\"\n}\n"
2414
2414
  }
2415
2415
  ]
2416
2416
  }