Skip to main content

vimp_engine_core/client/
game.rs

1//! Граница клиентского generic-каркаса ↔ игровой предикт (Этап 4b плана
2//! распила, PLAN.md §3.6/§4b). `ClientState<G>` (этот модуль) владеет сетевым
3//! буфером (`Interpolator`), очередью событийных кадров и hot-буфером
4//! рендер-тика; конкретная игра (`TanksClient` и т.п.) реализует
5//! `GameClientDef` — client-side prediction своего актора, визуальный
6//! спавн эффектов, панель. Форма трейта валидирована фикстурой
7//! (`tests` в этом модуле, `TestClient`) до миграции танков — см.
8//! PLAN_4_details.md.
9//!
10//! Камера и predicted-хвост hot-буфера разделены: движок пишет камеру как
11//! `[f32; 2]` (общий для любой игры смысл — «куда смотреть»), а форму
12//! хвоста (набор полей актора) собирает игра — движок дописывает его в
13//! hot-буфер как непрозрачный `Vec<f32>`, не зная раскладки (см.
14//! `RenderOverlay`).
15//!
16//! Тем же хвостом игра перекрывает и ЧУЖИЕ строки кадра
17//! (`GameClientDef::render_rows`): предсказанные телом игры сущности —
18//! динамика карты, контактирующие чужие акторы — дописываются записями
19//! после predicted-хвоста, а разбор hot-буфера кладёт запись в
20//! `game[key][id]`, поэтому последняя запись перекрывает интерполированную.
21
22use serde_json::{Map, Value, json};
23
24use super::divergence::{DivergenceTracker, Observation, Source};
25use super::interpolator::{FrameData, InterpolatedGame, Interpolator};
26use super::unpack::{self, DecodedSnapshot, UnpackError};
27use crate::config::{BlockKind, EngineClientConfig, FieldValue, PLAYER_STATE_LEN, SnapshotConfig};
28
29/// Predicted-запись рендер-тика: камера (общий смысл) + непрозрачный
30/// хвост hot-буфера (раскладку полей актора знает только игра).
31pub struct RenderOverlay {
32    pub camera: [f32; 2],
33    pub tail: Vec<f32>,
34}
35
36/// Строка рендер-тика, которой игра перекрывает интерполированную:
37/// `key_id` — числовой id ключа снапшот-реестра, `id` — id строки
38/// (`gameId` для `Indexed8`, индекс для `IndexedNoNull8`), `fields` — поля
39/// по схеме этого ключа. Ширину движок приводит к схеме сам: лишние поля
40/// отбрасываются, недостающие дописываются нулями, иначе одна кривая
41/// запись сдвинула бы разбор всего хвоста.
42pub struct PredictedRow {
43    pub key_id: u8,
44    pub id: u32,
45    pub fields: Vec<f32>,
46}
47
48/// Игровая половина клиентского ядра — зеркало `crate::sim::GameSim<G>` на
49/// клиентской стороне. `ClientState<G>` (ниже) зовёт эти методы в фиксированных
50/// точках рендер-тика; `Self` хранит всё нужное для предикта (модели, оружие,
51/// историю ввода) само — `ClientState<G>` игровых конфигов не хранит.
52pub trait GameClientDef: Sized {
53    type Config: serde::de::DeserializeOwned;
54
55    fn new(cfg: &Self::Config, engine_cfg: &EngineClientConfig) -> Self;
56
57    /// Reconciliation по player-блоку кадра (авторитетное состояние своего
58    /// актора). Раскладка `[f32; PLAYER_STATE_LEN]` — движковая (общая с
59    /// серверным `GameSim::prediction_state`), поэтому не параметризована
60    /// трейтом отдельно.
61    fn on_server_state(
62        &mut self,
63        state: [f32; PLAYER_STATE_LEN],
64        centering: bool,
65        server_time: f64,
66        offset: f64,
67        local_now: f64,
68    );
69
70    /// Шаг предикта до текущего рендер-времени.
71    fn update(&mut self, local_now: f64);
72
73    /// Авторитетный кадр ПЕРЕД replay предикта: игра снимает с него
74    /// состояние тел, которые ведёт сама (динамика карты, чужие акторы
75    /// в контакте). Дефолт пустой — игре, предсказывающей только своего
76    /// актора, кадр в этой точке не нужен.
77    fn begin_reconcile(&mut self, _snapshot: &DecodedSnapshot) {}
78
79    /// Парный хук ПОСЛЕ `on_server_state`: replay уже переиграл и тела
80    /// игры, так что расхождение старого предсказания с новым считается
81    /// здесь. Дефолт пустой.
82    fn finish_reconcile(&mut self) {}
83
84    /// Строки рендер-тика, которыми игра перекрывает интерполированные
85    /// (см. `PredictedRow`). Дефолт пустой — предсказанных чужих тел нет.
86    fn render_rows(&self) -> Vec<PredictedRow> {
87        Vec::new()
88    }
89
90    /// Отслеживание своего актора в пересечённом кадре (дискретные поля,
91    /// freeze при уничтожении, reset по forceReset камеры). `my_game_id` —
92    /// текущий id своего актора (из последнего player-блока).
93    fn track_frame(&mut self, my_game_id: Option<u32>, frame: &FrameData);
94
95    /// Фильтр собственных событий в JSON-форме кадра (подавление дублей
96    /// локально предсказанных эффектов).
97    fn filter_frame_game(&mut self, game: &mut Map<String, Value>, my_game_id: Option<u32>, local_now: f64);
98
99    /// Обновление игрового мира для дальнейшего локального спавна эффектов
100    /// (raycast и т.п.) — по каждому пересечённому кадру.
101    fn update_world(&mut self, snapshot: &DecodedSnapshot);
102
103    /// То же — по интерполированному состоянию (между кадрами).
104    fn update_world_interpolated(&mut self, game: &InterpolatedGame);
105
106    /// Полная predicted-запись рендер-тика; `None` — предикт не готов (нет
107    /// своего актора/модели/меты), тогда камера рендер-тика берётся из
108    /// интерполяции, а флаг HOT_HAS_PREDICTED не выставляется.
109    fn render_overlay(&self, my_game_id: Option<u32>) -> Option<RenderOverlay>;
110
111    /// Предсказанное состояние своего актора в раскладке player-блока —
112    /// уровень 1 детектора рассинхрона (`client::divergence`). Движок
113    /// снимает его непосредственно перед `on_server_state`, то есть до
114    /// затирания предикта авторитетным состоянием. Дефолт `None` — тогда
115    /// движок сравнивает камеру predicted-оверлея с x/y кадра (уровень 0),
116    /// и от плагина не требуется ничего.
117    fn predicted_state(&self) -> Option<[f32; PLAYER_STATE_LEN]> {
118        None
119    }
120
121    /// Окно локального времени истории ввода, переигранное последним
122    /// реконсилем: (начало, конец, число вводов). Реконсиляция идёт по
123    /// времени, а не по `seq`, поэтому именно окно локализует расхождение
124    /// в формуле движения. Дефолт `None`.
125    fn replayed_inputs(&self) -> Option<(f64, f64, usize)> {
126        None
127    }
128
129    fn apply_input(&mut self, action: &str, key_name: &str, local_now: f64);
130
131    /// Ввод указателем: мировая точка (движок уже пересчитал экранные
132    /// координаты через камеру) и биты состояния указателя — бит 0
133    /// «прижат», бит 1 «двойной тап». Дефолт пустой, как у `GameSim`.
134    fn apply_aim(&mut self, _x: f32, _y: f32, _flags: u32, _local_now: f64) {}
135    fn set_model(&mut self, model_name: &str);
136    fn set_active(&mut self, active: bool);
137    fn set_map(&mut self, map_json: &str) -> Result<(), String>;
138    fn sync_panel(&mut self, items: &[String]);
139    fn reset(&mut self);
140
141    /// Циклический выбор активного предмета/режима актора (нейтральный
142    /// аналог смены оружия — конкретный смысл определяет игра).
143    fn cycle_item(&mut self, back: bool);
144
145    /// Локальное визуальное действие актора (выстрел и т.п.; гейты внутри —
146    /// предикт активен, свой актор жив). JSON спавна либо `None`.
147    fn try_action(&mut self, my_game_id: Option<u32>, local_now: f64) -> Option<String>;
148
149    /// Игровые опкоды `dispatch` клиентского ядра — зеркало
150    /// `GameSim::dispatch_op`, с тем же дефолтом «не обработано».
151    fn dispatch_op(&mut self, _op: &str, _payload: &[u8]) -> Option<Vec<u8>> {
152        None
153    }
154
155    /// Зеркало `GameSim::dispatch_ops`.
156    fn dispatch_ops(&self) -> &'static [&'static str] {
157        &[]
158    }
159}
160
161// приводит любое поле строки к f32 для плоского hot-буфера
162fn field_as_f32(value: FieldValue) -> f32 {
163    match value {
164        FieldValue::F32(v) => v,
165        FieldValue::U8(v) => v as f32,
166        FieldValue::U16(v) => v as f32,
167        FieldValue::U32(v) => v as f32,
168    }
169}
170
171// блоки интерполированного кадра нужной формы (kind), с id ключа реестра
172// snapshot.keys (записывается в hot-буфер как keyId).
173fn blocks_of_kind<'a>(
174    snapshot_cfg: &'a SnapshotConfig,
175    game: &'a InterpolatedGame,
176    kind: BlockKind,
177) -> impl Iterator<Item = (u8, &'a Vec<super::interpolator::InterpolatedRow>)> {
178    game.blocks.iter().filter_map(move |(key, rows)| {
179        let schema = snapshot_cfg.keys.get(key)?;
180
181        (schema.kind == kind).then_some((schema.id, rows))
182    })
183}
184
185/// Generic оркестрация клиентского ядра поверх игровой `G: GameClientDef`:
186/// сетевой буфер (interpolator), очередь событийных кадров, hot-буфер
187/// рендер-тика. Байтовая раскладка hot-буфера (флаги/камера/N танков×поля/M
188/// динамики×поля/predicted-хвост) не меняется — см. `write_hot`.
189pub struct ClientState<G: GameClientDef> {
190    cfg: EngineClientConfig,
191    interpolator: Interpolator,
192    game: G,
193
194    // id своего актора из последнего player-блока
195    my_game_id: Option<u32>,
196
197    // очередь событийных кадров на take_frames (в форме applyShot)
198    frames_out: Vec<Value>,
199
200    // переиспользуемый плоский буфер рендер-тика
201    hot: Vec<f32>,
202
203    // детектор рассинхрона предикта; None в боевом конфиге — путь кадра
204    // остаётся ровно таким же, как до этапа 5
205    divergence: Option<DivergenceTracker>,
206
207    // обратный индекс «id ключа → ширина строки»: id однобайтовый, поэтому
208    // массив дешевле карты и без хеширования. Нужен write_hot, чтобы не
209    // искать схему перебором на каждую предсказанную строку каждого кадра
210    row_widths: Box<[Option<u16>; 256]>,
211}
212
213impl<G: GameClientDef> ClientState<G> {
214    pub fn new(cfg: EngineClientConfig, game_cfg: &G::Config) -> Self {
215        let interpolator = Interpolator::new(&cfg.interpolation, cfg.snapshot.clone());
216        let game = G::new(game_cfg, &cfg);
217        let divergence = cfg.divergence.clone().map(DivergenceTracker::new);
218
219        let mut row_widths = Box::new([None; 256]);
220
221        for schema in cfg.snapshot.keys.values() {
222            row_widths[schema.id as usize] = Some(schema.fields.len() as u16);
223        }
224
225        Self {
226            cfg,
227            interpolator,
228            game,
229            my_game_id: None,
230            frames_out: Vec::new(),
231            hot: Vec::new(),
232            divergence,
233            row_widths,
234        }
235    }
236
237    /// Бинарный кадр из транспорта: распаковка, вставка в буфер по seq,
238    /// reconciliation предикта по player-блоку. false — кадр отброшен
239    /// (чужой порт, версия или повреждённые данные).
240    pub fn push_frame(&mut self, data: &[u8], local_now: f64) -> bool {
241        let frame = match unpack::unpack_frame(data, &self.cfg.snapshot) {
242            Ok(frame) => frame,
243            Err(UnpackError::WrongVersion | UnpackError::Truncated) => return false,
244        };
245
246        if frame.port != self.cfg.snapshot.port {
247            return false;
248        }
249
250        // тела, которые игра ведёт сама, снимаются с кадра ДО replay —
251        // порядок тот же, что у своего актора: сначала авторитетное
252        // состояние, потом переигранная история ввода
253        if frame.player.is_some() {
254            self.game.begin_reconcile(&frame.snapshot);
255        }
256
257        self.interpolator.push(
258            FrameData {
259                snapshot: frame.snapshot,
260                camera: frame.camera,
261            },
262            frame.server_time,
263            local_now,
264            frame.seq,
265        );
266
267        if let Some(player) = frame.player {
268            self.my_game_id = Some(player.game_id as u32);
269
270            // после push оффсет всегда известен
271            let offset = self.interpolator.offset().unwrap_or(0.0);
272
273            self.observe_divergence(&player, frame.server_time, offset, local_now);
274
275            self.game.on_server_state(
276                player.state,
277                player.centering,
278                frame.server_time,
279                offset,
280                local_now,
281            );
282
283            // replay переиграл и тела игры — расхождение считается после него
284            self.game.finish_reconcile();
285        }
286
287        true
288    }
289
290    /// Игровая половина ядра: игровая обёртка ABI (`ClientCore` плагина)
291    /// достаёт через неё свои подсистемы — движок их формы не знает.
292    pub fn game(&self) -> &G {
293        &self.game
294    }
295
296    pub fn my_game_id(&self) -> Option<u32> {
297        self.my_game_id
298    }
299
300    pub fn offset(&self) -> Option<f64> {
301        self.interpolator.offset()
302    }
303
304    /// Рендер-тик: выдача пересечённых кадров (фильтр дублей → JSON-очередь),
305    /// интерполяция, шаг предикта, запись hot-буфера. Возвращает длину
306    /// hot-буфера в f32-элементах.
307    pub fn sample(&mut self, local_now: f64) -> usize {
308        let result = self.interpolator.sample(local_now);
309
310        // событийные кадры: свой актор → фильтр дублей → очередь → мир
311        for frame in result.frames {
312            self.game.track_frame(self.my_game_id, &frame);
313
314            let mut game = unpack::snapshot_to_json(&frame.snapshot);
315
316            self.game
317                .filter_frame_game(&mut game, self.my_game_id, local_now);
318
319            self.frames_out.push(json!({
320                "game": game,
321                "camera": unpack::camera_to_json(frame.camera.as_ref()),
322            }));
323
324            self.game.update_world(&frame.snapshot);
325        }
326
327        if let Some(game) = &result.game {
328            self.game.update_world_interpolated(game);
329        }
330
331        self.game.update(local_now);
332
333        let overlay = self.game.render_overlay(self.my_game_id);
334        let rows = self.game.render_rows();
335
336        self.write_hot(result.game.as_ref(), result.camera, overlay.as_ref(), &rows);
337        self.hot.len()
338    }
339
340    pub fn hot(&self) -> &[f32] {
341        &self.hot
342    }
343
344    /// Событийные кадры JSON-строкой [{game, camera}, ...]; очередь очищается.
345    pub fn take_frames(&mut self) -> String {
346        let frames = std::mem::take(&mut self.frames_out);
347
348        serde_json::to_string(&frames).unwrap_or_else(|_| "[]".to_string())
349    }
350
351    pub fn apply_input(&mut self, action: &str, key_name: &str, local_now: f64) {
352        self.game.apply_input(action, key_name, local_now);
353    }
354
355    pub fn apply_aim(&mut self, x: f32, y: f32, flags: u32, local_now: f64) {
356        self.game.apply_aim(x, y, flags, local_now);
357    }
358
359    pub fn try_action(&mut self, local_now: f64) -> Option<String> {
360        self.game.try_action(self.my_game_id, local_now)
361    }
362
363    pub fn cycle_item(&mut self, back: bool) {
364        self.game.cycle_item(back);
365    }
366
367    pub fn set_model(&mut self, model_name: &str) {
368        self.game.set_model(model_name);
369    }
370
371    /// Смена режима игрок/спектатор (KEYSET_DATA).
372    pub fn set_active(&mut self, active: bool) {
373        self.game.set_active(active);
374    }
375
376    /// Данные карты (MAP_DATA): мир raycast + сброс буфера и предикта.
377    pub fn set_map(&mut self, map_json: &str) -> Result<(), String> {
378        self.interpolator.reset();
379        self.frames_out.clear();
380        self.game.set_map(map_json)
381    }
382
383    pub fn sync_panel(&mut self, panel_json: &str) {
384        let Ok(Value::Array(items)) = serde_json::from_str(panel_json) else {
385            return;
386        };
387
388        let items: Vec<String> = items
389            .iter()
390            .map(|item| match item {
391                Value::String(s) => s.clone(),
392                other => other.to_string(),
393            })
394            .collect();
395
396        self.game.sync_panel(&items);
397    }
398
399    /// Полный сброс (порт CLEAR).
400    pub fn reset(&mut self) {
401        self.interpolator.reset();
402        self.game.reset();
403        self.frames_out.clear();
404        // мира больше нет: своя идентичность восстановится из первого же
405        // player-блока, а у наблюдателя его нет — значит и предсказанной
406        // сущности быть не должно
407        self.my_game_id = None;
408    }
409
410    /// Ресинк часов после долгой паузы вкладки (visibilitychange → visible):
411    /// сетевая половина начинает с чистого листа, чтобы следующий кадр
412    /// пересеял оффсет точно, а не догонял EMA десятки кадров. Игровую
413    /// половину (предикт, своя идентичность) не трогаем — сущности на
414    /// полотне живы.
415    pub fn resync(&mut self) {
416        self.interpolator.reset();
417        self.frames_out.clear();
418    }
419
420    /// Зеркало серверного `EngineSim::debug_json` на клиенте: состояние
421    /// сетевого буфера (глубина, окно seq, оффсет, последний кадр), свой
422    /// gameId, размеры hot-буфера и очереди событийных кадров.
423    pub fn debug_json(&self) -> String {
424        json!({
425            "myGameId": self.my_game_id,
426            "offset": self.interpolator.offset(),
427            "interpolator": self.interpolator.debug_json(),
428            "hotLen": self.hot.len(),
429            "framesOut": self.frames_out.len(),
430        })
431        .to_string()
432    }
433
434    /// Самоописание клиентского ядра: версия формата, версия движкового
435    /// крейта, опкоды `dispatch` (движковые + объявленные игрой).
436    pub fn abi_describe(&self) -> String {
437        crate::abi::describe_json(crate::abi::ENGINE_CLIENT_OPS, self.game.dispatch_ops())
438    }
439
440    /// Опкод движка, иначе игровой опкод, иначе «не обработан» (пустой
441    /// вектор). Единая точка роста клиентского ABI: таблица экспортов
442    /// заморожена, символа в dist уже опубликованной игры не появится.
443    pub fn dispatch(&mut self, op: &str, payload: &[u8]) -> Vec<u8> {
444        let out = match op {
445            "debug.json" => Some(self.debug_json().into_bytes()),
446            _ => self.game.dispatch_op(op, payload),
447        };
448
449        crate::abi::dispatch_result(out)
450    }
451
452    /// Записи расхождения предикта с авторитетным состоянием (JSON,
453    /// см. `client::divergence`); очередь очищается. `"null"` — детектор
454    /// выключен (боевой конфиг без секции `divergence`).
455    pub fn take_divergence(&mut self) -> String {
456        match &mut self.divergence {
457            Some(tracker) => tracker.take_json(),
458            None => "null".to_string(),
459        }
460    }
461
462    // Снимок предикта ДО реконсиляции: после on_server_state сравнивать уже
463    // не с чем — состояние затёрто авторитетным.
464    fn observe_divergence(
465        &mut self,
466        player: &unpack::DecodedPlayer,
467        server_time: f64,
468        offset: f64,
469        local_now: f64,
470    ) {
471        let Some(tracker) = &mut self.divergence else {
472            return;
473        };
474
475        // уровень 1 (predicted_state игры) либо уровень 0 (камера оверлея)
476        let (source, predicted) = match self.game.predicted_state() {
477            Some(state) => (Source::State, state.to_vec()),
478            None => match self.game.render_overlay(self.my_game_id) {
479                Some(overlay) => (Source::Camera, overlay.camera.to_vec()),
480                None => return,
481            },
482        };
483
484        tracker.observe(Observation {
485            source,
486            predicted: &predicted,
487            authoritative: &player.state,
488            server_time,
489            local_now,
490            offset,
491            input_seq: player.input_seq,
492            replayed: self.game.replayed_inputs(),
493        });
494    }
495
496    /// Чистая распаковка кадра v3 в JSON-форму unpackFrame (тесты/харнесс).
497    pub fn decode_frame(&self, data: &[u8]) -> String {
498        match unpack::unpack_frame(data, &self.cfg.snapshot) {
499            Ok(frame) => unpack::frame_to_json(&frame).to_string(),
500            Err(_) => "null".to_string(),
501        }
502    }
503
504    // плоский Float32-буфер рендер-тика:
505    // [0] flags, [1..2] камера x/y, [3] N строк hot-блоков (Indexed8), N×(2+поля),
506    // [..] M строк динамики (IndexedNoNull8), M×(2+поля), затем — непрозрачный
507    // predicted-хвост игры (см. GameClientDef::render_overlay) и её
508    // предсказанные строки (GameClientDef::render_rows), перекрывающие
509    // интерполированные: разбор кладёт запись в game[key][id], последняя
510    // побеждает.
511    fn write_hot(
512        &mut self,
513        game: Option<&InterpolatedGame>,
514        camera: Option<[f32; 2]>,
515        overlay: Option<&RenderOverlay>,
516        rows: &[PredictedRow],
517    ) {
518        self.hot.clear();
519
520        let mut flags = 0u32;
521
522        if game.is_some() {
523            flags |= super::HOT_HAS_GAME;
524        }
525
526        if !self.frames_out.is_empty() {
527            flags |= super::HOT_HAS_FRAMES;
528        }
529
530        // флаг означает «за группами есть хвостовые записи»: predicted-хвост
531        // своего актора и/или строки тел, которые игра предсказывает сама
532        // (render_rows). Без строк в флаге JS-потребитель, гейтящий разбор
533        // по HOT_HAS_GAME | HOT_HAS_PREDICTED, молча выбросил бы их
534        if overlay.is_some() || !rows.is_empty() {
535            flags |= super::HOT_HAS_PREDICTED;
536        }
537
538        // камера: предсказанная позиция либо интерполированная
539        let camera = overlay.map(|o| o.camera).or(camera);
540
541        if camera.is_some() {
542            flags |= super::HOT_HAS_CAMERA;
543        }
544
545        self.hot.push(flags as f32);
546
547        let camera = camera.unwrap_or([0.0, 0.0]);
548
549        self.hot.push(camera[0]);
550        self.hot.push(camera[1]);
551
552        let empty = InterpolatedGame::default();
553        let game = game.unwrap_or(&empty);
554
555        // строки блоков — по форме (BlockKind), не по игровой сущности:
556        // Indexed8 пишется как «танк» (keyId, gameId, поля), IndexedNoNull8
557        // — как «динамика карты» (keyId, index, поля); порядок и число
558        // полей идут напрямую из schema.fields.
559        let tank_count: usize = blocks_of_kind(&self.cfg.snapshot, game, BlockKind::Indexed8)
560            .map(|(_, rows)| rows.len())
561            .sum();
562
563        self.hot.push(tank_count as f32);
564
565        for (key_id, rows) in blocks_of_kind(&self.cfg.snapshot, game, BlockKind::Indexed8) {
566            for row in rows {
567                self.hot.push(key_id as f32);
568                self.hot.push(row.id as f32);
569
570                for field in &row.fields {
571                    self.hot.push(field_as_f32(*field));
572                }
573            }
574        }
575
576        let dynamic_count: usize =
577            blocks_of_kind(&self.cfg.snapshot, game, BlockKind::IndexedNoNull8)
578                .map(|(_, rows)| rows.len())
579                .sum();
580
581        self.hot.push(dynamic_count as f32);
582
583        for (key_id, rows) in blocks_of_kind(&self.cfg.snapshot, game, BlockKind::IndexedNoNull8) {
584            for row in rows {
585                self.hot.push(key_id as f32);
586                self.hot.push(row.id as f32);
587
588                for field in &row.fields {
589                    self.hot.push(field_as_f32(*field));
590                }
591            }
592        }
593
594        if let Some(overlay) = overlay {
595            self.hot.extend_from_slice(&overlay.tail);
596        }
597
598        for row in rows {
599            // ширина записи диктуется схемой ключа: неизвестный id пропускаем,
600            // поля подрезаем/дополняем нулями — иначе одна кривая строка
601            // сдвинет разбор всех следующих
602            let Some(width) = self.row_widths[row.key_id as usize] else {
603                continue;
604            };
605
606            let width = width as usize;
607
608            self.hot.push(row.key_id as f32);
609            self.hot.push(row.id as f32);
610
611            for index in 0..width {
612                self.hot.push(row.fields.get(index).copied().unwrap_or(0.0));
613            }
614        }
615    }
616}
617
618/// Фикстурный второй клиент — валидатор формы `GameClientDef` (Этап 4b,
619/// PLAN_4_details.md): доказывает, что трейт не завязан по форме на танки,
620/// до миграции `games/tanks` на него (см. `TanksClient`). Состояние —
621/// тривиальная линейная интеграция позиции по vx/vy, без формул движения
622/// игры (мотор здесь не нужен — только форма трейта).
623#[cfg(test)]
624mod fixture {
625    use super::*;
626    use serde::Deserialize;
627
628    #[derive(Deserialize)]
629    pub struct TestConfig {}
630
631    pub struct TestClient {
632        x: f32,
633        y: f32,
634        vx: f32,
635        vy: f32,
636        active: bool,
637        alive: bool,
638        last_update: Option<f64>,
639        // уровень детектора рассинхрона, которым фикстура притворяется:
640        // set_model("predicted") → уровень 1 (predicted_state), иначе — 0
641        report_state: bool,
642        // set_model("rows") → фикстура ведёт чужое тело и отдаёт его строкой
643        predicted_rows: bool,
644        // порядок вызовов хуков реконсиляции относительно on_server_state
645        pub reconcile_log: Vec<&'static str>,
646    }
647
648    impl GameClientDef for TestClient {
649        type Config = TestConfig;
650
651        fn new(_cfg: &Self::Config, _engine_cfg: &EngineClientConfig) -> Self {
652            Self {
653                x: 0.0,
654                y: 0.0,
655                vx: 0.0,
656                vy: 0.0,
657                active: false,
658                alive: true,
659                last_update: None,
660                report_state: false,
661                predicted_rows: false,
662                reconcile_log: Vec::new(),
663            }
664        }
665
666        fn on_server_state(
667            &mut self,
668            state: [f32; PLAYER_STATE_LEN],
669            _centering: bool,
670            _server_time: f64,
671            _offset: f64,
672            _local_now: f64,
673        ) {
674            self.x = state[0];
675            self.y = state[1];
676            self.vx = state[3];
677            self.vy = state[4];
678            self.reconcile_log.push("state");
679        }
680
681        fn begin_reconcile(&mut self, _snapshot: &DecodedSnapshot) {
682            self.reconcile_log.push("begin");
683        }
684
685        fn finish_reconcile(&mut self) {
686            self.reconcile_log.push("finish");
687        }
688
689        fn update(&mut self, local_now: f64) {
690            let dt = self
691                .last_update
692                .map(|last| (local_now - last) / 1000.0)
693                .unwrap_or(0.0) as f32;
694
695            self.x += self.vx * dt;
696            self.y += self.vy * dt;
697            self.last_update = Some(local_now);
698        }
699
700        fn track_frame(&mut self, _my_game_id: Option<u32>, _frame: &FrameData) {}
701
702        fn filter_frame_game(
703            &mut self,
704            _game: &mut Map<String, Value>,
705            _my_game_id: Option<u32>,
706            _local_now: f64,
707        ) {
708        }
709
710        fn update_world(&mut self, _snapshot: &DecodedSnapshot) {}
711
712        fn update_world_interpolated(&mut self, _game: &InterpolatedGame) {}
713
714        fn render_overlay(&self, my_game_id: Option<u32>) -> Option<RenderOverlay> {
715            let game_id = my_game_id?;
716
717            (self.active && self.alive).then(|| RenderOverlay {
718                camera: [self.x, self.y],
719                tail: vec![0.0, game_id as f32, self.x, self.y],
720            })
721        }
722
723        // чужое тело, которое фикстура «предсказывает»: строка с той же
724        // парой (keyId, id), что и в кадре, — она обязана перекрыть
725        // интерполированную. Вторая строка проверяет приведение ширины
726        // к схеме, третья — отсев неизвестного ключа
727        fn render_rows(&self) -> Vec<PredictedRow> {
728            if !self.predicted_rows {
729                return Vec::new();
730            }
731
732            vec![
733                PredictedRow {
734                    key_id: 1,
735                    id: 2,
736                    fields: vec![111.0, 222.0],
737                },
738                PredictedRow {
739                    key_id: 1,
740                    id: 7,
741                    fields: vec![333.0],
742                },
743                PredictedRow {
744                    key_id: 200,
745                    id: 9,
746                    fields: vec![1.0, 2.0],
747                },
748            ]
749        }
750
751        fn predicted_state(&self) -> Option<[f32; PLAYER_STATE_LEN]> {
752            self.report_state
753                .then_some([self.x, self.y, 0.0, self.vx, self.vy, 0.0, 0.0, 0.0])
754        }
755
756        fn replayed_inputs(&self) -> Option<(f64, f64, usize)> {
757            self.last_update.map(|last| (last - 50.0, last, 2))
758        }
759
760        fn apply_input(&mut self, _action: &str, _key_name: &str, _local_now: f64) {}
761
762        fn set_model(&mut self, model_name: &str) {
763            self.report_state = model_name == "predicted";
764            self.predicted_rows = model_name == "rows";
765        }
766
767        fn set_active(&mut self, active: bool) {
768            self.active = active;
769        }
770
771        fn set_map(&mut self, _map_json: &str) -> Result<(), String> {
772            Ok(())
773        }
774
775        fn sync_panel(&mut self, _items: &[String]) {}
776
777        fn reset(&mut self) {
778            self.x = 0.0;
779            self.y = 0.0;
780            self.last_update = None;
781        }
782
783        fn cycle_item(&mut self, _back: bool) {}
784
785        fn try_action(&mut self, _my_game_id: Option<u32>, _local_now: f64) -> Option<String> {
786            None
787        }
788    }
789}
790
791#[cfg(test)]
792mod tests {
793    use super::fixture::{TestClient, TestConfig};
794    use super::*;
795    use crate::client::{HOT_HAS_CAMERA, HOT_HAS_FRAMES, HOT_HAS_GAME, HOT_HAS_PREDICTED};
796    use crate::snapshot::{Block, CameraData, PlayerBlock, SnapshotPacker};
797
798    fn config_json() -> serde_json::Value {
799        serde_json::json!({
800            "timeStepMs": 1000.0 / 120.0,
801            "snapshot": {
802                "version": 3,
803                "port": 5,
804                "keys": {
805                    "actor": { "id": 1, "kind": "indexed8", "class": "hot", "fields": [
806                        { "name": "x", "ty": "f32", "interp": "lerp" },
807                        { "name": "y", "ty": "f32", "interp": "lerp" }
808                    ] }
809                }
810            },
811            "interpolation": { "delay": 100, "maxFrameAge": 1000 }
812        })
813    }
814
815    fn engine_client_config() -> EngineClientConfig {
816        serde_json::from_value(config_json()).unwrap()
817    }
818
819    // конфиг с включённым детектором рассинхрона (в боевом конфиге секции
820    // divergence нет — путь кадра тогда не меняется вовсе)
821    fn config_with_divergence(capacity: usize) -> EngineClientConfig {
822        let mut json = config_json();
823
824        json["divergence"] = serde_json::json!({
825            "defaultThreshold": 1.0,
826            "capacity": capacity,
827        });
828
829        serde_json::from_value(json).unwrap()
830    }
831
832    fn make_state() -> ClientState<TestClient> {
833        ClientState::new(engine_client_config(), &TestConfig {})
834    }
835
836    fn frame_bytes(server_time: f64, seq: u32, x: f32, with_player: bool) -> Vec<u8> {
837        let cfg = engine_client_config();
838        let mut packer = SnapshotPacker::new(cfg.snapshot.clone());
839
840        packer
841            .pack_body(&[(
842                "actor".to_string(),
843                Block::Indexed8(vec![(2, Some(vec![FieldValue::F32(x), FieldValue::F32(0.0)]))]),
844            )])
845            .unwrap();
846
847        let camera = CameraData {
848            x,
849            y: 0.0,
850            force_reset: false,
851            shake: None,
852        };
853        let player = PlayerBlock {
854            game_id: 2,
855            input_seq: 0,
856            state: [x, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
857            centering: false,
858        };
859
860        packer
861            .pack_frame(
862                server_time,
863                seq,
864                Some(&camera),
865                with_player.then_some(&player),
866            )
867            .to_vec()
868    }
869
870    #[test]
871    fn push_frame_and_sample_writes_hot_layout() {
872        let mut state = make_state();
873
874        state.push_frame(&frame_bytes(1000.0, 1, 10.0, false), 1000.0);
875        state.push_frame(&frame_bytes(1100.0, 2, 20.0, false), 1100.0);
876
877        // renderTime = 1150 − 100 = 1050 → alpha 0.5
878        let len = state.sample(1150.0);
879        let hot = state.hot().to_vec();
880
881        assert_eq!(len, hot.len());
882
883        let flags = hot[0] as u32;
884
885        assert!(flags & HOT_HAS_GAME != 0);
886        assert!(flags & HOT_HAS_CAMERA != 0);
887        assert!(flags & HOT_HAS_FRAMES != 0);
888        assert!(flags & HOT_HAS_PREDICTED == 0);
889
890        // один актор: keyId 1, gameId 2, x = 15 (лерп)
891        assert_eq!(hot[3], 1.0);
892        assert_eq!(hot[4], 1.0);
893        assert_eq!(hot[5], 2.0);
894        assert_eq!(hot[6], 15.0);
895
896        let frames: Vec<serde_json::Value> =
897            serde_json::from_str(&state.take_frames()).unwrap();
898
899        assert_eq!(frames.len(), 1);
900        assert_eq!(state.take_frames(), "[]");
901    }
902
903    #[test]
904    fn render_overlay_appends_opaque_tail_and_sets_flag() {
905        let mut state = make_state();
906
907        state.set_active(true);
908        state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
909
910        assert_eq!(state.my_game_id(), Some(2));
911
912        state.sample(1150.0);
913
914        let hot = state.hot().to_vec();
915        let flags = hot[0] as u32;
916
917        assert!(flags & HOT_HAS_PREDICTED != 0);
918
919        // хвост — последние 4 f32 (форма TestClient::render_overlay)
920        let tail = &hot[hot.len() - 4..];
921
922        assert_eq!(tail[1], 2.0); // gameId
923        assert_eq!(hot[1], tail[2]); // камера следует хвосту (x)
924    }
925
926    #[test]
927    fn reconcile_hooks_wrap_the_replay() {
928        let mut state = make_state();
929
930        state.set_active(true);
931
932        // кадр без player-блока реконсиляции не запускает
933        state.push_frame(&frame_bytes(1000.0, 1, 10.0, false), 1000.0);
934        assert!(state.game.reconcile_log.is_empty());
935
936        state.push_frame(&frame_bytes(1100.0, 2, 20.0, true), 1100.0);
937
938        // begin — до replay (авторитетное состояние тел игры),
939        // finish — после (расхождение старого предсказания с новым)
940        assert_eq!(state.game.reconcile_log, vec!["begin", "state", "finish"]);
941    }
942
943    #[test]
944    fn render_rows_follow_the_tail_and_keep_schema_width() {
945        let mut state = make_state();
946
947        state.set_active(true);
948        state.set_model("rows");
949        state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
950        state.push_frame(&frame_bytes(1100.0, 2, 20.0, true), 1100.0);
951        state.sample(1150.0);
952
953        let hot = state.hot().to_vec();
954
955        // ширина записи — 2 + поля схемы (2) = 4; неизвестный ключ (200)
956        // отброшен, поэтому строк две, а не три
957        let rows = &hot[hot.len() - 8..];
958
959        assert_eq!(rows[0], 1.0); // keyId
960        assert_eq!(rows[1], 2.0); // id — та же строка, что в кадре
961        assert_eq!(rows[2], 111.0);
962        assert_eq!(rows[3], 222.0);
963
964        // короткая строка дополнена нулём до ширины схемы
965        assert_eq!(rows[4], 1.0);
966        assert_eq!(rows[5], 7.0);
967        assert_eq!(rows[6], 333.0);
968        assert_eq!(rows[7], 0.0);
969
970        // интерполированная строка на месте: перекрытие делает разбор
971        // (последняя запись с той же парой ключ/id побеждает)
972        assert_eq!(hot[5], 2.0);
973        assert_eq!(hot[6], 15.0);
974    }
975
976    #[test]
977    fn game_rows_alone_still_raise_the_tail_flag() {
978        // строки игры без predicted-хвоста своего актора: флаг обязан
979        // подняться, иначе JS-потребитель не станет разбирать буфер.
980        // Кадры идут БЕЗ player-блока (спектатор): только так my_game_id
981        // остаётся None и render_overlay отдаёт None — иначе хвост своего
982        // актора поднял бы флаг сам, и ветка осталась бы непроверенной
983        let mut state = make_state();
984
985        state.set_active(true);
986        state.set_model("rows");
987        state.push_frame(&frame_bytes(1000.0, 1, 10.0, false), 1000.0);
988        state.push_frame(&frame_bytes(1100.0, 2, 20.0, false), 1100.0);
989        state.sample(1150.0);
990
991        let hot = state.hot().to_vec();
992        let flags = hot[0] as u32;
993
994        assert!(flags & HOT_HAS_PREDICTED != 0);
995
996        // хвост на месте: две строки игры по 4 f32
997        let rows = &hot[hot.len() - 8..];
998
999        assert_eq!(rows[0], 1.0);
1000        assert_eq!(rows[1], 2.0);
1001        assert_eq!(rows[4], 1.0);
1002        assert_eq!(rows[5], 7.0);
1003    }
1004
1005    #[test]
1006    fn render_rows_default_to_empty() {
1007        let mut state = make_state();
1008
1009        state.set_active(true);
1010        state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
1011        state.sample(1150.0);
1012
1013        // без строк игры буфер заканчивается predicted-хвостом (4 f32)
1014        let hot = state.hot().to_vec();
1015
1016        assert_eq!(hot.len(), 3 + 1 + 4 + 1 + 4);
1017    }
1018
1019    #[test]
1020    fn reset_clears_predictor_and_frame_queue() {
1021        let mut state = make_state();
1022
1023        state.set_active(true);
1024        state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
1025        state.sample(1150.0);
1026
1027        state.reset();
1028
1029        assert_eq!(state.take_frames(), "[]");
1030    }
1031
1032    #[test]
1033    fn resync_clears_network_half_only() {
1034        let mut state = make_state();
1035
1036        state.set_active(true);
1037        state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
1038        state.push_frame(&frame_bytes(1100.0, 2, 20.0, false), 1100.0);
1039        state.sample(1150.0);
1040
1041        state.resync();
1042
1043        let dump: serde_json::Value = serde_json::from_str(&state.debug_json()).unwrap();
1044
1045        assert_eq!(dump["interpolator"]["buffered"], 0);
1046        assert!(dump["interpolator"]["lastFrame"].is_null());
1047        assert!(dump["offset"].is_null());
1048        assert_eq!(dump["framesOut"], 0);
1049        assert_eq!(state.take_frames(), "[]");
1050
1051        // игровая половина цела: своя идентичность не потеряна
1052        assert_eq!(state.my_game_id(), Some(2));
1053    }
1054
1055    #[test]
1056    fn debug_json_reports_buffer_seq_window_and_offset() {
1057        let mut state = make_state();
1058
1059        let empty: serde_json::Value = serde_json::from_str(&state.debug_json()).unwrap();
1060
1061        assert!(empty["myGameId"].is_null());
1062        assert_eq!(empty["interpolator"]["buffered"], 0);
1063        assert!(empty["interpolator"]["lastFrame"].is_null());
1064
1065        state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
1066        state.push_frame(&frame_bytes(1100.0, 2, 20.0, false), 1100.0);
1067        state.sample(1150.0);
1068
1069        let dump: serde_json::Value = serde_json::from_str(&state.debug_json()).unwrap();
1070
1071        assert_eq!(dump["myGameId"], 2);
1072        assert_eq!(dump["interpolator"]["buffered"], 2);
1073        assert_eq!(dump["interpolator"]["seqWindow"], serde_json::json!([1, 2]));
1074        assert_eq!(dump["interpolator"]["lastFrame"]["seq"], 2);
1075        assert_eq!(dump["interpolator"]["lastFrame"]["serverTime"], 1100.0);
1076        assert_eq!(dump["interpolator"]["delay"], 100.0);
1077        assert_eq!(dump["offset"], dump["interpolator"]["offset"]);
1078        assert_eq!(dump["hotLen"], state.hot().len());
1079        assert_eq!(dump["framesOut"], 1);
1080    }
1081
1082    // ***** детектор рассинхрона предикта (этап 5 plan/done/ai-debug) ***** //
1083
1084    #[test]
1085    fn divergence_is_off_without_config() {
1086        let mut state = make_state();
1087
1088        state.set_active(true);
1089        state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
1090
1091        assert_eq!(state.take_divergence(), "null");
1092    }
1093
1094    // уровень 0: плагин не реализовал predicted_state — сравнивается камера
1095    // predicted-оверлея с x/y авторитетного состояния
1096    #[test]
1097    fn divergence_falls_back_to_overlay_camera() {
1098        let mut state = ClientState::<TestClient>::new(config_with_divergence(8), &TestConfig {});
1099
1100        state.set_active(true);
1101        state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
1102
1103        let dump: serde_json::Value = serde_json::from_str(&state.take_divergence()).unwrap();
1104        let record = &dump["records"][0];
1105
1106        assert_eq!(dump["samples"], 1);
1107        assert_eq!(dump["violations"], 1);
1108        assert_eq!(record["source"], "camera");
1109        assert_eq!(record["predicted"], serde_json::json!([0.0, 0.0]));
1110        assert_eq!(record["authoritative"], serde_json::json!([10.0, 0.0]));
1111        assert_eq!(record["delta"][0], -10.0);
1112        assert_eq!(record["exceeded"], serde_json::json!([0]));
1113        assert_eq!(record["thresholds"][0], 1.0);
1114        assert!(record["replayed"].is_null());
1115    }
1116
1117    // уровень 1: predicted_state сравнивается покомпонентно, а отчёт несёт
1118    // serverTime/offset/окно переигранного ввода — реконсиляция идёт по
1119    // времени, а не по seq
1120    #[test]
1121    fn divergence_reports_predicted_state_and_replay_window() {
1122        let mut state = ClientState::<TestClient>::new(config_with_divergence(8), &TestConfig {});
1123
1124        state.set_active(true);
1125        state.set_model("predicted");
1126
1127        // первый кадр совпадает с предиктом — записи быть не должно
1128        state.push_frame(&frame_bytes(1000.0, 1, 0.0, true), 1000.0);
1129        state.sample(1100.0);
1130        state.push_frame(&frame_bytes(1100.0, 2, 50.0, true), 1100.0);
1131
1132        let dump: serde_json::Value = serde_json::from_str(&state.take_divergence()).unwrap();
1133
1134        assert_eq!(dump["samples"], 2);
1135        assert_eq!(dump["violations"], 1);
1136        assert_eq!(dump["records"].as_array().unwrap().len(), 1);
1137        assert_eq!(dump["maxDelta"][0], 50.0);
1138
1139        let record = &dump["records"][0];
1140
1141        assert_eq!(record["source"], "state");
1142        assert_eq!(record["serverTime"], 1100.0);
1143        assert_eq!(record["localNow"], 1100.0);
1144        assert_eq!(record["inputSeq"], 0);
1145        assert_eq!(record["delta"][0], -50.0);
1146        assert_eq!(record["exceeded"], serde_json::json!([0]));
1147        assert_eq!(record["replayed"]["from"], 1050.0);
1148        assert_eq!(record["replayed"]["to"], 1100.0);
1149        assert_eq!(record["replayed"]["count"], 2);
1150
1151        // очередь вычерпана, агрегаты — накопительные
1152        let drained: serde_json::Value = serde_json::from_str(&state.take_divergence()).unwrap();
1153
1154        assert_eq!(drained["records"].as_array().unwrap().len(), 0);
1155        assert_eq!(drained["samples"], 2);
1156        assert_eq!(drained["maxDelta"][0], 50.0);
1157    }
1158
1159    #[test]
1160    fn divergence_ring_buffer_evicts_oldest_records() {
1161        let mut state = ClientState::<TestClient>::new(config_with_divergence(1), &TestConfig {});
1162
1163        state.set_active(true);
1164        state.set_model("predicted");
1165        state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
1166        state.push_frame(&frame_bytes(1100.0, 2, 90.0, true), 1100.0);
1167
1168        let dump: serde_json::Value = serde_json::from_str(&state.take_divergence()).unwrap();
1169
1170        assert_eq!(dump["violations"], 2);
1171        assert_eq!(dump["dropped"], 1);
1172        assert_eq!(dump["records"].as_array().unwrap().len(), 1);
1173        assert_eq!(dump["records"][0]["serverTime"], 1100.0);
1174    }
1175
1176    // capacity: 0 — буфер всё равно держит одну запись, значит и вытеснений
1177    // на две записи ровно одно: иначе отчёт врал бы про «вытеснено N»
1178    #[test]
1179    fn divergence_zero_capacity_counts_evictions_honestly() {
1180        let mut state = ClientState::<TestClient>::new(config_with_divergence(0), &TestConfig {});
1181
1182        state.set_active(true);
1183        state.set_model("predicted");
1184        state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
1185        state.push_frame(&frame_bytes(1100.0, 2, 90.0, true), 1100.0);
1186
1187        let dump: serde_json::Value = serde_json::from_str(&state.take_divergence()).unwrap();
1188
1189        assert_eq!(dump["violations"], 2);
1190        assert_eq!(dump["dropped"], 1);
1191        assert_eq!(dump["records"].as_array().unwrap().len(), 1);
1192    }
1193
1194    // Расширение сценариев фикстуры (Этап 7 плана отделения движка): второй
1195    // ключ схемы другого BlockKind (IndexedNoNull8 — «динамика карты» по
1196    // форме) наряду с Indexed8 («танк» по форме) — доказывает, что hot-буфер
1197    // остаётся schema-driven для произвольного набора ключей, а не только
1198    // для одного actor-блока выше.
1199    #[test]
1200    fn second_schema_key_of_different_block_kind_flows_into_hot_buffer() {
1201        let config = serde_json::json!({
1202            "timeStepMs": 1000.0 / 120.0,
1203            "snapshot": {
1204                "version": 3,
1205                "port": 5,
1206                "keys": {
1207                    "actor": { "id": 1, "kind": "indexed8", "class": "hot", "fields": [
1208                        { "name": "x", "ty": "f32", "interp": "lerp" },
1209                        { "name": "y", "ty": "f32", "interp": "lerp" }
1210                    ] },
1211                    "zone": { "id": 2, "kind": "indexedNoNull8", "class": "hot", "fields": [
1212                        { "name": "level", "ty": "f32", "interp": "discrete" }
1213                    ] }
1214                }
1215            },
1216            "interpolation": { "delay": 100, "maxFrameAge": 1000 }
1217        });
1218        let cfg: EngineClientConfig = serde_json::from_value(config).unwrap();
1219        let mut state = ClientState::<TestClient>::new(cfg.clone(), &TestConfig {});
1220        let mut packer = SnapshotPacker::new(cfg.snapshot.clone());
1221
1222        packer
1223            .pack_body(&[
1224                (
1225                    "actor".to_string(),
1226                    Block::Indexed8(vec![(2, Some(vec![FieldValue::F32(10.0), FieldValue::F32(0.0)]))]),
1227                ),
1228                (
1229                    "zone".to_string(),
1230                    Block::IndexedNoNull8(vec![(0, vec![FieldValue::F32(7.0)])]),
1231                ),
1232            ])
1233            .unwrap();
1234
1235        let frame = packer.pack_frame(1000.0, 1, None, None).to_vec();
1236
1237        state.push_frame(&frame, 1000.0);
1238        state.push_frame(&frame, 1100.0);
1239        state.sample(1150.0);
1240
1241        let hot = state.hot().to_vec();
1242
1243        // [flags, camX, camY, tankCount, keyId, gameId, x, y, dynamicCount, keyId, index, level]
1244        assert_eq!(hot[3], 1.0); // tankCount (Indexed8)
1245        assert_eq!(hot[8], 1.0); // dynamicCount (IndexedNoNull8)
1246        assert_eq!(hot[9], 2.0); // keyId зоны
1247        assert_eq!(hot[11], 7.0); // level
1248    }
1249
1250    #[test]
1251    fn reset_clears_my_game_id() {
1252        let mut state = make_state();
1253
1254        state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
1255        state.push_frame(&frame_bytes(1100.0, 2, 20.0, true), 1100.0);
1256        state.sample(1150.0);
1257        assert_eq!(state.my_game_id(), Some(2));
1258
1259        // CLEAR означает «мира больше нет»: без сброса идентичности предикт
1260        // продолжил бы рисовать сущность, которой на хосте уже нет
1261        state.reset();
1262        assert_eq!(state.my_game_id(), None);
1263    }
1264}