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    /// Индекс первого поля опционального хвоста: поля `[optional_from..]`
172    /// пишутся в кадр не всегда, их наличие задаёт флаг-байт перед строкой
173    /// (кадр v4, блок динамики карты: покоящееся тело не платит за
174    /// скорости). `None` — строка фиксированной ширины, флаг-байта нет.
175    /// Раскладка полей при этом не меняется: распаковка всегда отдаёт
176    /// строку полной ширины, отсутствующий хвост читается как нули
177    /// (см. `client/unpack.rs`), поэтому потребители строки — интерполятор,
178    /// hot-буфер, JS — остаются фиксированной ширины.
179    #[serde(default)]
180    pub optional_from: Option<usize>,
181}
182
183impl BlockSchema {
184    /// Число полей, которые пишутся в строку всегда (без хвоста).
185    pub fn required_len(&self) -> usize {
186        self.optional_from.unwrap_or(self.fields.len())
187    }
188}
189
190/// Длина player-блока предикшена (predicted player state), см.
191/// `Tank::prediction_state`. Единая константа вместо 4 независимых
192/// литералов `8` (snapshot.rs/unpack.rs/tank.rs/predictor.rs).
193pub const PLAYER_STATE_LEN: usize = 8;
194
195/// Реестр снапшот-ключей и версия формата (src/config/opcodes.js).
196#[derive(Clone, Deserialize)]
197#[serde(rename_all = "camelCase")]
198pub struct SnapshotConfig {
199    pub version: u8,
200    /// Номер порта SHOT_DATA (первый байт кадра).
201    pub port: u8,
202    pub keys: IndexMap<String, BlockSchema>,
203}
204
205impl SnapshotConfig {
206    /// Проверяет структурные инварианты, общие для любой игры: `id` блока —
207    /// это опкод в бинарном кадре (см. `core/src/snapshot.rs`), совпадение
208    /// двух `id` молча портит распаковку на клиенте. Должна вызываться на
209    /// границе конструирования (`GameCore::new`/`ClientCore::new`).
210    pub fn validate(&self) -> Result<(), String> {
211        let mut seen_ids: Vec<u8> = Vec::new();
212
213        for (key, schema) in &self.keys {
214            if seen_ids.contains(&schema.id) {
215                return Err(format!(
216                    "[core snapshot] Ключ '{key}': id {} уже используется другим блоком",
217                    schema.id
218                ));
219            }
220
221            seen_ids.push(schema.id);
222
223            // хвост, который начинается за последним полем (или охватывает
224            // всю строку), не кодирует ничего — флаг-байт стал бы чистым
225            // расходом трафика на каждой строке
226            if let Some(from) = schema.optional_from
227                && (from == 0 || from >= schema.fields.len())
228            {
229                return Err(format!(
230                    "[core snapshot] Ключ '{key}': optionalFrom {from} вне диапазона \
231                     1..{} — опциональный хвост должен быть непустым, а обязательная \
232                     часть строки не может быть пустой",
233                    schema.fields.len().saturating_sub(1)
234                ));
235            }
236        }
237
238        Ok(())
239    }
240}
241
242/// Настройки snapshot-интерполяции (src/config/client.js interpolation).
243#[derive(Clone, Deserialize)]
244#[serde(rename_all = "camelCase")]
245pub struct InterpolationConfig {
246    /// Задержка рендера в прошлом (мс).
247    pub delay: f64,
248    /// Максимальный возраст кадра в буфере (мс).
249    pub max_frame_age: f64,
250}
251
252/// Канонические схемы 5 сегодняшних kind — общий тестовый фикстур для
253/// snapshot.rs/unpack.rs/interpolator.rs/client/mod.rs (единый source of
254/// truth вместо копипаста SnapshotConfig в каждом test-модуле).
255#[cfg(test)]
256pub mod test_support {
257    use super::*;
258
259    fn field(name: &str, ty: FieldType) -> FieldSchema {
260        FieldSchema {
261            name: name.to_string(),
262            ty,
263            interp: Interp::Discrete,
264        }
265    }
266
267    fn field_interp(name: &str, ty: FieldType, interp: Interp) -> FieldSchema {
268        FieldSchema {
269            name: name.to_string(),
270            ty,
271            interp,
272        }
273    }
274
275    pub fn tanks_schema(id: u8) -> BlockSchema {
276        BlockSchema {
277            id,
278            kind: BlockKind::Indexed8,
279            class: BlockClass::Hot,
280            optional_from: None,
281            fields: vec![
282                field_interp("x", FieldType::F32, Interp::Lerp),
283                field_interp("y", FieldType::F32, Interp::Lerp),
284                field_interp("angle", FieldType::F32, Interp::LerpAngle),
285                field_interp("gunRotation", FieldType::F32, Interp::LerpAngle),
286                field_interp("vx", FieldType::F32, Interp::Lerp),
287                field_interp("vy", FieldType::F32, Interp::Lerp),
288                field_interp("engineLoad", FieldType::F32, Interp::Lerp),
289                field("condition", FieldType::U8),
290                field("size", FieldType::U8),
291                field("team", FieldType::U8),
292            ],
293        }
294    }
295
296    pub fn tracers_schema(id: u8) -> BlockSchema {
297        BlockSchema {
298            id,
299            kind: BlockKind::List16,
300            class: BlockClass::Event,
301            optional_from: None,
302            fields: vec![
303                field("startX", FieldType::F32),
304                field("startY", FieldType::F32),
305                field("endX", FieldType::F32),
306                field("endY", FieldType::F32),
307                field("bodyX", FieldType::F32),
308                field("bodyY", FieldType::F32),
309                field("wasHit", FieldType::U8),
310                field("shooterId", FieldType::U8),
311            ],
312        }
313    }
314
315    pub fn bombs_schema(id: u8) -> BlockSchema {
316        BlockSchema {
317            id,
318            kind: BlockKind::Indexed32,
319            class: BlockClass::Event,
320            optional_from: None,
321            fields: vec![
322                field("x", FieldType::F32),
323                field("y", FieldType::F32),
324                field("angle", FieldType::F32),
325                field("size", FieldType::U8),
326                field("time", FieldType::U16),
327                field("ownerId", FieldType::U8),
328            ],
329        }
330    }
331
332    pub fn explosions_schema(id: u8) -> BlockSchema {
333        BlockSchema {
334            id,
335            kind: BlockKind::List16,
336            class: BlockClass::Event,
337            optional_from: None,
338            fields: vec![
339                field("x", FieldType::F32),
340                field("y", FieldType::F32),
341                field("radius", FieldType::F32),
342            ],
343        }
344    }
345
346    /// Динамика карты с опциональным хвостом скоростей (кадр v4):
347    /// покоящееся тело шлёт только трансформацию.
348    pub fn dynamics_schema(id: u8) -> BlockSchema {
349        BlockSchema {
350            id,
351            kind: BlockKind::IndexedNoNull8,
352            class: BlockClass::Hot,
353            optional_from: Some(3),
354            fields: vec![
355                field_interp("x", FieldType::F32, Interp::Lerp),
356                field_interp("y", FieldType::F32, Interp::Lerp),
357                field_interp("angle", FieldType::F32, Interp::LerpAngle),
358                field_interp("vx", FieldType::F32, Interp::Lerp),
359                field_interp("vy", FieldType::F32, Interp::Lerp),
360                // угловая СКОРОСТЬ, а не угол — интерполируется как число
361                field_interp("angvel", FieldType::F32, Interp::Lerp),
362            ],
363        }
364    }
365
366    /// Реестр из 5 ключей — 5 из 6 канонических ключей opcodes.js
367    /// (без `c2`, второго динамического слоя с идентичной `c1` схемой).
368    pub fn full_snapshot_config(version: u8, port: u8) -> SnapshotConfig {
369        let mut keys = IndexMap::new();
370
371        keys.insert("m1".to_string(), tanks_schema(1));
372        keys.insert("w1".to_string(), tracers_schema(2));
373        keys.insert("w2".to_string(), bombs_schema(3));
374        keys.insert("w2e".to_string(), explosions_schema(4));
375        keys.insert("c1".to_string(), dynamics_schema(5));
376
377        SnapshotConfig {
378            version,
379            port,
380            keys,
381        }
382    }
383}
384
385#[cfg(test)]
386mod validate_tests {
387    use super::test_support::full_snapshot_config;
388
389    #[test]
390    fn valid_schema_passes() {
391        assert!(full_snapshot_config(3, 5).validate().is_ok());
392    }
393
394    #[test]
395    fn optional_from_out_of_range_fails() {
396        let mut cfg = full_snapshot_config(3, 5);
397
398        cfg.keys.get_mut("c1").unwrap().optional_from = Some(6);
399
400        let err = cfg.validate().unwrap_err();
401        assert!(err.contains("optionalFrom"));
402    }
403
404    #[test]
405    fn optional_from_zero_fails() {
406        let mut cfg = full_snapshot_config(3, 5);
407
408        cfg.keys.get_mut("c1").unwrap().optional_from = Some(0);
409
410        assert!(cfg.validate().is_err());
411    }
412
413    #[test]
414    fn duplicate_id_fails() {
415        let mut cfg = full_snapshot_config(3, 5);
416
417        cfg.keys.get_mut("w2").unwrap().id = cfg.keys["m1"].id;
418
419        let err = cfg.validate().unwrap_err();
420        assert!(err.contains("w2"));
421    }
422}