Skip to main content

proof_engine/config/
mod.rs

1//! Engine configuration — hierarchical, hot-reloadable, command-line overridable.
2//!
3//! Loads from `engine.toml` with sensible defaults for all settings. Supports:
4//! - TOML serialization/deserialization
5//! - Profile system (default, debug, release, steam_deck, low_end)
6//! - Command-line argument overrides (`--width`, `--height`, `--no-audio`, etc.)
7//! - Hot reload: watch `engine.toml` for changes and re-apply at runtime
8//! - Validation with clamped/sanitized values
9//! - Diff-based change detection for per-subsystem notifications
10
11use serde::{Deserialize, Serialize};
12
13// ── Top-level ─────────────────────────────────────────────────────────────────
14
15/// Top-level engine configuration, loadable from a TOML file.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct EngineConfig {
18    pub window_title:  String,
19    pub window_width:  u32,
20    pub window_height: u32,
21    pub target_fps:    u32,
22    pub vsync:         bool,
23    pub audio:         AudioConfig,
24    pub render:        RenderConfig,
25    pub physics:       PhysicsConfig,
26    pub input:         InputConfig,
27    pub debug:         DebugConfig,
28    pub gameplay:      GameplayConfig,
29    pub accessibility: AccessibilityConfig,
30}
31
32impl Default for EngineConfig {
33    fn default() -> Self {
34        Self {
35            window_title:  "Proof Engine".to_string(),
36            window_width:  1280,
37            window_height: 800,
38            target_fps:    60,
39            vsync:         true,
40            audio:         AudioConfig::default(),
41            render:        RenderConfig::default(),
42            physics:       PhysicsConfig::default(),
43            input:         InputConfig::default(),
44            debug:         DebugConfig::default(),
45            gameplay:      GameplayConfig::default(),
46            accessibility: AccessibilityConfig::default(),
47        }
48    }
49}
50
51impl EngineConfig {
52    // ── Load/Save ──────────────────────────────────────────────────────────────
53
54    /// Load from a TOML file, falling back to defaults on error.
55    pub fn load(path: &str) -> Self {
56        let mut cfg: Self = std::fs::read_to_string(path)
57            .ok()
58            .and_then(|s| toml::from_str(&s).ok())
59            .unwrap_or_default();
60        cfg.validate();
61        cfg
62    }
63
64    /// Save to a TOML file.
65    pub fn save(&self, path: &str) -> bool {
66        toml::to_string_pretty(self)
67            .ok()
68            .and_then(|s| std::fs::write(path, s).ok())
69            .is_some()
70    }
71
72    // ── Profiles ──────────────────────────────────────────────────────────────
73
74    /// Low-end PC profile: reduced resolution, no post-fx, minimal effects.
75    pub fn profile_low_end() -> Self {
76        Self {
77            window_width:  960,
78            window_height: 540,
79            target_fps:    30,
80            vsync:         true,
81            render:        RenderConfig {
82                bloom_enabled:        false,
83                motion_blur_enabled:  false,
84                chromatic_aberration: 0.0,
85                film_grain:           0.0,
86                scanlines_enabled:    false,
87                particle_multiplier:  0.5,
88                shadow_quality:       ShadowQuality::Off,
89                ..RenderConfig::default()
90            },
91            physics: PhysicsConfig {
92                fluid_grid_size:  16,
93                soft_body_iters:  2,
94                ..PhysicsConfig::default()
95            },
96            ..Self::default()
97        }
98    }
99
100    /// Steam Deck profile: 1280×800 locked 60, moderate effects.
101    pub fn profile_steam_deck() -> Self {
102        Self {
103            window_width:  1280,
104            window_height: 800,
105            target_fps:    60,
106            vsync:         true,
107            render:        RenderConfig {
108                bloom_intensity:      0.6,
109                chromatic_aberration: 0.001,
110                particle_multiplier:  0.75,
111                shadow_quality:       ShadowQuality::Low,
112                ..RenderConfig::default()
113            },
114            ..Self::default()
115        }
116    }
117
118    /// Ultra profile: max everything.
119    pub fn profile_ultra() -> Self {
120        Self {
121            window_width:  2560,
122            window_height: 1440,
123            target_fps:    144,
124            vsync:         false,
125            render:        RenderConfig {
126                bloom_intensity:      1.5,
127                chromatic_aberration: 0.003,
128                film_grain:           0.03,
129                particle_multiplier:  2.0,
130                shadow_quality:       ShadowQuality::Ultra,
131                ..RenderConfig::default()
132            },
133            physics: PhysicsConfig {
134                fluid_grid_size:  128,
135                soft_body_iters:  8,
136                ..PhysicsConfig::default()
137            },
138            ..Self::default()
139        }
140    }
141
142    /// Debug profile: all overlays on, no vsync, fast timestep.
143    pub fn profile_debug() -> Self {
144        Self {
145            vsync:      false,
146            target_fps: 0, // uncapped
147            debug: DebugConfig {
148                show_fps:          true,
149                show_frame_graph:  true,
150                show_physics:      true,
151                show_spawn_zones:  true,
152                show_entity_ids:   true,
153                log_level:         LogLevel::Debug,
154                ..DebugConfig::default()
155            },
156            ..Self::default()
157        }
158    }
159
160    // ── Command-line override ──────────────────────────────────────────────────
161
162    /// Parse and apply command-line arguments as overrides.
163    /// Supported flags: `--width N`, `--height N`, `--fps N`, `--no-audio`,
164    /// `--no-vsync`, `--no-bloom`, `--fullscreen`, `--windowed`.
165    pub fn apply_args(&mut self, args: &[String]) {
166        let mut i = 0;
167        while i < args.len() {
168            match args[i].as_str() {
169                "--width" => {
170                    if let Some(v) = args.get(i + 1).and_then(|s| s.parse().ok()) {
171                        self.window_width = v;
172                        i += 1;
173                    }
174                }
175                "--height" => {
176                    if let Some(v) = args.get(i + 1).and_then(|s| s.parse().ok()) {
177                        self.window_height = v;
178                        i += 1;
179                    }
180                }
181                "--fps" => {
182                    if let Some(v) = args.get(i + 1).and_then(|s| s.parse().ok()) {
183                        self.target_fps = v;
184                        i += 1;
185                    }
186                }
187                "--no-audio"    => self.audio.enabled = false,
188                "--no-vsync"    => self.vsync = false,
189                "--no-bloom"    => self.render.bloom_enabled = false,
190                "--no-postfx"   => {
191                    self.render.bloom_enabled = false;
192                    self.render.distortion_enabled = false;
193                    self.render.motion_blur_enabled = false;
194                    self.render.chromatic_aberration = 0.0;
195                    self.render.film_grain = 0.0;
196                }
197                "--fullscreen"  => self.render.fullscreen = true,
198                "--windowed"    => self.render.fullscreen = false,
199                "--low-end"     => *self = Self::profile_low_end(),
200                "--ultra"       => *self = Self::profile_ultra(),
201                "--debug"       => *self = Self::profile_debug(),
202                _ => {}
203            }
204            i += 1;
205        }
206        self.validate();
207    }
208
209    // ── Validation ─────────────────────────────────────────────────────────────
210
211    /// Clamp and sanitize all values to valid ranges.
212    pub fn validate(&mut self) {
213        self.window_width  = self.window_width.clamp(320, 7680);
214        self.window_height = self.window_height.clamp(240, 4320);
215        self.target_fps    = if self.target_fps == 0 { 0 } else { self.target_fps.clamp(15, 360) };
216        self.audio.validate();
217        self.render.validate();
218        self.physics.validate();
219    }
220
221    // ── Aspect ratio ──────────────────────────────────────────────────────────
222
223    pub fn aspect_ratio(&self) -> f32 {
224        self.window_width as f32 / self.window_height as f32
225    }
226
227    /// Check if resolution is standard 16:9.
228    pub fn is_widescreen(&self) -> bool {
229        let ar = self.aspect_ratio();
230        (ar - 16.0 / 9.0).abs() < 0.05
231    }
232
233    /// Check if the config differs from another (for hot-reload diffs).
234    pub fn diff(&self, other: &Self) -> ConfigDiff {
235        ConfigDiff {
236            window_changed:  self.window_width  != other.window_width
237                          || self.window_height != other.window_height,
238            audio_changed:   self.audio.enabled != other.audio.enabled
239                          || (self.audio.master_volume - other.audio.master_volume).abs() > 0.01,
240            render_changed:  self.render.bloom_enabled    != other.render.bloom_enabled
241                          || self.render.bloom_intensity  != other.render.bloom_intensity,
242            physics_changed: self.physics.fluid_grid_size != other.physics.fluid_grid_size,
243        }
244    }
245}
246
247/// Bitmask of which subsystems changed in a hot-reload diff.
248#[derive(Debug, Default)]
249pub struct ConfigDiff {
250    pub window_changed:  bool,
251    pub audio_changed:   bool,
252    pub render_changed:  bool,
253    pub physics_changed: bool,
254}
255
256impl ConfigDiff {
257    pub fn any_changed(&self) -> bool {
258        self.window_changed || self.audio_changed || self.render_changed || self.physics_changed
259    }
260}
261
262// ── AudioConfig ───────────────────────────────────────────────────────────────
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct AudioConfig {
266    pub enabled:       bool,
267    pub master_volume: f32,
268    pub music_volume:  f32,
269    pub sfx_volume:    f32,
270    /// Sample rate override (0 = use device default).
271    pub sample_rate:   u32,
272    /// Buffer size in frames (0 = auto).
273    pub buffer_size:   u32,
274    /// Enable spatialized 3D audio.
275    pub spatial_audio: bool,
276    /// Reverb room size (0.0 = dry, 1.0 = large hall).
277    pub reverb_room:   f32,
278    pub audio_backend: AudioBackend,
279}
280
281impl Default for AudioConfig {
282    fn default() -> Self {
283        Self {
284            enabled:       true,
285            master_volume: 1.0,
286            music_volume:  0.6,
287            sfx_volume:    0.8,
288            sample_rate:   0,
289            buffer_size:   0,
290            spatial_audio: true,
291            reverb_room:   0.2,
292            audio_backend: AudioBackend::Default,
293        }
294    }
295}
296
297impl AudioConfig {
298    pub fn validate(&mut self) {
299        self.master_volume = self.master_volume.clamp(0.0, 1.0);
300        self.music_volume  = self.music_volume.clamp(0.0, 1.0);
301        self.sfx_volume    = self.sfx_volume.clamp(0.0, 1.0);
302        self.reverb_room   = self.reverb_room.clamp(0.0, 1.0);
303    }
304}
305
306#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
307pub enum AudioBackend { Default, Wasapi, Asio, PulseAudio, Alsa, CoreAudio }
308
309// ── RenderConfig ──────────────────────────────────────────────────────────────
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct RenderConfig {
313    pub bloom_enabled:        bool,
314    pub bloom_intensity:      f32,
315    /// Bloom radius in pixels (higher = wider glow).
316    pub bloom_radius:         f32,
317    pub distortion_enabled:   bool,
318    pub motion_blur_enabled:  bool,
319    /// Motion blur sample count (2-16).
320    pub motion_blur_samples:  u32,
321    pub chromatic_aberration: f32,
322    pub film_grain:           f32,
323    pub scanlines_enabled:    bool,
324    pub scanline_intensity:   f32,
325
326    // ── Grade ────────────────────────────────────────────────────────────────
327    //
328    // The composite shader has had tint, saturation, contrast and a vignette
329    // since it was written, and every one of them was hard-coded in the
330    // pipeline where nothing could reach it. A game could turn bloom on and
331    // that was the whole of its control over how the picture looked.
332    /// Linear exposure, applied before the tonemap. 1.0 is neutral.
333    pub exposure:             f32,
334    /// How much of the filmic curve to apply, 0.0 to 1.0.
335    ///
336    /// Without it the image is clipped rather than rolled off, which is what
337    /// makes a rendered frame look rendered.
338    pub tonemap:              f32,
339    /// Warm bleed around bright edges, the way film halates. 0.0 to ~0.5.
340    pub halation:             f32,
341    pub saturation:           f32,
342    pub contrast:             f32,
343    pub brightness:           f32,
344    /// Strength of the corner darkening, 0.0 to 1.0.
345    pub vignette:             f32,
346    /// Where the vignette starts, as a fraction of the way to the corner.
347    pub vignette_softness:    f32,
348    /// Colour cast, multiplied over everything.
349    pub tint:                 [f32; 3],
350    /// Shadows, lifted toward this colour.
351    pub lift:                 [f32; 3],
352    /// Highlights, multiplied by this colour.
353    pub gain:                 [f32; 3],
354    /// Nothing dimmer than this blooms. 0.0 blooms everything.
355    pub bloom_threshold:      f32,
356    /// How soft the edge of that threshold is.
357    pub bloom_knee:           f32,
358    /// Horizontal light streaking, the way anamorphic glass flares. 0.0 off.
359    pub anamorphic:           f32,
360    /// Unsharp mask after the tonemap, to put back the edge bloom takes off.
361    pub sharpen:              f32,
362    /// Ordered dither before output, in 8-bit steps.
363    ///
364    /// This engine renders very dark scenes, and a dark gradient in eight bits
365    /// per channel bands visibly. A quarter-step of ordered noise costs
366    /// nothing and removes it.
367    pub dither:               f32,
368    /// Barrel distortion, as a fraction of the frame. Small values only.
369    pub barrel:               f32,
370    pub font_size:            u32,
371    pub fullscreen:           bool,
372    /// Render scale (1.0 = native, 0.5 = half res).
373    pub render_scale:         f32,
374    /// Particle system multiplier (1.0 = full, 0.5 = half particles).
375    pub particle_multiplier:  f32,
376    pub shadow_quality:       ShadowQuality,
377    /// Enable anti-aliasing (FXAA approximation for terminal renderer).
378    pub antialiasing:         bool,
379    /// Color depth per channel for dithering: 8, 16, or 32.
380    pub color_depth:          u8,
381    /// Voxel-cone global illumination: voxelise the scene, inject light,
382    /// propagate. Worth it for a lit 3D scene and pure cost for anything else.
383    ///
384    /// This used to run unconditionally, every frame, whatever was on screen —
385    /// which meant a game drawing its whole interface in the screen-space UI
386    /// layer paid for a global illumination solve it could never see.
387    pub global_illumination:  bool,
388    /// Volumetric fog. Same story: a real effect, and a real cost, that a
389    /// scene with nothing in it cannot benefit from.
390    pub volumetric_fog:       bool,
391
392    // ── Light ────────────────────────────────────────────────────────────────
393    //
394    // Everything above shapes the picture. These add light to it.
395    /// Screen-space indirect light: how strongly matter near something
396    /// bright is lit by it, in that thing's colour. 0.0 off; 0.5 to 1.5 is
397    /// a lit room.
398    pub indirect_light:       f32,
399    /// Radial light shafts from the source the game sets on `engine.fx`.
400    /// 0.0 off; the source's own strength scales it further.
401    pub light_shafts:         f32,
402    /// Lens flare ghosts and halo off anything that blooms. 0.0 off; 0.1 to
403    /// 0.4 is a visible lens without being a screensaver.
404    pub lens_flare:           f32,
405    /// How far, in pixels, the world pass moves at full camera trauma. The
406    /// HUD does not move. 0.0 turns screen shake off for the world pass.
407    pub shake_pixels:         f32,
408    /// Anti-aliasing on the finished frame, between the composite and the
409    /// HUD. A real FXAA pass, not the terminal approximation `antialiasing`
410    /// names.
411    pub fxaa:                 bool,
412    /// Motion trails. The fraction of the previous frame's scene that
413    /// survives into this one, 0.0 to about 0.9. Applies to the scene and
414    /// the UI world pass, never to the HUD.
415    pub persistence:          f32,
416    /// Wait for the display's vertical refresh before presenting. Off, the
417    /// loop runs as fast as the GPU allows, which burns power and makes
418    /// every time-based effect run at whatever rate the machine happens to.
419    pub vsync:                bool,
420    /// Whether the UI layer's world pass is painted into the scene buffer.
421    /// Off, every UI command paints in the HUD pass after post-processing,
422    /// which is how the layer behaved before the world pass existed.
423    pub world_ui_in_scene:    bool,
424}
425
426impl Default for RenderConfig {
427    fn default() -> Self {
428        Self {
429            bloom_enabled:        true,
430            bloom_intensity:      0.0,
431            bloom_radius:         0.0,
432            distortion_enabled:   false,
433            motion_blur_enabled:  false,
434            motion_blur_samples:  2,
435            chromatic_aberration: 0.0,
436            film_grain:           0.0,
437            scanlines_enabled:    false,
438            scanline_intensity:   0.15,
439            // Neutral: the same picture the pipeline produced before the grade
440            // was exposed, so nothing that already existed changes look.
441            exposure:             1.0,
442            tonemap:              0.0,
443            halation:             0.0,
444            saturation:           1.0,
445            contrast:             1.05,
446            brightness:           0.0,
447            vignette:             0.25,
448            vignette_softness:    0.35,
449            tint:                 [1.0, 1.0, 1.0],
450            lift:                 [0.0, 0.0, 0.0],
451            gain:                 [1.0, 1.0, 1.0],
452            bloom_threshold:      0.0,
453            bloom_knee:           0.1,
454            anamorphic:           0.0,
455            sharpen:              0.0,
456            dither:               0.0,
457            barrel:               0.0,
458            font_size:            32,
459            fullscreen:           false,
460            render_scale:         1.0,
461            particle_multiplier:  1.0,
462            shadow_quality:       ShadowQuality::Medium,
463            antialiasing:         true,
464            color_depth:          8,
465            // On by default, because turning them off silently would change
466            // how every existing scene looks. A caller that draws in the UI
467            // layer should turn them off deliberately.
468            global_illumination:  true,
469            volumetric_fog:       true,
470            indirect_light:       0.0,
471            light_shafts:         0.0,
472            lens_flare:           0.0,
473            shake_pixels:         0.0,
474            fxaa:                 false,
475            persistence:          0.0,
476            vsync:                true,
477            world_ui_in_scene:    true,
478        }
479    }
480}
481
482impl RenderConfig {
483    pub fn validate(&mut self) {
484        self.bloom_intensity      = self.bloom_intensity.clamp(0.0, 20.0);
485        self.bloom_radius         = self.bloom_radius.clamp(1.0, 128.0);
486        self.chromatic_aberration = self.chromatic_aberration.clamp(0.0, 0.2);
487        self.film_grain           = self.film_grain.clamp(0.0, 0.5);
488        self.scanline_intensity   = self.scanline_intensity.clamp(0.0, 1.0);
489        self.font_size            = self.font_size.clamp(8, 64);
490        self.render_scale         = self.render_scale.clamp(0.25, 8.0);
491        self.particle_multiplier  = self.particle_multiplier.max(0.0);
492        self.motion_blur_samples  = self.motion_blur_samples.clamp(1, 16);
493        self.indirect_light       = self.indirect_light.clamp(0.0, 8.0);
494        self.light_shafts         = self.light_shafts.clamp(0.0, 8.0);
495        self.lens_flare           = self.lens_flare.clamp(0.0, 4.0);
496        self.shake_pixels         = self.shake_pixels.clamp(0.0, 200.0);
497        self.persistence          = self.persistence.clamp(0.0, 0.98);
498    }
499}
500
501#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
502pub enum ShadowQuality { Off, Low, Medium, High, Ultra }
503
504// ── PhysicsConfig ─────────────────────────────────────────────────────────────
505
506#[derive(Debug, Clone, Serialize, Deserialize)]
507pub struct PhysicsConfig {
508    /// Fixed timestep for physics simulation (seconds).
509    pub fixed_dt:         f32,
510    /// Maximum physics sub-steps per frame.
511    pub max_sub_steps:    u32,
512    /// Fluid simulation grid size (cells per axis).
513    pub fluid_grid_size:  usize,
514    /// Soft body position correction iterations.
515    pub soft_body_iters:  usize,
516    /// Gravity vector (Y-down = negative).
517    pub gravity_y:        f32,
518    /// Collision detection broadphase strategy.
519    pub broadphase:       BroadphaseStrategy,
520    /// Enable sleeping for idle bodies.
521    pub sleep_enabled:    bool,
522    /// Velocity threshold below which bodies can sleep.
523    pub sleep_threshold:  f32,
524}
525
526impl Default for PhysicsConfig {
527    fn default() -> Self {
528        Self {
529            fixed_dt:        1.0 / 60.0,
530            max_sub_steps:   4,
531            fluid_grid_size: 64,
532            soft_body_iters: 4,
533            gravity_y:       -9.8,
534            broadphase:      BroadphaseStrategy::Grid,
535            sleep_enabled:   true,
536            sleep_threshold: 0.01,
537        }
538    }
539}
540
541impl PhysicsConfig {
542    pub fn validate(&mut self) {
543        self.fixed_dt        = self.fixed_dt.clamp(1.0 / 240.0, 1.0 / 15.0);
544        self.max_sub_steps   = self.max_sub_steps.clamp(1, 16);
545        self.fluid_grid_size = self.fluid_grid_size.clamp(8, 256);
546        self.soft_body_iters = self.soft_body_iters.clamp(1, 32);
547        self.sleep_threshold = self.sleep_threshold.clamp(0.0, 1.0);
548    }
549}
550
551#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
552pub enum BroadphaseStrategy { BruteForce, Grid, BvhTree }
553
554// ── InputConfig ───────────────────────────────────────────────────────────────
555
556#[derive(Debug, Clone, Serialize, Deserialize)]
557pub struct InputConfig {
558    /// Mouse sensitivity multiplier.
559    pub mouse_sensitivity: f32,
560    /// Mouse Y-axis invert.
561    pub mouse_invert_y: bool,
562    /// Deadzone for analog gamepad axes.
563    pub gamepad_deadzone: f32,
564    /// Rumble intensity (0.0-1.0).
565    pub gamepad_rumble: f32,
566    /// Enable key repeat for held keys.
567    pub key_repeat: bool,
568    /// Key repeat initial delay (seconds).
569    pub key_repeat_delay: f32,
570    /// Key repeat rate (repeats/second).
571    pub key_repeat_rate: f32,
572}
573
574impl Default for InputConfig {
575    fn default() -> Self {
576        Self {
577            mouse_sensitivity: 1.0,
578            mouse_invert_y:    false,
579            gamepad_deadzone:  0.15,
580            gamepad_rumble:    0.7,
581            key_repeat:        true,
582            key_repeat_delay:  0.4,
583            key_repeat_rate:   30.0,
584        }
585    }
586}
587
588// ── DebugConfig ───────────────────────────────────────────────────────────────
589
590#[derive(Debug, Clone, Serialize, Deserialize)]
591pub struct DebugConfig {
592    pub show_fps:         bool,
593    pub show_frame_graph: bool,
594    pub show_physics:     bool,
595    pub show_spawn_zones: bool,
596    pub show_entity_ids:  bool,
597    pub show_force_fields: bool,
598    pub show_particle_count: bool,
599    pub log_level:        LogLevel,
600    /// Cap the log output to this many bytes per second to avoid spam.
601    pub log_rate_limit:   usize,
602    /// Enable Tracy/puffin profiler integration.
603    pub profiler_enabled: bool,
604}
605
606impl Default for DebugConfig {
607    fn default() -> Self {
608        Self {
609            show_fps:            false,
610            show_frame_graph:    false,
611            show_physics:        false,
612            show_spawn_zones:    false,
613            show_entity_ids:     false,
614            show_force_fields:   false,
615            show_particle_count: false,
616            log_level:           LogLevel::Info,
617            log_rate_limit:      1_000_000,
618            profiler_enabled:    false,
619        }
620    }
621}
622
623#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, PartialOrd)]
624pub enum LogLevel { Off, Error, Warn, Info, Debug, Trace }
625
626// ── GameplayConfig ────────────────────────────────────────────────────────────
627
628#[derive(Debug, Clone, Serialize, Deserialize)]
629pub struct GameplayConfig {
630    /// Seed for the world generator (0 = random).
631    pub world_seed: u64,
632    /// Starting dungeon depth.
633    pub start_depth: u32,
634    /// Global difficulty multiplier (0.5 = easy, 1.0 = normal, 2.0 = brutal).
635    pub difficulty: f32,
636    /// Enable permadeath.
637    pub permadeath: bool,
638    /// Auto-save every N seconds (0 = disabled).
639    pub autosave_interval: u32,
640    /// Enable developer cheats.
641    pub cheats_enabled: bool,
642    /// Entity tick budget per frame (max entities updated per frame).
643    pub entity_tick_budget: usize,
644    /// Maximum number of active enemies.
645    pub max_enemies: usize,
646}
647
648impl Default for GameplayConfig {
649    fn default() -> Self {
650        Self {
651            world_seed:         0,
652            start_depth:        1,
653            difficulty:         1.0,
654            permadeath:         false,
655            autosave_interval:  60,
656            cheats_enabled:     false,
657            entity_tick_budget: 256,
658            max_enemies:        64,
659        }
660    }
661}
662
663// ── AccessibilityConfig ───────────────────────────────────────────────────────
664
665#[derive(Debug, Clone, Serialize, Deserialize)]
666pub struct AccessibilityConfig {
667    /// Enable colorblind compensation mode.
668    pub colorblind_mode:   ColorblindMode,
669    /// Global text size multiplier.
670    pub text_size:         f32,
671    /// Reduce motion (disables screen shake, reduces particles).
672    pub reduce_motion:     bool,
673    /// High contrast mode (brighter UI, bolder outlines).
674    pub high_contrast:     bool,
675    /// Screen flash warning suppression (warn when flash > this intensity).
676    pub flash_warning:     f32,
677    /// Enable subtitles / audio cues for deaf accessibility.
678    pub audio_cues_visual: bool,
679}
680
681impl Default for AccessibilityConfig {
682    fn default() -> Self {
683        Self {
684            colorblind_mode:   ColorblindMode::None,
685            text_size:         1.0,
686            reduce_motion:     false,
687            high_contrast:     false,
688            flash_warning:     0.5,
689            audio_cues_visual: false,
690        }
691    }
692}
693
694#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
695pub enum ColorblindMode { None, Deuteranopia, Protanopia, Tritanopia, Achromatopsia }
696
697// ── Unit tests ─────────────────────────────────────────────────────────────────
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702
703    #[test]
704    fn test_default_config() {
705        let c = EngineConfig::default();
706        assert_eq!(c.window_width,  1280);
707        assert_eq!(c.window_height, 800);
708        assert_eq!(c.target_fps,    60);
709        assert!(c.audio.enabled);
710        assert!(c.render.bloom_enabled);
711    }
712
713    #[test]
714    fn test_validate_clamps() {
715        let mut c = EngineConfig::default();
716        c.window_width  = 99999;
717        c.window_height = 0;
718        c.render.bloom_intensity = 100.0;
719        c.validate();
720        assert_eq!(c.window_width, 7680);
721        assert_eq!(c.window_height, 240);
722        assert!(c.render.bloom_intensity <= 20.0);
723    }
724
725    #[test]
726    fn test_apply_args_width_height() {
727        let mut c = EngineConfig::default();
728        c.apply_args(&["--width".to_string(), "1920".to_string(),
729                       "--height".to_string(), "1080".to_string()]);
730        assert_eq!(c.window_width,  1920);
731        assert_eq!(c.window_height, 1080);
732    }
733
734    #[test]
735    fn test_apply_args_no_audio() {
736        let mut c = EngineConfig::default();
737        c.apply_args(&["--no-audio".to_string()]);
738        assert!(!c.audio.enabled);
739    }
740
741    #[test]
742    fn test_apply_args_no_postfx() {
743        let mut c = EngineConfig::default();
744        c.apply_args(&["--no-postfx".to_string()]);
745        assert!(!c.render.bloom_enabled);
746        assert_eq!(c.render.chromatic_aberration, 0.0);
747    }
748
749    #[test]
750    fn test_profile_low_end() {
751        let c = EngineConfig::profile_low_end();
752        assert!(!c.render.bloom_enabled);
753        assert_eq!(c.target_fps, 30);
754    }
755
756    #[test]
757    fn test_profile_steam_deck() {
758        let c = EngineConfig::profile_steam_deck();
759        assert_eq!(c.window_width, 1280);
760        assert_eq!(c.window_height, 800);
761    }
762
763    #[test]
764    fn test_aspect_ratio() {
765        let c = EngineConfig::default(); // 1280x800
766        let ar = c.aspect_ratio();
767        assert!((ar - 1.6).abs() < 0.01);
768    }
769
770    #[test]
771    fn test_diff_detects_changes() {
772        let a = EngineConfig::default();
773        let mut b = a.clone();
774        b.window_width = 1920;
775        let diff = a.diff(&b);
776        assert!(diff.window_changed);
777        assert!(!diff.audio_changed);
778    }
779
780    #[test]
781    fn test_round_trip_toml() {
782        let c = EngineConfig::default();
783        let toml_str = toml::to_string_pretty(&c).expect("serialize");
784        let c2: EngineConfig = toml::from_str(&toml_str).expect("deserialize");
785        assert_eq!(c.window_width, c2.window_width);
786        assert_eq!(c.target_fps,   c2.target_fps);
787    }
788}