use indexmap::IndexMap;
use serde::Deserialize;
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EngineConfig {
pub time_step: f32,
#[serde(default = "default_map_scale")]
pub map_scale: f32,
#[serde(default = "default_map_set_id")]
pub map_set_id: String,
pub snapshot: SnapshotConfig,
#[serde(default = "default_seed")]
pub seed: u64,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EngineClientConfig {
pub time_step_ms: f64,
pub snapshot: SnapshotConfig,
pub interpolation: InterpolationConfig,
#[serde(default)]
pub divergence: Option<DivergenceConfig>,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DivergenceConfig {
#[serde(default)]
pub thresholds: Vec<f32>,
#[serde(default = "default_divergence_threshold")]
pub default_threshold: f32,
#[serde(default = "default_divergence_capacity")]
pub capacity: usize,
}
impl DivergenceConfig {
pub fn threshold(&self, index: usize) -> f32 {
self.thresholds
.get(index)
.copied()
.unwrap_or(self.default_threshold)
}
}
fn default_divergence_threshold() -> f32 {
1.0
}
fn default_divergence_capacity() -> usize {
64
}
fn default_map_scale() -> f32 {
1.0
}
fn default_map_set_id() -> String {
"c1".to_string()
}
fn default_seed() -> u64 {
0x5644_4d49_5056_494d }
#[derive(Clone, Copy, PartialEq, Eq, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum BlockKind {
Indexed8,
Indexed32,
List16,
IndexedNoNull8,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FieldType {
F32,
U8,
U16,
U32,
}
#[derive(Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Interp {
Lerp,
LerpAngle,
Discrete,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FieldSchema {
pub name: String,
pub ty: FieldType,
#[serde(default = "default_interp")]
pub interp: Interp,
}
fn default_interp() -> Interp {
Interp::Discrete
}
#[derive(Clone, Copy)]
pub enum FieldValue {
F32(f32),
U8(u8),
U16(u16),
U32(u32),
}
#[derive(Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum BlockClass {
Hot,
Event,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BlockSchema {
pub id: u8,
pub kind: BlockKind,
pub class: BlockClass,
#[serde(default)]
pub fields: Vec<FieldSchema>,
#[serde(default)]
pub optional_from: Option<usize>,
}
impl BlockSchema {
pub fn required_len(&self) -> usize {
self.optional_from.unwrap_or(self.fields.len())
}
}
pub const PLAYER_STATE_LEN: usize = 8;
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SnapshotConfig {
pub version: u8,
pub port: u8,
pub keys: IndexMap<String, BlockSchema>,
}
impl SnapshotConfig {
pub fn validate(&self) -> Result<(), String> {
let mut seen_ids: Vec<u8> = Vec::new();
for (key, schema) in &self.keys {
if seen_ids.contains(&schema.id) {
return Err(format!(
"[core snapshot] Ключ '{key}': id {} уже используется другим блоком",
schema.id
));
}
seen_ids.push(schema.id);
if let Some(from) = schema.optional_from
&& (from == 0 || from >= schema.fields.len())
{
return Err(format!(
"[core snapshot] Ключ '{key}': optionalFrom {from} вне диапазона \
1..{} — опциональный хвост должен быть непустым, а обязательная \
часть строки не может быть пустой",
schema.fields.len().saturating_sub(1)
));
}
}
Ok(())
}
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InterpolationConfig {
pub delay: f64,
pub max_frame_age: f64,
}
#[cfg(test)]
pub mod test_support {
use super::*;
fn field(name: &str, ty: FieldType) -> FieldSchema {
FieldSchema {
name: name.to_string(),
ty,
interp: Interp::Discrete,
}
}
fn field_interp(name: &str, ty: FieldType, interp: Interp) -> FieldSchema {
FieldSchema {
name: name.to_string(),
ty,
interp,
}
}
pub fn tanks_schema(id: u8) -> BlockSchema {
BlockSchema {
id,
kind: BlockKind::Indexed8,
class: BlockClass::Hot,
optional_from: None,
fields: vec![
field_interp("x", FieldType::F32, Interp::Lerp),
field_interp("y", FieldType::F32, Interp::Lerp),
field_interp("angle", FieldType::F32, Interp::LerpAngle),
field_interp("gunRotation", FieldType::F32, Interp::LerpAngle),
field_interp("vx", FieldType::F32, Interp::Lerp),
field_interp("vy", FieldType::F32, Interp::Lerp),
field_interp("engineLoad", FieldType::F32, Interp::Lerp),
field("condition", FieldType::U8),
field("size", FieldType::U8),
field("team", FieldType::U8),
],
}
}
pub fn tracers_schema(id: u8) -> BlockSchema {
BlockSchema {
id,
kind: BlockKind::List16,
class: BlockClass::Event,
optional_from: None,
fields: vec![
field("startX", FieldType::F32),
field("startY", FieldType::F32),
field("endX", FieldType::F32),
field("endY", FieldType::F32),
field("bodyX", FieldType::F32),
field("bodyY", FieldType::F32),
field("wasHit", FieldType::U8),
field("shooterId", FieldType::U8),
],
}
}
pub fn bombs_schema(id: u8) -> BlockSchema {
BlockSchema {
id,
kind: BlockKind::Indexed32,
class: BlockClass::Event,
optional_from: None,
fields: vec![
field("x", FieldType::F32),
field("y", FieldType::F32),
field("angle", FieldType::F32),
field("size", FieldType::U8),
field("time", FieldType::U16),
field("ownerId", FieldType::U8),
],
}
}
pub fn explosions_schema(id: u8) -> BlockSchema {
BlockSchema {
id,
kind: BlockKind::List16,
class: BlockClass::Event,
optional_from: None,
fields: vec![
field("x", FieldType::F32),
field("y", FieldType::F32),
field("radius", FieldType::F32),
],
}
}
pub fn dynamics_schema(id: u8) -> BlockSchema {
BlockSchema {
id,
kind: BlockKind::IndexedNoNull8,
class: BlockClass::Hot,
optional_from: Some(3),
fields: vec![
field_interp("x", FieldType::F32, Interp::Lerp),
field_interp("y", FieldType::F32, Interp::Lerp),
field_interp("angle", FieldType::F32, Interp::LerpAngle),
field_interp("vx", FieldType::F32, Interp::Lerp),
field_interp("vy", FieldType::F32, Interp::Lerp),
field_interp("angvel", FieldType::F32, Interp::Lerp),
],
}
}
pub fn full_snapshot_config(version: u8, port: u8) -> SnapshotConfig {
let mut keys = IndexMap::new();
keys.insert("m1".to_string(), tanks_schema(1));
keys.insert("w1".to_string(), tracers_schema(2));
keys.insert("w2".to_string(), bombs_schema(3));
keys.insert("w2e".to_string(), explosions_schema(4));
keys.insert("c1".to_string(), dynamics_schema(5));
SnapshotConfig {
version,
port,
keys,
}
}
}
#[cfg(test)]
mod validate_tests {
use super::test_support::full_snapshot_config;
#[test]
fn valid_schema_passes() {
assert!(full_snapshot_config(3, 5).validate().is_ok());
}
#[test]
fn optional_from_out_of_range_fails() {
let mut cfg = full_snapshot_config(3, 5);
cfg.keys.get_mut("c1").unwrap().optional_from = Some(6);
let err = cfg.validate().unwrap_err();
assert!(err.contains("optionalFrom"));
}
#[test]
fn optional_from_zero_fails() {
let mut cfg = full_snapshot_config(3, 5);
cfg.keys.get_mut("c1").unwrap().optional_from = Some(0);
assert!(cfg.validate().is_err());
}
#[test]
fn duplicate_id_fails() {
let mut cfg = full_snapshot_config(3, 5);
cfg.keys.get_mut("w2").unwrap().id = cfg.keys["m1"].id;
let err = cfg.validate().unwrap_err();
assert!(err.contains("w2"));
}
}