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