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