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