Ловушки и инварианты
Проверьте плагин по этому списку, прежде чем объявлять его готовым. Почти каждый пункт здесь ломается молча или ошибкой, далёкой от причины.
Пункты с пометкой ⚙ проверяются машиной: запустите 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.
| Правило | Уровень | Проверяет |
|---|---|---|
A1 | error | package.json: type, files, pixi.js, vimp-engine, publishConfig |
A2 | error | стандартные скрипты сборки и тестов существуют |
A3 | error | входы лежат в index и index |
A4 | error | vite.config.js несёт обязательные опции rollup |
A5 | error | core/Cargo.toml: crate-type, детерминизм rapier2d, пин крейта движка |
A6 | error | dist/manifest.json: id, входы на диске, покрытие roomDefaults |
A7 | warn | package.json объявляет homepage или repository |
B1 | error | HostPlugin экспортирует каждое поле, которое движок разыменовывает |
B2 | error | engineApi согласован; requires называет существующие capabilities и совпадает в манифесте и обеих половинах |
B3 | error | в gameConfig есть пути, которые движок читает до любой логики |
B4 | error | spectatorTeam — ключ teams и есть хотя бы одна играющая команда |
B5 | error | roomForm использует учитываемые имена полей и существующие контролы |
B6 | error | panel.fields хоста не использует ключ движка t |
B7 | error | чат-команды корректны и уникальны |
B8 | error | коды системных сообщений не заходят в диапазоны движка |
B9 | error | пользовательские голосования не переиспользуют зарезервированные имена и префикс @ |
B10 | error | у каждой играющей команды есть респауны, и их хватает на maxPlayers |
C1 | error | ClientPlugin экспортирует parts, bakers, styles и все три хука |
C2 | error | каждый класс из gameSets есть в entitiesOnCanvas и экспортирован в parts |
C3 | error | у каждого ключа снапшота и setId карты есть запись gameSets |
C4 | error | componentDependencies называют только существующие сервисы |
C5 | error | клиентская панель сопоставляет ключ t полю type: 'time' |
C6 | warn | колонки статистики сверх раскладки движка стилизованы плагином |
C7 | error | keySetList: действия наблюдателя, коды клавиш движка, паритет с playerKeys |
C8 | error | имена bakedAssets есть в ClientPlugin.bakers |
C9 | error | у каждого кода системного сообщения есть клиентский текст |
C10 | error | authSchema: fieldsId, нет поля ника, поле model, встроенные options, разрешимые валидаторы |
C11 | error | gameConfig.statMode и modules.stat.params.mode согласованы |
D1 | error | id блоков снапшота уникальны |
D2 | error | класс hot только на indexed8/indexedNoNull8; list16/indexed32 — class: 'event' |
D3 | error | interp только на f32-полях hot-блоков; role: 'state' — u8 сразу после головы строки |
E1 | error | каждый объявленный звук поставляется парой webm + mp3 |
E2 | error | картинки карт существуют в dist/img/ |
E3 | warn | реестр звуков не пуст |
E4 | error | многоуровневые (2.5D) карты структурно корректны |
E5 | error | твёрдые тайлы названы слоем отрисовки |
E6 | warn | блок пространственного звука корректен |
E7 | error | поле game карты — простой объект |
Версия и идентичность
- [ ] ⚙
B2engineApiвстречается в трёх местах (манифест,HostPlugin,ClientPlugin), и во всех трёх импортируетсяENGINE_API_VERSION, а не зашито число. - [ ] ⚙
A6manifest.idравенidи хост-, и клиентского плагина. - [ ]
manifest.idтакже равен id, под которым игра зарегистрирована на платформе (ручная проверка, см. submitting.md). Несовпадение заставляет платформу пропустить игру — в лобби она просто не появляется. - [ ] Игра, которую платформа не может прочитать (нет манифеста, несовпадение id), пропускается, а не сообщается пользователю. Если игры нет в лобби, сначала запустите
check:contractи проверьтеdist/manifest.json. Возраст больше не причина: игра, собранная под более старый движок, отдаётся как есть, а игра, чейrequiresназывает неизвестную capability, показывается в лобби как недоступная с указанием причины.
Host-плагин
- [ ] ⚙
B1chatCommandsприсутствует и является массивом — движок итерирует его без защиты. Для «нет команд» используйте[]. - [ ] ⚙
B1createModulesприсутствует, и в его результате есть ключscripted. - [ ] ⚙
B1buildClientGameConfigприсутствует — он вызывается безусловно. - [ ] ⚙
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'. - [ ] ⚙
D3role: '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. - [ ] ⚙
C6CSS движка раскладывает пять колонок статистики (#stat …:nth-child(1)…(5)) и классы строк.line1–.line3. Шестая колонка допустима, но своей ширины не получит, пока вы не повторите раскладку вClientPlugin.styles—C6предупреждает о каждой объявленной колонке, которую ваши стили не затрагивают. Он ищет объявление ширины (width/min-width/flex/…) на селекторе#stat, называющем ячейку, поэтому правило, задающее только цвет, не считается раскладкой колонки. - [ ] ⚙
C7keySetList[0]— набор наблюдателя, в нём обязаны бытьnextPlayerиprevPlayer;keySetList[1]— набор игрока. - [ ] ⚙
C7Коды клавиш67(чат),77(голосование),9(статистика),27(escape),13(enter) принадлежат движку. - [ ] ⚙
C7keySetList(код → имя) и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— функции, не сериализуются и выполняются на хосте. - [ ] ⚙
C10validatorпараметра называет функцию, которую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. - [ ] ⚙
B5regExp, который не компилируется, не ограничивает ничего: движок отбрасывает проверку с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— движок его перезаписывает. - [ ] ⚙
B10respawns[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. - [ ] ⚙
A4emptyOutDir: false,assetsInlineLimit: 0,preserveEntrySignatures: 'strict',inlineDynamicImports: true— все обязательны. БезpreserveEntrySignaturesdefault-экспорт плагина выкидывается tree-shaking-ом. - [ ] ⚙
A4Не используйтеbuild.libVite — он всегда инлайнит ассеты. - [ ] ⚙
A1, A4pixi.jsвнешний и является peer-зависимостью. Его бандлинг создаёт второй экземпляр PixiJS со своим реестром расширений; объекты, перешедшие между экземплярами, ломаются в рантайме. - [ ] ⚙
A3Пути входов — ровноindexиindex: dev-режим их зашивает. - [ ]
core/pkg-web/должен существовать для dev-режима (npm run devимпортирует.wasmоттуда). - [ ] Плагин должен быть собран хотя бы раз (
npm run core:build) до первогоnpm run dev. - [ ] ⚙
A1files: ["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-прогон и браузерная половина) · Следующая: Подача игры →