Skip to main content

vimp_engine_core/client/
divergence.rs

1//! Детектор рассинхрона предикта (`plan/done/ai-debug/stage_5.md`): сравнение
2//! предсказанного состояния своего актора с авторитетным player-блоком
3//! кадра — покомпонентно, с порогами из конфига.
4//!
5//! Существенно: предиктор игры реконсилится **по времени**, а не по `seq`
6//! (история ввода переигрывается от момента авторитетного состояния), поэтому
7//! запись несёт `serverTime`/`offset`/`localNow` и окно переигранной истории
8//! ввода, а `inputSeq` — только справочно, как подтверждённый хостом номер.
9//! Сравнение «по тому же seq» здесь ввело бы в заблуждение.
10//!
11//! Уровень 0 (`Source::Camera`) не требует от плагина ничего: сравнивается
12//! камера predicted-оверлея с x/y авторитетного состояния. Уровень 1
13//! (`Source::State`) — если игра реализовала `GameClientDef::predicted_state`.
14
15use std::collections::VecDeque;
16
17use serde_json::{Value, json};
18
19use crate::config::{DivergenceConfig, PLAYER_STATE_LEN};
20
21/// Откуда взято предсказанное состояние.
22#[derive(Clone, Copy, PartialEq, Eq)]
23pub enum Source {
24    /// Камера predicted-оверлея (x/y) — уровень 0.
25    Camera,
26    /// `GameClientDef::predicted_state` — уровень 1.
27    State,
28}
29
30impl Source {
31    fn as_str(self) -> &'static str {
32        match self {
33            Source::Camera => "camera",
34            Source::State => "state",
35        }
36    }
37}
38
39/// Одно сравнение: момент прихода кадра со своим player-блоком.
40pub struct Observation<'a> {
41    pub source: Source,
42    /// Предсказанное состояние (2 компонента для `Camera`, `PLAYER_STATE_LEN`
43    /// для `State`) — снимается ДО `on_server_state`.
44    pub predicted: &'a [f32],
45    pub authoritative: &'a [f32; PLAYER_STATE_LEN],
46    pub server_time: f64,
47    pub local_now: f64,
48    pub offset: f64,
49    pub input_seq: u32,
50    /// Окно локального времени истории ввода, переигранное последним
51    /// реконсилем: (начало, конец, число вводов).
52    pub replayed: Option<(f64, f64, usize)>,
53}
54
55pub struct DivergenceTracker {
56    cfg: DivergenceConfig,
57    records: VecDeque<Value>,
58    samples: u64,
59    violations: u64,
60    dropped: u64,
61    max_delta: [f32; PLAYER_STATE_LEN],
62}
63
64impl DivergenceTracker {
65    pub fn new(mut cfg: DivergenceConfig) -> Self {
66        // capacity: 0 держал бы буфер пустым, но считал вытеснение на каждой
67        // записи — отчёт сообщал бы «вытеснено N» при N-1 вытеснениях
68        cfg.capacity = cfg.capacity.max(1);
69
70        Self {
71            cfg,
72            records: VecDeque::new(),
73            samples: 0,
74            violations: 0,
75            dropped: 0,
76            max_delta: [0.0; PLAYER_STATE_LEN],
77        }
78    }
79
80    /// Сравнивает предсказанное с авторитетным; запись кладётся в кольцевой
81    /// буфер, только если хотя бы один компонент вышел за свой порог —
82    /// иначе отчёт утонул бы в шуме здоровых кадров.
83    pub fn observe(&mut self, obs: Observation) {
84        self.samples += 1;
85
86        let width = obs.predicted.len().min(PLAYER_STATE_LEN);
87        let mut deltas = Vec::with_capacity(width);
88        let mut exceeded = Vec::new();
89
90        for index in 0..width {
91            let delta = obs.predicted[index] - obs.authoritative[index];
92
93            deltas.push(delta);
94
95            if delta.abs() > self.max_delta[index] {
96                self.max_delta[index] = delta.abs();
97            }
98
99            if delta.abs() > self.cfg.threshold(index) {
100                exceeded.push(index);
101            }
102        }
103
104        if exceeded.is_empty() {
105            return;
106        }
107
108        self.violations += 1;
109
110        if self.records.len() >= self.cfg.capacity {
111            self.records.pop_front();
112            self.dropped += 1;
113        }
114
115        let thresholds: Vec<Value> = (0..width)
116            .map(|index| json!(round4(self.cfg.threshold(index))))
117            .collect();
118
119        self.records.push_back(json!({
120            "source": obs.source.as_str(),
121            "serverTime": obs.server_time,
122            "localNow": obs.local_now,
123            "offset": obs.offset,
124            "inputSeq": obs.input_seq,
125            "replayed": obs.replayed.map(|(from, to, count)| json!({
126                "from": from,
127                "to": to,
128                "count": count,
129            })),
130            "predicted": floats(&obs.predicted[..width]),
131            "authoritative": floats(&obs.authoritative[..width]),
132            "delta": floats(&deltas),
133            "exceeded": exceeded,
134            "thresholds": thresholds,
135        }));
136    }
137
138    /// Вычерпывает записи; агрегаты (`samples`/`violations`/`dropped`/
139    /// `maxDelta`) — накопительные за весь прогон, они и есть ответ на «дрейф
140    /// был вообще?» после того, как записи уже прочитаны.
141    pub fn take_json(&mut self) -> String {
142        let records: Vec<Value> = self.records.drain(..).collect();
143
144        json!({
145            "samples": self.samples,
146            "violations": self.violations,
147            "dropped": self.dropped,
148            "maxDelta": floats(&self.max_delta),
149            "records": records,
150        })
151        .to_string()
152    }
153}
154
155fn floats(values: &[f32]) -> Vec<Value> {
156    values.iter().map(|v| json!(round4(*v))).collect()
157}
158
159// f32 → JSON без хвоста двоичного представления (10.100000381469727)
160fn round4(value: f32) -> f64 {
161    ((value as f64) * 10_000.0).round() / 10_000.0
162}