//! Stage 3c2 lineage-vault v2, death effects, immutable markers, and bounded reads. //! //! Binary run/replay representations of the identifier newtypes are deliberately //! numeric: `LineageId` is its 16 raw bytes, `RunId` is its non-zero `u64`, and //! `LotId` is `(origin_run_sequence, origin_item_id)`. JSON uses the canonical //! display spellings validated by each newtype. use std::collections::BTreeMap; use std::fmt; use std::io::Read; use std::path::Path; use std::str::FromStr; use hexwork_content::runtime::RuntimePack; use hexwork_core::item::{Condition, Instance}; use hexwork_core::modes::Mode; use hexwork_core::runtime::{validate_knowledge_id, ActorRole, KnowledgeCategory}; use hexwork_core::state::State; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::protocol::RunIdentity; use crate::write_bytes_atomic; pub const VAULT_FORMAT_VERSION: u32 = 2; pub const PENDING_DEATH_VERSION: u32 = 1; pub const COMMITTED_DEATH_VERSION: u32 = 1; pub const MAX_VAULT_BYTES: usize = 8 * 1024 * 1024; pub const MAX_MARKER_BYTES: usize = 16 * 1024; pub const MAX_MAPPED_REGIONS: usize = 1_024; pub const MAX_RECIPES: usize = 1_024; pub const MAX_TRANSLATIONS: usize = 256; pub const MAX_IDENTIFIED_TEMPLATES: usize = 2_048; pub const MAX_GHOSTS: usize = 128; pub const MAX_AUCTION_LOTS: usize = 256; pub const MAX_INITIAL_ROWS: usize = 8_192; pub const MAX_INITIAL_TAGS: usize = 8_192; pub const MAX_INITIAL_STRING_BYTES: usize = 2 * 1024 * 1024; pub const MAX_APPLIED_DEATHS: usize = 16_384; pub const MAX_RUN_SEQUENCE: u64 = 65_536; pub const MAX_LOT_TAGS: usize = 32; pub const MAX_LOT_TAG_BYTES: usize = 64; pub const MAX_GHOST_WARNING_BYTES: usize = 512; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct LineageId([u8; 16]); impl LineageId { pub const BINARY_LEN: usize = 16; #[must_use] pub const fn from_bytes(bytes: [u8; 16]) -> Self { Self(bytes) } #[must_use] pub const fn as_bytes(&self) -> &[u8; 16] { &self.0 } } impl fmt::Display for LineageId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("lineage-")?; for byte in self.0 { write!(f, "{byte:02x}")?; } Ok(()) } } impl FromStr for LineageId { type Err = SchemaError; fn from_str(value: &str) -> Result { let hex = value .strip_prefix("lineage-") .ok_or_else(|| invalid("lineage_id", "must start with lineage-"))?; if hex.len() != 32 || !hex .bytes() .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) { return Err(invalid( "lineage_id", "must contain exactly 32 lowercase hexadecimal digits", )); } let mut bytes = [0; 16]; for (index, slot) in bytes.iter_mut().enumerate() { *slot = u8::from_str_radix(&hex[index * 2..index * 2 + 2], 16) .map_err(|_| invalid("lineage_id", "invalid hexadecimal byte"))?; } Ok(Self(bytes)) } } #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct RunId(u64); impl RunId { pub const BINARY_LEN: usize = 8; pub fn new(sequence: u64) -> Result { if sequence == 0 { Err(invalid("run_id", "sequence must be nonzero")) } else { Ok(Self(sequence)) } } #[must_use] pub const fn sequence(self) -> u64 { self.0 } } impl fmt::Display for RunId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "run-{:020}", self.0) } } impl FromStr for RunId { type Err = SchemaError; fn from_str(value: &str) -> Result { if value.len() != 24 || !value.starts_with("run-") || !value[4..].bytes().all(|b| b.is_ascii_digit()) { return Err(invalid( "run_id", "must be run- plus exactly 20 decimal digits", )); } let sequence = value[4..] .parse::() .map_err(|_| invalid("run_id", "sequence overflows u64"))?; let id = Self::new(sequence)?; if id.to_string() != value { return Err(invalid("run_id", "decimal spelling is not canonical")); } Ok(id) } } #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct LotId { origin_run_id: RunId, origin_item_id: u64, } impl LotId { pub const BINARY_LEN: usize = 16; pub fn new(origin_run_id: RunId, origin_item_id: u64) -> Result { if origin_item_id == 0 { Err(invalid("lot_id", "origin item ID must be nonzero")) } else { Ok(Self { origin_run_id, origin_item_id, }) } } #[must_use] pub const fn origin_run_id(self) -> RunId { self.origin_run_id } #[must_use] pub const fn origin_item_id(self) -> u64 { self.origin_item_id } } impl fmt::Display for LotId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}-item-{:020}", self.origin_run_id, self.origin_item_id) } } impl FromStr for LotId { type Err = SchemaError; fn from_str(value: &str) -> Result { if value.len() != 50 || &value[24..30] != "-item-" { return Err(invalid( "lot_id", "must be {RunId}-item-{20 digit nonzero item ID}", )); } let run_id = value[..24].parse()?; let item = value[30..] .parse::() .map_err(|_| invalid("lot_id", "invalid origin item ID"))?; let id = Self::new(run_id, item)?; if id.to_string() != value { return Err(invalid("lot_id", "spelling is not canonical")); } Ok(id) } } macro_rules! string_serde { ($ty:ty) => { impl Serialize for $ty { fn serialize(&self, serializer: S) -> Result { serializer.serialize_str(&self.to_string()) } } impl<'de> Deserialize<'de> for $ty { fn deserialize>(deserializer: D) -> Result { String::deserialize(deserializer)? .parse() .map_err(serde::de::Error::custom) } } }; } string_serde!(LineageId); string_serde!(RunId); string_serde!(LotId); #[derive(Clone, Debug, PartialEq, Eq)] pub enum SchemaError { Io(String), TooLarge { context: &'static str, actual: usize, limit: usize, }, Malformed(String), OlderVersion { context: &'static str, found: u32, current: u32, }, NewerVersion { context: &'static str, found: u32, current: u32, }, Invalid { context: String, }, Conflict { context: String, }, } impl fmt::Display for SchemaError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Io(e) => write!(f, "I/O failed: {e}"), Self::TooLarge { context, actual, limit } => write!(f, "{context} is {actual} bytes; limit is {limit}"), Self::Malformed(e) => write!(f, "malformed JSON: {e}"), Self::OlderVersion { context, found, current } => write!(f, "{context} version {found} is older than supported version {current}; no silent migration exists"), Self::NewerVersion { context, found, current } => write!(f, "{context} version {found} is newer than supported version {current}; update HEXWORK"), Self::Invalid { context } => write!(f, "invalid persistence field: {context}"), Self::Conflict { context } => write!(f, "persistence conflict: {context}"), } } } impl std::error::Error for SchemaError {} fn invalid(context: impl Into, reason: impl AsRef) -> SchemaError { SchemaError::Invalid { context: format!("{}: {}", context.into(), reason.as_ref()), } } #[derive(Debug)] pub enum BoundedReadError { Io(std::io::Error), TooLarge { actual: u64, limit: usize }, } impl fmt::Display for BoundedReadError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Io(e) => write!(f, "bounded file read failed: {e}"), Self::TooLarge { actual, limit } => { write!(f, "file is at least {actual} bytes; limit is {limit}") } } } } impl std::error::Error for BoundedReadError {} /// Read at most `cap + 1`: metadata rejects known oversize files before a body /// allocation, while `take(cap + 1)` also catches growth races and streams. pub fn read_file_bounded(path: impl AsRef, cap: usize) -> Result, BoundedReadError> { let mut file = std::fs::File::open(path).map_err(BoundedReadError::Io)?; let metadata_len = file.metadata().map_err(BoundedReadError::Io)?.len(); if metadata_len > cap as u64 { return Err(BoundedReadError::TooLarge { actual: metadata_len, limit: cap, }); } let mut bytes = Vec::with_capacity(metadata_len.min(cap as u64) as usize); file.by_ref() .take(cap as u64 + 1) .read_to_end(&mut bytes) .map_err(BoundedReadError::Io)?; if bytes.len() > cap { return Err(BoundedReadError::TooLarge { actual: bytes.len() as u64, limit: cap, }); } Ok(bytes) } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct InitialLineage { pub mapped_regions: Vec, pub recipes: Vec, pub translated_hymns: Vec, pub identified_item_templates: Vec, pub ghosts: Vec, pub auction_lots: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct GhostMemory { pub origin_run_id: RunId, pub item_template_id: String, pub warning: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct AuctionLot { pub lot_id: LotId, pub origin_run_id: RunId, pub origin_item_id: u64, pub template_id: String, pub material: u8, pub identified: bool, pub cursed: bool, pub blessed: bool, pub condition: Condition, pub silvered: bool, pub illegal: bool, pub tags: Vec, pub fuel: u8, pub wet_ticks: u64, pub base_price_cu: u32, pub buyback_price_cu: u32, } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct DeathEffect { pub mapped_regions: Vec, pub recipes: Vec, pub translated_hymns: Vec, pub identified_item_templates: Vec, pub ghost: Option, pub auction_lots: Vec, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct AppliedDeathReceipt { pub terminal_core_hash: u64, pub effect_digest: [u8; 32], } #[derive(Clone, Debug, PartialEq, Eq)] pub struct VaultV2 { pub version: u32, pub lineage_id: LineageId, pub next_run_sequence: u64, pub current: InitialLineage, pub applied_deaths: BTreeMap, } pub type LineageVault = VaultV2; impl VaultV2 { pub fn fresh(lineage_id: LineageId) -> Self { Self { version: VAULT_FORMAT_VERSION, lineage_id, next_run_sequence: 1, current: InitialLineage::default(), applied_deaths: BTreeMap::new(), } } pub fn validate(&self) -> Result<(), SchemaError> { validate_vault(self) } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct PendingDeathV1 { pub version: u32, pub lineage_id: LineageId, pub run_id: RunId, pub terminal_tick: u64, pub terminal_core_hash: u64, pub terminal_snapshot_digest: [u8; 32], pub content_hash: String, pub effect_digest: [u8; 32], } #[derive(Clone, Debug, PartialEq, Eq)] pub struct CommittedDeathV1 { pub version: u32, pub lineage_id: LineageId, pub run_id: RunId, pub terminal_tick: u64, pub terminal_core_hash: u64, pub terminal_snapshot_digest: [u8; 32], pub content_hash: String, pub effect_digest: [u8; 32], pub replay_digest: [u8; 32], pub epitaph_digest: [u8; 32], pub vault_effect_digest: [u8; 32], } #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct InitialWire { mapped_regions: Vec, recipes: Vec, translated_hymns: Vec, identified_item_templates: Vec, ghosts: Vec, auction_lots: Vec, } #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct GhostWire { origin_run_id: RunId, item_template_id: String, warning: String, } #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct LotWire { lot_id: LotId, origin_run_id: RunId, origin_item_id: u64, template_id: String, material: u8, identified: bool, cursed: bool, blessed: bool, condition: u8, silvered: bool, illegal: bool, tags: Vec, fuel: u8, wet_ticks: u64, base_price_cu: u32, buyback_price_cu: u32, } #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct ReceiptWire { run_id: RunId, terminal_core_hash: u64, effect_digest: String, } #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct VaultWire { version: u32, lineage_id: LineageId, next_run_sequence: u64, current: InitialWire, applied_deaths: Vec, } #[derive(Deserialize)] struct VersionProbe { version: u32, } fn condition_from_tag(tag: u8) -> Result { Condition::ALL .get(tag as usize) .copied() .ok_or_else(|| invalid("condition", format!("unknown tag {tag}"))) } fn ghost_to_wire(v: &GhostMemory) -> GhostWire { GhostWire { origin_run_id: v.origin_run_id, item_template_id: v.item_template_id.clone(), warning: v.warning.clone(), } } fn ghost_from_wire(v: GhostWire) -> GhostMemory { GhostMemory { origin_run_id: v.origin_run_id, item_template_id: v.item_template_id, warning: v.warning, } } fn lot_to_wire(v: &AuctionLot) -> LotWire { LotWire { lot_id: v.lot_id, origin_run_id: v.origin_run_id, origin_item_id: v.origin_item_id, template_id: v.template_id.clone(), material: v.material, identified: v.identified, cursed: v.cursed, blessed: v.blessed, condition: v.condition.canonical_tag(), silvered: v.silvered, illegal: v.illegal, tags: v.tags.clone(), fuel: v.fuel, wet_ticks: v.wet_ticks, base_price_cu: v.base_price_cu, buyback_price_cu: v.buyback_price_cu, } } fn lot_from_wire(v: LotWire) -> Result { Ok(AuctionLot { lot_id: v.lot_id, origin_run_id: v.origin_run_id, origin_item_id: v.origin_item_id, template_id: v.template_id, material: v.material, identified: v.identified, cursed: v.cursed, blessed: v.blessed, condition: condition_from_tag(v.condition)?, silvered: v.silvered, illegal: v.illegal, tags: v.tags, fuel: v.fuel, wet_ticks: v.wet_ticks, base_price_cu: v.base_price_cu, buyback_price_cu: v.buyback_price_cu, }) } fn initial_to_wire(v: &InitialLineage) -> InitialWire { InitialWire { mapped_regions: v.mapped_regions.clone(), recipes: v.recipes.clone(), translated_hymns: v.translated_hymns.clone(), identified_item_templates: v.identified_item_templates.clone(), ghosts: v.ghosts.iter().map(ghost_to_wire).collect(), auction_lots: v.auction_lots.iter().map(lot_to_wire).collect(), } } fn initial_from_wire(v: InitialWire) -> Result { Ok(InitialLineage { mapped_regions: v.mapped_regions, recipes: v.recipes, translated_hymns: v.translated_hymns, identified_item_templates: v.identified_item_templates, ghosts: v.ghosts.into_iter().map(ghost_from_wire).collect(), auction_lots: v .auction_lots .into_iter() .map(lot_from_wire) .collect::>()?, }) } fn parse_digest(value: &str, context: &str) -> Result<[u8; 32], SchemaError> { if value.len() != 64 || !value .bytes() .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) { return Err(invalid( context, "digest must be exactly 64 lowercase hexadecimal characters", )); } let mut out = [0; 32]; for (i, slot) in out.iter_mut().enumerate() { *slot = u8::from_str_radix(&value[i * 2..i * 2 + 2], 16) .map_err(|_| invalid(context, "invalid digest"))?; } Ok(out) } fn digest_hex(value: &[u8; 32]) -> String { value.iter().map(|b| format!("{b:02x}")).collect() } fn gate_version(context: &'static str, found: u32, current: u32) -> Result<(), SchemaError> { if found < current { Err(SchemaError::OlderVersion { context, found, current, }) } else if found > current { Err(SchemaError::NewerVersion { context, found, current, }) } else { Ok(()) } } fn json_bounded( value: &T, context: &'static str, cap: usize, ) -> Result, SchemaError> { let mut bytes = serde_json::to_vec(value).map_err(|e| SchemaError::Malformed(e.to_string()))?; bytes.push(b'\n'); if bytes.len() > cap { return Err(SchemaError::TooLarge { context, actual: bytes.len(), limit: cap, }); } Ok(bytes) } pub fn encode_vault(vault: &VaultV2) -> Result, SchemaError> { let vault = canonical_vault(vault)?; let wire = VaultWire { version: vault.version, lineage_id: vault.lineage_id, next_run_sequence: vault.next_run_sequence, current: initial_to_wire(&vault.current), applied_deaths: vault .applied_deaths .iter() .map(|(run_id, receipt)| ReceiptWire { run_id: *run_id, terminal_core_hash: receipt.terminal_core_hash, effect_digest: digest_hex(&receipt.effect_digest), }) .collect(), }; json_bounded(&wire, "lineage vault", MAX_VAULT_BYTES) } pub fn decode_vault(bytes: &[u8]) -> Result { if bytes.len() > MAX_VAULT_BYTES { return Err(SchemaError::TooLarge { context: "lineage vault", actual: bytes.len(), limit: MAX_VAULT_BYTES, }); } let probe: VersionProbe = serde_json::from_slice(bytes).map_err(|e| SchemaError::Malformed(e.to_string()))?; gate_version("lineage vault", probe.version, VAULT_FORMAT_VERSION)?; let wire: VaultWire = serde_json::from_slice(bytes).map_err(|e| SchemaError::Malformed(e.to_string()))?; let mut receipts = BTreeMap::new(); for row in wire.applied_deaths { let receipt = AppliedDeathReceipt { terminal_core_hash: row.terminal_core_hash, effect_digest: parse_digest(&row.effect_digest, "applied_deaths.effect_digest")?, }; if receipts.insert(row.run_id, receipt).is_some() { return Err(invalid( "applied_deaths", format!("duplicate run key {}", row.run_id), )); } } let vault = VaultV2 { version: wire.version, lineage_id: wire.lineage_id, next_run_sequence: wire.next_run_sequence, current: initial_from_wire(wire.current)?, applied_deaths: receipts, }; canonical_vault(&vault) } pub fn load_vault(path: impl AsRef) -> Result, SchemaError> { let bytes = match read_file_bounded(path, MAX_VAULT_BYTES) { Ok(v) => v, Err(BoundedReadError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => { return Ok(None) } Err(BoundedReadError::Io(e)) => return Err(SchemaError::Io(e.to_string())), Err(BoundedReadError::TooLarge { actual, limit }) => { return Err(SchemaError::TooLarge { context: "lineage vault", actual: actual as usize, limit, }) } }; decode_vault(&bytes).map(Some) } /// Legacy best-effort replacement retained only until ArtifactStore lands. pub fn write_vault_atomic(path: impl AsRef, vault: &VaultV2) -> Result<(), SchemaError> { write_bytes_atomic(path, &encode_vault(vault)?).map_err(|e| SchemaError::Io(e.to_string())) } fn validate_id(id: &str, category: KnowledgeCategory, context: &str) -> Result<(), SchemaError> { validate_knowledge_id(category, id).map_err(|e| invalid(context, format!("{e:?}"))) } fn strict_sorted(rows: &[T], context: &str) -> Result<(), SchemaError> { if rows.windows(2).any(|w| w[0] >= w[1]) { Err(invalid(context, "rows must be strictly sorted and unique")) } else { Ok(()) } } fn validate_warning(s: &str) -> Result<(), SchemaError> { if s.is_empty() || s.len() > MAX_GHOST_WARNING_BYTES || s.trim().is_empty() || s.chars().any(|c| c <= '\u{1f}' || c == '\u{7f}') { Err(invalid( "ghost.warning", "must be nonblank, 1..512 UTF-8 bytes, without C0/DEL controls", )) } else { Ok(()) } } fn validate_tag(s: &str) -> Result<(), SchemaError> { if s.is_empty() || s.len() > MAX_LOT_TAG_BYTES || !s.is_ascii() || s.bytes().any(|b| b.is_ascii_control()) { Err(invalid( "auction_lot.tag", "must be 1..64 printable ASCII bytes", )) } else { Ok(()) } } pub fn validate_initial_lineage(v: &InitialLineage, strict: bool) -> Result<(), SchemaError> { for (rows, cap, cat, name) in [ ( &v.mapped_regions, MAX_MAPPED_REGIONS, KnowledgeCategory::MappedRegions, "mapped_regions", ), ( &v.recipes, MAX_RECIPES, KnowledgeCategory::Recipes, "recipes", ), ( &v.translated_hymns, MAX_TRANSLATIONS, KnowledgeCategory::TranslatedHymns, "translated_hymns", ), ( &v.identified_item_templates, MAX_IDENTIFIED_TEMPLATES, KnowledgeCategory::IdentifiedTemplates, "identified_item_templates", ), ] { if rows.len() > cap { return Err(invalid(name, format!("{} rows exceeds {cap}", rows.len()))); } if strict { strict_sorted(rows, name)?; } for id in rows { validate_id(id, cat, name)?; } } if v.ghosts.len() > MAX_GHOSTS || v.auction_lots.len() > MAX_AUCTION_LOTS { return Err(invalid( "initial_lineage", "ghost or lot category cap exceeded", )); } if strict { strict_sorted( &v.ghosts.iter().map(|g| g.origin_run_id).collect::>(), "ghosts", )?; strict_sorted( &v.auction_lots.iter().map(|l| l.lot_id).collect::>(), "auction_lots", )?; } let rows = v.mapped_regions.len() + v.recipes.len() + v.translated_hymns.len() + v.identified_item_templates.len() + v.ghosts.len() + v.auction_lots.len(); if rows > MAX_INITIAL_ROWS { return Err(invalid("initial_lineage", "combined row cap exceeded")); } let mut string_bytes = 0usize; let mut tags = 0usize; for g in &v.ghosts { validate_id( &g.item_template_id, KnowledgeCategory::IdentifiedTemplates, "ghost.item_template_id", )?; validate_warning(&g.warning)?; string_bytes = string_bytes .checked_add(g.item_template_id.len() + g.warning.len()) .ok_or_else(|| invalid("initial_lineage", "string byte overflow"))?; } for lot in &v.auction_lots { if lot.lot_id != LotId::new(lot.origin_run_id, lot.origin_item_id)? { return Err(invalid( "auction_lot.lot_id", "does not match origin run/item", )); } validate_id( &lot.template_id, KnowledgeCategory::IdentifiedTemplates, "auction_lot.template_id", )?; if lot.tags.len() > MAX_LOT_TAGS { return Err(invalid("auction_lot.tags", "more than 32 tags")); } for tag in &lot.tags { validate_tag(tag)?; string_bytes = string_bytes .checked_add(tag.len()) .ok_or_else(|| invalid("initial_lineage", "string byte overflow"))?; } tags = tags .checked_add(lot.tags.len()) .ok_or_else(|| invalid("initial_lineage", "tag count overflow"))?; string_bytes = string_bytes .checked_add(lot.template_id.len()) .ok_or_else(|| invalid("initial_lineage", "string byte overflow"))?; if lot.buyback_price_cu != hexwork_core::services::auction_buyback(lot.base_price_cu) { return Err(invalid( "auction_lot.price", "buyback must equal the canonical price derived from base", )); } } if tags > MAX_INITIAL_TAGS { return Err(invalid("initial_lineage", "total tag cap exceeded")); } for id in v .mapped_regions .iter() .chain(&v.recipes) .chain(&v.translated_hymns) .chain(&v.identified_item_templates) { string_bytes = string_bytes .checked_add(id.len()) .ok_or_else(|| invalid("initial_lineage", "string byte overflow"))?; } if string_bytes > MAX_INITIAL_STRING_BYTES { return Err(invalid("initial_lineage", "total string byte cap exceeded")); } Ok(()) } fn canonical_initial(v: &InitialLineage) -> Result { // Reject category/aggregate overflows before cloning attacker-sized vectors. validate_initial_lineage(v, false)?; let mut out = v.clone(); for rows in [ &mut out.mapped_regions, &mut out.recipes, &mut out.translated_hymns, &mut out.identified_item_templates, ] { rows.sort(); rows.dedup(); } out.ghosts.sort_by_key(|g| g.origin_run_id); out.auction_lots.sort_by_key(|l| l.lot_id); collapse_keyed(&mut out.ghosts, |g| g.origin_run_id, "ghosts")?; collapse_keyed(&mut out.auction_lots, |l| l.lot_id, "auction_lots")?; validate_initial_lineage(&out, true)?; Ok(out) } fn collapse_keyed( rows: &mut Vec, key: impl Fn(&T) -> K, context: &str, ) -> Result<(), SchemaError> { let mut out = Vec::with_capacity(rows.len()); for row in rows.iter() { if let Some(last) = out.last() { if key(last) == key(row) { if last == row { continue; } return Err(SchemaError::Conflict { context: format!("{context} contains conflicting equal keys"), }); } } out.push(row.clone()); } *rows = out; Ok(()) } fn canonical_vault(v: &VaultV2) -> Result { gate_version("lineage vault", v.version, VAULT_FORMAT_VERSION)?; if !(1..=MAX_RUN_SEQUENCE + 1).contains(&v.next_run_sequence) { return Err(invalid("next_run_sequence", "must be in 1..=65537")); } if v.applied_deaths.len() > MAX_APPLIED_DEATHS { return Err(invalid("applied_deaths", "receipt cap exceeded")); } let out = VaultV2 { current: canonical_initial(&v.current)?, ..v.clone() }; validate_vault(&out)?; Ok(out) } fn validate_vault(v: &VaultV2) -> Result<(), SchemaError> { gate_version("lineage vault", v.version, VAULT_FORMAT_VERSION)?; validate_initial_lineage(&v.current, true)?; if !(1..=MAX_RUN_SEQUENCE + 1).contains(&v.next_run_sequence) { return Err(invalid("next_run_sequence", "out of range")); } if v.applied_deaths.len() > MAX_APPLIED_DEATHS { return Err(invalid("applied_deaths", "receipt cap exceeded")); } Ok(()) } pub fn death_effect_digest(effect: &DeathEffect) -> Result<[u8; 32], SchemaError> { for (actual, limit, context) in [ ( effect.mapped_regions.len(), MAX_MAPPED_REGIONS, "mapped_regions", ), (effect.recipes.len(), MAX_RECIPES, "recipes"), ( effect.translated_hymns.len(), MAX_TRANSLATIONS, "translated_hymns", ), ( effect.identified_item_templates.len(), MAX_IDENTIFIED_TEMPLATES, "identified_item_templates", ), (effect.auction_lots.len(), MAX_AUCTION_LOTS, "auction_lots"), ] { if actual > limit { return Err(invalid(context, format!("{actual} rows exceeds {limit}"))); } } let rows = effect .mapped_regions .len() .checked_add(effect.recipes.len()) .and_then(|n| n.checked_add(effect.translated_hymns.len())) .and_then(|n| n.checked_add(effect.identified_item_templates.len())) .and_then(|n| n.checked_add(usize::from(effect.ghost.is_some()))) .and_then(|n| n.checked_add(effect.auction_lots.len())) .ok_or_else(|| invalid("death_effect", "combined row count overflow"))?; if rows > MAX_INITIAL_ROWS { return Err(invalid("death_effect", "combined row cap exceeded")); } let initial = InitialLineage { mapped_regions: effect.mapped_regions.clone(), recipes: effect.recipes.clone(), translated_hymns: effect.translated_hymns.clone(), identified_item_templates: effect.identified_item_templates.clone(), ghosts: effect.ghost.clone().into_iter().collect(), auction_lots: effect.auction_lots.clone(), }; validate_initial_lineage(&initial, true)?; let mut h = Sha256::new(); framed(&mut h, b"hexwork-death-effect-v1"); for rows in [ &effect.mapped_regions, &effect.recipes, &effect.translated_hymns, &effect.identified_item_templates, ] { h.update((rows.len() as u64).to_le_bytes()); for s in rows { framed(&mut h, s.as_bytes()); } } match &effect.ghost { None => h.update([0]), Some(g) => { h.update([1]); hash_run(&mut h, g.origin_run_id); framed(&mut h, g.item_template_id.as_bytes()); framed(&mut h, g.warning.as_bytes()); } } h.update((effect.auction_lots.len() as u64).to_le_bytes()); for l in &effect.auction_lots { hash_lot(&mut h, l); } Ok(h.finalize().into()) } fn framed(h: &mut Sha256, b: &[u8]) { h.update((b.len() as u64).to_le_bytes()); h.update(b); } fn hash_run(h: &mut Sha256, id: RunId) { h.update(id.sequence().to_le_bytes()); } fn hash_lot(h: &mut Sha256, l: &AuctionLot) { hash_run(h, l.lot_id.origin_run_id()); h.update(l.lot_id.origin_item_id().to_le_bytes()); hash_run(h, l.origin_run_id); h.update(l.origin_item_id.to_le_bytes()); framed(h, l.template_id.as_bytes()); h.update([ l.material, l.identified as u8, l.cursed as u8, l.blessed as u8, l.condition.canonical_tag(), l.silvered as u8, l.illegal as u8, ]); h.update((l.tags.len() as u64).to_le_bytes()); for t in &l.tags { framed(h, t.as_bytes()); } h.update([l.fuel]); h.update(l.wet_ticks.to_le_bytes()); h.update(l.base_price_cu.to_le_bytes()); h.update(l.buyback_price_cu.to_le_bytes()); } pub fn prepare_death_merge( vault: &VaultV2, run_id: RunId, terminal_core_hash: u64, effect: &DeathEffect, ) -> Result { validate_vault(vault)?; validate_effect_provenance(run_id, effect)?; let digest = death_effect_digest(effect)?; let receipt = AppliedDeathReceipt { terminal_core_hash, effect_digest: digest, }; if let Some(existing) = vault.applied_deaths.get(&run_id) { if *existing == receipt { verify_effect_present(vault, run_id, effect)?; return Ok(vault.clone()); } return Err(SchemaError::Conflict { context: format!("run {run_id} already has a differing terminal hash or effect digest"), }); } if vault.applied_deaths.len() >= MAX_APPLIED_DEATHS { return Err(invalid("applied_deaths", "receipt cap exceeded")); } let mut next = vault.clone(); let source = InitialLineage { mapped_regions: effect.mapped_regions.clone(), recipes: effect.recipes.clone(), translated_hymns: effect.translated_hymns.clone(), identified_item_templates: effect.identified_item_templates.clone(), ghosts: effect.ghost.clone().into_iter().collect(), auction_lots: effect.auction_lots.clone(), }; merge_initial(&mut next.current, &source)?; next.applied_deaths.insert(run_id, receipt); let next = canonical_vault(&next)?; let _ = encode_vault(&next)?; Ok(next) } /// Prove that a matching applied receipt is backed by the complete effect in /// the current vault projection. Knowledge rows use set inclusion; keyed ghost /// and auction rows require exact value equality for the effect's key. pub fn verify_effect_present( vault: &VaultV2, run_id: RunId, effect: &DeathEffect, ) -> Result<(), SchemaError> { validate_vault(vault)?; validate_effect_provenance(run_id, effect)?; let required = InitialLineage { mapped_regions: effect.mapped_regions.clone(), recipes: effect.recipes.clone(), translated_hymns: effect.translated_hymns.clone(), identified_item_templates: effect.identified_item_templates.clone(), ghosts: effect.ghost.clone().into_iter().collect(), auction_lots: effect.auction_lots.clone(), }; let required = canonical_initial(&required)?; for (present, needed, context) in [ ( &vault.current.mapped_regions, &required.mapped_regions, "mapped_regions", ), (&vault.current.recipes, &required.recipes, "recipes"), ( &vault.current.translated_hymns, &required.translated_hymns, "translated_hymns", ), ( &vault.current.identified_item_templates, &required.identified_item_templates, "identified_item_templates", ), ] { if needed.iter().any(|row| present.binary_search(row).is_err()) { return Err(SchemaError::Conflict { context: format!( "run {run_id} receipt exists but vault current lacks {context} effect" ), }); } } for ghost in &required.ghosts { match vault .current .ghosts .binary_search_by_key(&ghost.origin_run_id, |row| row.origin_run_id) { Ok(index) if vault.current.ghosts[index] == *ghost => {} _ => { return Err(SchemaError::Conflict { context: format!("run {run_id} receipt exists but keyed ghost effect differs"), }) } } } for lot in &required.auction_lots { match vault .current .auction_lots .binary_search_by_key(&lot.lot_id, |row| row.lot_id) { Ok(index) if vault.current.auction_lots[index] == *lot => {} _ => { return Err(SchemaError::Conflict { context: format!( "run {run_id} receipt exists but keyed auction effect differs" ), }) } } } Ok(()) } fn validate_effect_provenance(run_id: RunId, effect: &DeathEffect) -> Result<(), SchemaError> { if let Some(ghost) = &effect.ghost { if ghost.origin_run_id != run_id { return Err(invalid( "death_effect.ghost.origin_run_id", format!("{} does not match supplied {run_id}", ghost.origin_run_id), )); } } for (index, lot) in effect.auction_lots.iter().enumerate() { if lot.origin_run_id != run_id { return Err(invalid( format!("death_effect.auction_lots[{index}].origin_run_id"), format!("{} does not match supplied {run_id}", lot.origin_run_id), )); } if lot.lot_id.origin_run_id() != run_id { return Err(invalid( format!("death_effect.auction_lots[{index}].lot_id"), format!( "origin run {} does not match supplied {run_id}", lot.lot_id.origin_run_id() ), )); } } Ok(()) } fn merge_initial(target: &mut InitialLineage, source: &InitialLineage) -> Result<(), SchemaError> { let source = canonical_initial(source)?; for (dst, src) in [ (&mut target.mapped_regions, &source.mapped_regions), (&mut target.recipes, &source.recipes), (&mut target.translated_hymns, &source.translated_hymns), ( &mut target.identified_item_templates, &source.identified_item_templates, ), ] { dst.extend(src.iter().cloned()); dst.sort(); dst.dedup(); } merge_keyed( &mut target.ghosts, &source.ghosts, |g| g.origin_run_id, "ghosts", )?; merge_keyed( &mut target.auction_lots, &source.auction_lots, |l| l.lot_id, "auction_lots", )?; *target = canonical_initial(target)?; Ok(()) } fn merge_keyed( target: &mut Vec, source: &[T], key: impl Fn(&T) -> K, context: &str, ) -> Result<(), SchemaError> { for row in source { match target.iter().find(|x| key(x) == key(row)) { Some(old) if old == row => {} Some(_) => { return Err(SchemaError::Conflict { context: format!("{context} key differs"), }) } None => target.push(row.clone()), } } target.sort_by_key(|x| key(x)); Ok(()) } #[derive(Clone, Debug, PartialEq, Eq)] pub enum DeathDiagnostic { NoEligiblePackItem, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct DerivedDeathEffect { pub effect: DeathEffect, pub diagnostic: Option, } pub fn derive_death_effect( state: &State, identity: &RunIdentity, pack: &RuntimePack, ) -> Result { if !matches!(identity.mode, Mode::Classic | Mode::Ironcartographer) { return Err(invalid( "death_effect.mode", "Story does not merge lineage death effects", )); } if state.seed != identity.seed { return Err(invalid("death_effect.seed", "state and identity differ")); } if pack.hash() != identity.content_hash { return Err(invalid( "death_effect.content_hash", "recorded pack hash does not match exact pack", )); } state .validate_runtime() .map_err(|e| invalid("death_effect.state", format!("{e:?}")))?; let players: Vec<_> = state .actors .iter() .filter_map(|actor| { state .identity(actor.id) .filter(|v| v.role == ActorRole::Player) .map(|identity| (actor.id, identity)) }) .collect(); if players.len() != 1 { return Err(invalid( "death_effect.player", "requires exactly one Player", )); } let (player_id, player_identity) = players[0]; if player_identity.display_name != identity.display_name { return Err(invalid("death_effect.player", "display name differs")); } let actor = state .actors .iter() .find(|a| a.id == player_id) .ok_or_else(|| invalid("death_effect.player", "identity has no actor"))?; if actor.alive || actor.hp > 0 { return Err(invalid( "death_effect.player", "terminal Player is not dead", )); } let character = state .characters .get(&player_id) .ok_or_else(|| invalid("death_effect.player", "missing Character"))?; if character.ancestry != identity.ancestry || character.calling != identity.calling { return Err(invalid("death_effect.player", "ancestry/calling differ")); } for region_id in &state.knowledge().mapped_regions { validate_mapped_region_reference(region_id, pack)?; } for hymn_id in &state.knowledge().translated_hymns { if !pack.hymns().iter().any(|hymn| hymn.id == *hymn_id) { return Err(invalid( "death_effect.translated_hymns", format!("unresolved hymn {hymn_id}"), )); } } let identified_item_templates: Vec<_> = state.known_templates().map(str::to_owned).collect(); for template_id in &identified_item_templates { if pack.item(template_id).is_none() { return Err(invalid( "death_effect.identified_item_templates", format!("unresolved item template {template_id}"), )); } } let player_pack = state .packs .get(&player_id) .ok_or_else(|| invalid("death_effect.player", "missing authoritative Pack"))?; let mut lots = Vec::new(); for item in player_pack.items.values() { if item.template == "corpse" { continue; } let template = pack.item(&item.template).ok_or_else(|| { invalid( "death_effect.item", format!("unresolved non-corpse template {}", item.template), ) })?; lots.push(lot_from_instance( identity.run_id, item, template.base_price_cu, )?); } let ghost = lots.first().map(|lot| GhostMemory { origin_run_id: identity.run_id, item_template_id: lot.template_id.clone(), warning: ghost_warning_v1(state), }); let effect = DeathEffect { mapped_regions: state.knowledge().mapped_regions.iter().cloned().collect(), recipes: state.knowledge().recipes.iter().cloned().collect(), translated_hymns: state.knowledge().translated_hymns.iter().cloned().collect(), identified_item_templates, ghost, auction_lots: lots, }; let _ = death_effect_digest(&effect)?; Ok(DerivedDeathEffect { diagnostic: effect .ghost .is_none() .then_some(DeathDiagnostic::NoEligiblePackItem), effect, }) } fn validate_mapped_region_reference( region_id: &str, pack: &RuntimePack, ) -> Result<(), SchemaError> { if let Some(town_id) = region_id.strip_prefix("town:") { if town_id.is_empty() || pack.town(town_id).is_none() { return Err(invalid( "death_effect.mapped_regions", format!("unknown town region {region_id}"), )); } return Ok(()); } if let Some(depth_text) = region_id.strip_prefix("deep:") { let depth = depth_text.parse::().map_err(|_| { invalid( "death_effect.mapped_regions", format!("invalid deep region {region_id}"), ) })?; if depth_text != depth.to_string() || !(1..=12).contains(&depth) { return Err(invalid( "death_effect.mapped_regions", format!("deep region is not canonical depth 1..12: {region_id}"), )); } if pack.floor(depth).is_none() { return Err(invalid( "death_effect.mapped_regions", format!("missing floor for region {region_id}"), )); } return Ok(()); } Err(invalid( "death_effect.mapped_regions", format!("region must be town: or deep:: {region_id}"), )) } fn lot_from_instance(run_id: RunId, item: &Instance, base: u32) -> Result { let lot = AuctionLot { lot_id: LotId::new(run_id, item.id)?, origin_run_id: run_id, origin_item_id: item.id, template_id: item.template.clone(), material: item.material, identified: item.identified, cursed: item.cursed, blessed: item.blessed, condition: item.condition, silvered: item.silvered, illegal: item.illegal, tags: item.tags.clone(), fuel: item.fuel, wet_ticks: item.wet_ticks, base_price_cu: base, buyback_price_cu: hexwork_core::services::auction_buyback(base), }; validate_initial_lineage( &InitialLineage { auction_lots: vec![lot.clone()], ..InitialLineage::default() }, true, )?; Ok(lot) } #[must_use] pub fn ghost_warning_v1(state: &State) -> String { let selected = state .causes .links() .iter() .rev() .find(|l| l.rule == "death") .or_else(|| state.causes.links().last()); let raw = selected.map_or(String::new(), |l| format!("{}: {}", l.rule, l.detail)); let cleaned: String = raw .chars() .map(|c| if c.is_control() { ' ' } else { c }) .collect(); let normalized = cleaned.split_whitespace().collect::>().join(" "); let mut out = if normalized.trim_matches(':').trim().is_empty() { "The fallen delver left no readable warning.".to_owned() } else { normalized }; if out.len() > MAX_GHOST_WARNING_BYTES { let mut end = 509.min(out.len()); while !out.is_char_boundary(end) { end -= 1; } out.truncate(end); out.push('…'); } out } #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct PendingWire { version: u32, lineage_id: LineageId, run_id: RunId, terminal_tick: u64, terminal_core_hash: u64, terminal_snapshot_digest: String, content_hash: String, effect_digest: String, } #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct CommittedWire { version: u32, lineage_id: LineageId, run_id: RunId, terminal_tick: u64, terminal_core_hash: u64, terminal_snapshot_digest: String, content_hash: String, effect_digest: String, replay_digest: String, epitaph_digest: String, vault_effect_digest: String, } fn validate_marker_common( lineage_id: LineageId, run_id: RunId, content_hash: &str, ) -> Result<(), SchemaError> { let _ = (lineage_id, run_id); if content_hash.is_empty() || content_hash.len() > 4096 { return Err(invalid( "marker.content_hash", "must be nonblank and at most 4096 bytes", )); } Ok(()) } pub fn encode_pending_death(v: &PendingDeathV1) -> Result, SchemaError> { gate_version("pending death marker", v.version, PENDING_DEATH_VERSION)?; validate_marker_common(v.lineage_id, v.run_id, &v.content_hash)?; json_bounded( &PendingWire { version: v.version, lineage_id: v.lineage_id, run_id: v.run_id, terminal_tick: v.terminal_tick, terminal_core_hash: v.terminal_core_hash, terminal_snapshot_digest: digest_hex(&v.terminal_snapshot_digest), content_hash: v.content_hash.clone(), effect_digest: digest_hex(&v.effect_digest), }, "pending death marker", MAX_MARKER_BYTES, ) } pub fn decode_pending_death(bytes: &[u8]) -> Result { if bytes.len() > MAX_MARKER_BYTES { return Err(SchemaError::TooLarge { context: "pending death marker", actual: bytes.len(), limit: MAX_MARKER_BYTES, }); } let probe: VersionProbe = serde_json::from_slice(bytes).map_err(|e| SchemaError::Malformed(e.to_string()))?; gate_version("pending death marker", probe.version, PENDING_DEATH_VERSION)?; let w: PendingWire = serde_json::from_slice(bytes).map_err(|e| SchemaError::Malformed(e.to_string()))?; validate_marker_common(w.lineage_id, w.run_id, &w.content_hash)?; Ok(PendingDeathV1 { version: w.version, lineage_id: w.lineage_id, run_id: w.run_id, terminal_tick: w.terminal_tick, terminal_core_hash: w.terminal_core_hash, terminal_snapshot_digest: parse_digest( &w.terminal_snapshot_digest, "terminal_snapshot_digest", )?, content_hash: w.content_hash, effect_digest: parse_digest(&w.effect_digest, "effect_digest")?, }) } pub fn encode_committed_death(v: &CommittedDeathV1) -> Result, SchemaError> { gate_version("committed death marker", v.version, COMMITTED_DEATH_VERSION)?; validate_marker_common(v.lineage_id, v.run_id, &v.content_hash)?; if v.vault_effect_digest != v.effect_digest { return Err(invalid("vault_effect_digest", "must equal effect_digest")); } json_bounded( &CommittedWire { version: v.version, lineage_id: v.lineage_id, run_id: v.run_id, terminal_tick: v.terminal_tick, terminal_core_hash: v.terminal_core_hash, terminal_snapshot_digest: digest_hex(&v.terminal_snapshot_digest), content_hash: v.content_hash.clone(), effect_digest: digest_hex(&v.effect_digest), replay_digest: digest_hex(&v.replay_digest), epitaph_digest: digest_hex(&v.epitaph_digest), vault_effect_digest: digest_hex(&v.vault_effect_digest), }, "committed death marker", MAX_MARKER_BYTES, ) } pub fn decode_committed_death(bytes: &[u8]) -> Result { if bytes.len() > MAX_MARKER_BYTES { return Err(SchemaError::TooLarge { context: "committed death marker", actual: bytes.len(), limit: MAX_MARKER_BYTES, }); } let probe: VersionProbe = serde_json::from_slice(bytes).map_err(|e| SchemaError::Malformed(e.to_string()))?; gate_version( "committed death marker", probe.version, COMMITTED_DEATH_VERSION, )?; let w: CommittedWire = serde_json::from_slice(bytes).map_err(|e| SchemaError::Malformed(e.to_string()))?; validate_marker_common(w.lineage_id, w.run_id, &w.content_hash)?; let out = CommittedDeathV1 { version: w.version, lineage_id: w.lineage_id, run_id: w.run_id, terminal_tick: w.terminal_tick, terminal_core_hash: w.terminal_core_hash, terminal_snapshot_digest: parse_digest( &w.terminal_snapshot_digest, "terminal_snapshot_digest", )?, content_hash: w.content_hash, effect_digest: parse_digest(&w.effect_digest, "effect_digest")?, replay_digest: parse_digest(&w.replay_digest, "replay_digest")?, epitaph_digest: parse_digest(&w.epitaph_digest, "epitaph_digest")?, vault_effect_digest: parse_digest(&w.vault_effect_digest, "vault_effect_digest")?, }; if out.vault_effect_digest != out.effect_digest { return Err(invalid("vault_effect_digest", "must equal effect_digest")); } Ok(out) } #[cfg(any())] mod legacy { //! Lineage knowledge: the part of progression that outlives the delver. //! //! Design traces: docs/03-systems-design.md §3.1 ("No levels. Reputation IS //! progression ... *knowledge* (identified recipes, mapped regions, //! translated hymns persist across delvers in the same lineage — see §3.9 //! death)") and §3.9 ("Lineage vault: maps, recipes, translated hymns //! persist; gear does not (the Audit auctions it — you may buy back YOUR OWN //! sword three runs later ...)"). //! //! Model: [`Knowledge`] is the live, deduped, deterministically ordered set //! form used at runtime; [`LineageVault`](super::LineageVault) is the //! persisted form (sorted `Vec`s, see `hexwork-save/src/lib.rs`). Convert //! with [`Knowledge::from_vault`] / [`Knowledge::into_vault`]. Vault merge //! call sites land Phase 11; Phase 6 only guarantees the type + round-trip //! (Phase 6 wiring plan §2). //! //! GEAR NEVER PERSISTS. This module has no item/gear field, takes no //! item/gear parameter, and [`Knowledge::into_vault`] writes only the three //! knowledge vectors, leaving `ghosts` (and any future non-knowledge field) //! untouched. Pinned by `into_vault_touches_only_knowledge_fields` and //! `gear_never_persists`. //! //! Determinism: `BTreeSet` storage (dedup + sorted iteration), sorted `Vec` //! round-trips, no RNG/time/IO/threads. use std::collections::BTreeSet; use std::fmt; use std::path::Path; use super::{write_bytes_atomic, LineageVault}; /// Current lineage-vault JSON wrapper version. No older production migration exists. pub const VAULT_FORMAT_VERSION: u32 = 1; /// Maximum entries in each persisted lineage collection. pub const MAX_VAULT_ENTRIES: usize = 16_384; /// Maximum bytes in one map/recipe/hymn/ghost identifier. pub const MAX_VAULT_ENTRY_BYTES: usize = 4 * 1024; /// Maximum lineage-vault file size accepted before JSON parsing (8 MiB). pub const MAX_VAULT_BYTES: usize = 8 * 1024 * 1024; #[derive(serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] struct VaultFile { version: u32, mapped_regions: Vec, recipes: Vec, translated_hymns: Vec, ghosts: Vec, } /// Actionable lineage-vault load/write failure. #[derive(Debug)] pub enum VaultError { Io(std::io::Error), TooLarge { actual: usize, limit: usize, }, Malformed(String), OlderVersion { found: u32, current: u32, }, NewerVersion { found: u32, current: u32, }, Invalid { collection: &'static str, index: usize, reason: String, }, } impl fmt::Display for VaultError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Io(e) => write!(f, "lineage vault I/O failed: {e}"), Self::TooLarge { actual, limit } => write!(f, "lineage vault is {actual} bytes; limit is {limit}"), Self::Malformed(e) => write!(f, "malformed lineage vault JSON: {e}"), Self::OlderVersion { found, current } => write!(f, "lineage vault version {found} is older than supported version {current}; no silent migration exists"), Self::NewerVersion { found, current } => write!(f, "lineage vault version {found} is newer than supported version {current}; update HEXWORK"), Self::Invalid { collection, index, reason } => write!(f, "invalid lineage vault {collection}[{index}]: {reason}"), } } } impl std::error::Error for VaultError {} impl From for VaultError { fn from(value: std::io::Error) -> Self { Self::Io(value) } } /// Load a versioned lineage vault. A missing path is a fresh lineage; all other /// I/O, malformed JSON, and unsupported versions are errors. pub fn load_vault(path: impl AsRef) -> Result { let bytes = match std::fs::read(path) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { return Ok(LineageVault::default()) } Err(e) => return Err(VaultError::Io(e)), }; if bytes.len() > MAX_VAULT_BYTES { return Err(VaultError::TooLarge { actual: bytes.len(), limit: MAX_VAULT_BYTES, }); } let file: VaultFile = serde_json::from_slice(&bytes).map_err(|e| VaultError::Malformed(e.to_string()))?; if file.version < VAULT_FORMAT_VERSION { return Err(VaultError::OlderVersion { found: file.version, current: VAULT_FORMAT_VERSION, }); } if file.version > VAULT_FORMAT_VERSION { return Err(VaultError::NewerVersion { found: file.version, current: VAULT_FORMAT_VERSION, }); } canonical_vault(LineageVault { mapped_regions: file.mapped_regions, recipes: file.recipes, translated_hymns: file.translated_hymns, ghosts: file.ghosts, }) } /// Canonicalize, validate, and atomically replace a lineage-vault JSON file. pub fn write_vault_atomic( path: impl AsRef, vault: &LineageVault, ) -> Result<(), VaultError> { let vault = canonical_vault(vault.clone())?; let file = VaultFile { version: VAULT_FORMAT_VERSION, mapped_regions: vault.mapped_regions, recipes: vault.recipes, translated_hymns: vault.translated_hymns, ghosts: vault.ghosts, }; let mut bytes = serde_json::to_vec_pretty(&file).map_err(|e| VaultError::Malformed(e.to_string()))?; bytes.push(b'\n'); if bytes.len() > MAX_VAULT_BYTES { return Err(VaultError::TooLarge { actual: bytes.len(), limit: MAX_VAULT_BYTES, }); } write_bytes_atomic(path, &bytes).map_err(VaultError::Io) } fn canonical_vault(mut vault: LineageVault) -> Result { canonical_entries("mapped_regions", &mut vault.mapped_regions)?; canonical_entries("recipes", &mut vault.recipes)?; canonical_entries("translated_hymns", &mut vault.translated_hymns)?; canonical_entries("ghosts", &mut vault.ghosts)?; Ok(vault) } fn canonical_entries( collection: &'static str, values: &mut Vec, ) -> Result<(), VaultError> { if values.len() > MAX_VAULT_ENTRIES { return Err(VaultError::Invalid { collection, index: values.len(), reason: format!("entry count exceeds {MAX_VAULT_ENTRIES}"), }); } for (index, value) in values.iter().enumerate() { if value.trim().is_empty() { return Err(VaultError::Invalid { collection, index, reason: "blank entry".into(), }); } if value.len() > MAX_VAULT_ENTRY_BYTES { return Err(VaultError::Invalid { collection, index, reason: format!("{} bytes exceeds {MAX_VAULT_ENTRY_BYTES}", value.len()), }); } } values.sort(); values.dedup(); Ok(()) } /// Knowledge a lineage has accumulated across delvers: mapped regions, /// identified recipes, translated hymns (docs/03 §3.1, §3.9). /// /// Ids are opaque slugs (e.g. `"shallows-1"`, `"tinker-moss-poultice"`, /// `"hymn-12-elevator"`); this type never interprets them, so it can never /// hide or filter them. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Knowledge { /// Mapped region ids (docs/03 §3.9: "maps ... persist"). pub maps: BTreeSet, /// Identified recipe ids ("recipes ... persist"). pub recipes: BTreeSet, /// Translated hymn ids ("translated hymns persist"). pub translations: BTreeSet, } impl Knowledge { /// Empty knowledge: a fresh lineage that has mapped nothing yet. #[must_use] pub fn new() -> Self { Self::default() } /// Learn a map region id. Returns `true` if newly learned. pub fn learn_map(&mut self, id: &str) -> bool { self.maps.insert(id.to_owned()) } /// Learn an identified recipe id. Returns `true` if newly learned. pub fn learn_recipe(&mut self, id: &str) -> bool { self.recipes.insert(id.to_owned()) } /// Learn a translated hymn id. Returns `true` if newly learned. pub fn learn_translation(&mut self, id: &str) -> bool { self.translations.insert(id.to_owned()) } /// Union `other` into `self`: the lineage vault merge across runs (the /// next delver inherits the fallen delver's finds plus their own). /// Never consumes `other`; order-independent and idempotent. pub fn merge(&mut self, other: &Knowledge) { self.maps.extend(other.maps.iter().cloned()); self.recipes.extend(other.recipes.iter().cloned()); self.translations.extend(other.translations.iter().cloned()); } /// Load live knowledge from a vault. Duplicate entries in the stored /// `Vec`s collapse (set semantics); order normalises to sorted. #[must_use] pub fn from_vault(v: &LineageVault) -> Self { Self { maps: v.mapped_regions.iter().cloned().collect(), recipes: v.recipes.iter().cloned().collect(), translations: v.translated_hymns.iter().cloned().collect(), } } /// Store live knowledge back into a vault as sorted `Vec`s (`BTreeSet` /// iteration order, so the bytes are deterministic). Writes ONLY the /// three knowledge fields; `ghosts` is untouched, and there is no gear /// field to write — gear is auctioned parent-side, never stored here. pub fn into_vault(&self, v: &mut LineageVault) { v.mapped_regions = self.maps.iter().cloned().collect(); v.recipes = self.recipes.iter().cloned().collect(); v.translated_hymns = self.translations.iter().cloned().collect(); } } #[cfg(test)] mod tests { use super::*; fn vault_with( maps: &[&str], recipes: &[&str], hymns: &[&str], ghosts: &[&str], ) -> LineageVault { LineageVault { mapped_regions: maps.iter().map(|s| (*s).to_owned()).collect(), recipes: recipes.iter().map(|s| (*s).to_owned()).collect(), translated_hymns: hymns.iter().map(|s| (*s).to_owned()).collect(), ghosts: ghosts.iter().map(|s| (*s).to_owned()).collect(), } } #[test] fn learn_map_new_returns_true() { let mut k = Knowledge::new(); assert!(k.learn_map("shallows-1")); assert!(k.maps.contains("shallows-1")); } #[test] fn relearn_returns_false_and_dedups() { let mut k = Knowledge::new(); assert!(k.learn_map("shallows-1")); assert!(!k.learn_map("shallows-1")); assert!(k.learn_recipe("oil-poultice")); assert!(!k.learn_recipe("oil-poultice")); assert!(k.learn_translation("hymn-12-elevator")); assert!(!k.learn_translation("hymn-12-elevator")); assert_eq!(k.maps.len(), 1); assert_eq!(k.recipes.len(), 1); assert_eq!(k.translations.len(), 1); } #[test] fn merge_is_union_and_keeps_other() { let mut a = Knowledge::new(); a.learn_map("shallows-1"); a.learn_recipe("oil-poultice"); let mut b = Knowledge::new(); b.learn_map("copperwash-reach"); b.learn_translation("hymn-12-elevator"); a.merge(&b); assert!(a.maps.contains("shallows-1")); assert!(a.maps.contains("copperwash-reach")); assert!(a.recipes.contains("oil-poultice")); assert!(a.translations.contains("hymn-12-elevator")); assert_eq!(b.maps.len(), 1, "merge must not consume other"); assert_eq!(b.translations.len(), 1, "merge must not consume other"); } #[test] fn merge_is_idempotent() { let mut a = Knowledge::new(); a.learn_map("shallows-1"); let b = a.clone(); a.merge(&b); assert_eq!(a.maps.len(), 1, "merging a copy must not duplicate"); } #[test] fn from_vault_dedups_and_sorts() { let v = vault_with( &["zebra-deep", "apple-shallows", "zebra-deep"], &["r2", "r1"], &[], &["ghost-tomm"], ); let k = Knowledge::from_vault(&v); let maps: Vec<&str> = k.maps.iter().map(String::as_str).collect(); assert_eq!(maps, ["apple-shallows", "zebra-deep"]); let recipes: Vec<&str> = k.recipes.iter().map(String::as_str).collect(); assert_eq!(recipes, ["r1", "r2"]); assert!(k.translations.is_empty()); } #[test] fn vault_round_trip_is_sorted_and_deterministic() { let mut k = Knowledge::new(); for id in ["zebra-deep", "apple-shallows", "mango-vent"] { k.learn_map(id); } k.learn_recipe("oil-poultice"); let mut v = LineageVault::default(); k.into_vault(&mut v); assert_eq!( v.mapped_regions, ["apple-shallows", "mango-vent", "zebra-deep"] ); assert_eq!(v.recipes, ["oil-poultice"]); let k2 = Knowledge::from_vault(&v); assert_eq!(k, k2, "vault round-trip must preserve knowledge"); let mut v2 = LineageVault::default(); k2.into_vault(&mut v2); assert_eq!(v.mapped_regions, v2.mapped_regions, "bytes must be stable"); assert_eq!(v.recipes, v2.recipes, "bytes must be stable"); assert_eq!( v.translated_hymns, v2.translated_hymns, "bytes must be stable" ); } #[test] fn into_vault_touches_only_knowledge_fields() { let mut v = vault_with( &["stale-map"], &["stale-recipe"], &["stale-hymn"], &["ghost-tomm"], ); let mut k = Knowledge::new(); k.learn_map("fresh-map"); k.into_vault(&mut v); assert_eq!(v.mapped_regions, ["fresh-map"]); assert!(v.recipes.is_empty(), "empty knowledge clears the field"); assert!( v.translated_hymns.is_empty(), "empty knowledge clears the field" ); assert_eq!(v.ghosts, ["ghost-tomm"], "ghosts must survive into_vault"); } fn scratch_dir(tag: &str) -> std::path::PathBuf { let path = std::env::temp_dir() .join(format!("hexwork-vault-test-{}-{tag}", std::process::id())); let _ = std::fs::remove_dir_all(&path); std::fs::create_dir_all(&path).expect("scratch directory"); path } #[test] fn vault_io_is_versioned_deterministic_and_atomic() { let dir = scratch_dir("roundtrip"); let path = dir.join("lineage.json"); let vault = vault_with( &["z-map", "a-map", "z-map"], &["r2", "r1", "r1"], &["h2", "h1"], &["ghost-z", "ghost-a", "ghost-z"], ); write_vault_atomic(&path, &vault).expect("initial write"); let first = std::fs::read(&path).expect("read bytes"); assert!(String::from_utf8_lossy(&first).contains("\"version\": 1")); let loaded = load_vault(&path).expect("load"); assert_eq!(loaded.mapped_regions, ["a-map", "z-map"]); assert_eq!(loaded.recipes, ["r1", "r2"]); assert_eq!(loaded.translated_hymns, ["h1", "h2"]); assert_eq!(loaded.ghosts, ["ghost-a", "ghost-z"]); write_vault_atomic(&path, &loaded).expect("replacement write"); assert_eq!(std::fs::read(&path).unwrap(), first); assert_eq!( std::fs::read_dir(&dir).unwrap().count(), 1, "no temp remains" ); std::fs::remove_dir_all(dir).unwrap(); } #[test] fn vault_missing_is_fresh_and_malformed_or_versioned_is_error() { let dir = scratch_dir("errors"); let path = dir.join("missing.json"); assert!(load_vault(&path).unwrap().mapped_regions.is_empty()); std::fs::write(&path, b"not json").unwrap(); assert!(matches!(load_vault(&path), Err(VaultError::Malformed(_)))); for (version, newer) in [(0, false), (2, true)] { std::fs::write( &path, format!("{{\"version\":{version},\"mapped_regions\":[],\"recipes\":[],\"translated_hymns\":[],\"ghosts\":[]}}"), ) .unwrap(); if newer { assert!(matches!( load_vault(&path), Err(VaultError::NewerVersion { .. }) )); } else { assert!(matches!( load_vault(&path), Err(VaultError::OlderVersion { .. }) )); } } std::fs::remove_dir_all(dir).unwrap(); } #[test] fn vault_rejects_blank_oversize_and_collection_cap() { let dir = scratch_dir("limits"); let path = dir.join("lineage.json"); let mut vault = LineageVault::default(); vault.mapped_regions.push(" ".into()); assert!(matches!( write_vault_atomic(&path, &vault), Err(VaultError::Invalid { .. }) )); vault.mapped_regions = vec!["x".repeat(MAX_VAULT_ENTRY_BYTES + 1)]; assert!(matches!( write_vault_atomic(&path, &vault), Err(VaultError::Invalid { .. }) )); vault.mapped_regions = vec!["x".into(); MAX_VAULT_ENTRIES + 1]; assert!(matches!( write_vault_atomic(&path, &vault), Err(VaultError::Invalid { .. }) )); assert!( !path.exists(), "validation failure creates no target or temp" ); assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0); std::fs::remove_dir_all(dir).unwrap(); } #[test] fn gear_never_persists() { // Compile-time: `Knowledge` has exactly three fields (maps, recipes, // translations) — there is no gear/item field to transfer, and // `into_vault` takes no gear parameter. Runtime: a full learn → // vault cycle stores knowledge ids and conjures nothing else. let mut k = Knowledge::new(); k.learn_map("shallows-1"); k.learn_recipe("tinker-moss-poultice"); k.learn_translation("hymn-12-elevator"); let mut v = LineageVault::default(); k.into_vault(&mut v); assert_eq!(v.mapped_regions, ["shallows-1"]); assert_eq!(v.recipes, ["tinker-moss-poultice"]); assert_eq!(v.translated_hymns, ["hymn-12-elevator"]); assert!(v.ghosts.is_empty(), "learning must not invent ghosts"); let debug = format!("{v:?}"); assert!( !debug.contains("sword") && !debug.contains("gear") && !debug.contains("item"), "vault must not conjure gear entries: {debug}" ); } } }