Skip to main content

vimp_engine_core/
config.rs

1use indexmap::IndexMap;
2use serde::Deserialize;
3
4/// Движковая половина конфигурации ядра (Wasm Host ABI, PLAN.md §3.4):
5/// общая для любой игры, не знает про модели/оружие/панель. Игровая
6/// половина — `G::Config` (`crate::sim::GameDef::Config`), парсится и
7/// живёт в game-crate; корневой JSON, который собирает JS, имеет форму
8/// `{engine: {...}, game: {...}}` — сборка обоих кусков в один объект
9/// init-конфига делает `GameCore::new` в game-crate.
10#[derive(Clone, Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub struct EngineConfig {
13    /// Интервал фиксированного шага физики (секунды, напр. 1/120).
14    pub time_step: f32,
15    /// Масштаб карты по умолчанию (перекрывается scale самой карты).
16    #[serde(default = "default_map_scale")]
17    pub map_scale: f32,
18    /// Дефолтный setId конструктора карт (game.js mapSetId).
19    #[serde(default = "default_map_set_id")]
20    pub map_set_id: String,
21    pub snapshot: SnapshotConfig,
22    /// Сид PRNG ботов/разброса (детерминизм воспроизводим при равном сиде).
23    #[serde(default = "default_seed")]
24    pub seed: u64,
25}
26
27/// Движковая половина клиентского конфига (игровая половина — конфиг
28/// предиктора/шот-предиктора игры, напр. `TanksClientConfig`).
29#[derive(Clone, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct EngineClientConfig {
32    /// Шаг симуляции предикта (миллисекунды; EngineConfig.timeStep — секунды).
33    pub time_step_ms: f64,
34    pub snapshot: SnapshotConfig,
35    pub interpolation: InterpolationConfig,
36    /// Детектор рассинхрона предикта (plan/done/ai-debug/stage_5.md). Отсутствует
37    /// в боевом конфиге — тогда движок не делает вообще ничего лишнего на
38    /// пути кадра.
39    #[serde(default)]
40    pub divergence: Option<DivergenceConfig>,
41}
42
43/// Пороги детектора рассинхрона предикта: расхождение считается нарушением,
44/// если |предсказанное − авторитетное| по компоненту player-блока превышает
45/// порог. Раскладка компонентов — игровая, поэтому пороги задаются позиционно
46/// (`thresholds[i]`), а недостающие берутся из `default_threshold`.
47#[derive(Clone, Deserialize)]
48#[serde(rename_all = "camelCase")]
49pub struct DivergenceConfig {
50    #[serde(default)]
51    pub thresholds: Vec<f32>,
52    #[serde(default = "default_divergence_threshold")]
53    pub default_threshold: f32,
54    /// Ёмкость кольцевого буфера записей (лишние вытесняются, их число
55    /// попадает в отчёт как `dropped`).
56    #[serde(default = "default_divergence_capacity")]
57    pub capacity: usize,
58}
59
60impl DivergenceConfig {
61    pub fn threshold(&self, index: usize) -> f32 {
62        self.thresholds
63            .get(index)
64            .copied()
65            .unwrap_or(self.default_threshold)
66    }
67}
68
69fn default_divergence_threshold() -> f32 {
70    1.0
71}
72
73fn default_divergence_capacity() -> usize {
74    64
75}
76
77fn default_map_scale() -> f32 {
78    1.0
79}
80
81fn default_map_set_id() -> String {
82    "c1".to_string()
83}
84
85fn default_seed() -> u64 {
86    0x5644_4d49_5056_494d // произвольная константа
87}
88
89/// Форма блока в бинарном снапшоте (kind из src/config/opcodes.js):
90/// ширина id/count и наличие null-маркера для удалённых строк. Раскладка
91/// ПОЛЕЙ внутри строки — генерик, описывается отдельно схемой `fields`
92/// (см. `BlockSchema`) и не зашита в Rust; движок не знает игровых имён
93/// сущностей (танк/снаряд/трассер), только форму их строк.
94#[derive(Clone, Copy, PartialEq, Eq, Debug, Deserialize)]
95#[serde(rename_all = "camelCase")]
96pub enum BlockKind {
97    /// u8 id + null-маркер (удаление строки), u8 count.
98    Indexed8,
99    /// u32 id + null-маркер, u16 count.
100    Indexed32,
101    /// без id, без null-маркера, u16 count.
102    List16,
103    /// u8 индекс без null-маркера, u8 count.
104    IndexedNoNull8,
105}
106
107/// Бинарный тип поля строки блока.
108#[derive(Clone, Copy, PartialEq, Eq, Debug, Deserialize)]
109#[serde(rename_all = "lowercase")]
110pub enum FieldType {
111    F32,
112    U8,
113    U16,
114    U32,
115}
116
117/// Способ интерполяции поля на клиенте между кадрами A/B
118/// (применяется только к блокам класса `Hot` — см. `BlockClass`).
119#[derive(Clone, Copy, PartialEq, Eq, Deserialize)]
120#[serde(rename_all = "camelCase")]
121pub enum Interp {
122    Lerp,
123    LerpAngle,
124    Discrete,
125}
126
127/// Описание одного поля строки блока — порядок в векторе равен порядку
128/// байтов в раскладке (и порядку полей в конкретной Row-структуре
129/// core/src/snapshot.rs — молчаливый контракт, проверяемый тестами).
130#[derive(Clone, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct FieldSchema {
133    pub name: String,
134    pub ty: FieldType,
135    #[serde(default = "default_interp")]
136    pub interp: Interp,
137}
138
139fn default_interp() -> Interp {
140    Interp::Discrete
141}
142
143/// Значение поля строки во время упаковки/распаковки (рантайм-парность
144/// `FieldType`).
145#[derive(Clone, Copy)]
146pub enum FieldValue {
147    F32(f32),
148    U8(u8),
149    U16(u16),
150    U32(u32),
151}
152
153/// Класс блока: «горячий» — интерполируется клиентом между кадрами
154/// (танки, динамика карты), «событийный» — одноразовый, кадром как есть
155/// (трассеры/бомбы/взрывы).
156#[derive(Clone, Copy, PartialEq, Eq, Deserialize)]
157#[serde(rename_all = "camelCase")]
158pub enum BlockClass {
159    Hot,
160    Event,
161}
162
163#[derive(Clone, Deserialize)]
164#[serde(rename_all = "camelCase")]
165pub struct BlockSchema {
166    pub id: u8,
167    pub kind: BlockKind,
168    pub class: BlockClass,
169    #[serde(default)]
170    pub fields: Vec<FieldSchema>,
171}
172
173/// Длина player-блока предикшена (predicted player state), см.
174/// `Tank::prediction_state`. Единая константа вместо 4 независимых
175/// литералов `8` (snapshot.rs/unpack.rs/tank.rs/predictor.rs).
176pub const PLAYER_STATE_LEN: usize = 8;
177
178/// Реестр снапшот-ключей и версия формата (src/config/opcodes.js).
179#[derive(Clone, Deserialize)]
180#[serde(rename_all = "camelCase")]
181pub struct SnapshotConfig {
182    pub version: u8,
183    /// Номер порта SHOT_DATA (первый байт кадра).
184    pub port: u8,
185    pub keys: IndexMap<String, BlockSchema>,
186}
187
188impl SnapshotConfig {
189    /// Проверяет структурные инварианты, общие для любой игры: `id` блока —
190    /// это опкод в бинарном кадре (см. `core/src/snapshot.rs`), совпадение
191    /// двух `id` молча портит распаковку на клиенте. Должна вызываться на
192    /// границе конструирования (`GameCore::new`/`ClientCore::new`).
193    pub fn validate(&self) -> Result<(), String> {
194        let mut seen_ids: Vec<u8> = Vec::new();
195
196        for (key, schema) in &self.keys {
197            if seen_ids.contains(&schema.id) {
198                return Err(format!(
199                    "[core snapshot] Ключ '{key}': id {} уже используется другим блоком",
200                    schema.id
201                ));
202            }
203
204            seen_ids.push(schema.id);
205        }
206
207        Ok(())
208    }
209}
210
211/// Настройки snapshot-интерполяции (src/config/client.js interpolation).
212#[derive(Clone, Deserialize)]
213#[serde(rename_all = "camelCase")]
214pub struct InterpolationConfig {
215    /// Задержка рендера в прошлом (мс).
216    pub delay: f64,
217    /// Максимальный возраст кадра в буфере (мс).
218    pub max_frame_age: f64,
219}
220
221/// Канонические схемы 5 сегодняшних kind — общий тестовый фикстур для
222/// snapshot.rs/unpack.rs/interpolator.rs/client/mod.rs (единый source of
223/// truth вместо копипаста SnapshotConfig в каждом test-модуле).
224#[cfg(test)]
225pub mod test_support {
226    use super::*;
227
228    fn field(name: &str, ty: FieldType) -> FieldSchema {
229        FieldSchema {
230            name: name.to_string(),
231            ty,
232            interp: Interp::Discrete,
233        }
234    }
235
236    fn field_interp(name: &str, ty: FieldType, interp: Interp) -> FieldSchema {
237        FieldSchema {
238            name: name.to_string(),
239            ty,
240            interp,
241        }
242    }
243
244    pub fn tanks_schema(id: u8) -> BlockSchema {
245        BlockSchema {
246            id,
247            kind: BlockKind::Indexed8,
248            class: BlockClass::Hot,
249            fields: vec![
250                field_interp("x", FieldType::F32, Interp::Lerp),
251                field_interp("y", FieldType::F32, Interp::Lerp),
252                field_interp("angle", FieldType::F32, Interp::LerpAngle),
253                field_interp("gunRotation", FieldType::F32, Interp::LerpAngle),
254                field_interp("vx", FieldType::F32, Interp::Lerp),
255                field_interp("vy", FieldType::F32, Interp::Lerp),
256                field_interp("engineLoad", FieldType::F32, Interp::Lerp),
257                field("condition", FieldType::U8),
258                field("size", FieldType::U8),
259                field("team", FieldType::U8),
260            ],
261        }
262    }
263
264    pub fn tracers_schema(id: u8) -> BlockSchema {
265        BlockSchema {
266            id,
267            kind: BlockKind::List16,
268            class: BlockClass::Event,
269            fields: vec![
270                field("startX", FieldType::F32),
271                field("startY", FieldType::F32),
272                field("endX", FieldType::F32),
273                field("endY", FieldType::F32),
274                field("bodyX", FieldType::F32),
275                field("bodyY", FieldType::F32),
276                field("wasHit", FieldType::U8),
277                field("shooterId", FieldType::U8),
278            ],
279        }
280    }
281
282    pub fn bombs_schema(id: u8) -> BlockSchema {
283        BlockSchema {
284            id,
285            kind: BlockKind::Indexed32,
286            class: BlockClass::Event,
287            fields: vec![
288                field("x", FieldType::F32),
289                field("y", FieldType::F32),
290                field("angle", FieldType::F32),
291                field("size", FieldType::U8),
292                field("time", FieldType::U16),
293                field("ownerId", FieldType::U8),
294            ],
295        }
296    }
297
298    pub fn explosions_schema(id: u8) -> BlockSchema {
299        BlockSchema {
300            id,
301            kind: BlockKind::List16,
302            class: BlockClass::Event,
303            fields: vec![
304                field("x", FieldType::F32),
305                field("y", FieldType::F32),
306                field("radius", FieldType::F32),
307            ],
308        }
309    }
310
311    pub fn dynamics_schema(id: u8) -> BlockSchema {
312        BlockSchema {
313            id,
314            kind: BlockKind::IndexedNoNull8,
315            class: BlockClass::Hot,
316            fields: vec![
317                field_interp("x", FieldType::F32, Interp::Lerp),
318                field_interp("y", FieldType::F32, Interp::Lerp),
319                field_interp("angle", FieldType::F32, Interp::LerpAngle),
320            ],
321        }
322    }
323
324    /// Реестр из 5 ключей — 5 из 6 канонических ключей opcodes.js
325    /// (без `c2`, второго динамического слоя с идентичной `c1` схемой).
326    pub fn full_snapshot_config(version: u8, port: u8) -> SnapshotConfig {
327        let mut keys = IndexMap::new();
328
329        keys.insert("m1".to_string(), tanks_schema(1));
330        keys.insert("w1".to_string(), tracers_schema(2));
331        keys.insert("w2".to_string(), bombs_schema(3));
332        keys.insert("w2e".to_string(), explosions_schema(4));
333        keys.insert("c1".to_string(), dynamics_schema(5));
334
335        SnapshotConfig {
336            version,
337            port,
338            keys,
339        }
340    }
341}
342
343#[cfg(test)]
344mod validate_tests {
345    use super::test_support::full_snapshot_config;
346
347    #[test]
348    fn valid_schema_passes() {
349        assert!(full_snapshot_config(3, 5).validate().is_ok());
350    }
351
352    #[test]
353    fn duplicate_id_fails() {
354        let mut cfg = full_snapshot_config(3, 5);
355
356        cfg.keys.get_mut("w2").unwrap().id = cfg.keys["m1"].id;
357
358        let err = cfg.validate().unwrap_err();
359        assert!(err.contains("w2"));
360    }
361}