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`.
316// Строки приходят из распаковки всегда полной ширины: отсутствующий в кадре
317// опциональный хвост (`optionalFrom`) уже добит нулями, то есть покоящееся
318// тело интерполируется со скоростями 0 — при ударе скорость плавно нарастает
319// от нуля к значению соседнего кадра, а не прыгает.
320fn interpolate_row(
321    schema_fields: &[crate::config::FieldSchema],
322    fields_a: &[FieldValue],
323    fields_b: &[FieldValue],
324    alpha: f32,
325) -> Vec<FieldValue> {
326    fields_a
327        .iter()
328        .zip(fields_b)
329        .enumerate()
330        .map(|(i, (a, b))| {
331            let interp = schema_fields.get(i).map_or(Interp::Discrete, |f| f.interp);
332
333            interp_field(interp, *a, *b, alpha)
334        })
335        .collect()
336}
337
338// интерполирует непрерывную часть снапшота; блок участвует, только если
339// присутствует в обоих кадрах, его класс — Hot (событийные блоки —
340// трассеры/бомбы/взрывы — не интерполируются, выдаются кадром как есть
341// через take_frames), и его форма — `Indexed8`/`IndexedNoNull8` (у них
342// есть устойчивая id-идентичность строки между кадрами A/B; `Indexed32`/
343// `List16` сегодня используются только для событийных блоков).
344fn interpolate_game(
345    a: &DecodedSnapshot,
346    b: &DecodedSnapshot,
347    alpha: f32,
348    cfg: &SnapshotConfig,
349) -> InterpolatedGame {
350    let mut game = InterpolatedGame::default();
351
352    for block in &a.blocks {
353        let Some(block_b) = b.block_by_key(&block.key) else {
354            continue;
355        };
356
357        let Some(schema) = cfg.keys.get(&block.key) else {
358            continue;
359        };
360
361        if schema.class != BlockClass::Hot {
362            continue;
363        }
364
365        let rows: Vec<InterpolatedRow> = match (&block.data, block_b) {
366            (BlockData::Indexed8(items_a), BlockData::Indexed8(items_b)) => items_a
367                .iter()
368                .filter_map(|(id, row_a)| {
369                    let (Some(row_a), Some(Some(row_b))) = (row_a, items_b.get(id)) else {
370                        return None;
371                    };
372
373                    Some(InterpolatedRow {
374                        id: *id as u32,
375                        fields: interpolate_row(&schema.fields, row_a, row_b, alpha),
376                    })
377                })
378                .collect(),
379            (BlockData::IndexedNoNull8(items_a), BlockData::IndexedNoNull8(items_b)) => items_a
380                .iter()
381                .filter_map(|(index, fields_a)| {
382                    let fields_b = items_b.get(index)?;
383
384                    Some(InterpolatedRow {
385                        id: *index as u32,
386                        fields: interpolate_row(&schema.fields, fields_a, fields_b, alpha),
387                    })
388                })
389                .collect(),
390            // Indexed32/List16 — событийные формы сегодня, не интерполируются
391            _ => continue,
392        };
393
394        if !rows.is_empty() {
395            game.blocks.insert(block.key.clone(), rows);
396        }
397    }
398
399    game
400}
401
402// камера: lerp x/y; reset/shake не дублируются (уже выданы кадрами)
403fn interpolate_camera(
404    a: Option<&DecodedCamera>,
405    b: Option<&DecodedCamera>,
406    alpha: f32,
407) -> Option<[f32; 2]> {
408    match (a, b) {
409        (Some(a), Some(b)) => Some([lerp(a.x, b.x, alpha), lerp(a.y, b.y, alpha)]),
410        _ => strip_camera(a),
411    }
412}
413
414// оставляет от камеры только координаты (без флагов reset/shake)
415fn strip_camera(camera: Option<&DecodedCamera>) -> Option<[f32; 2]> {
416    camera.map(|camera| [camera.x, camera.y])
417}
418
419#[cfg(test)]
420mod tests {
421    use indexmap::IndexMap;
422
423    use super::*;
424    use crate::config::test_support::full_snapshot_config;
425
426    use super::super::unpack::DecodedBlock;
427
428    const DELAY: f64 = 100.0;
429
430    fn make() -> Interpolator {
431        Interpolator::new(
432            &InterpolationConfig {
433                delay: DELAY,
434                max_frame_age: 1000.0,
435            },
436            full_snapshot_config(3, 5),
437        )
438    }
439
440    fn tank_row(x: f32, angle: f32) -> Vec<FieldValue> {
441        vec![
442            FieldValue::F32(x),
443            FieldValue::F32(0.0),
444            FieldValue::F32(angle),
445            FieldValue::F32(0.0),
446            FieldValue::F32(0.0),
447            FieldValue::F32(0.0),
448            FieldValue::F32(0.0),
449            FieldValue::U8(3),
450            FieldValue::U8(2),
451            FieldValue::U8(1),
452        ]
453    }
454
455    fn tank_frame(entries: &[(u8, Option<Vec<FieldValue>>)]) -> FrameData {
456        let mut items = IndexMap::new();
457
458        for (id, row) in entries {
459            items.insert(*id, row.clone());
460        }
461
462        FrameData {
463            snapshot: DecodedSnapshot {
464                blocks: vec![DecodedBlock {
465                    key: "m1".to_string(),
466                    key_id: 1,
467                    data: BlockData::Indexed8(items),
468                }],
469            },
470            camera: None,
471        }
472    }
473
474    // push с оффсетом 0 (serverTime == localNow): renderTime = now − delay
475    fn push_zero_offset(i: &mut Interpolator, frame: FrameData, server_time: f64, seq: u32) {
476        i.push(frame, server_time, server_time, seq);
477    }
478
479    fn tanks(game: &InterpolatedGame) -> &[InterpolatedRow] {
480        game.blocks.get("m1").map_or(&[], Vec::as_slice)
481    }
482
483    fn f32_at(row: &InterpolatedRow, i: usize) -> f32 {
484        match row.fields[i] {
485            FieldValue::F32(v) => v,
486            _ => panic!("поле {i} не F32"),
487        }
488    }
489
490    fn u8_at(row: &InterpolatedRow, i: usize) -> u8 {
491        match row.fields[i] {
492            FieldValue::U8(v) => v,
493            _ => panic!("поле {i} не U8"),
494        }
495    }
496
497    #[test]
498    fn empty_buffer_returns_nothing() {
499        let mut i = make();
500        let result = i.sample(0.0);
501
502        assert!(result.frames.is_empty());
503        assert!(result.game.is_none());
504        assert!(result.camera.is_none());
505    }
506
507    #[test]
508    fn render_time_before_first_frame_returns_nothing() {
509        let mut i = make();
510
511        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(0.0, 0.0)))]), 1000.0, 1);
512
513        // renderTime = 1000 + 0 − 100 = 900 < 1000
514        let result = i.sample(1000.0);
515
516        assert!(result.frames.is_empty());
517        assert!(result.game.is_none());
518    }
519
520    #[test]
521    fn positions_lerp_between_frames() {
522        let mut i = make();
523
524        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(0.0, 0.0)))]), 1000.0, 1);
525        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(100.0, 0.0)))]), 1100.0, 2);
526
527        // renderTime = 1150 − 100 = 1050 → alpha 0.5
528        let result = i.sample(1150.0);
529        let game = result.game.unwrap();
530        let rows = tanks(&game);
531
532        assert_eq!(rows.len(), 1);
533        assert_eq!(f32_at(&rows[0], 0), 50.0);
534        assert_eq!(rows[0].id, 1);
535    }
536
537    #[test]
538    fn angles_lerp_shortest_path() {
539        use std::f32::consts::PI;
540
541        let mut i = make();
542
543        push_zero_offset(
544            &mut i,
545            tank_frame(&[(1, Some(tank_row(0.0, PI - 0.1)))]),
546            1000.0,
547            1,
548        );
549        push_zero_offset(
550            &mut i,
551            tank_frame(&[(1, Some(tank_row(0.0, -PI + 0.1)))]),
552            1100.0,
553            2,
554        );
555
556        let result = i.sample(1150.0);
557        let game = result.game.unwrap();
558        let angle = f32_at(&tanks(&game)[0], 2);
559
560        // кратчайший путь через ±PI, а не через 0
561        assert!((angle.abs() - PI).abs() < 0.01);
562    }
563
564    #[test]
565    fn discrete_fields_come_from_frame_a() {
566        let mut i = make();
567        let mut row_b = tank_row(100.0, 0.0);
568
569        row_b[7] = FieldValue::U8(1); // condition
570
571        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(0.0, 0.0)))]), 1000.0, 1);
572        push_zero_offset(&mut i, tank_frame(&[(1, Some(row_b))]), 1100.0, 2);
573
574        let result = i.sample(1150.0);
575        let game = result.game.unwrap();
576
577        assert_eq!(u8_at(&tanks(&game)[0], 7), 3);
578    }
579
580    #[test]
581    fn null_and_missing_tanks_are_not_interpolated() {
582        let mut i = make();
583
584        push_zero_offset(
585            &mut i,
586            tank_frame(&[(1, Some(tank_row(0.0, 0.0))), (2, Some(tank_row(5.0, 0.0)))]),
587            1000.0,
588            1,
589        );
590        // танк 1 удалён (null), танк 2 отсутствует
591        push_zero_offset(&mut i, tank_frame(&[(1, None)]), 1100.0, 2);
592
593        let result = i.sample(1150.0);
594
595        assert!(tanks(&result.game.unwrap()).is_empty());
596    }
597
598    #[test]
599    fn frames_are_issued_exactly_once() {
600        let mut i = make();
601
602        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(0.0, 0.0)))]), 1000.0, 1);
603        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(10.0, 0.0)))]), 1100.0, 2);
604
605        assert_eq!(i.sample(1150.0).frames.len(), 1); // кадр A выдан
606        assert_eq!(i.sample(1151.0).frames.len(), 0); // повторно не выдаётся
607        assert_eq!(i.sample(1250.0).frames.len(), 1); // пересечён кадр B
608    }
609
610    #[test]
611    fn hold_on_last_frame_without_extrapolation() {
612        let mut i = make();
613
614        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(50.0, 0.0)))]), 1000.0, 1);
615
616        let result = i.sample(1500.0);
617
618        // hold: непрерывная часть отдаётся с alpha 0 по кадру A
619        let game = result.game.unwrap();
620        assert_eq!(f32_at(&tanks(&game)[0], 0), 50.0);
621    }
622
623    #[test]
624    fn out_of_order_insert_and_duplicates() {
625        let mut i = make();
626
627        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(0.0, 0.0)))]), 1000.0, 1);
628        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(100.0, 0.0)))]), 1200.0, 3);
629        // опоздавший в буфер (renderTime ещё не прошёл) — встаёт между
630        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(50.0, 0.0)))]), 1100.0, 2);
631        // дубликат seq — отброшен
632        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(999.0, 0.0)))]), 1100.0, 2);
633
634        // renderTime = 1150: A = seq 2 (x=50), B = seq 3 (x=100), alpha 0.5
635        let result = i.sample(1250.0);
636
637        let game = result.game.unwrap();
638        assert_eq!(f32_at(&tanks(&game)[0], 0), 75.0);
639        assert_eq!(result.frames.len(), 2); // seq 1 и seq 2 пересечены
640    }
641
642    #[test]
643    fn late_frame_events_are_issued_immediately() {
644        let mut i = make();
645
646        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(0.0, 0.0)))]), 1000.0, 1);
647        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(20.0, 0.0)))]), 1200.0, 3);
648        i.sample(1250.0); // renderTime 1150
649
650        // seq 2 опоздал: его serverTime 1100 позади renderTime
651        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(10.0, 0.0)))]), 1100.0, 2);
652
653        let result = i.sample(1251.0);
654
655        assert_eq!(result.frames.len(), 1); // выдан немедленно
656
657        // повторный push того же seq игнорируется (память выданных)
658        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(10.0, 0.0)))]), 1100.0, 2);
659        assert!(i.sample(1252.0).frames.is_empty());
660    }
661
662    #[test]
663    fn camera_lerp_and_strip() {
664        let mut i = make();
665        let frame = |x: f32| FrameData {
666            snapshot: DecodedSnapshot::default(),
667            camera: Some(DecodedCamera {
668                x,
669                y: 0.0,
670                force_reset: true,
671                shake: None,
672            }),
673        };
674
675        push_zero_offset(&mut i, frame(0.0), 1000.0, 1);
676        push_zero_offset(&mut i, frame(100.0), 1100.0, 2);
677
678        let result = i.sample(1150.0);
679
680        // флаги reset/shake не попадают в интерполированную камеру
681        assert_eq!(result.camera, Some([50.0, 0.0]));
682    }
683
684    #[test]
685    fn offset_ema_smoothing() {
686        let mut i = make();
687
688        i.push(tank_frame(&[]), 1000.0, 500.0, 1); // offset 500
689        assert_eq!(i.offset(), Some(500.0));
690
691        i.push(tank_frame(&[]), 1100.0, 500.0, 2); // offset 600
692        // EMA: 500 + (600 − 500) · 0.1 = 510
693        assert_eq!(i.offset(), Some(510.0));
694    }
695
696    #[test]
697    fn old_frames_are_pruned_by_age() {
698        let mut i = make();
699
700        push_zero_offset(&mut i, tank_frame(&[]), 1000.0, 1);
701        push_zero_offset(&mut i, tank_frame(&[]), 1100.0, 2);
702        // кадр старше maxFrameAge (1000 мс) относительно новейшего
703        push_zero_offset(&mut i, tank_frame(&[]), 2500.0, 3);
704
705        // кадр seq 1 вычищен без выдачи: sample его уже не увидит
706        let result = i.sample(2500.0); // renderTime 2400 → пересечены 2 и...
707
708        // seq 1 (1000.0) удалён pruning'ом, выданы seq 2 и seq 3? renderTime
709        // 2400 пересекает 1100 и не пересекает 2500
710        assert_eq!(result.frames.len(), 1);
711    }
712
713    #[test]
714    fn reset_clears_everything() {
715        let mut i = make();
716
717        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(0.0, 0.0)))]), 1000.0, 1);
718        i.reset();
719
720        assert!(i.offset().is_none());
721
722        let result = i.sample(2000.0);
723
724        assert!(result.frames.is_empty());
725        assert!(result.game.is_none());
726
727        // после reset тот же seq принимается заново
728        push_zero_offset(&mut i, tank_frame(&[(1, Some(tank_row(5.0, 0.0)))]), 3000.0, 1);
729        assert_eq!(i.sample(3200.0).frames.len(), 1);
730    }
731}