Skip to main content

vimp_engine_core/client/
game.rs

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