Skip to content

Ловушки и инварианты ​

Проверьте плагин по этому списку, прежде чем объявлять его готовым. Почти каждый пункт здесь ломается молча или ошибкой, далёкой от причины.

Пункты с пометкой ⚙ проверяются машиной: запустите npx vimp-contract в своём пакете, и идентификатор правила в пометке (A1 … E7) назовёт нарушение. Не проверяйте их глазами — запустите инструмент (debugging.md). Остальная часть списка по-прежнему на вас: headless-раннер (npm run sim) закрывает её часть в рантайме, остальное — ревью.

Правила контракта вкратце ​

Источник истины: vimp-engine/src/devtools/contract/rules/ (по файлу на правило). error проваливает проверку, warn — только под --strict. Правила B2, B3, B5, C4 и C10 на отдельном прогоне могут опустить конкретное нарушение до warn (verdict(..., WARN) в contract/result.js), поэтому уровень в отчёте может быть ниже, чем в колонке Level. Правила, не названные в чек-листе ниже, видны только в этой таблице и в отчёте vimp-contract.

ПравилоУровеньПроверяет
A1errorpackage.json: type, files, pixi.js, vimp-engine, publishConfig
A2errorстандартные скрипты сборки и тестов существуют
A3errorвходы лежат в index и index
A4errorvite.config.js несёт обязательные опции rollup
A5errorcore/Cargo.toml: crate-type, детерминизм rapier2d, пин крейта движка
A6errordist/manifest.json: id, входы на диске, покрытие roomDefaults
A7warnpackage.json объявляет homepage или repository
B1errorHostPlugin экспортирует каждое поле, которое движок разыменовывает
B2errorengineApi согласован; requires называет существующие capabilities и совпадает в манифесте и обеих половинах
B3errorв gameConfig есть пути, которые движок читает до любой логики
B4errorspectatorTeam — ключ teams и есть хотя бы одна играющая команда
B5errorroomForm использует учитываемые имена полей и существующие контролы
B6errorpanel.fields хоста не использует ключ движка t
B7errorчат-команды корректны и уникальны
B8errorкоды системных сообщений не заходят в диапазоны движка
B9errorпользовательские голосования не переиспользуют зарезервированные имена и префикс @
B10errorу каждой играющей команды есть респауны, и их хватает на maxPlayers
C1errorClientPlugin экспортирует parts, bakers, styles и все три хука
C2errorкаждый класс из gameSets есть в entitiesOnCanvas и экспортирован в parts
C3errorу каждого ключа снапшота и setId карты есть запись gameSets
C4errorcomponentDependencies называют только существующие сервисы
C5errorклиентская панель сопоставляет ключ t полю type: 'time'
C6warnколонки статистики сверх раскладки движка стилизованы плагином
C7errorkeySetList: действия наблюдателя, коды клавиш движка, паритет с playerKeys
C8errorимена bakedAssets есть в ClientPlugin.bakers
C9errorу каждого кода системного сообщения есть клиентский текст
C10errorauthSchema: fieldsId, нет поля ника, поле model, встроенные options, разрешимые валидаторы
C11errorgameConfig.statMode и modules.stat.params.mode согласованы
D1errorid блоков снапшота уникальны
D2errorкласс hot только на indexed8/indexedNoNull8; list16/indexed32 — class: 'event'
D3errorinterp только на f32-полях hot-блоков; role: 'state' — u8 сразу после головы строки
E1errorкаждый объявленный звук поставляется парой webm + mp3
E2errorкартинки карт существуют в dist/img/
E3warnреестр звуков не пуст
E4errorмногоуровневые (2.5D) карты структурно корректны
E5errorтвёрдые тайлы названы слоем отрисовки
E6warnблок пространственного звука корректен
E7errorполе game карты — простой объект

Версия и идентичность ​

  • [ ] ⚙ B2 engineApi встречается в трёх местах (манифест, HostPlugin, ClientPlugin), и во всех трёх импортируется ENGINE_API_VERSION, а не зашито число.
  • [ ] ⚙ A6 manifest.id равен id и хост-, и клиентского плагина.
  • [ ] manifest.id также равен id, под которым игра зарегистрирована на платформе (ручная проверка, см. submitting.md). Несовпадение заставляет платформу пропустить игру — в лобби она просто не появляется.
  • [ ] Игра, которую платформа не может прочитать (нет манифеста, несовпадение id), пропускается, а не сообщается пользователю. Если игры нет в лобби, сначала запустите check:contract и проверьте dist/manifest.json. Возраст больше не причина: игра, собранная под более старый движок, отдаётся как есть, а игра, чей requires называет неизвестную capability, показывается в лобби как недоступная с указанием причины.

Host-плагин ​

  • [ ] ⚙ B1 chatCommands присутствует и является массивом — движок итерирует его без защиты. Для «нет команд» используйте [].
  • [ ] ⚙ B1 createModules присутствует, и в его результате есть ключ scripted.
  • [ ] ⚙ B1 buildClientGameConfig присутствует — он вызывается безусловно.
  • [ ] ⚙ B3 Все четыре REQUIRED_GAME_CONFIG_PATHS существуют: parts.models, playerKeys, snapshot, teams. Всё остальное, что читает движок, имеет значение по умолчанию (createGameConfigView) — B3 лишь предупреждает об этом. Если объявляете spectatorTeam, пишите его ровно как один из ключей teams (шлюз проверяет и это); если опущен, он разрешается в ключ spectators либо в null с console.warn, если такого ключа нет.
  • [ ] В контексте createModules нет timerManager и нетvoteCoordinator (комментарий в исходниках tanks говорит обратное — он неверен). Их получают только обработчики чат-команд.
  • [ ] gameConfig сливается с умолчаниями движка поверхностно: переопределение timers, rtt или idleKickTimeout заменяет объект целиком. Повторите каждый ключ, который всё ещё нужен.
  • [ ] timeStep ядра берётся из hostDefaults движка, а не из вашего слитого gameConfig.timers.timeStep. Его переопределение рассинхронизирует цикл Worker и шаг физики.
  • [ ] ⚙ B5 Поля формы комнаты вне белого списка (maps, maxPlayers, map, roundTime, mapTime, friendlyFire) форма принимает, а хост молча отбрасывает. Не стройте правила вокруг пользовательской настройки комнаты.
  • [ ] Мета-модули движка (Panel, Stat, Chat, Vote, TimerManager) — синглтоны. Создание второго возвращает первый — «свежий» экземпляр молча делит состояние.

Системные сообщения и голосования ​

  • [ ] ⚙ B8 В группах s, v, m, c уже лежат сообщения движка (индексы s:0..11, v:0..15, m:0..1, c:0..1). Регистрация — слепой Object.assign: ваш код на зарезервированном индексе перезаписывает сообщение движка без предупреждения. Начинайте свои коды выше них.
  • [ ] Нет команды /name и любой другой смены ника — движок не умеет переименовывать игрока.
  • [ ] ⚙ C9 У каждого регистрируемого вами кода есть соответствующий текст с тем же индексом в modules.chat.params.messages[group] клиента. Отсутствующий текст отображается как ничто.
  • [ ] ⚙ B9 Имена голосований mapChange и teamChange, а также строки-шаблоны values 'teams' / 'maps' зарезервированы.
  • [ ] ⚙ B9 Имена голосований, начинающиеся с @ (шаблоны и пункты меню), и чат-команда /changehost зарезервированы движком (голосование «Change host» платформы, режим лобби). У кодов движка на клиенте есть английские значения по умолчанию; '' с тем же индексом заглушает такой код.
  • [ ] Категория голосования на паузе timers.timeBlockedVote (30 с); всегда сначала вызывайте canCreateVote.
  • [ ] Ничьи в голосованиях разрешаются случайно — не полагайтесь на детерминированный исход.

Протокол снапшотов ​

  • [ ] ⚙ D1 Каждый id снапшота уникален. Проверяется только уникальность; никто не сверяет поля со строками Rust.
  • [ ] Порядок полей и interp позиционно привязаны к построению строки в Rust. Перестановка только с одной стороны даёт мусор, а не ошибку.
  • [ ] ⚙ D2 Горячий буфер несёт только блоки indexed8 и indexedNoNull8. Всё, что отрисовывается плавно с частотой кадров, обязано использовать один из этих видов; indexed32 / list16 приходят через take_frames().
  • [ ] ⚙ D3 Интерполируются только поля f32 и только в блоках class: 'hot'.
  • [ ] ⚙ D3 role: 'state' — u8 сразу после головы строки динамики (индекс 5 при уровнях, 3 на плоской карте) и до optionalFrom.
  • [ ] Ваше ядро вызывает vimp_engine_core::physics::round2 на f32, которые пакует. Упаковщик не округляет; округляет декодер, поэтому неокруглённое значение молча отличается на хосте и клиенте. Блок игрока исключение (пакуется и декодируется как есть).
  • [ ] Блоки событий оружия кладут id автора последним — от этого зависит подавление дубликатов на клиенте.
  • [ ] ⚙ D2 События должны жить в ключах class: 'event', иначе кадр не классифицируется как надёжный и может быть отброшен ненадёжным каналом.

WASM-ядро ​

  • [ ] PLAYER_STATE_LEN = 8 — предсказываемое локальное состояние ровно из восьми f32. Всё остальное нужно выводить на клиенте или не предсказывать.
  • [ ] JSON инициализации хоста использует timeStep в секундах; JSON инициализации клиента — timeStepMs в миллисекундах.
  • [ ] Макрос ABI требует, чтобы поля структуры назывались ровно state и packer (хост) / state (клиент), с точными типами движка.
  • [ ] new() пишется вручную; макрос его не генерирует.
  • [ ] Игровые клиентские методы (set_model, sync_panel, try_fire, cycle_weapon) пишутся вручную и вызываются только из ClientPlugin.hooks.
  • [ ] Младший байт тега тела 1 — тег объекта карты движка; игровые виды начинаются с 2. Динамические тела карты несут свой индекс в старших битах — используйте is_map_object, а не == MAP_OBJECT_TAG.
  • [ ] Никогда не удаляйте тело карты из мира: EngineSim удаляет их при следующей загрузке карты, а двойное удаление паникует. Отключите его (set_enabled(false)) и переключите байт map_body_state.
  • [ ] Не больше 255 динамических тел карты — индекс строки это u8. Обломки и остовы рисуются, а не порождаются как новые тела.
  • [ ] on_map_loaded не вызывается при deserialize_state; всё, что выводится из карты, должно сохраняться в вашем собственном дампе.
  • [ ] Вся случайность идёт через Rng движка (SplitMix64) с зерном из конфига. rand, Math.random и зерно от часов ломают детерминизм, а значит, и предсказание.
  • [ ] ⚙ A5 У rapier2d включена фича enhanced-determinism.
  • [ ] Математика движения общая у авторитетной симуляции и предиктора, и parity-тест cargo проверяет их согласие. Перезапускайте его после любого изменения движения.
  • [ ] Ядро не пересобирается командой npm run build — запускайте npm run core:build после правок Rust, иначе уедет устаревший .wasm.

Client-плагин ​

  • [ ] ⚙ C2 Класс части регистрируется, только если он есть в entitiesOnCanvas. Перечисления в parts и gameSets недостаточно — получите Constructor for X not found.
  • [ ] ⚙ C3 У каждого ключа снапшота (и у каждого setId карты) есть запись gameSets, иначе первый же кадр бросит исключение.
  • [ ] createClientCore возвращает { core, memory }. Возврат одного ядра ломает zero-copy чтение горячего буфера.
  • [ ] ⚙ C1 Существуют все три хука (onAuth, onPanel, onLocalAction); пустые тела допустимы, отсутствующие — падение.
  • [ ] Порядок отрисовки: задавайте zIndex на экземплярах частей — ничто другое на него не влияет. Движок помечает сцену sortableChildren и вызывает sortChildren() после каждого addChild; PixiJS v8 сортирует там по zIndex. Свойство layer у экземпляра ничего не делает.
  • [ ] ⚙ C4 Движок предоставляет ровно шесть сервисов зависимостей: renderer, soundManager, assetsBase, localPlayer, accolades, diagnostics. Игра добавляет свои, возвращая их из ClientPlugin.hooks.services(core) — движок сливает эту карту в пул. Имя, которого нет ни у одной стороны, в части молча разрешается в undefined. C4 сообщает об этом как об ошибке, если плагин не объявляет hooks.services(), и как о предупреждении, если объявляет (чекер не может вызвать хук без живого ядра — запустите с --strict, чтобы они тоже проваливали проверку).
  • [ ] Никогда не перечисляйте diagnostics в requires: сервис необязателен, старый движок отдаёт части undefined, а запись в requires отвергла бы игру там впустую. Вызывайте как diagnostics?.warn(…).
  • [ ] Не переиспользуйте имя сервиса движка в hooks.services(): движок сливает вашу карту первой ({ ...gameServices, renderer, … }), поэтому ваш renderer молча перезаписывается движковым, и ни одно правило этого не видит.
  • [ ] ⚙ C8 Имена бейкеров в bakedAssets должны существовать в bakers; неизвестные имена молча пропускаются.
  • [ ] ⚙ B6, C5 Ключ панели t зашит движком (время раунда в секундах). Объявите для него на клиенте поле type: 'time' и не используйте t в схеме панели хоста.
  • [ ] panel.fields[*].value на хосте — одновременно стартовое значение HUD и стартовое количество ресурса в ядре, поэтому они не могут разойтись.
  • [ ] Движок пишет пять собственных имён статистики: name, status, score, deaths, latency, причём только в колонки, объявленные вашей схемой — запись в необъявленную отбрасывается. Любую дальнейшую колонку вы объявляете и заполняете из кода хоста сами; движок её не наполнит.
  • [ ] Сортировка статистики числовая (~~textContent); текстовая колонка сортируется как 0.
  • [ ] ⚙ C6 CSS движка раскладывает пять колонок статистики (#stat …:nth-child(1)…(5)) и классы строк .line1–.line3. Шестая колонка допустима, но своей ширины не получит, пока вы не повторите раскладку в ClientPlugin.styles — C6 предупреждает о каждой объявленной колонке, которую ваши стили не затрагивают. Он ищет объявление ширины (width/min-width/flex/…) на селекторе #stat, называющем ячейку, поэтому правило, задающее только цвет, не считается раскладкой колонки.
  • [ ] ⚙ C7 keySetList[0] — набор наблюдателя, в нём обязаны быть nextPlayer и prevPlayer; keySetList[1] — набор игрока.
  • [ ] ⚙ C7 Коды клавиш 67 (чат), 77 (голосование), 9 (статистика), 27 (escape), 13 (enter) принадлежат движку.
  • [ ] ⚙ C7 keySetList (код → имя) и playerKeys (имя → бит) обязаны перечислять одни и те же имена действий.
  • [ ] Семантику type: 1 (одиночное нажатие) реализует ваше ядро, а не движок — движок лишь пересылает playerKeys и сырые события down/up. Реализуйте шаблон маски (down взводит отложенный бит, один фиксированный шаг его потребляет, up игнорируется) и в симе, и в предикторе, иначе клавиша огня с type: 1 будет автоогнём.
  • [ ] ⚙ C10 Ключ элемента схемы авторизации — fieldsId, а не formId: он называет контейнер, который движок заполняет полями формы. Неверный ключ разрешается в null, и экран авторизации падает с TypeError при первой же отрисовке.
  • [ ] ⚙ C10 Поля никнейма нет — идентичность приходит из JWT лобби.
  • [ ] ⚙ C10 Ни один параметр не использует options.source — форма авторизации строится без каталогов движка, поэтому такое поле разрешается в пустой список, показывает no options available, и никто не может войти. Встраивайте options. (source допустим в roomForm, куда движок передаёт карты.)
  • [ ] authSchema.validators — функции, не сериализуются и выполняются на хосте.
  • [ ] ⚙ C10 validator параметра называет функцию, которую authSchema.validators действительно предоставляет — опечатка (или нефункциональное значение) оставляет поле непроверенным никем: хост молча пропускает неразрешённый валидатор и пишет console.error при построении машины портов.
  • [ ] Хост также применяет собственные декларативные правила дескриптора авторизации (validateAuth), поэтому клиент, обошедший форму, ими тоже связан: длина, принадлежность объявленным options поля select/radio (not an option; пустой или отсутствующий список не принимает ничего, как и в форме), затем maxlength/regExp у текстовых полей. required и min/max — исключения: пустое значение остаётся на совести вашего валидатора, а числовое поле авторизации работать не может вовсе (значение должно быть строкой).
  • [ ] Поле без maxlength на хосте ограничено 256 символами: ваш regExp выполняется там над тем, что прислал клиент, а катастрофический шаблон заморозил бы матч, а не только вкладку.
  • [ ] ⚙ B5 Объявляйте один из четырёх нативных контролов — text, select, checkbox, radio. Четыре, выведенные в v3 (range, number, toggle, segmented), работают вечно как постоянные алиасы text+numeric, text+numeric, checkbox и radio, поэтому старая игра продолжает отрисовываться, — но B5 (и C10 для authSchema) на них предупреждает, так что пишите нативное имя. Алиас действует и на авторитетную валидацию хоста, не только на отрисовку: поле segmented проверяется по списку options точно как radio, а поле number/range — по min/max в объявленной единице отображения. control, которого реестр движка не знает вовсе, пропускает поле с console.error. Нативные атрибуты min/max/step не выдаются никогда (эти поля — type=text): вместо них подсказку метки и проверку определяют собственные числа min/max дескриптора вместе с regExp.
  • [ ] ⚙ B5 regExp, который не компилируется, не ограничивает ничего: движок отбрасывает проверку с console.error, и поле проходит. Движок якорит его как ^(?:…)$, как браузер применяет pattern, поэтому пишите без якорей.
  • [ ] Держите этот regExp линейным. Форма перепроверяет себя на каждое нажатие клавиши, поэтому катастрофически бэктрекающий шаблон замораживает вкладку во время набора, а не только при отправке.

Ассеты и карты ​

  • [ ] ⚙ E2 Тайлсеты и картинки динамических объектов поставляются в вашем пакете (dist/img/) и грузятся из ${assetsBase}img/, точно как звуки из ${assetsBase}sounds/. Движок не отдаёт игровых картинок.
  • [ ] Часть, загружающая картинки, обязана объявить assetsBase в componentDependencies — необъявленный сервис молча undefined, и карта рисуется пустым холстом без единой записи в консоли (C4 проверяет лишь, что объявленное имя сервиса существует).
  • [ ] ⚙ E2 Карта, называющая картинку, которой нет в dist/img/, молча ломается в рантайме. Ловите это при сборке манифеста.
  • [ ] ⚙ E1 Каждый звук существует как пара webm + mp3; отсутствующий .mp3 ломает Safari.
  • [ ] Не задавайте sounds.path — движок его перезаписывает.
  • [ ] ⚙ B10 respawns[team].length — жёсткая вместимость команды на этой карте. Слишком мало точек молча ограничивает комнату ниже maxPlayers.
  • [ ] Хост масштабирует карты (step, позиции и размеры динамики, респауны); не масштабируйте повторно в части.
  • [ ] ⚙ E7 Поле game карты никем не масштабируется: кладите в него клетки сетки (или немасштабированные единицы, которые вы умножаете на scale сами). Это объект или отсутствует — массив проваливает загрузку карты.
  • [ ] Не больше 30 одновременных мировых голосов; ранжирование — priority² / max(distance², 1).
  • [ ] ⚙ E6 Опечатка в ключе parts.sounds.spatial или неизвестный движку mode не даёт ни ошибки, ни слышимой подсказки: движок молча откатывается к умолчанию. Статически это ловит только правило E6 (npx vimp-contract).
  • [ ] Сетка levels.<n>.map, чьи размеры отличаются от map, в рантайме молчит: актор едет в пустоту, и в консоли ничего нет. Ловит это только MapConfig::validate — не обходите его, строя GameMap напрямую.
  • [ ] Тайл перил, указанный в levels.<n>.walls, но не в .floor, висит в воздухе: он блокирует движение на этом уровне, но не защищает край плиты от выстрела снизу. Каждые перила обязаны входить в floor.

Сборка и упаковка ​

  • [ ] Клиент и хост собираются двумя отдельными запусками Vite (--mode client, --mode host), никогда одним multi-entry графом — общий чанк затаскивает DOM-код в бандл Worker.
  • [ ] ⚙ A4 emptyOutDir: false, assetsInlineLimit: 0, preserveEntrySignatures: 'strict', inlineDynamicImports: true — все обязательны. Без preserveEntrySignatures default-экспорт плагина выкидывается tree-shaking-ом.
  • [ ] ⚙ A4 Не используйте build.lib Vite — он всегда инлайнит ассеты.
  • [ ] ⚙ A1, A4 pixi.js внешний и является peer-зависимостью. Его бандлинг создаёт второй экземпляр PixiJS со своим реестром расширений; объекты, перешедшие между экземплярами, ломаются в рантайме.
  • [ ] ⚙ A3 Пути входов — ровно index и index: dev-режим их зашивает.
  • [ ] core/pkg-web/ должен существовать для dev-режима (npm run dev импортирует .wasm оттуда).
  • [ ] Плагин должен быть собран хотя бы раз (npm run core:build) до первого npm run dev.
  • [ ] ⚙ A1 files: ["dist"] — публикуется только dist/.

Гигиена кода хоста ​

  • [ ] Код стороны хоста Worker-безопасен: никаких window, document, DOM, PixiJS и глобалов Node.
  • [ ] Состояние модулей хоста не переживает передачу: физический мир не сериализуется, а состояние ваших JS-модулей не переносится (если игра не включила migration.midRound, см. ниже).
  • [ ] gameConfig.migration.midRound: true — тогда всё состояние должно быть в GameSim::serialize (ядро) или в serializeState/restoreState (каждый модуль хоста с состоянием); всё прочее сбрасывается при смене хоста. Проверяйте через vimp-sim --checkpoint-every <ms> (checkpointRestore): в ## Host checkpoints отчёта должно стоять midRound.

Чего не существует ​

  • Нет голосования за кик, нет эндпоинта /ban, нет серверного рейтинга /like · /unlike (удалены).
  • Нет поля views: { Panel, Stat } у ClientPlugin.
  • Нет GameClientDef::motion_step, render_from_state и STATE_LEN.
  • Нет spawn_scripted / build_blocks (настоящие имена — spawn_scripted_actor / build_snapshot_blocks).
  • Нет обобщённого SimCtx<'a, G> и поля game_cfg у него.
  • Нет gameConfig.models / gameConfig.weapons на верхнем уровне — они живут под parts.
  • Нет окна неуязвимости, подбираемых предметов, системы разрушаемой местности и правил прогрессии отдельных игроков: моделируйте их внутри своего ядра и схемы снапшота, если нужно.
  • Нет рантайм-локализации: один язык на конфиг.
  • Нет внутриигрового отладочного оверлея или HUD. Есть только внешнее: headless-раннер движка (npm run sim), который воспроизводит сценарий без браузера и называет по имени каждый нарушенный контракт с этой страницы, а также дампы мира debug_json() и детектор дрейфа предсказания — всё это доступно вашему плагину бесплатно. См. debugging.md; проверка чек-листа раннером лучше проверки глазами.

← Предыдущая: Отладка (headless-прогон и браузерная половина) · Следующая: Подача игры →

vimp-engine 0.38.1 · vimp-engine-core 0.23.2 · create-vimp-game 0.5.4