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