Something went wrong. Try again.
HEXWORK - A Lantern in the Deep: 3D ASCII terminal roguelike RPG in Rust.
Entirely designed and built by Muse Spark 1.3 to evaluate if I like the model.
Something went wrong. Try again.
31 kB · 921 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922//! Strict, bounded boot-time presentation preferences.//!//! This module is temporarily test-only until the production CLI wiring lands.
use hexwork_render::camera::View;use hexwork_render::palette::{self, Palette};use hexwork_render::FrameBuffer;use hexwork_save::artifact::{durable_replace, ArtifactStore, ReplaceError, ReplaceOutcome};use hexwork_ui::settings::Settings;use serde::{Deserialize, Serialize};use std::fmt;use std::fs::{self, File};use std::io::{self, Read};use std::path::Path;
const FILE_NAME: &str = "presentation.json";const SCHEMA_VERSION: u8 = 1;const MAX_PREFERENCES_BYTES: usize = 16 * 1024;const GAMMA_MIN: f32 = 0.5;const GAMMA_MAX: f32 = 2.0;const FOV_MIN_DEG: f32 = 1.0;const FOV_MAX_DEG: f32 = 75.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum PaletteChoice { Default, Protan, Deutan, Tritan, HighContrast,}
impl PaletteChoice { pub fn parse(value: &str) -> Result<Self, PresentationLoadError> { match value { "default" => Ok(Self::Default), "protan" => Ok(Self::Protan), "deutan" => Ok(Self::Deutan), "tritan" => Ok(Self::Tritan), "high-contrast" => Ok(Self::HighContrast), _ => Err(PresentationLoadError::Invalid( "palette must be one of default, protan, deutan, tritan, high-contrast".into(), )), } }
fn name(self) -> &'static str { match self { Self::Default => "default", Self::Protan => "protan", Self::Deutan => "deutan", Self::Tritan => "tritan", Self::HighContrast => "high-contrast", } }
fn palette(self) -> &'static Palette { match self { Self::Default => &palette::DEFAULT, Self::Protan => &palette::PROTAN, Self::Deutan => &palette::DEUTAN, Self::Tritan => &palette::TRITAN, Self::HighContrast => &palette::HIGH_CONTRAST, } }}
#[derive(Clone, Debug, PartialEq)]pub struct PresentationPreferences { gamma: f32, fov_deg: f32, palette: PaletteChoice,}
impl Default for PresentationPreferences { fn default() -> Self { Self { gamma: 1.0, fov_deg: 75.0, palette: PaletteChoice::Default, } }}
impl PresentationPreferences { pub fn gamma(&self) -> f32 { self.gamma }
pub fn fov_deg(&self) -> f32 { self.fov_deg }
pub fn palette_choice(&self) -> PaletteChoice { self.palette }
pub fn with_overrides( &self, overrides: &PresentationOverrides, ) -> Result<Self, PresentationLoadError> { let gamma = overrides.gamma.unwrap_or(self.gamma); let fov_deg = overrides.fov_deg.unwrap_or(self.fov_deg); validate_gamma(gamma)?; validate_fov(fov_deg)?; Ok(Self { gamma, fov_deg, palette: overrides.palette.unwrap_or(self.palette), }) }
pub fn render_tuning(&self) -> RenderTuning { RenderTuning::new(self) }}
#[derive(Clone, Copy, Debug, Default, PartialEq)]pub struct PresentationOverrides { pub gamma: Option<f32>, pub fov_deg: Option<f32>, pub palette: Option<PaletteChoice>,}
impl PresentationOverrides { pub fn is_requested(&self) -> bool { self.gamma.is_some() || self.fov_deg.is_some() || self.palette.is_some() }}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum PreferenceSource { Defaults, File,}
#[derive(Clone, Debug)]pub struct LoadedPreferences { preferences: PresentationPreferences, original_bytes: Option<Vec<u8>>,}
impl LoadedPreferences { pub fn preferences(&self) -> &PresentationPreferences { &self.preferences }
pub fn source(&self) -> PreferenceSource { if self.original_bytes.is_some() { PreferenceSource::File } else { PreferenceSource::Defaults } }}
#[derive(Debug)]pub enum PresentationLoadError { Io(io::Error), NotRegular, TooLarge { limit: usize }, Invalid(String),}
impl fmt::Display for PresentationLoadError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Io(error) => write!(formatter, "presentation preferences I/O failed: {error}"), Self::NotRegular => { formatter.write_str("presentation preferences are not a regular file") } Self::TooLarge { limit } => write!( formatter, "presentation preferences exceed the {limit}-byte limit" ), Self::Invalid(detail) => { write!(formatter, "invalid presentation preferences: {detail}") } } }}
impl std::error::Error for PresentationLoadError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Io(error) => Some(error), _ => None, } }}
#[derive(Debug)]pub(crate) enum PresentationCommitError { Io(io::Error), SourceChanged, Encode(String), Write(ReplaceError),}
impl fmt::Display for PresentationCommitError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Io(error) => write!(formatter, "presentation source recheck failed: {error}"), Self::SourceChanged => formatter.write_str("presentation preference source changed"), Self::Encode(detail) => write!( formatter, "presentation preference encoding failed: {detail}" ), Self::Write(error) => { write!(formatter, "presentation preference write failed: {error}") } } }}
impl std::error::Error for PresentationCommitError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Io(error) => Some(error), Self::Write(error) => Some(error), _ => None, } }}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum CommitOutcome { Unchanged, Durable,}
#[derive(Clone, Debug)]pub struct RenderTuning { gamma_lut: [u8; 256], fov_deg: f32, palette: PaletteChoice,}
impl RenderTuning { fn new(preferences: &PresentationPreferences) -> Self { let mut settings = Settings::default(); settings.set_gamma(preferences.gamma); let mut gamma_lut = [0; 256]; for (value, mapped) in gamma_lut.iter_mut().enumerate() { *mapped = settings.apply_gamma([value as u8; 3])[0]; } Self { gamma_lut, fov_deg: preferences.fov_deg, palette: preferences.palette, } }
pub fn view(&self, mut view: View) -> View { if !view.is_orthographic() { view.fov_deg = self.fov_deg; } view }
pub fn palette(&self) -> &'static Palette { self.palette.palette() }
pub fn apply_gamma(&self, frame: &mut FrameBuffer) { for cell in &mut frame.cells { cell.fg = cell.fg.map(|channel| self.gamma_lut[usize::from(channel)]); cell.bg = cell.bg.map(|channel| self.gamma_lut[usize::from(channel)]); } }}
#[derive(Deserialize)]#[serde(deny_unknown_fields)]struct FileV1Input { schema_version: u8, gamma: f32, fov_deg: f32, palette: String,}
#[derive(Serialize)]struct FileV1Output { schema_version: u8, gamma: f32, fov_deg: f32, palette: &'static str,}
pub fn parse_gamma(value: &str) -> Result<f32, PresentationLoadError> { let gamma = value.parse::<f32>().map_err(|_| { PresentationLoadError::Invalid("gamma must be a finite number from 0.5 through 2".into()) })?; validate_gamma(gamma)?; Ok(gamma)}
pub fn parse_fov(value: &str) -> Result<f32, PresentationLoadError> { let fov = value.parse::<f32>().map_err(|_| { PresentationLoadError::Invalid("fov must be a finite number from 1 through 75".into()) })?; validate_fov(fov)?; Ok(fov)}
fn validate_gamma(gamma: f32) -> Result<(), PresentationLoadError> { if gamma.is_finite() && (GAMMA_MIN..=GAMMA_MAX).contains(&gamma) { Ok(()) } else { Err(PresentationLoadError::Invalid( "gamma must be a finite number from 0.5 through 2".into(), )) }}
fn validate_fov(fov_deg: f32) -> Result<(), PresentationLoadError> { if fov_deg.is_finite() && (FOV_MIN_DEG..=FOV_MAX_DEG).contains(&fov_deg) { Ok(()) } else { Err(PresentationLoadError::Invalid( "fov must be a finite number from 1 through 75".into(), )) }}
pub fn load(data_root: &Path) -> Result<LoadedPreferences, PresentationLoadError> { let path = data_root.join(FILE_NAME); let metadata = match fs::symlink_metadata(&path) { Ok(metadata) => metadata, Err(error) if error.kind() == io::ErrorKind::NotFound => { return Ok(LoadedPreferences { preferences: PresentationPreferences::default(), original_bytes: None, }); } Err(error) => return Err(PresentationLoadError::Io(error)), }; if !metadata.file_type().is_file() || metadata.file_type().is_symlink() { return Err(PresentationLoadError::NotRegular); } if metadata.len() > MAX_PREFERENCES_BYTES as u64 { return Err(PresentationLoadError::TooLarge { limit: MAX_PREFERENCES_BYTES, }); }
let file = File::open(&path).map_err(PresentationLoadError::Io)?; let opened_metadata = file.metadata().map_err(PresentationLoadError::Io)?; if !opened_metadata.file_type().is_file() { return Err(PresentationLoadError::NotRegular); } if opened_metadata.len() > MAX_PREFERENCES_BYTES as u64 { return Err(PresentationLoadError::TooLarge { limit: MAX_PREFERENCES_BYTES, }); } let bytes = read_cap_plus_one(file).map_err(PresentationLoadError::Io)?; if bytes.len() > MAX_PREFERENCES_BYTES { return Err(PresentationLoadError::TooLarge { limit: MAX_PREFERENCES_BYTES, }); } let input: FileV1Input = serde_json::from_slice(&bytes) .map_err(|error| PresentationLoadError::Invalid(error.to_string()))?; if input.schema_version != SCHEMA_VERSION { return Err(PresentationLoadError::Invalid( "unsupported presentation schema version".into(), )); } validate_gamma(input.gamma)?; validate_fov(input.fov_deg)?; let palette = PaletteChoice::parse(&input.palette)?; Ok(LoadedPreferences { preferences: PresentationPreferences { gamma: input.gamma, fov_deg: input.fov_deg, palette, }, original_bytes: Some(bytes), })}
fn read_cap_plus_one(file: File) -> io::Result<Vec<u8>> { let mut bytes = Vec::new(); file.take((MAX_PREFERENCES_BYTES + 1) as u64) .read_to_end(&mut bytes)?; Ok(bytes)}
pub(crate) fn commit( store: &ArtifactStore, expected: &LoadedPreferences, effective: &PresentationPreferences, explicitly_requested: bool,) -> Result<CommitOutcome, PresentationCommitError> { commit_with_writer( store, expected, effective, explicitly_requested, |path, bytes| durable_replace(path, bytes), )}
fn commit_with_writer<F>( store: &ArtifactStore, expected: &LoadedPreferences, effective: &PresentationPreferences, explicitly_requested: bool, writer: F,) -> Result<CommitOutcome, PresentationCommitError>where F: FnOnce(&Path, &[u8]) -> Result<ReplaceOutcome, ReplaceError>,{ let path = store.root().join(FILE_NAME); let current = recheck_source(&path)?; if current != expected.original_bytes { return Err(PresentationCommitError::SourceChanged); } if !explicitly_requested || (expected.original_bytes.is_some() && expected.preferences == *effective) { return Ok(CommitOutcome::Unchanged); }
validate_gamma(effective.gamma).map_err(|error| match error { PresentationLoadError::Invalid(detail) => PresentationCommitError::Encode(detail), _ => unreachable!("numeric validation only returns Invalid"), })?; validate_fov(effective.fov_deg).map_err(|error| match error { PresentationLoadError::Invalid(detail) => PresentationCommitError::Encode(detail), _ => unreachable!("numeric validation only returns Invalid"), })?; let output = FileV1Output { schema_version: SCHEMA_VERSION, gamma: effective.gamma, fov_deg: effective.fov_deg, palette: effective.palette.name(), }; let bytes = serde_json::to_vec(&output) .map_err(|error| PresentationCommitError::Encode(error.to_string()))?; if bytes.len() > MAX_PREFERENCES_BYTES { return Err(PresentationCommitError::Encode( "encoded preferences exceed internal size bound".into(), )); } match writer(&path, &bytes).map_err(PresentationCommitError::Write)? { ReplaceOutcome::Durable => Ok(CommitOutcome::Durable), }}
fn recheck_source(path: &Path) -> Result<Option<Vec<u8>>, PresentationCommitError> { let metadata = match fs::symlink_metadata(path) { Ok(metadata) => metadata, Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), Err(error) => return Err(PresentationCommitError::Io(error)), }; if !metadata.file_type().is_file() || metadata.file_type().is_symlink() { return Err(PresentationCommitError::SourceChanged); } if metadata.len() > MAX_PREFERENCES_BYTES as u64 { return Err(PresentationCommitError::SourceChanged); }
let file = File::open(path).map_err(PresentationCommitError::Io)?; let opened_metadata = file.metadata().map_err(PresentationCommitError::Io)?; if !opened_metadata.file_type().is_file() { return Err(PresentationCommitError::SourceChanged); } if opened_metadata.len() > MAX_PREFERENCES_BYTES as u64 { return Err(PresentationCommitError::SourceChanged); } let bytes = read_cap_plus_one(file).map_err(PresentationCommitError::Io)?; if bytes.len() > MAX_PREFERENCES_BYTES { return Err(PresentationCommitError::SourceChanged); } Ok(Some(bytes))}
#[cfg(test)]mod tests { use super::*; use hexwork_render::camera::{first_person, top_tactical}; use hexwork_render::{Camera, Cell}; use hexwork_save::artifact::{ArtifactErrorClass, DurabilityStage}; use std::path::PathBuf; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{SystemTime, UNIX_EPOCH};
const VALID: &[u8] = br#"{"schema_version":1,"gamma":1.25,"fov_deg":60.0,"palette":"protan"}"#;
struct Scratch(PathBuf);
impl Scratch { fn new(label: &str) -> Self { let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); let path = PathBuf::from(format!( "/var/tmp/hexwork-presentation-{label}-{}-{nonce}", std::process::id() )); fs::create_dir_all(&path).unwrap(); Self(path) }
fn preferences_path(&self) -> PathBuf { self.0.join(FILE_NAME) } }
impl Drop for Scratch { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); let _ = fs::remove_file(&self.0); } }
fn store(root: &Scratch) -> ArtifactStore { ArtifactStore::open(&root.0).unwrap().0 }
#[test] fn presentation_defaults_missing_without_writes() { let root = Scratch::new("defaults"); let loaded = load(&root.0).unwrap(); assert_eq!(loaded.source(), PreferenceSource::Defaults); assert_eq!(loaded.preferences(), &PresentationPreferences::default()); assert!(!root.preferences_path().exists()); }
#[test] fn presentation_v1_strict_fields_version_ranges() { let root = Scratch::new("strict"); fs::write(root.preferences_path(), VALID).unwrap(); let loaded = load(&root.0).unwrap(); assert_eq!(loaded.source(), PreferenceSource::File); assert_eq!(loaded.preferences().gamma(), 1.25); assert_eq!(loaded.preferences().fov_deg(), 60.0); assert_eq!(loaded.preferences().palette_choice(), PaletteChoice::Protan);
let invalid = [ r#"{"schema_version":2,"gamma":1.0,"fov_deg":75.0,"palette":"default"}"#, r#"{"schema_version":1,"fov_deg":75.0,"palette":"default"}"#, r#"{"schema_version":1,"gamma":1.0,"fov_deg":75.0,"palette":"default","extra":0}"#, r#"{"schema_version":1,"gamma":1.0,"gamma":1.5,"fov_deg":75.0,"palette":"default"}"#, r#"{"schema_version":1,"gamma":0.49,"fov_deg":75.0,"palette":"default"}"#, r#"{"schema_version":1,"gamma":2.01,"fov_deg":75.0,"palette":"default"}"#, r#"{"schema_version":1,"gamma":1e400,"fov_deg":75.0,"palette":"default"}"#, r#"{"schema_version":1,"gamma":1.0,"fov_deg":0.99,"palette":"default"}"#, r#"{"schema_version":1,"gamma":1.0,"fov_deg":75.01,"palette":"default"}"#, r#"{"schema_version":1,"gamma":1.0,"fov_deg":75.0,"palette":"no-flicker"}"#, "not json", ]; for contents in invalid { fs::write(root.preferences_path(), contents).unwrap(); assert!(matches!( load(&root.0), Err(PresentationLoadError::Invalid(_)) )); } fs::write(root.preferences_path(), [0xff]).unwrap(); assert!(matches!( load(&root.0), Err(PresentationLoadError::Invalid(_)) )); }
#[test] fn presentation_cap_plus_one_and_nonregular_reject() { let root = Scratch::new("bounds"); fs::write( root.preferences_path(), vec![b' '; MAX_PREFERENCES_BYTES + 1], ) .unwrap(); assert!(matches!( load(&root.0), Err(PresentationLoadError::TooLarge { limit: MAX_PREFERENCES_BYTES }) ));
fs::remove_file(root.preferences_path()).unwrap(); fs::create_dir(root.preferences_path()).unwrap(); assert!(matches!( load(&root.0), Err(PresentationLoadError::NotRegular) ));
#[cfg(unix)] { use std::os::unix::fs::symlink; use std::os::unix::net::UnixListener;
fs::remove_dir(root.preferences_path()).unwrap(); let target = root.0.join("target"); fs::write(&target, VALID).unwrap(); symlink(&target, root.preferences_path()).unwrap(); assert!(matches!( load(&root.0), Err(PresentationLoadError::NotRegular) )); fs::remove_file(root.preferences_path()).unwrap(); let _listener = UnixListener::bind(root.preferences_path()).unwrap(); assert!(matches!( load(&root.0), Err(PresentationLoadError::NotRegular) )); } }
#[cfg(unix)] #[test] fn presentation_fifo_child_helper() { let Some(root) = std::env::var_os("HEXWORK_PRESENTATION_FIFO_HELPER") else { return; }; assert!(matches!( load(Path::new(&root)), Err(PresentationLoadError::NotRegular) )); }
#[cfg(unix)] #[test] fn presentation_fifo_is_rejected_without_opening() { use std::process::{Command, Stdio}; use std::time::{Duration, Instant};
let root = Scratch::new("fifo"); let status = Command::new("mkfifo") .arg(root.preferences_path()) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .expect("required mkfifo test tool is unavailable"); assert!(status.success()); let mut child = Command::new(std::env::current_exe().unwrap()) .arg("--exact") .arg("presentation_settings::tests::presentation_fifo_child_helper") .arg("--nocapture") .env("HEXWORK_PRESENTATION_FIFO_HELPER", &root.0) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .unwrap(); let deadline = Instant::now() + Duration::from_secs(5); loop { if let Some(status) = child.try_wait().unwrap() { assert!(status.success()); break; } if Instant::now() >= deadline { let _ = child.kill(); let _ = child.wait(); panic!("FIFO rejection helper blocked while opening a nonregular source"); } std::thread::yield_now(); } }
#[test] fn presentation_overrides_validate_and_merge() { assert_eq!(parse_gamma("0.5").unwrap(), 0.5); assert_eq!(parse_gamma("2").unwrap(), 2.0); assert_eq!(parse_fov("1").unwrap(), 1.0); assert_eq!(parse_fov("75").unwrap(), 75.0); for invalid in ["NaN", "inf", "0.49", "2.01", "not-a-number"] { assert!(matches!( parse_gamma(invalid), Err(PresentationLoadError::Invalid(_)) )); } for invalid in ["NaN", "inf", "0.99", "75.01", "not-a-number"] { assert!(matches!( parse_fov(invalid), Err(PresentationLoadError::Invalid(_)) )); } for (name, expected) in [ ("default", PaletteChoice::Default), ("protan", PaletteChoice::Protan), ("deutan", PaletteChoice::Deutan), ("tritan", PaletteChoice::Tritan), ("high-contrast", PaletteChoice::HighContrast), ] { assert_eq!(PaletteChoice::parse(name).unwrap(), expected); } assert!(PaletteChoice::parse("no-flicker").is_err());
let overrides = PresentationOverrides { gamma: Some(1.5), fov_deg: None, palette: Some(PaletteChoice::Tritan), }; assert!(overrides.is_requested()); let merged = PresentationPreferences::default() .with_overrides(&overrides) .unwrap(); assert_eq!(merged.gamma(), 1.5); assert_eq!(merged.fov_deg(), 75.0); assert_eq!(merged.palette_choice(), PaletteChoice::Tritan); assert!(!PresentationOverrides::default().is_requested()); }
#[test] fn presentation_source_changed_refuses_overwrite() { let root = Scratch::new("changed"); fs::write(root.preferences_path(), VALID).unwrap(); let loaded = load(&root.0).unwrap(); let store = store(&root); let effective = loaded .preferences() .with_overrides(&PresentationOverrides { gamma: Some(1.5), ..PresentationOverrides::default() }) .unwrap();
let changed = b"concurrent editor"; fs::write(root.preferences_path(), changed).unwrap(); assert!(matches!( commit(&store, &loaded, &effective, true), Err(PresentationCommitError::SourceChanged) )); assert_eq!(fs::read(root.preferences_path()).unwrap(), changed);
fs::write( root.preferences_path(), vec![b'x'; MAX_PREFERENCES_BYTES + 1], ) .unwrap(); assert!(matches!( commit(&store, &loaded, &effective, true), Err(PresentationCommitError::SourceChanged) )); }
#[test] fn presentation_commit_requires_owned_store_and_preserves_source() { let root = Scratch::new("commit"); fs::write(root.preferences_path(), VALID).unwrap(); let loaded = load(&root.0).unwrap(); let store = store(&root); let contention = match ArtifactStore::open(&root.0) { Ok(_) => panic!("second store unexpectedly acquired the held root lock"), Err(error) => error, }; assert_eq!(contention.class(), ArtifactErrorClass::Contention); let effective = loaded .preferences() .with_overrides(&PresentationOverrides { fov_deg: Some(55.0), palette: Some(PaletteChoice::Deutan), ..PresentationOverrides::default() }) .unwrap(); assert_eq!( commit(&store, &loaded, &effective, true).unwrap(), CommitOutcome::Durable ); let saved = load(&root.0).unwrap(); assert_eq!(saved.preferences(), &effective); assert_eq!(loaded.original_bytes.as_deref(), Some(VALID)); }
#[test] fn presentation_before_publish_failure_retains_old_bytes() { let root = Scratch::new("before-publish"); fs::write(root.preferences_path(), VALID).unwrap(); let loaded = load(&root.0).unwrap(); let store = store(&root); let effective = loaded .preferences() .with_overrides(&PresentationOverrides { gamma: Some(1.5), ..PresentationOverrides::default() }) .unwrap(); let error = commit_with_writer(&store, &loaded, &effective, true, |path, _| { Err(ReplaceError::BeforePublish { stage: DurabilityStage::WriteTemp, path: path.to_owned(), detail: "injected".into(), }) }) .unwrap_err(); assert!(matches!( error, PresentationCommitError::Write(ReplaceError::BeforePublish { .. }) )); assert_eq!(fs::read(root.preferences_path()).unwrap(), VALID); }
#[test] fn presentation_sync_uncertainty_aborts_without_compensation() { let root = Scratch::new("sync-failed"); fs::write(root.preferences_path(), VALID).unwrap(); let loaded = load(&root.0).unwrap(); let store = store(&root); let effective = loaded .preferences() .with_overrides(&PresentationOverrides { gamma: Some(1.5), ..PresentationOverrides::default() }) .unwrap(); let calls = AtomicUsize::new(0); let error = commit_with_writer(&store, &loaded, &effective, true, |path, bytes| { calls.fetch_add(1, Ordering::SeqCst); fs::write(path, bytes).unwrap(); Err(ReplaceError::PublishedSyncFailed { stage: DurabilityStage::SyncParent, path: path.to_owned(), detail: "injected".into(), }) }) .unwrap_err(); assert!(matches!( error, PresentationCommitError::Write(ReplaceError::PublishedSyncFailed { .. }) )); assert_eq!(calls.load(Ordering::SeqCst), 1); let saved = load(&root.0).unwrap(); assert_eq!(saved.preferences(), &effective); }
#[test] fn presentation_unchanged_or_no_override_never_writes() { let root = Scratch::new("no-write"); fs::write(root.preferences_path(), VALID).unwrap(); let loaded = load(&root.0).unwrap(); let store = store(&root); let no_writer = |_: &Path, _: &[u8]| -> Result<ReplaceOutcome, ReplaceError> { panic!("unchanged commit invoked writer") }; assert_eq!( commit_with_writer(&store, &loaded, loaded.preferences(), false, no_writer).unwrap(), CommitOutcome::Unchanged ); assert_eq!( commit_with_writer(&store, &loaded, loaded.preferences(), true, no_writer).unwrap(), CommitOutcome::Unchanged ); assert_eq!(fs::read(root.preferences_path()).unwrap(), VALID); }
#[test] fn presentation_explicit_defaults_on_missing_publish_full_v1() { let root = Scratch::new("publish-defaults"); let loaded = load(&root.0).unwrap(); let store = store(&root); assert_eq!( commit(&store, &loaded, &PresentationPreferences::default(), true).unwrap(), CommitOutcome::Durable ); let saved = load(&root.0).unwrap(); assert_eq!(saved.source(), PreferenceSource::File); assert_eq!(saved.preferences(), &PresentationPreferences::default()); }
#[test] fn tuning_getters_curve_and_different_colors() { let preferences = PresentationPreferences::default() .with_overrides(&PresentationOverrides { gamma: Some(2.0), palette: Some(PaletteChoice::HighContrast), ..PresentationOverrides::default() }) .unwrap(); let tuning = preferences.render_tuning(); assert_eq!(tuning.palette(), &palette::HIGH_CONTRAST); assert_ne!(tuning.palette(), &palette::DEFAULT); let mut frame = FrameBuffer::new(1, 1); frame.cells[0] = Cell { glyph: '@', fg: [64, 128, 255], bg: [1, 16, 0], }; let mut settings = Settings::default(); settings.set_gamma(2.0); let expected_fg = settings.apply_gamma(frame.cells[0].fg); let expected_bg = settings.apply_gamma(frame.cells[0].bg); tuning.apply_gamma(&mut frame); assert_eq!(frame.cells[0].fg, expected_fg); assert_eq!(frame.cells[0].bg, expected_bg); assert_ne!(frame.cells[0].fg, [64, 128, 255]);
let identity = PresentationPreferences::default().render_tuning(); let before = frame.cells[0]; identity.apply_gamma(&mut frame); assert_eq!(frame.cells[0], before); }
#[test] fn tuning_view_preserves_all_fields_except_perspective_fov() { let tuning = PresentationPreferences::default() .with_overrides(&PresentationOverrides { fov_deg: Some(42.0), ..PresentationOverrides::default() }) .unwrap() .render_tuning(); let perspective = first_person([1.0, 2.0, 3.0], 0.25, -0.5); let changed = tuning.view(perspective); assert_eq!(changed.fov_deg, 42.0); let mut restored = changed; restored.fov_deg = perspective.fov_deg; assert_eq!(restored, perspective); assert_eq!(changed.mode, Camera::FirstPerson); assert_eq!(changed.tactical_slice_z, perspective.tactical_slice_z);
let tactical = top_tactical([8.0, 9.0, -3.0], -7, 1.5); assert!(tactical.is_orthographic()); assert_eq!(tuning.view(tactical), tactical); }}