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