@hezzlgames/sdk 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,415 @@
1
+ # Hezzl Games SDK
2
+
3
+ Библиотека, через которую игра разговаривает с игровым центром. Один
4
+ файл без зависимостей, ES5, около 20 КБ. Распространяется пакетом
5
+ `@hezzlgames/sdk`; исходник живёт в `play.hezzl.com/sdk`.
6
+
7
+ Полные требования к играм — `docs/GAME-REQUIREMENTS.md`. Здесь только
8
+ то, как ими пользоваться; ссылки на разделы ведут туда.
9
+
10
+ hezzl-sdk.js библиотека — кладётся в папку игры
11
+ index.mjs, .cjs та же библиотека для import / require
12
+ index.d.ts типы
13
+ bin/hezzl-sdk.mjs команды: стенд, проверка манифеста, копирование
14
+ demo/game.html пример игры на ней, с hezzl.json и economy.json
15
+ demo/centre.html стенд: поддельный центр для проверки
16
+
17
+ ## Установка
18
+
19
+ ```bash
20
+ npm i @hezzlgames/sdk
21
+ ```
22
+
23
+ Три способа подключить — один и тот же файл:
24
+
25
+ ```html
26
+ <!-- без сборки: файл лежит в папке игры -->
27
+ <script src="hezzl-sdk.js"></script>
28
+ ```
29
+
30
+ ```bash
31
+ npx hezzl-sdk copy . # положить hezzl-sdk.js в папку игры
32
+ ```
33
+
34
+ ```js
35
+ // со сборкой: Vite, Webpack, esbuild
36
+ import { Hezzl } from '@hezzlgames/sdk';
37
+ ```
38
+
39
+ Второй и третий способ — тот же `hezzl-sdk.js`: пакет только доставляет
40
+ его, а игра по-прежнему обязана работать по прямой ссылке и без сети
41
+ (раздел 10). **С нашего домена и с CDN его не подключают.** Обновление
42
+ SDK приезжает вместе с новой сборкой игры: подняли версию пакета —
43
+ пересобрали — сдали.
44
+
45
+ **Версии.** Версия пакета (`Hezzl.version`, semver) растёт на каждый
46
+ выпуск и уходит в `ready` полем `sdkVersion` — по нему видно, какие
47
+ игры собраны на чём. Номер протокола (`Hezzl.sdk`, сейчас 1) — другое
48
+ число: он меняется только когда меняется смысл существующего поля (3.8).
49
+
50
+ ### Команды
51
+
52
+ ```bash
53
+ npx hezzl-sdk stand [port] # поддельный центр на localhost, по умолчанию 8791
54
+ npx hezzl-sdk check [dir] # проверить hezzl.json и economy.json
55
+ npx hezzl-sdk copy [dir] # положить hezzl-sdk.js в папку игры
56
+ ```
57
+
58
+ `check` — то, что мы смотрим первым на приёмке (41.1, 41.2): реестр
59
+ режимов, имена умений, обязательные поля, лестница цен и норма
60
+ сундуков из 37.5. Гоняйте его перед сдачей; он же стоит в `prepack`
61
+ пакета.
62
+
63
+ ### `economy.json`
64
+
65
+ Для игры без сервера, которая тратит валюту центра (`currency: true`
66
+ в манифесте). Все цены на экране — отсюда и ниоткуда больше (37.5):
67
+
68
+ ```json
69
+ {
70
+ "boosters": { "hint": 10, "hammer": 40, "rocket": 40 },
71
+ "continue": [50, 100, 1500],
72
+ "levels": 60,
73
+ "chests": [
74
+ { "stars": 15, "boosters": [
75
+ { "key": "hammer", "count": 1, "chance": 0.5 },
76
+ { "key": "rocket", "count": 1, "chance": 0.5 }
77
+ ] }
78
+ ]
79
+ }
80
+ ```
81
+
82
+ | поле | правило |
83
+ |---|---|
84
+ | `boosters` | ключ — строчные латиница, цифры, подчёркивание; тот же ключ идёт в `item` просьбы `spend`. Цена — целое больше нуля |
85
+ | `continue` | лестница цен продолжения уровня, не убывает; последняя ступень — предел (37.5) |
86
+ | `chests` | сундуки на карте: за сколько звёзд и что может выпасть. Не больше двух бустеров, не больше одного каждого вида, `chance` — доля, которую видит человек (37.1) |
87
+ | `levels` | длина карты, для оценки нормы бесплатных бустеров; без поля — 60 |
88
+
89
+ ## Зачем он
90
+
91
+ Половину требований первой части он выполняет за вас:
92
+
93
+ * разбирает `lang`, `theme`, `accent`, `muted`, `haptics`, `ref` из адреса
94
+ и проверяет их формат;
95
+ * проверяет источник входящих сообщений — обе проверки из 3.2, включая
96
+ ту, про которую забывают и отключают защиту при прямом заходе;
97
+ * держит рукопожатие: узнаёт, есть ли центр рядом и что он умеет;
98
+ * складывает команды, пришедшие раньше готовности, и не теряет их;
99
+ * держит хранилище: через центр, а без центра — своё, с префиксом;
100
+ * соблюдает частоту сообщений и пропускает финальный счёт мимо неё;
101
+ * ведёт разговор о валюте центра: баланс, товары, покупка, списание,
102
+ кошелёк — с типизированными отказами и без своих окон;
103
+ * собирает `window.HezzlGame` с правильными именами методов.
104
+
105
+ **Файл кладётся в папку игры и подключается оттуда.** С нашего домена
106
+ его не подключают: игра обязана работать по прямой ссылке и без сети,
107
+ а обращения на сторонние домены запрещены (раздел 10). Обновление SDK
108
+ приезжает вместе с новой сборкой игры.
109
+
110
+ ## Как подключить
111
+
112
+ ```html
113
+ <script src="hezzl-sdk.js"></script>
114
+ <script src="game.js"></script>
115
+ ```
116
+
117
+ ## Минимальная игра
118
+
119
+ ```js
120
+ Hezzl.init({
121
+ id: 'my-game', // ярлык из каталога, как в hezzl.json
122
+ version: '1.0.0',
123
+ modes: ['quick'], // ключи из реестра 20.1
124
+ orientation: 'any',
125
+ on: {
126
+ mute: function (muted) { sound.enabled = !muted; },
127
+ pause: function () { engine.pause(); },
128
+ resume: function () { engine.resume(); }
129
+ }
130
+ });
131
+
132
+ Hezzl.ready(); // когда игра действительно принимает ввод
133
+ ```
134
+
135
+ **`supports` выводится из обработчиков**, объявлять его отдельно не надо.
136
+ Объявить умение и не реализовать его по построению невозможно — а это
137
+ самое частое расхождение между манифестом и поведением.
138
+
139
+ ### `ready` — обязателен, и это не формальность
140
+
141
+ Зовётся, когда игра принимает ввод, а не когда загрузился скрипт: по нему
142
+ центр отпускает заставку.
143
+
144
+ **Подключив библиотеку, вы делаете `ready` обязательным на практике.**
145
+ SDK объявляет игру на `window`, центр видит объявление и начинает ждать
146
+ её слова. Игра без `ready` держит заставку **20 секунд**, а потом человек
147
+ получает экран «Игра долго не отвечает». Без библиотеки та же игра просто
148
+ открылась бы — центр снял бы заставку по загрузке кадра.
149
+
150
+ Раньше `ready` не позвать было незаметно. Теперь — заметно, и заметно
151
+ человеку, а не вам.
152
+
153
+ Заставку `ready` при этом **не сокращает**: у неё своя минимальная
154
+ длительность и своя сцена, а `ready` только отпускает её. Позвать его
155
+ рано, «чтобы быстрее», смысла не имеет — раньше минимума заставка
156
+ не уйдёт. Позвать поздно — человек лишнее время смотрит на лоадер.
157
+
158
+ Правильный момент — первый кадр, на котором касание уже что-то делает.
159
+
160
+ ## Настройки при запуске
161
+
162
+ ```js
163
+ Hezzl.settings // { lang, theme, accent, muted, haptics, ref }
164
+ ```
165
+
166
+ Пустая строка означает «решай сама»: человек оставил настройку
167
+ на «Системе». Порядок разрешения — адрес, своя сохранённая, браузер:
168
+
169
+ ```js
170
+ var lang = Hezzl.resolve('lang', saved.lang, function () {
171
+ return (navigator.language || 'ru').slice(0, 2) === 'en' ? 'en' : 'ru';
172
+ });
173
+ ```
174
+
175
+ Настройка из адреса не запоминается как выбор человека (раздел 2).
176
+
177
+ ## Хранилище
178
+
179
+ ```js
180
+ Hezzl.storage.get('progress').then(function (data) { ... });
181
+ Hezzl.storage.set('progress', data, { sync: true, schema: 3 });
182
+ Hezzl.storage.remove('draft');
183
+ Hezzl.storage.keys();
184
+ ```
185
+
186
+ Префикс не пишется — его ставит SDK. Вызовы до рукопожатия копятся
187
+ и разрешаются, как только известен режим: ждать `ready`, чтобы прочитать
188
+ прогресс, не нужно.
189
+
190
+ **`sync`** — уносить ли на сервер, когда человек войдёт. Прогресс
191
+ и состояние обучения — да. Громкость, скин, уровень качества — нет.
192
+
193
+ **Отказ типизирован**, ветвиться надо по `reason`, а не по тексту:
194
+
195
+ ```js
196
+ Hezzl.storage.set('progress', data, { sync: true }).catch(function (e) {
197
+ if (e.reason === 'quota') dropCaches(); // освободить необязательное
198
+ else if (e.reason === 'unavailable') sayOnce('Прогресс не сохранится');
199
+ });
200
+ ```
201
+
202
+ Значения: `unavailable`, `quota`, `invalid`, `unknown` (раздел 7.6).
203
+
204
+ ## События
205
+
206
+ ```js
207
+ Hezzl.gameStart({ mode: 'levels', level: 3 });
208
+ Hezzl.score(1200, { best: 4000 });
209
+ Hezzl.milestone('combo', 5); // what — из реестра 3.4
210
+ Hezzl.levelComplete({ level: 3, stars: 2 });
211
+ Hezzl.levelFailed({ level: 3 });
212
+ Hezzl.gameOver({ score: 1200, best: 4000 });
213
+ ```
214
+
215
+ `score` можно звать на каждое очко: SDK сам придержит лишнее и отправит
216
+ последнее значение хвостом. Перед `gameOver` финальный счёт уходит
217
+ всегда, мимо ограничения, — иначе центр покажет предпоследний результат.
218
+
219
+ ## Просьбы к центру
220
+
221
+ ```js
222
+ Hezzl.share({ text: 'Собрал 2048 за 214 ходов' });
223
+ Hezzl.support({ message: '...', state: { level: 12 } });
224
+ Hezzl.exit();
225
+ Hezzl.signin();
226
+
227
+ Hezzl.auth().then(function (a) { ... }); // a.signedIn, a.code
228
+ Hezzl.profile(['name', 'avatar']).then(function (a) { ... }); // a.profile
229
+ ```
230
+
231
+ **Показывать эти кнопки можно только после рукопожатия и только те,
232
+ что центр умеет:**
233
+
234
+ ```js
235
+ if (Hezzl.can('share')) showShareButton();
236
+ ```
237
+
238
+ **`signin()` ничего не возвращает** — это просьба, а не вопрос. Центр
239
+ поднимает свой экран входа поверх игры; игра под ним продолжает жить,
240
+ и человек может закрыть его крестиком и играть дальше. Ответ придёт
241
+ отдельно и не сразу: `signedin` — когда человек вошёл, `signincancelled` —
242
+ когда закрыл экран, так и не войдя. Ждать в цепочке нельзя: разговор
243
+ занимает минуту, а бывает, что не заканчивается вовсе.
244
+
245
+ ```js
246
+ Hezzl.init({ on: {
247
+ signedin: function (id) { unlockTournament(id); },
248
+ signincancelled: function () { showLocalOnlyNotice(); }
249
+ } });
250
+ ```
251
+
252
+ Отказ приходит только тому, кто вход просил. Человек, открывший вход
253
+ своей кнопкой в шапке центра, игре ничего не обещал, и та об этом
254
+ не узнаёт.
255
+
256
+ **`auth().code` появится вместе с серверной частью.** Сейчас центр
257
+ отвечает только `signedIn`: кода обмена он не выдумывает, потому что
258
+ игра пошла бы менять его на сеанс и получила бы отказ. Ветвитесь
259
+ по `signedIn`, а `code` проверяйте на существование.
260
+
261
+ `Hezzl.mode()` возвращает `unknown`, `centre` или `standalone`. Пока
262
+ `unknown` — рукопожатие идёт, зависимые элементы не показываются.
263
+ При прямом заходе режим известен сразу.
264
+
265
+ ## Валюта центра
266
+
267
+ У центра одна твёрдая валюта на все игры. Человек получает её
268
+ за задания и покупает за деньги — в центре, не в игре. Игра её только
269
+ тратит: на товары из нашего каталога или на своё, по своей цене.
270
+ Полные правила — раздел 7.9 требований; здесь — как этим пользоваться.
271
+
272
+ ```js
273
+ if (Hezzl.can('balance')) showShop(); // у центра без валюты магазина нет
274
+
275
+ Hezzl.currency(); // { key, title, icon, amount } или null
276
+ Hezzl.balance().then(function (a) { hud(a.amount); }); // a.signedIn, a.amount
277
+ ```
278
+
279
+ `currency()` синхронный: значок и название приходят с рукопожатием,
280
+ сумма — с первым же ответом, где она есть. Рисовать счётчик можно
281
+ сразу после `ack`, не дожидаясь `balance()`.
282
+
283
+ ### Кейс 1 — товар из нашего каталога
284
+
285
+ Товар заведён у нас: цена в валюте центра, награда в ресурсах игры.
286
+ Игра не хранит ни того ни другого — берёт из `goods()`:
287
+
288
+ ```js
289
+ Hezzl.goods().then(function (a) { renderShop(a.goods); });
290
+ // a.goods: [{ id, title, picture, price, award }]
291
+
292
+ Hezzl.buy(good.id).then(function (done) {
293
+ give(done.award); // выдаём то, что ответил центр
294
+ hud(done.amount); // сколько осталось
295
+ }, function (e) {
296
+ if (e.reason === 'insufficient') say('Не хватает ' + e.need);
297
+ else if (e.reason !== 'cancelled') say('Не получилось');
298
+ });
299
+ ```
300
+
301
+ Между вызовом и ответом центр делает две вещи сам, и игре о них
302
+ знать не нужно: **спрашивает подтверждение** своим окном поверх
303
+ кадра и, если валюты не хватает, **показывает кошелёк** — где взять.
304
+ На это время игра получает `pause`, по закрытии — `resume`.
305
+ Обещание висит столько, сколько человек думает: срока у него нет,
306
+ пока центр держит разговор.
307
+
308
+ `Hezzl.buy(id, { wallet: false })` — не показывать кошелёк, а сразу
309
+ отказать `insufficient`. Нужно редко: когда игра сама хочет сказать
310
+ про нехватку и открыть кошелёк позже, `wallet()`.
311
+
312
+ ### Кейс 2 — товар свой, цена своя
313
+
314
+ Игра без сервера: бустеры, подсказки, продолжение партии. Цену
315
+ назначает игра, центр только списывает — с тем же подтверждением
316
+ и тем же кошельком при нехватке:
317
+
318
+ ```js
319
+ Hezzl.spend(10, { item: 'Подсказка' }).then(function (done) {
320
+ bag.hints++; // выдаём после ok, не по нажатию
321
+ hud(done.amount);
322
+ }, refuse);
323
+ ```
324
+
325
+ `item` — что куплено, до 80 знаков: его человек видит в окне
326
+ подтверждения, а мы — в отчёте.
327
+
328
+ ### Кошелёк и уведомления
329
+
330
+ ```js
331
+ Hezzl.wallet({ reason: 'Магазин' }).then(function (w) { hud(w.amount); }); // w.bought
332
+
333
+ Hezzl.init({ on: {
334
+ balance: function (amount) { hud(amount); }, // изменился — по любой причине
335
+ walletclosed: function (amount, d) { /* d.bought */ }
336
+ } });
337
+ ```
338
+
339
+ `balance` приходит без просьбы: награда за задание, покупка в кошельке,
340
+ покупка в другой вкладке. Опрашивать баланс по таймеру не нужно.
341
+
342
+ ### Отказы
343
+
344
+ Ветвиться по `reason`; текст — технический и не для показа:
345
+
346
+ | `reason` | что случилось | что делать |
347
+ |---|---|---|
348
+ | `insufficient` | не хватило, и кошелёк закрыли, не пополнив; `need` — сколько | сказать число, оставить кнопку |
349
+ | `cancelled` | человек нажал «отмена» в подтверждении | ничего: он сам передумал |
350
+ | `signin` | человека нет, а вход он закрыл | «войдите, чтобы покупать» |
351
+ | `notfound`, `disabled` | товара нет или он выключен у нас | убрать из витрины, обновить `goods()` |
352
+ | `unavailable` | центра нет или у него нет валюты | магазина не показывать вовсе |
353
+ | `invalid` | плохие доводы: не целое, не больше нуля | ошибка сборки |
354
+ | `unknown` | сервер не ответил или отказал | «попробуйте ещё раз», ничего не выдавать |
355
+
356
+ **Ничего не выдаётся до `ok`.** Списание подтверждает и проводит
357
+ центр; игра, выдавшая товар по нажатию, отдаёт его бесплатно
358
+ при любом отказе.
359
+
360
+ ## Отказы
361
+
362
+ ```js
363
+ Hezzl.error('asset_load', 'sprites.png не загрузился', false);
364
+ ```
365
+
366
+ Коды — из своего набора: `asset_load`, `storage_write`, `webgl_lost`,
367
+ `runtime` (раздел 38). `message` техническая, для нас, без личных данных.
368
+
369
+ ## Стенд
370
+
371
+ `demo/centre.html` — поддельный центр. Он говорит по тому же протоколу
372
+ и показывает каждое сообщение в обе стороны.
373
+
374
+ ```bash
375
+ npx hezzl-sdk stand
376
+ # затем открыть http://localhost:8791/demo/centre.html
377
+ # из репозитория: node tools/serve.mjs 8791 sdk
378
+ ```
379
+
380
+ Что стоит проверить на нём до сдачи:
381
+
382
+ | проверка | как | что должно быть |
383
+ |---|---|---|
384
+ | рукопожатие | открыть игру | `ready` от игры, `ack` в ответ |
385
+ | умения | посмотреть `ready` | `supports` совпадает с `hezzl.json` |
386
+ | настройки | выставить язык, тему, цвет, метку | применились при запуске |
387
+ | команды | нажать кнопки команд | игра отреагировала на каждую |
388
+ | частота | быстро набрать очки | `score` не чаще раза в секунду |
389
+ | финальный счёт | закончить партию | последний `score` совпал с показанным |
390
+ | хранилище | сыграть, перезапустить | прогресс на месте |
391
+ | отказ по квоте | галка «хранилище переполнено» | игра сказала, а не промолчала |
392
+ | **центр молчит** | снять галку «отвечать на `ready`» | режим `standalone`, кнопки центра скрыты |
393
+ | **`ready` не позван** | закомментировать `Hezzl.ready()` | заставка висит 20 с и сменяется отказом — так это увидит человек |
394
+ | прямой заход | открыть `demo/game.html` без стенда | игра работает, прогресс в своём хранилище |
395
+ | покупка | нажать цену товара | окно центра поверх игры, `pause`; после «списать» — награда и `resume` |
396
+ | нехватка | цена выше баланса | кошелёк с точной нехваткой; пополнить — покупка продолжится, закрыть — `insufficient` с числом |
397
+ | отмена | «отмена» в подтверждении | игра молчит, ничего не выдано |
398
+ | **без валюты** | снять галку «центр умеет валюту» | магазина и кнопок за валюту нет вовсе |
399
+ | сбой сервера | галка «сервер Balance отказывает» | `unknown`, баланс не тронут, товар не выдан |
400
+
401
+ Три строки, выделенные жирным, — самые полезные. Игра, которая ломается
402
+ без центра или молчит вместо `ready`, доезжает до приёмки чаще остальных,
403
+ а стоит проверки в одну минуту.
404
+
405
+ ## Чего SDK не делает
406
+
407
+ * не решает, какой язык показать: порядок ваш, `resolve` только
408
+ подсказывает;
409
+ * не рисует интерфейс — ни кнопок, ни окон; окно подтверждения
410
+ и кошелёк — тоже не его, а центра;
411
+ * не хранит цены и состав товаров: они у нас, приходят в `goods()`;
412
+ * не проверяет манифест: совпадение `hezzl.json` и `ready` смотрим
413
+ на приёмке;
414
+ * не пишет в консоль. Отладочный вывод включается `debug: true`
415
+ в `init` и в собранной игре остаться не должен (раздел 38).
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hezzl-sdk — команды пакета @hezzlgames/sdk.
4
+ *
5
+ * hezzl-sdk stand [port] поддельный игровой центр на localhost
6
+ * hezzl-sdk check [dir] проверить hezzl.json и economy.json игры
7
+ * hezzl-sdk copy [dir] положить hezzl-sdk.js в папку игры
8
+ *
9
+ * Без зависимостей: подрядчику достаточно node 18.
10
+ */
11
+ import { createServer } from 'node:http';
12
+ import { readFile, stat, copyFile } from 'node:fs/promises';
13
+ import { join, extname, normalize, dirname, resolve } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
17
+ const [, , command = 'help', arg] = process.argv;
18
+
19
+ const MODES = ['quick', 'levels', 'pvp_bot', 'arena', 'hotseat']; /* реестр 20.1 */
20
+ const COMMANDS = ['mute', 'haptics', 'pause', 'resume', 'restart',
21
+ 'setlang', 'settheme', 'setaccent']; /* таблица 3.1 */
22
+ const ORIENTATIONS = ['portrait', 'landscape', 'any'];
23
+
24
+ const TYPES = {
25
+ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
26
+ '.mjs': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8',
27
+ '.css': 'text/css; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml',
28
+ '.jpg': 'image/jpeg', '.webp': 'image/webp', '.mp3': 'audio/mpeg', '.woff2': 'font/woff2'
29
+ };
30
+
31
+ async function stand(port) {
32
+ port = Number(port) || 8791;
33
+ /* Корень — сам пакет: demo/centre.html подключает ../hezzl-sdk.js.
34
+ Своя игра открывается по адресу в поле стенда, например
35
+ http://localhost:5173/index.html — стенд её только показывает. */
36
+ const server = createServer(async (req, res) => {
37
+ const path = normalize(decodeURIComponent(req.url.split('?')[0]));
38
+ let file = join(PKG, path === '/' ? '/demo/centre.html' : path);
39
+ if (!file.startsWith(PKG)) { res.writeHead(403); res.end(); return; }
40
+ try {
41
+ if ((await stat(file)).isDirectory()) file = join(file, 'index.html');
42
+ const body = await readFile(file);
43
+ res.writeHead(200, { 'Content-Type': TYPES[extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store' });
44
+ res.end(body);
45
+ } catch { res.writeHead(404); res.end('нет такого файла'); }
46
+ });
47
+ server.listen(port, () => {
48
+ console.log(`стенд: http://localhost:${port}/demo/centre.html`);
49
+ console.log('в поле «адрес игры» — адрес вашей сборки; пример игры — game.html');
50
+ });
51
+ }
52
+
53
+ /* ── проверка манифеста и экономики ──────────────────────────────── */
54
+
55
+ function isInt(v) { return Number.isInteger(v) && v > 0; }
56
+
57
+ async function readJson(file) {
58
+ try { return JSON.parse(await readFile(file, 'utf8')); }
59
+ catch (e) { return e.code === 'ENOENT' ? undefined : { __broken: String(e.message) }; }
60
+ }
61
+
62
+ async function check(dir) {
63
+ dir = resolve(dir || '.');
64
+ const bad = [], warn = [];
65
+ const manifest = await readJson(join(dir, 'hezzl.json'));
66
+ if (manifest === undefined) bad.push('нет hezzl.json (41.1)');
67
+ else if (manifest.__broken) bad.push('hezzl.json не разбирается: ' + manifest.__broken);
68
+ else {
69
+ const m = manifest;
70
+ if (!/^[a-z0-9-]{2,40}$/.test(m.id || '')) bad.push('id: ярлык строчными латиницей и дефисом, как в каталоге');
71
+ if (!/^\d+\.\d+\.\d+/.test(m.version || '')) bad.push('version: семантическая версия');
72
+ if (m.sdk !== 1) bad.push('sdk: сегодня протокол 1');
73
+ if (!Array.isArray(m.modes) || !m.modes.length) bad.push('modes: непустой список из реестра 20.1');
74
+ else for (const x of m.modes) if (!MODES.includes(x)) bad.push(`modes: «${x}» нет в реестре 20.1 (${MODES.join(', ')})`);
75
+ if (!ORIENTATIONS.includes(m.orientation)) bad.push('orientation: portrait | landscape | any');
76
+ if (!Array.isArray(m.supports)) bad.push('supports: список умений (3.6)');
77
+ else for (const x of m.supports) {
78
+ if (!COMMANDS.includes(x)) bad.push(`supports: «${x}» — не команда игры; storage, auth, balance — умения центра (3.1)`);
79
+ }
80
+ if (typeof m.offline !== 'boolean') bad.push('offline: true | false (раздел 11)');
81
+ if (!Array.isArray(m.hosts)) bad.push('hosts: список доменов, пусто — норма (раздел 10)');
82
+ if (m.entry && !/\.html?$/.test(m.entry)) bad.push('entry: html-файл');
83
+ if (!m.title || typeof m.title.ru !== 'string') bad.push('title.ru обязателен (раздел 27)');
84
+ if (m.currency === undefined) warn.push('currency не указан: считаем, что бустеров и продолжений в игре нет (37.5)');
85
+ }
86
+
87
+ const economy = await readJson(join(dir, 'economy.json'));
88
+ const needsEconomy = manifest && manifest.currency === true && manifest.purchases !== true;
89
+ if (needsEconomy && economy === undefined) bad.push('currency: true у игры без сервера требует economy.json (37.5, 41.2)');
90
+ if (economy && economy.__broken) bad.push('economy.json не разбирается: ' + economy.__broken);
91
+ else if (economy) {
92
+ const e = economy;
93
+ if (!e.boosters || typeof e.boosters !== 'object') bad.push('economy.boosters: { ключ: цена }');
94
+ else for (const [key, price] of Object.entries(e.boosters)) {
95
+ if (!/^[a-z0-9_]{1,32}$/.test(key)) bad.push(`economy.boosters: ключ «${key}» — строчные латиница, цифры, подчёркивание; тот же ключ уходит в item просьбы spend`);
96
+ if (!isInt(price)) bad.push(`economy.boosters.${key}: цена — целое больше нуля`);
97
+ }
98
+ if (e.continue !== undefined) {
99
+ if (!Array.isArray(e.continue) || !e.continue.length || !e.continue.every(isInt)) bad.push('economy.continue: лестница цен, целые больше нуля, например [50, 100, 1500]');
100
+ else for (let i = 1; i < e.continue.length; i++) if (e.continue[i] < e.continue[i - 1]) bad.push('economy.continue: цена не убывает с каждым продолжением (37.5)');
101
+ }
102
+ if (e.chests !== undefined) {
103
+ if (!Array.isArray(e.chests)) bad.push('economy.chests: список сундуков');
104
+ else e.chests.forEach((c, i) => {
105
+ const at = `economy.chests[${i}]`;
106
+ if (!isInt(c.stars)) bad.push(`${at}.stars: за сколько звёзд (22.2)`);
107
+ const drops = Array.isArray(c.boosters) ? c.boosters : [];
108
+ let total = 0;
109
+ const seen = new Set();
110
+ for (const d of drops) {
111
+ if (!d || !e.boosters || !(d.key in e.boosters)) bad.push(`${at}: бустер «${d && d.key}» не описан в economy.boosters`);
112
+ if (!isInt(d.count) || d.count > 1) bad.push(`${at}: не больше одного бустера каждого вида (37.5)`);
113
+ if (typeof d.chance !== 'number' || d.chance <= 0 || d.chance > 1) bad.push(`${at}: chance — доля от 0 до 1, шансы показываются человеку (37.1)`);
114
+ if (seen.has(d.key)) bad.push(`${at}: «${d.key}» дважды`);
115
+ seen.add(d.key);
116
+ total += d.count || 0;
117
+ }
118
+ if (total > 2) bad.push(`${at}: не больше двух бустеров в сундуке (37.5)`);
119
+ });
120
+ if (e.chests.length && manifest && Array.isArray(manifest.modes) && manifest.modes.includes('levels')) {
121
+ const levels = isInt(e.levels) ? e.levels : 60;
122
+ const stars = e.chests.reduce((n, c) => Math.max(n, c.stars || 0), 0);
123
+ const chestsOnMap = stars ? Math.floor(levels * 3 / stars) : 0;
124
+ const perChest = e.chests[0].boosters ? e.chests[0].boosters.reduce((n, d) => n + (d.count || 0), 0) : 0;
125
+ const free = chestsOnMap * perChest;
126
+ if (free > levels / 4) warn.push(`бесплатных бустеров до ${free} на ${levels} уровней — чаще одного на четыре уровня, это раздача (37.5)`);
127
+ }
128
+ }
129
+ }
130
+
131
+ for (const w of warn) console.log(' ~ ' + w);
132
+ for (const b of bad) console.log(' ✗ ' + b);
133
+ if (bad.length) { console.log(`\n${bad.length} ошибок в ${dir}`); process.exit(1); }
134
+ console.log(`✓ ${dir}: hezzl.json${economy ? ' и economy.json' : ''} в порядке`);
135
+ }
136
+
137
+ async function copy(dir) {
138
+ const to = join(resolve(dir || '.'), 'hezzl-sdk.js');
139
+ await copyFile(join(PKG, 'hezzl-sdk.js'), to);
140
+ const { version } = JSON.parse(await readFile(join(PKG, 'package.json'), 'utf8'));
141
+ console.log(`hezzl-sdk.js ${version} → ${to}`);
142
+ console.log('подключите до кода игры: <script src="hezzl-sdk.js"></script>');
143
+ }
144
+
145
+ const commands = { stand, check, copy };
146
+ if (!commands[command]) {
147
+ console.log(`hezzl-sdk <команда>\n\n stand [port] поддельный игровой центр, по умолчанию 8791\n check [dir] проверить hezzl.json и economy.json\n copy [dir] положить hezzl-sdk.js в папку игры`);
148
+ process.exit(command === 'help' ? 0 : 1);
149
+ }
150
+ commands[command](arg).catch((e) => { console.error(e.message); process.exit(1); });