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