Skip to main content

vimp_engine_core/client/
interpolator.rs

1//! Snapshot-интерполяция — порт src/client/SnapshotInterpolator.js
2//! (срез 2.6). Кадры складываются в буфер по seq с дедупликацией, мир
3//! рендерится в прошлом (renderTime = serverNow − delay); события
4//! опоздавших кадров выдаются немедленно следующим sample().
5
6use std::collections::HashSet;
7
8use indexmap::IndexMap;
9
10use crate::config::{BlockClass, FieldValue, Interp, InterpolationConfig, SnapshotConfig};
11use crate::physics::{lerp, lerp_angle};
12
13use super::unpack::{BlockData, DecodedCamera, DecodedSnapshot};
14
15// коэффициент EMA-сглаживания оффсета серверного времени
16const OFFSET_SMOOTHING: f64 = 0.1;
17
18// окно памяти выданных seq для дедупликации (кадров; ~4 c при 30 пакетах/сек)
19const SEQ_DEDUP_WINDOW: u32 = 128;
20
21// жёсткий cap буфера — страховка памяти WASM (в JS-версии не требовался:
22// maxFrameAge чистил буфер при живом потоке кадров)
23const MAX_BUFFER_FRAMES: usize = 256;
24
25/// Содержимое кадра для выдачи: снапшот + камера.
26#[derive(Clone)]
27pub struct FrameData {
28    pub snapshot: DecodedSnapshot,
29    pub camera: Option<DecodedCamera>,
30}
31
32struct BufferedFrame {
33    seq: u32,
34    server_time: f64,
35    data: FrameData,
36    issued: bool,
37}
38
39/// Интерполированная строка блока: id/индекс строки + поля в порядке
40/// `schema.fields`, каждое интерполировано по своему `Interp`
41/// (Discrete-поля — значение кадра A, как раньше condition/size/team).
42pub struct InterpolatedRow {
43    pub id: u32,
44    pub fields: Vec<FieldValue>,
45}
46
47/// Непрерывная часть sample(): строки Hot-блоков по ключу реестра
48/// (`opcodes.js`/игровой плагин) — движок не знает, что ключ значит «танк»
49/// или «динамика карты», только форму (`Indexed8`/`IndexedNoNull8`, у
50/// которых есть устойчивая id-идентичность строки между кадрами A/B).
51#[derive(Default)]
52pub struct InterpolatedGame {
53    pub blocks: IndexMap<String, Vec<InterpolatedRow>>,
54}
55
56/// Результат sample().
57pub struct SampleResult {
58    /// Невыданные кадры, пересечённые renderTime (события — ровно один раз).
59    pub frames: Vec<FrameData>,
60    pub game: Option<InterpolatedGame>,
61    /// Интерполированные координаты камеры (без флагов reset/shake).
62    pub camera: Option<[f32; 2]>,
63}
64
65pub struct Interpolator {
66    delay: f64,
67    max_frame_age: f64,
68    snapshot_cfg: SnapshotConfig,
69    frames: Vec<BufferedFrame>,
70    offset_ema: Option<f64>,
71    last_render_time: Option<f64>,
72    pending_late: Vec<FrameData>,
73    issued_seqs: HashSet<u32>,
74}
75
76impl Interpolator {
77    pub fn new(cfg: &InterpolationConfig, snapshot_cfg: SnapshotConfig) -> Self {
78        Self {
79            delay: cfg.delay,
80            max_frame_age: cfg.max_frame_age,
81            snapshot_cfg,
82            frames: Vec::new(),
83            offset_ema: None,
84            last_render_time: None,
85            pending_late: Vec::new(),
86            issued_seqs: HashSet::new(),
87        }
88    }
89
90    /// Текущая оценка (serverTime − localNow); None, если кадров ещё не было.
91    pub fn offset(&self) -> Option<f64> {
92        self.offset_ema
93    }
94
95    /// Добавляет кадр в буфер (вставка по seq с дедупликацией).
96    pub fn push(&mut self, data: FrameData, server_time: f64, local_now: f64, seq: u32) {
97        if self.issued_seqs.contains(&seq) {
98            return;
99        }
100
101        // позиция вставки по seq (кадры почти всегда приходят по порядку —
102        // поиск с конца); заодно дедупликация по буферу
103        let mut index = self.frames.len();
104
105        while index > 0 && self.frames[index - 1].seq >= seq {
106            if self.frames[index - 1].seq == seq {
107                return;
108            }
109
110            index -= 1;
111        }
112
113        let offset = server_time - local_now;
114
115        match &mut self.offset_ema {
116            None => self.offset_ema = Some(offset),
117            Some(ema) => *ema += (offset - *ema) * OFFSET_SMOOTHING,
118        }
119
120        // кадр опоздал (renderTime уже прошёл его serverTime): в буфере он
121        // сдвинул бы опорный кадр назад — события выдаются немедленно
122        if let Some(last) = self.last_render_time
123            && server_time <= last
124        {
125            self.issued_seqs.insert(seq);
126            self.pending_late.push(data);
127
128            return;
129        }
130
131        self.frames.insert(
132            index,
133            BufferedFrame {
134                seq,
135                server_time,
136                data,
137                issued: false,
138            },
139        );
140
141        // страховочная очистка слишком старых кадров
142        let newest_time = self.frames[self.frames.len() - 1].server_time;
143        let min_time = newest_time - self.max_frame_age;
144
145        while self.frames.len() > 2 && self.frames[0].server_time < min_time {
146            self.frames.remove(0);
147        }
148
149        while self.frames.len() > MAX_BUFFER_FRAMES {
150            self.frames.remove(0);
151        }
152    }
153
154    /// Выборка состояния мира на момент рендера.
155    pub fn sample(&mut self, local_now: f64) -> SampleResult {
156        // опоздавшие кадры выдаются немедленно, даже если буфер пуст
157        let mut frames = std::mem::take(&mut self.pending_late);
158
159        let Some(offset) = self.offset_ema else {
160            return SampleResult {
161                frames,
162                game: None,
163                camera: None,
164            };
165        };
166
167        if self.frames.is_empty() {
168            return SampleResult {
169                frames,
170                game: None,
171                camera: None,
172            };
173        }
174
175        let render_time = local_now + offset - self.delay;
176
177        self.last_render_time = Some(render_time);
178
179        // индекс кадра A: последний с serverTime <= renderTime
180        let mut index_a: isize = -1;
181
182        for (i, frame) in self.frames.iter().enumerate() {
183            if frame.server_time <= render_time {
184                index_a = i as isize;
185            } else {
186                break;
187            }
188        }
189
190        // renderTime раньше первого кадра — мир ещё «не начался»
191        if index_a == -1 {
192            return SampleResult {
193                frames,
194                game: None,
195                camera: None,
196            };
197        }
198
199        let index_a = index_a as usize;
200
201        // кадры до A больше не нужны (A остаётся опорным);
202        // невыданные — в выдачу (события — ровно один раз)
203        for frame in self.frames.drain(0..index_a) {
204            if !frame.issued {
205                self.issued_seqs.insert(frame.seq);
206                frames.push(frame.data);
207            }
208        }
209
210        if !self.frames[0].issued {
211            self.frames[0].issued = true;
212            self.issued_seqs.insert(self.frames[0].seq);
213            frames.push(self.frames[0].data.clone());
214        }
215
216        self.prune_issued_seqs();
217
218        let frame_a = &self.frames[0];
219
220        // нет следующего кадра — hold на A без экстраполяции
221        let Some(frame_b) = self.frames.get(1) else {
222            let game = interpolate_game(
223                &frame_a.data.snapshot,
224                &frame_a.data.snapshot,
225                0.0,
226                &self.snapshot_cfg,
227            );
228            let camera = strip_camera(frame_a.data.camera.as_ref());
229
230            return SampleResult {
231                frames,
232                game: Some(game),
233                camera,
234            };
235        };
236
237        let alpha = ((render_time - frame_a.server_time)
238            / (frame_b.server_time - frame_a.server_time))
239            .clamp(0.0, 1.0) as f32;
240
241        let game = interpolate_game(
242            &frame_a.data.snapshot,
243            &frame_b.data.snapshot,
244            alpha,
245            &self.snapshot_cfg,
246        );
247        let camera = interpolate_camera(
248            frame_a.data.camera.as_ref(),
249            frame_b.data.camera.as_ref(),
250            alpha,
251        );
252
253        SampleResult {
254            frames,
255            game: Some(game),
256            camera,
257        }
258    }
259
260    /// Дамп сетевого буфера для отладки: глубина буфера, окно seq,
261    /// оффсет, последний принятый кадр (см. `crate::debug` — серверное
262    /// зеркало этого дампа).
263    pub fn debug_json(&self) -> serde_json::Value {
264        let last = self.frames.last();
265
266        serde_json::json!({
267            "delay": self.delay,
268            "maxFrameAge": self.max_frame_age,
269            "buffered": self.frames.len(),
270            "seqWindow": self.frames.first().map(|frame| [frame.seq, self.frames[self.frames.len() - 1].seq]),
271            "lastFrame": last.map(|frame| serde_json::json!({
272                "seq": frame.seq,
273                "serverTime": frame.server_time,
274                "issued": frame.issued,
275            })),
276            "offset": self.offset_ema,
277            "lastRenderTime": self.last_render_time,
278            "pendingLate": self.pending_late.len(),
279            "issuedSeqs": self.issued_seqs.len(),
280        })
281    }
282
283    /// Сбрасывает буфер и оценку времени (смена карты, очистка полотна).
284    pub fn reset(&mut self) {
285        self.frames.clear();
286        self.offset_ema = None;
287        self.last_render_time = None;
288        self.pending_late.clear();
289        self.issued_seqs.clear();
290    }
291
292    // выкидывает из памяти дедупликации seq далеко позади опорного кадра
293    fn prune_issued_seqs(&mut self) {
294        let min_seq = self.frames[0].seq.saturating_sub(SEQ_DEDUP_WINDOW);
295
296        self.issued_seqs.retain(|seq| *seq >= min_seq);
297    }
298}
299
300// интерполирует одно значение поля по способу интерполяции схемы;
301// Discrete — без интерполяции, значение кадра A (как раньше condition/
302// size/team); Lerp/LerpAngle применимы только к F32-полям — не-F32 поле с
303// таким interp в схеме молча падает на Discrete (защита — на стороне
304// SnapshotConfig::validate/интеграционных тестов схемы, не рантайма).
305fn interp_field(interp: Interp, a: FieldValue, b: FieldValue, alpha: f32) -> FieldValue {
306    match (interp, a, b) {
307        (Interp::Lerp, FieldValue::F32(a), FieldValue::F32(b)) => FieldValue::F32(lerp(a, b, alpha)),
308        (Interp::LerpAngle, FieldValue::F32(a), FieldValue::F32(b)) => {
309            FieldValue::F32(lerp_angle(a, b, alpha))
310        }
311        _ => a,
312    }
313}
314
315// интерполирует строку блока по позиционной схеме `schema.fields`.
316fn interpolate_row(
317    schema_fields: &[crate::config::FieldSchema],
318    fields_a: &[FieldValue],
319    fields_b: &[FieldValue],
320    alpha: f32,
321) -> Vec<FieldValue> {
322    fields_a
323        .iter()
324        .zip(fields_b)
325        .enumerate()
326        .map(|(i, (a, b))| {
327            let interp = schema_fields.get(i).map_or(Interp::Discrete, |f| f.interp);
328
329            interp_field(interp, *a, *b, alpha)
330        })
331        .collect()
332}
333
334// интерполирует непрерывную часть снапшота; блок участвует, только если
335// присутствует в обоих кадрах, его класс — Hot (событийные блоки —
336// трассеры/бомбы/взрывы — не интерполируются, выдаются кадром как есть
337// через take_frames), и его форма — `Indexed8`/`IndexedNoNull8` (у них
338// есть устойчивая id-идентичность строки между кадрами A/B; `Indexed32`/
339// `List16` сегодня используются только для событийных блоков).
340fn interpolate_game(
341    a: &DecodedSnapshot,
342    b: &DecodedSnapshot,
343    alpha: f32,
344    cfg: &SnapshotConfig,
345) -> InterpolatedGame {
346    let mut game = InterpolatedGame::default();
347
348    for block in &a.blocks {
349        let Some(block_b) = b.block_by_key(&block.key) else {
350            continue;
351        };
352
353        let Some(schema) = cfg.keys.get(&block.key) else {
354            continue;
355        };
356
357        if schema.class != BlockClass::Hot {
358            continue;
359        }
360
361        let rows: Vec<InterpolatedRow> = match (&block.data, block_b) {
362            (BlockData::Indexed8(items_a), BlockData::Indexed8(items_b)) => items_a
363                .iter()
364                .filter_map(|(id, row_a)| {
365                    let (Some(row_a), Some(Some(row_b))) = (row_a, items_b.get(id)) else {
366                        return None;
367                    };
368
369                    Some(InterpolatedRow {
370                        id: *id as u32,
371                        fields: interpolate_row(&schema.fields, row_a, row_b, alpha),
372                    })
373                })
374                .collect(),
375            (BlockData::IndexedNoNull8(items_a), BlockData::IndexedNoNull8(items_b)) => items_a
376                .iter()
377                .filter_map(|(index, fields_a)| {
378                    let fields_b = items_b.get(index)?;
379
380                    Some(InterpolatedRow {
381                        id: *index as u32,
382                        fields: interpolate_row(&schema.fields, fields_a, fields_b, alpha),
383                    })
384                })
385                .collect(),
386            // Indexed32/List16 — событийные формы сегодня, не интерполируются
387            _ => continue,
388        };
389
390        if !rows.is_empty() {
391            game.blocks.insert(block.key.clone(), rows);
392        }
393    }
394
395    game
396}
397
398// камера: lerp x/y; reset/shake не дублируются (уже выданы кадрами)
399fn interpolate_camera(
400    a: Option<&DecodedCamera>,
401    b: Option<&DecodedCamera>,
402    alpha: f32,
403) -> Option<[f32; 2]> {
404    match (a, b) {
405        (Some(a), Some(b)) => Some([lerp(a.x, b.x, alpha), lerp(a.y, b.y, alpha)]),
406        _ => strip_camera(a),
407    }
408}
409
410// оставляет от камеры только координаты (без флагов reset/shake)
411fn strip_camera(camera: Option<&DecodedCamera>) -> Option<[f32; 2]> {
412    camera.map(|camera| [camera.x, camera.y])
413}
414
415#[cfg(test)]
416mod tests {
417    use indexmap::IndexMap;
418
419    use super::*;
420    use crate::config::test_support::full_snapshot_config;
421
422    use super::super::unpack::DecodedBlock;
423
424    const DELAY: f64 = 100.0;
425
426    fn make() -> Interpolator {
427        Interpolator::new(
428            &InterpolationConfig {
429                delay: DELAY,
430                max_frame_age: 1000.0,
431            },
432            full_snapshot_config(3, 5),
433        )
434    }
435
436    fn tank_row(x: f32, angle: f32) -> Vec<FieldValue> {
437        vec![
438            FieldValue::F32(x),
439            FieldValue::F32(0.0),
440            FieldValue::F32(angle),
441            FieldValue::F32(0.0),
442            FieldValue::F32(0.0),
443            FieldValue::F32(0.0),
444            FieldValue::F32(0.0),
445            FieldValue::U8(3),
446            FieldValue::U8(2),
447            FieldValue::U8(1),
448        ]
449    }
450
451    fn tank_frame(entries: &[(u8, Option<Vec<FieldValue>>)]) -> FrameData {
452        let mut items = IndexMap::new();
453
454        for (id, row) in entries {
455            items.insert(*id, row.clone());
456        }
457
458        FrameData {
459            snapshot: DecodedSnapshot {
460                blocks: vec![DecodedBlock {
461                    key: "m1".to_string(),
462                    key_id: 1,
463                    data: BlockData::Indexed8(items),
464                }],
465            },
466            camera: None,
467        }
468    }
469
470    // push с оффсетом 0 (serverTime == localNow): renderTime = now − delay
471    fn push_zero_offset(i: &mut Interpolator, frame: FrameData, server_time: f64, seq: u32) {
472        i.push(frame, server_time, server_time, seq);
473    }
474
475    fn tanks(game: &InterpolatedGame) -> &[InterpolatedRow] {
476        game.blocks.get("m1").map_or(&[], Vec::as_slice)
477    }
478
479    fn f32_at(row: &InterpolatedRow, i: usize) -> f32 {
480        match row.fields[i] {
481            FieldValue::F32(v) => v,
482            _ => panic!("поле {i} не F32"),
483        }
484    }
485
486    fn u8_at(row: &InterpolatedRow, i: usize) -> u8 {
487        match row.fields[i] {
488            FieldValue::U8(v) => v,
489            _ => panic!("поле {i} не U8"),
490        }
491    }
492
493    #[test]
494    fn empty_buffer_returns_nothing() {
495        let mut i = make();
496        let result = i.sample(0.0);
497
498        assert!(result.frames.is_empty());
499        assert!(result.game.is_none());
500        assert!(result.camera.is_none());
501    }
502
503    #[test]
504    fn render_time_before_first_frame_returns_nothing() {
505        let mut i = make();
506
507        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(0.0, 0.0)))]), 1000.0, 1);
508
509        // renderTime = 1000 + 0 − 100 = 900 < 1000
510        let result = i.sample(1000.0);
511
512        assert!(result.frames.is_empty());
513        assert!(result.game.is_none());
514    }
515
516    #[test]
517    fn positions_lerp_between_frames() {
518        let mut i = make();
519
520        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(0.0, 0.0)))]), 1000.0, 1);
521        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(100.0, 0.0)))]), 1100.0, 2);
522
523        // renderTime = 1150 − 100 = 1050 → alpha 0.5
524        let result = i.sample(1150.0);
525        let game = result.game.unwrap();
526        let rows = tanks(&game);
527
528        assert_eq!(rows.len(), 1);
529        assert_eq!(f32_at(&rows[0], 0), 50.0);
530        assert_eq!(rows[0].id, 1);
531    }
532
533    #[test]
534    fn angles_lerp_shortest_path() {
535        use std::f32::consts::PI;
536
537        let mut i = make();
538
539        push_zero_offset(
540            &mut i,
541            tank_frame(&[(1, Some(tank_row(0.0, PI - 0.1)))]),
542            1000.0,
543            1,
544        );
545        push_zero_offset(
546            &mut i,
547            tank_frame(&[(1, Some(tank_row(0.0, -PI + 0.1)))]),
548            1100.0,
549            2,
550        );
551
552        let result = i.sample(1150.0);
553        let game = result.game.unwrap();
554        let angle = f32_at(&tanks(&game)[0], 2);
555
556        // кратчайший путь через ±PI, а не через 0
557        assert!((angle.abs() - PI).abs() < 0.01);
558    }
559
560    #[test]
561    fn discrete_fields_come_from_frame_a() {
562        let mut i = make();
563        let mut row_b = tank_row(100.0, 0.0);
564
565        row_b[7] = FieldValue::U8(1); // condition
566
567        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(0.0, 0.0)))]), 1000.0, 1);
568        push_zero_offset(&mut i, tank_frame(&[(1, Some(row_b))]), 1100.0, 2);
569
570        let result = i.sample(1150.0);
571        let game = result.game.unwrap();
572
573        assert_eq!(u8_at(&tanks(&game)[0], 7), 3);
574    }
575
576    #[test]
577    fn null_and_missing_tanks_are_not_interpolated() {
578        let mut i = make();
579
580        push_zero_offset(
581            &mut i,
582            tank_frame(&[(1, Some(tank_row(0.0, 0.0))), (2, Some(tank_row(5.0, 0.0)))]),
583            1000.0,
584            1,
585        );
586        // танк 1 удалён (null), танк 2 отсутствует
587        push_zero_offset(&mut i, tank_frame(&[(1, None)]), 1100.0, 2);
588
589        let result = i.sample(1150.0);
590
591        assert!(tanks(&result.game.unwrap()).is_empty());
592    }
593
594    #[test]
595    fn frames_are_issued_exactly_once() {
596        let mut i = make();
597
598        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(0.0, 0.0)))]), 1000.0, 1);
599        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(10.0, 0.0)))]), 1100.0, 2);
600
601        assert_eq!(i.sample(1150.0).frames.len(), 1); // кадр A выдан
602        assert_eq!(i.sample(1151.0).frames.len(), 0); // повторно не выдаётся
603        assert_eq!(i.sample(1250.0).frames.len(), 1); // пересечён кадр B
604    }
605
606    #[test]
607    fn hold_on_last_frame_without_extrapolation() {
608        let mut i = make();
609
610        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(50.0, 0.0)))]), 1000.0, 1);
611
612        let result = i.sample(1500.0);
613
614        // hold: непрерывная часть отдаётся с alpha 0 по кадру A
615        let game = result.game.unwrap();
616        assert_eq!(f32_at(&tanks(&game)[0], 0), 50.0);
617    }
618
619    #[test]
620    fn out_of_order_insert_and_duplicates() {
621        let mut i = make();
622
623        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(0.0, 0.0)))]), 1000.0, 1);
624        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(100.0, 0.0)))]), 1200.0, 3);
625        // опоздавший в буфер (renderTime ещё не прошёл) — встаёт между
626        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(50.0, 0.0)))]), 1100.0, 2);
627        // дубликат seq — отброшен
628        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(999.0, 0.0)))]), 1100.0, 2);
629
630        // renderTime = 1150: A = seq 2 (x=50), B = seq 3 (x=100), alpha 0.5
631        let result = i.sample(1250.0);
632
633        let game = result.game.unwrap();
634        assert_eq!(f32_at(&tanks(&game)[0], 0), 75.0);
635        assert_eq!(result.frames.len(), 2); // seq 1 и seq 2 пересечены
636    }
637
638    #[test]
639    fn late_frame_events_are_issued_immediately() {
640        let mut i = make();
641
642        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(0.0, 0.0)))]), 1000.0, 1);
643        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(20.0, 0.0)))]), 1200.0, 3);
644        i.sample(1250.0); // renderTime 1150
645
646        // seq 2 опоздал: его serverTime 1100 позади renderTime
647        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(10.0, 0.0)))]), 1100.0, 2);
648
649        let result = i.sample(1251.0);
650
651        assert_eq!(result.frames.len(), 1); // выдан немедленно
652
653        // повторный push того же seq игнорируется (память выданных)
654        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(10.0, 0.0)))]), 1100.0, 2);
655        assert!(i.sample(1252.0).frames.is_empty());
656    }
657
658    #[test]
659    fn camera_lerp_and_strip() {
660        let mut i = make();
661        let frame = |x: f32| FrameData {
662            snapshot: DecodedSnapshot::default(),
663            camera: Some(DecodedCamera {
664                x,
665                y: 0.0,
666                force_reset: true,
667                shake: None,
668            }),
669        };
670
671        push_zero_offset(&mut i, frame(0.0), 1000.0, 1);
672        push_zero_offset(&mut i, frame(100.0), 1100.0, 2);
673
674        let result = i.sample(1150.0);
675
676        // флаги reset/shake не попадают в интерполированную камеру
677        assert_eq!(result.camera, Some([50.0, 0.0]));
678    }
679
680    #[test]
681    fn offset_ema_smoothing() {
682        let mut i = make();
683
684        i.push(tank_frame(&[]), 1000.0, 500.0, 1); // offset 500
685        assert_eq!(i.offset(), Some(500.0));
686
687        i.push(tank_frame(&[]), 1100.0, 500.0, 2); // offset 600
688        // EMA: 500 + (600 − 500) · 0.1 = 510
689        assert_eq!(i.offset(), Some(510.0));
690    }
691
692    #[test]
693    fn old_frames_are_pruned_by_age() {
694        let mut i = make();
695
696        push_zero_offset(&mut i, tank_frame(&[]), 1000.0, 1);
697        push_zero_offset(&mut i, tank_frame(&[]), 1100.0, 2);
698        // кадр старше maxFrameAge (1000 мс) относительно новейшего
699        push_zero_offset(&mut i, tank_frame(&[]), 2500.0, 3);
700
701        // кадр seq 1 вычищен без выдачи: sample его уже не увидит
702        let result = i.sample(2500.0); // renderTime 2400 → пересечены 2 и...
703
704        // seq 1 (1000.0) удалён pruning'ом, выданы seq 2 и seq 3? renderTime
705        // 2400 пересекает 1100 и не пересекает 2500
706        assert_eq!(result.frames.len(), 1);
707    }
708
709    #[test]
710    fn reset_clears_everything() {
711        let mut i = make();
712
713        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(0.0, 0.0)))]), 1000.0, 1);
714        i.reset();
715
716        assert!(i.offset().is_none());
717
718        let result = i.sample(2000.0);
719
720        assert!(result.frames.is_empty());
721        assert!(result.game.is_none());
722
723        // после reset тот же seq принимается заново
724        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(5.0, 0.0)))]), 3000.0, 1);
725        assert_eq!(i.sample(3200.0).frames.len(), 1);
726    }
727}