From fb52abf7281fd4454b3d738d1ca705b71c36b25b Mon Sep 17 00:00:00 2001 From: Teakowa Date: Fri, 28 Aug 2026 21:47:10 +0800 Subject: [PATCH 1/4] fix(settings): reconcile raw projections Refs #110 --- .../src/settings/data/generated_entries.rs | 149 +----------------- crates/workshop-rs/src/settings/schema.rs | 143 +++++++++++++---- crates/workshop-rs/src/settings/table.rs | 40 ++++- 3 files changed, 151 insertions(+), 181 deletions(-) diff --git a/crates/workshop-rs/src/settings/data/generated_entries.rs b/crates/workshop-rs/src/settings/data/generated_entries.rs index 6e1f5e1..c2a4130 100644 --- a/crates/workshop-rs/src/settings/data/generated_entries.rs +++ b/crates/workshop-rs/src/settings/data/generated_entries.rs @@ -1,4 +1,6 @@ -// Generated from the reviewed workshop-data export; do not edit by hand. +// Generated from the reviewed workshop-data export. Entries with fixture-owned +// path-specific rendering or enum identities are reconciled out of this +// projection; schema validation rejects any remaining divergent overlap. pub static GENERATED_ENTRIES: &[TableEntry] = &[ entry!( [PathPart::Part("extensions"), PathPart::Part("beamEffects")], @@ -640,11 +642,6 @@ pub static GENERATED_ENTRIES: &[TableEntry] = &[ "Health Pack Respawn Time Scalar", KeyKind::Percent ), - entry!( - [PathPart::Part("gamemodes"), PathPart::Part("general"), PathPart::Part("heroLimit")], - "Hero Limit", - KeyKind::Enum("setting_gamemodes_general_heroLimit") - ), entry!( [PathPart::Part("gamemodes"), PathPart::Part("general"), PathPart::Part("perkEliminationCatchupLevelAmount%")], "Perk Elimination Catchup Level Amount", @@ -665,16 +662,6 @@ pub static GENERATED_ENTRIES: &[TableEntry] = &[ "Respawn Time Scalar", KeyKind::Percent ), - entry!( - [PathPart::Part("gamemodes"), PathPart::Part("general"), PathPart::Part("roleLimit")], - "Limit Roles", - KeyKind::Enum("setting_gamemodes_general_roleLimit") - ), - entry!( - [PathPart::Part("gamemodes"), PathPart::Part("general"), PathPart::Part("spawnHealthPacks")], - "Spawn Health Packs", - KeyKind::Enum("setting_gamemodes_general_spawnHealthPacks") - ), entry!( [PathPart::Part("gamemodes"), PathPart::Part("general"), PathPart::Part("tankPassiveHealthBonus")], "Tank Role Passive Health Bonus", @@ -1250,11 +1237,6 @@ pub static GENERATED_ENTRIES: &[TableEntry] = &[ "Call Mech Knockback Scalar", KeyKind::Percent ), - entry!( - [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero, PathPart::Part("combatUltGen%")], - "Ultimate Generation - Combat %1$s", - KeyKind::Percent - ), entry!( [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero, PathPart::Part("damageDealt%")], "Damage Dealt", @@ -1265,16 +1247,6 @@ pub static GENERATED_ENTRIES: &[TableEntry] = &[ "Damage Received", KeyKind::Percent ), - entry!( - [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero, PathPart::Part("enableAbility1")], - "%1$s", - KeyKind::Bool - ), - entry!( - [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero, PathPart::Part("enableAbility2")], - "%1$s", - KeyKind::Bool - ), entry!( [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero, PathPart::Part("enableAbility3")], "%1$s", @@ -1345,11 +1317,6 @@ pub static GENERATED_ENTRIES: &[TableEntry] = &[ "No Scope", KeyKind::Bool ), - entry!( - [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero, PathPart::Part("enableSecondaryFire")], - "%1$s", - KeyKind::Bool - ), entry!( [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero, PathPart::Part("enableSpawningWithUlt")], "Spawn With Ultimate Ready", @@ -1420,11 +1387,6 @@ pub static GENERATED_ENTRIES: &[TableEntry] = &[ "Hover Jets Recharge Rate", KeyKind::Percent ), - entry!( - [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero, PathPart::Part("passiveUltGen%")], - "Ultimate Generation - Passive %1$s", - KeyKind::Percent - ), entry!( [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero, PathPart::Part("passiveVerticalSpeed%")], "Hover Jets Vertical Speed Scalar", @@ -1645,11 +1607,6 @@ pub static GENERATED_ENTRIES: &[TableEntry] = &[ "Max FFA Players", KeyKind::Number ), - entry!( - [PathPart::Part("lobby"), PathPart::Part("mapRotation")], - "Map Rotation", - KeyKind::Enum("setting_lobby_mapRotation") - ), entry!( [PathPart::Part("lobby"), PathPart::Part("minimumLatencyInNs")], "Minimum Latency milliseconds", @@ -1660,11 +1617,6 @@ pub static GENERATED_ENTRIES: &[TableEntry] = &[ "Pause Game On Player Disconnect", KeyKind::Bool ), - entry!( - [PathPart::Part("lobby"), PathPart::Part("returnToLobby")], - "Return To Lobby", - KeyKind::Enum("setting_lobby_returnToLobby") - ), entry!( [PathPart::Part("lobby"), PathPart::Part("spectatorSlots")], "Max Spectators", @@ -1675,16 +1627,6 @@ pub static GENERATED_ENTRIES: &[TableEntry] = &[ "Swap Teams After Match", KeyKind::Bool ), - entry!( - [PathPart::Part("lobby"), PathPart::Part("team1Slots")], - "Max %1$s Players", - KeyKind::Number - ), - entry!( - [PathPart::Part("lobby"), PathPart::Part("team2Slots")], - "Max %1$s Players", - KeyKind::Number - ), entry!( [PathPart::Part("lobby"), PathPart::Part("teamBalancing")], "Team Balancing", @@ -1892,61 +1834,6 @@ pub static GENERATED_ENUM_MEMBERS: &[EnumMember] = &[ member: "manual", name: "Manual", }, - EnumMember { - domain: "setting_gamemodes_general_heroLimit", - member: "off", - name: "Off", - }, - EnumMember { - domain: "setting_gamemodes_general_heroLimit", - member: "1PerTeam", - name: "1 Per Team", - }, - EnumMember { - domain: "setting_gamemodes_general_heroLimit", - member: "2PerTeam", - name: "2 Per Team", - }, - EnumMember { - domain: "setting_gamemodes_general_heroLimit", - member: "1PerGame", - name: "1 Per Game", - }, - EnumMember { - domain: "setting_gamemodes_general_heroLimit", - member: "2PerGame", - name: "2 Per Game", - }, - EnumMember { - domain: "setting_gamemodes_general_roleLimit", - member: "off", - name: "Off", - }, - EnumMember { - domain: "setting_gamemodes_general_roleLimit", - member: "2OfEachRolePerTeam", - name: "2 Of Each Role Per Team", - }, - EnumMember { - domain: "setting_gamemodes_general_roleLimit", - member: "1Tank2Offense2Support", - name: "1 Tank 2 Offense 2 Support", - }, - EnumMember { - domain: "setting_gamemodes_general_spawnHealthPacks", - member: "modeDependent", - name: "Determined By Mode", - }, - EnumMember { - domain: "setting_gamemodes_general_spawnHealthPacks", - member: "enabled", - name: "Enabled", - }, - EnumMember { - domain: "setting_gamemodes_general_spawnHealthPacks", - member: "disabled", - name: "Disabled", - }, EnumMember { domain: "setting_gamemodes_general_tankPassiveHealthBonus", member: "1Tank2Offense2Support", @@ -2232,36 +2119,6 @@ pub static GENERATED_ENUM_MEMBERS: &[EnumMember] = &[ member: "usWest2", name: "USA - West 2", }, - EnumMember { - domain: "setting_lobby_mapRotation", - member: "afterMirrorMatch", - name: "After A Mirror Match", - }, - EnumMember { - domain: "setting_lobby_mapRotation", - member: "afterGame", - name: "After A Game", - }, - EnumMember { - domain: "setting_lobby_mapRotation", - member: "paused", - name: "Paused", - }, - EnumMember { - domain: "setting_lobby_returnToLobby", - member: "never", - name: "Never", - }, - EnumMember { - domain: "setting_lobby_returnToLobby", - member: "afterGame", - name: "After A Game", - }, - EnumMember { - domain: "setting_lobby_returnToLobby", - member: "afterMirrorMatch", - name: "After A Mirror Match", - }, EnumMember { domain: "setting_lobby_teamBalancing", member: "off", diff --git a/crates/workshop-rs/src/settings/schema.rs b/crates/workshop-rs/src/settings/schema.rs index 526bc3c..7d4f65e 100644 --- a/crates/workshop-rs/src/settings/schema.rs +++ b/crates/workshop-rs/src/settings/schema.rs @@ -1159,23 +1159,12 @@ pub fn validate_catalog() -> Result<(), Vec> { use std::collections::{HashMap, HashSet}; let mut errors = Vec::new(); - let mut raw_paths: HashMap = HashMap::new(); - for entry in table::raw_entries() { - let path = table::path_string(entry.path); - if let Some(previous) = raw_paths.insert(path.clone(), *entry) { - let same_path = previous.path.len() == entry.path.len() - && previous - .path - .iter() - .zip(entry.path.iter()) - .all(|(left, right)| left == right); - if !same_path || !same_entry_shape(previous.kind, entry.kind) { - errors.push(format!( - "conflicting duplicate settings path between catalog projections: {path}" - )); - } - } - } + errors.extend(validate_raw_projection(table::raw_entries())); + errors.extend(validate_enum_projection( + table::ENUM_MEMBERS + .iter() + .chain(table::GENERATED_ENUM_MEMBERS.iter()), + )); let mut paths = HashSet::new(); let mut concepts: HashMap<(String, SettingTargetKind, String), SettingValueDomain> = HashMap::new(); @@ -1231,6 +1220,65 @@ pub fn validate_catalog() -> Result<(), Vec> { } } +/// Reject raw table overlaps unless their complete parser/emitter contract is +/// identical. Effective lookup may deduplicate exact repeats, but must never +/// make a divergent generated or fixture projection silently win. +fn validate_raw_projection( + entries: impl IntoIterator, +) -> Vec { + use std::collections::HashMap; + + let mut errors = Vec::new(); + let mut paths = HashMap::new(); + for projected in entries { + let entry = projected.entry; + if let Some(previous) = paths.insert(entry.path, projected) { + if previous.entry != entry { + errors.push(format!( + "conflicting duplicate settings path between {} and {}: {}", + previous.source.label(), + projected.source.label(), + table::path_string(entry.path), + )); + } + } + } + errors +} + +/// Validate enum members independently of entry lookup order. This catches +/// both stale enum projections and conflicting duplicate spellings that the +/// lookup helper would otherwise hide. +fn validate_enum_projection( + entries: impl IntoIterator, +) -> Vec { + use std::collections::{HashMap, HashSet}; + + let domains: HashSet<_> = table::entries() + .filter_map(|entry| match entry.kind { + KeyKind::Enum(domain) => Some(domain), + _ => None, + }) + .collect(); + let mut errors = Vec::new(); + let mut members = HashMap::new(); + for member in entries { + if !domains.contains(member.domain) { + errors.push(format!("orphaned settings enum domain: {}", member.domain)); + } + let key = (member.domain, member.member); + if let Some(previous) = members.insert(key, member.name) { + if previous != member.name { + errors.push(format!( + "conflicting settings enum member {}.{}: {previous:?} vs {:?}", + member.domain, member.member, member.name + )); + } + } + } + errors +} + fn semantic_identity_key(key: &str) -> String { match key { "enableSecondaryFire" | "enableGenericSecondaryFire" => "enableSecondaryFire".to_string(), @@ -1238,24 +1286,33 @@ fn semantic_identity_key(key: &str) -> String { } } -fn same_entry_shape(left: KeyKind, right: KeyKind) -> bool { - matches!( - (left, right), - (KeyKind::Flag, KeyKind::Flag) - | (KeyKind::String, KeyKind::String) - | (KeyKind::Bool, KeyKind::Bool) - | (KeyKind::Number, KeyKind::Number) - | (KeyKind::Percent, KeyKind::Percent) - | (KeyKind::ListMap, KeyKind::ListMap) - | (KeyKind::ListHero, KeyKind::ListHero) - | (KeyKind::Enum(_), KeyKind::Enum(_)) - ) -} - #[cfg(test)] mod tests { use super::*; + static DUPLICATE_PATH: [PathPart<'static>; 2] = + [PathPart::Part("test"), PathPart::Part("value")]; + static FIXTURE_ENTRY: TableEntry = TableEntry { + path: &DUPLICATE_PATH, + workshop_name: "Fixture Value", + kind: KeyKind::Bool, + }; + static GENERATED_ENTRY: TableEntry = TableEntry { + path: &DUPLICATE_PATH, + workshop_name: "Generated Value", + kind: KeyKind::Bool, + }; + static FIXTURE_ENUM_MEMBER: table::EnumMember = table::EnumMember { + domain: "mapRotation", + member: "afterGame", + name: "After A Game", + }; + static GENERATED_ENUM_MEMBER: table::EnumMember = table::EnumMember { + domain: "mapRotation", + member: "afterGame", + name: "After Game", + }; + fn definition(target: TargetPattern) -> SettingDefinition { SettingDefinition { identity: SettingIdentity::Known(SettingId::new("setting.test.value")), @@ -1305,4 +1362,28 @@ mod tests { Applicability::NotApplicable ); } + + #[test] + fn raw_projection_conflicts_include_presentation_contract() { + let errors = validate_raw_projection([ + table::ProjectedEntry { + source: table::ProjectionSource::FixtureTable, + entry: &FIXTURE_ENTRY, + }, + table::ProjectedEntry { + source: table::ProjectionSource::WorkshopDataExport, + entry: &GENERATED_ENTRY, + }, + ]); + assert_eq!(errors.len(), 1); + assert!(errors[0].contains("fixture table")); + assert!(errors[0].contains("Workshop-data export")); + } + + #[test] + fn enum_projection_conflicts_are_not_hidden_by_lookup_order() { + let errors = validate_enum_projection([&FIXTURE_ENUM_MEMBER, &GENERATED_ENUM_MEMBER]); + assert_eq!(errors.len(), 1); + assert!(errors[0].contains("mapRotation.afterGame")); + } } diff --git a/crates/workshop-rs/src/settings/table.rs b/crates/workshop-rs/src/settings/table.rs index 23d6c83..cc0b323 100644 --- a/crates/workshop-rs/src/settings/table.rs +++ b/crates/workshop-rs/src/settings/table.rs @@ -65,7 +65,7 @@ pub enum KeyKind { } /// One segment of an exact settings path. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Hash)] pub enum PathPart<'a> { /// A literal key (mode names under `gamemodes` are literal keys too: /// per-key subsets are exact-path entries, #86). @@ -90,7 +90,7 @@ impl<'b> PartialEq> for PathPart<'_> { impl Eq for PathPart<'_> {} /// One table entry: an exact key path, its workshop name, and its kind. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TableEntry { pub path: &'static [PathPart<'static>], pub workshop_name: &'static str, @@ -967,8 +967,40 @@ pub fn entries() -> impl Iterator { /// Iterate both catalog projections without applying effective lookup /// precedence. The semantic validator uses this to compare duplicate paths /// instead of allowing `entries()` to hide stale or conflicting data. -pub(crate) fn raw_entries() -> impl Iterator { - ENTRIES.iter().chain(GENERATED_ENTRIES.iter()) +pub(crate) fn raw_entries() -> impl Iterator { + ENTRIES + .iter() + .map(|entry| ProjectedEntry { + source: ProjectionSource::FixtureTable, + entry, + }) + .chain(GENERATED_ENTRIES.iter().map(|entry| ProjectedEntry { + source: ProjectionSource::WorkshopDataExport, + entry, + })) +} + +/// One setting leaf before effective lookup resolves duplicated projections. +#[derive(Debug, Clone, Copy)] +pub(crate) struct ProjectedEntry { + pub(crate) source: ProjectionSource, + pub(crate) entry: &'static TableEntry, +} + +/// The source that supplied a raw settings projection entry. +#[derive(Debug, Clone, Copy)] +pub(crate) enum ProjectionSource { + FixtureTable, + WorkshopDataExport, +} + +impl ProjectionSource { + pub(crate) const fn label(self) -> &'static str { + match self { + Self::FixtureTable => "fixture table", + Self::WorkshopDataExport => "Workshop-data export", + } + } } fn deduplicated_entries( From 5fa213b57a0567b9092ec66744c83ffb072a3cfb Mon Sep 17 00:00:00 2001 From: Teakowa Date: Sat, 29 Aug 2026 00:47:37 +0800 Subject: [PATCH 2/4] fix(settings): reconcile export enum domains Refs #110 --- .../src/settings/data/generated_entries.rs | 149 +++++++++++++++++- .../data/projection_reconciliation.json | 36 +++++ crates/workshop-rs/src/settings/mod.rs | 1 + .../src/settings/reconciliation.rs | 48 ++++++ crates/workshop-rs/src/settings/schema.rs | 148 ++++++++++++++++- crates/workshop-rs/src/settings/table.rs | 50 ++++++ crates/workshop-rs/tests/settings_pipeline.rs | 24 +++ 7 files changed, 451 insertions(+), 5 deletions(-) create mode 100644 crates/workshop-rs/src/settings/data/projection_reconciliation.json create mode 100644 crates/workshop-rs/src/settings/reconciliation.rs diff --git a/crates/workshop-rs/src/settings/data/generated_entries.rs b/crates/workshop-rs/src/settings/data/generated_entries.rs index c2a4130..6e1f5e1 100644 --- a/crates/workshop-rs/src/settings/data/generated_entries.rs +++ b/crates/workshop-rs/src/settings/data/generated_entries.rs @@ -1,6 +1,4 @@ -// Generated from the reviewed workshop-data export. Entries with fixture-owned -// path-specific rendering or enum identities are reconciled out of this -// projection; schema validation rejects any remaining divergent overlap. +// Generated from the reviewed workshop-data export; do not edit by hand. pub static GENERATED_ENTRIES: &[TableEntry] = &[ entry!( [PathPart::Part("extensions"), PathPart::Part("beamEffects")], @@ -642,6 +640,11 @@ pub static GENERATED_ENTRIES: &[TableEntry] = &[ "Health Pack Respawn Time Scalar", KeyKind::Percent ), + entry!( + [PathPart::Part("gamemodes"), PathPart::Part("general"), PathPart::Part("heroLimit")], + "Hero Limit", + KeyKind::Enum("setting_gamemodes_general_heroLimit") + ), entry!( [PathPart::Part("gamemodes"), PathPart::Part("general"), PathPart::Part("perkEliminationCatchupLevelAmount%")], "Perk Elimination Catchup Level Amount", @@ -662,6 +665,16 @@ pub static GENERATED_ENTRIES: &[TableEntry] = &[ "Respawn Time Scalar", KeyKind::Percent ), + entry!( + [PathPart::Part("gamemodes"), PathPart::Part("general"), PathPart::Part("roleLimit")], + "Limit Roles", + KeyKind::Enum("setting_gamemodes_general_roleLimit") + ), + entry!( + [PathPart::Part("gamemodes"), PathPart::Part("general"), PathPart::Part("spawnHealthPacks")], + "Spawn Health Packs", + KeyKind::Enum("setting_gamemodes_general_spawnHealthPacks") + ), entry!( [PathPart::Part("gamemodes"), PathPart::Part("general"), PathPart::Part("tankPassiveHealthBonus")], "Tank Role Passive Health Bonus", @@ -1237,6 +1250,11 @@ pub static GENERATED_ENTRIES: &[TableEntry] = &[ "Call Mech Knockback Scalar", KeyKind::Percent ), + entry!( + [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero, PathPart::Part("combatUltGen%")], + "Ultimate Generation - Combat %1$s", + KeyKind::Percent + ), entry!( [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero, PathPart::Part("damageDealt%")], "Damage Dealt", @@ -1247,6 +1265,16 @@ pub static GENERATED_ENTRIES: &[TableEntry] = &[ "Damage Received", KeyKind::Percent ), + entry!( + [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero, PathPart::Part("enableAbility1")], + "%1$s", + KeyKind::Bool + ), + entry!( + [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero, PathPart::Part("enableAbility2")], + "%1$s", + KeyKind::Bool + ), entry!( [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero, PathPart::Part("enableAbility3")], "%1$s", @@ -1317,6 +1345,11 @@ pub static GENERATED_ENTRIES: &[TableEntry] = &[ "No Scope", KeyKind::Bool ), + entry!( + [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero, PathPart::Part("enableSecondaryFire")], + "%1$s", + KeyKind::Bool + ), entry!( [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero, PathPart::Part("enableSpawningWithUlt")], "Spawn With Ultimate Ready", @@ -1387,6 +1420,11 @@ pub static GENERATED_ENTRIES: &[TableEntry] = &[ "Hover Jets Recharge Rate", KeyKind::Percent ), + entry!( + [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero, PathPart::Part("passiveUltGen%")], + "Ultimate Generation - Passive %1$s", + KeyKind::Percent + ), entry!( [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero, PathPart::Part("passiveVerticalSpeed%")], "Hover Jets Vertical Speed Scalar", @@ -1607,6 +1645,11 @@ pub static GENERATED_ENTRIES: &[TableEntry] = &[ "Max FFA Players", KeyKind::Number ), + entry!( + [PathPart::Part("lobby"), PathPart::Part("mapRotation")], + "Map Rotation", + KeyKind::Enum("setting_lobby_mapRotation") + ), entry!( [PathPart::Part("lobby"), PathPart::Part("minimumLatencyInNs")], "Minimum Latency milliseconds", @@ -1617,6 +1660,11 @@ pub static GENERATED_ENTRIES: &[TableEntry] = &[ "Pause Game On Player Disconnect", KeyKind::Bool ), + entry!( + [PathPart::Part("lobby"), PathPart::Part("returnToLobby")], + "Return To Lobby", + KeyKind::Enum("setting_lobby_returnToLobby") + ), entry!( [PathPart::Part("lobby"), PathPart::Part("spectatorSlots")], "Max Spectators", @@ -1627,6 +1675,16 @@ pub static GENERATED_ENTRIES: &[TableEntry] = &[ "Swap Teams After Match", KeyKind::Bool ), + entry!( + [PathPart::Part("lobby"), PathPart::Part("team1Slots")], + "Max %1$s Players", + KeyKind::Number + ), + entry!( + [PathPart::Part("lobby"), PathPart::Part("team2Slots")], + "Max %1$s Players", + KeyKind::Number + ), entry!( [PathPart::Part("lobby"), PathPart::Part("teamBalancing")], "Team Balancing", @@ -1834,6 +1892,61 @@ pub static GENERATED_ENUM_MEMBERS: &[EnumMember] = &[ member: "manual", name: "Manual", }, + EnumMember { + domain: "setting_gamemodes_general_heroLimit", + member: "off", + name: "Off", + }, + EnumMember { + domain: "setting_gamemodes_general_heroLimit", + member: "1PerTeam", + name: "1 Per Team", + }, + EnumMember { + domain: "setting_gamemodes_general_heroLimit", + member: "2PerTeam", + name: "2 Per Team", + }, + EnumMember { + domain: "setting_gamemodes_general_heroLimit", + member: "1PerGame", + name: "1 Per Game", + }, + EnumMember { + domain: "setting_gamemodes_general_heroLimit", + member: "2PerGame", + name: "2 Per Game", + }, + EnumMember { + domain: "setting_gamemodes_general_roleLimit", + member: "off", + name: "Off", + }, + EnumMember { + domain: "setting_gamemodes_general_roleLimit", + member: "2OfEachRolePerTeam", + name: "2 Of Each Role Per Team", + }, + EnumMember { + domain: "setting_gamemodes_general_roleLimit", + member: "1Tank2Offense2Support", + name: "1 Tank 2 Offense 2 Support", + }, + EnumMember { + domain: "setting_gamemodes_general_spawnHealthPacks", + member: "modeDependent", + name: "Determined By Mode", + }, + EnumMember { + domain: "setting_gamemodes_general_spawnHealthPacks", + member: "enabled", + name: "Enabled", + }, + EnumMember { + domain: "setting_gamemodes_general_spawnHealthPacks", + member: "disabled", + name: "Disabled", + }, EnumMember { domain: "setting_gamemodes_general_tankPassiveHealthBonus", member: "1Tank2Offense2Support", @@ -2119,6 +2232,36 @@ pub static GENERATED_ENUM_MEMBERS: &[EnumMember] = &[ member: "usWest2", name: "USA - West 2", }, + EnumMember { + domain: "setting_lobby_mapRotation", + member: "afterMirrorMatch", + name: "After A Mirror Match", + }, + EnumMember { + domain: "setting_lobby_mapRotation", + member: "afterGame", + name: "After A Game", + }, + EnumMember { + domain: "setting_lobby_mapRotation", + member: "paused", + name: "Paused", + }, + EnumMember { + domain: "setting_lobby_returnToLobby", + member: "never", + name: "Never", + }, + EnumMember { + domain: "setting_lobby_returnToLobby", + member: "afterGame", + name: "After A Game", + }, + EnumMember { + domain: "setting_lobby_returnToLobby", + member: "afterMirrorMatch", + name: "After A Mirror Match", + }, EnumMember { domain: "setting_lobby_teamBalancing", member: "off", diff --git a/crates/workshop-rs/src/settings/data/projection_reconciliation.json b/crates/workshop-rs/src/settings/data/projection_reconciliation.json new file mode 100644 index 0000000..4867067 --- /dev/null +++ b/crates/workshop-rs/src/settings/data/projection_reconciliation.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 1, + "entryOverrides": [ + { "path": "gamemodes.general.heroLimit", "fixture": { "name": "Hero Limit", "kind": "enum", "domain": "heroLimit" }, "generated": { "name": "Hero Limit", "kind": "enum", "domain": "setting_gamemodes_general_heroLimit" } }, + { "path": "gamemodes.general.roleLimit", "fixture": { "name": "Limit Roles", "kind": "enum", "domain": "roleLimit" }, "generated": { "name": "Limit Roles", "kind": "enum", "domain": "setting_gamemodes_general_roleLimit" } }, + { "path": "gamemodes.general.spawnHealthPacks", "fixture": { "name": "Spawn Health Packs", "kind": "enum", "domain": "spawnHealthPacks" }, "generated": { "name": "Spawn Health Packs", "kind": "enum", "domain": "setting_gamemodes_general_spawnHealthPacks" } }, + { "path": "heroes...combatUltGen%", "fixture": { "name": "Ultimate Generation - Combat Blizzard", "kind": "percent" }, "generated": { "name": "Ultimate Generation - Combat %1$s", "kind": "percent" } }, + { "path": "heroes...enableAbility1", "fixture": { "name": "Ability 1", "kind": "bool" }, "generated": { "name": "%1$s", "kind": "bool" } }, + { "path": "heroes...enableAbility2", "fixture": { "name": "Ability 2", "kind": "bool" }, "generated": { "name": "%1$s", "kind": "bool" } }, + { "path": "heroes...enableSecondaryFire", "fixture": { "name": "Secondary Fire", "kind": "bool" }, "generated": { "name": "%1$s", "kind": "bool" } }, + { "path": "heroes...passiveUltGen%", "fixture": { "name": "Ultimate Generation - Passive Blizzard", "kind": "percent" }, "generated": { "name": "Ultimate Generation - Passive %1$s", "kind": "percent" } }, + { "path": "lobby.mapRotation", "fixture": { "name": "Map Rotation", "kind": "enum", "domain": "mapRotation" }, "generated": { "name": "Map Rotation", "kind": "enum", "domain": "setting_lobby_mapRotation" } }, + { "path": "lobby.returnToLobby", "fixture": { "name": "Return To Lobby", "kind": "enum", "domain": "returnToLobby" }, "generated": { "name": "Return To Lobby", "kind": "enum", "domain": "setting_lobby_returnToLobby" } }, + { "path": "lobby.team1Slots", "fixture": { "name": "Max Team 1 Players", "kind": "number" }, "generated": { "name": "Max %1$s Players", "kind": "number" } }, + { "path": "lobby.team2Slots", "fixture": { "name": "Max Team 2 Players", "kind": "number" }, "generated": { "name": "Max %1$s Players", "kind": "number" } } + ], + "enumMemberMappings": [ + { "sourceDomain": "setting_gamemodes_general_heroLimit", "sourceMember": "off", "targetDomain": "heroLimit", "targetMember": "off" }, + { "sourceDomain": "setting_gamemodes_general_heroLimit", "sourceMember": "1PerTeam", "targetDomain": "heroLimit", "targetMember": "1PerTeam" }, + { "sourceDomain": "setting_gamemodes_general_heroLimit", "sourceMember": "2PerTeam", "targetDomain": "heroLimit", "targetMember": "2PerTeam" }, + { "sourceDomain": "setting_gamemodes_general_heroLimit", "sourceMember": "1PerGame", "targetDomain": "heroLimit", "targetMember": "1PerGame" }, + { "sourceDomain": "setting_gamemodes_general_heroLimit", "sourceMember": "2PerGame", "targetDomain": "heroLimit", "targetMember": "2PerGame" }, + { "sourceDomain": "setting_gamemodes_general_roleLimit", "sourceMember": "off", "targetDomain": "roleLimit", "targetMember": "off" }, + { "sourceDomain": "setting_gamemodes_general_roleLimit", "sourceMember": "2OfEachRolePerTeam", "targetDomain": "roleLimit", "targetMember": "2OfEachRolePerTeam" }, + { "sourceDomain": "setting_gamemodes_general_roleLimit", "sourceMember": "1Tank2Offense2Support", "targetDomain": "roleLimit", "targetMember": "1Tank2Offense2Support" }, + { "sourceDomain": "setting_gamemodes_general_spawnHealthPacks", "sourceMember": "modeDependent", "targetDomain": "spawnHealthPacks", "targetMember": "modeDependent" }, + { "sourceDomain": "setting_gamemodes_general_spawnHealthPacks", "sourceMember": "enabled", "targetDomain": "spawnHealthPacks", "targetMember": "enabled" }, + { "sourceDomain": "setting_gamemodes_general_spawnHealthPacks", "sourceMember": "disabled", "targetDomain": "spawnHealthPacks", "targetMember": "disabled" }, + { "sourceDomain": "setting_lobby_mapRotation", "sourceMember": "afterMirrorMatch", "targetDomain": "mapRotation", "targetMember": "afterMirrorMatch" }, + { "sourceDomain": "setting_lobby_mapRotation", "sourceMember": "afterGame", "targetDomain": "mapRotation", "targetMember": "afterAGame" }, + { "sourceDomain": "setting_lobby_mapRotation", "sourceMember": "paused", "targetDomain": "mapRotation", "targetMember": "paused" }, + { "sourceDomain": "setting_lobby_returnToLobby", "sourceMember": "never", "targetDomain": "returnToLobby", "targetMember": "never" }, + { "sourceDomain": "setting_lobby_returnToLobby", "sourceMember": "afterGame", "targetDomain": "returnToLobby", "targetMember": "afterAGame" }, + { "sourceDomain": "setting_lobby_returnToLobby", "sourceMember": "afterMirrorMatch", "targetDomain": "returnToLobby", "targetMember": "afterMirrorMatch" } + ] +} diff --git a/crates/workshop-rs/src/settings/mod.rs b/crates/workshop-rs/src/settings/mod.rs index 18b5ec7..815fd65 100644 --- a/crates/workshop-rs/src/settings/mod.rs +++ b/crates/workshop-rs/src/settings/mod.rs @@ -8,6 +8,7 @@ //! Extracted from the Wright-authored `wright-ir` crate; see //! [`docs/provenance.md`](https://github.com/wrightkit/workshop-rs/blob/main/docs/provenance.md). +pub(crate) mod reconciliation; pub mod schema; pub mod table; diff --git a/crates/workshop-rs/src/settings/reconciliation.rs b/crates/workshop-rs/src/settings/reconciliation.rs new file mode 100644 index 0000000..a79275b --- /dev/null +++ b/crates/workshop-rs/src/settings/reconciliation.rs @@ -0,0 +1,48 @@ +//! Reviewed reconciliation rules for overlapping raw settings projections. + +use std::sync::OnceLock; + +use serde::Deserialize; + +const DATA: &str = include_str!("data/projection_reconciliation.json"); + +#[derive(Debug, Deserialize)] +pub(crate) struct Reconciliation { + #[serde(rename = "entryOverrides")] + pub(crate) entry_overrides: Vec, + #[serde(rename = "enumMemberMappings")] + pub(crate) enum_member_mappings: Vec, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct EntryOverride { + pub(crate) path: String, + pub(crate) fixture: EntryContract, + pub(crate) generated: EntryContract, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct EntryContract { + pub(crate) name: String, + pub(crate) kind: String, + pub(crate) domain: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct EnumMemberMapping { + #[serde(rename = "sourceDomain")] + pub(crate) source_domain: String, + #[serde(rename = "sourceMember")] + pub(crate) source_member: String, + #[serde(rename = "targetDomain")] + pub(crate) target_domain: String, + #[serde(rename = "targetMember")] + pub(crate) target_member: String, +} + +pub(crate) fn data() -> &'static Reconciliation { + static RECONCILIATION: OnceLock = OnceLock::new(); + RECONCILIATION.get_or_init(|| { + serde_json::from_str(DATA).expect("settings projection reconciliation data is valid JSON") + }) +} diff --git a/crates/workshop-rs/src/settings/schema.rs b/crates/workshop-rs/src/settings/schema.rs index 7d4f65e..a0535be 100644 --- a/crates/workshop-rs/src/settings/schema.rs +++ b/crates/workshop-rs/src/settings/schema.rs @@ -9,6 +9,7 @@ use std::fmt; use crate::gameplay::{AbilityVariant, HeroId, LogicalSlot}; use crate::{gameplay::GameplayDataError, gameplay_data}; +use super::reconciliation; use super::table::{self, KeyKind, PathPart, TableEntry}; use super::{Settings, SettingsNode}; @@ -1164,6 +1165,7 @@ pub fn validate_catalog() -> Result<(), Vec> { table::ENUM_MEMBERS .iter() .chain(table::GENERATED_ENUM_MEMBERS.iter()), + &reconciliation::data().enum_member_mappings, )); let mut paths = HashSet::new(); let mut concepts: HashMap<(String, SettingTargetKind, String), SettingValueDomain> = @@ -1233,7 +1235,13 @@ fn validate_raw_projection( for projected in entries { let entry = projected.entry; if let Some(previous) = paths.insert(entry.path, projected) { - if previous.entry != entry { + if previous.entry != entry + && !reconciled_entry_override( + table::path_string(entry.path).as_str(), + previous, + projected, + ) + { errors.push(format!( "conflicting duplicate settings path between {} and {}: {}", previous.source.label(), @@ -1246,11 +1254,52 @@ fn validate_raw_projection( errors } +fn reconciled_entry_override( + path: &str, + fixture: table::ProjectedEntry, + generated: table::ProjectedEntry, +) -> bool { + use table::ProjectionSource::{FixtureTable, WorkshopDataExport}; + + let (fixture, generated) = match (fixture.source, generated.source) { + (FixtureTable, WorkshopDataExport) => (fixture.entry, generated.entry), + (WorkshopDataExport, FixtureTable) => (generated.entry, fixture.entry), + _ => return false, + }; + reconciliation::data() + .entry_overrides + .iter() + .find(|override_| override_.path == path) + .is_some_and(|override_| { + entry_contract_matches(fixture, &override_.fixture) + && entry_contract_matches(generated, &override_.generated) + }) +} + +fn entry_contract_matches(entry: &TableEntry, expected: &reconciliation::EntryContract) -> bool { + entry.workshop_name == expected.name && key_kind_matches(entry.kind, expected) +} + +fn key_kind_matches(kind: KeyKind, expected: &reconciliation::EntryContract) -> bool { + match (kind, expected.kind.as_str(), expected.domain.as_deref()) { + (KeyKind::Flag, "flag", None) + | (KeyKind::String, "string", None) + | (KeyKind::Bool, "bool", None) + | (KeyKind::Number, "number", None) + | (KeyKind::Percent, "percent", None) + | (KeyKind::ListMap, "mapList", None) + | (KeyKind::ListHero, "heroList", None) => true, + (KeyKind::Enum(actual), "enum", Some(expected)) => actual == expected, + _ => false, + } +} + /// Validate enum members independently of entry lookup order. This catches /// both stale enum projections and conflicting duplicate spellings that the /// lookup helper would otherwise hide. fn validate_enum_projection( entries: impl IntoIterator, + mappings: &[reconciliation::EnumMemberMapping], ) -> Vec { use std::collections::{HashMap, HashSet}; @@ -1262,7 +1311,13 @@ fn validate_enum_projection( .collect(); let mut errors = Vec::new(); let mut members = HashMap::new(); + let mut names = HashMap::new(); + let mut generated = Vec::new(); for member in entries { + generated.push(member); + } + let fixture_count = table::ENUM_MEMBERS.len(); + for member in generated.iter().take(fixture_count).copied() { if !domains.contains(member.domain) { errors.push(format!("orphaned settings enum domain: {}", member.domain)); } @@ -1275,6 +1330,95 @@ fn validate_enum_projection( )); } } + if let Some(previous) = names.insert((member.domain, member.name), member.member) { + if previous != member.member { + errors.push(format!( + "conflicting settings enum display name {}.{:?}: {previous} vs {}", + member.domain, member.name, member.member + )); + } + } + } + let fixture_members: HashMap<_, _> = table::ENUM_MEMBERS + .iter() + .map(|member| ((member.domain, member.member), member)) + .collect(); + let mut mapped_sources = HashSet::new(); + for member in generated.into_iter().skip(fixture_count) { + let key = (member.domain, member.member); + if let Some(previous) = members.insert(key, member.name) { + if previous != member.name { + errors.push(format!( + "conflicting settings enum member {}.{}: {previous:?} vs {:?}", + member.domain, member.member, member.name + )); + } + } + let mapping = mappings.iter().find(|mapping| { + mapping.source_domain == member.domain && mapping.source_member == member.member + }); + if mapping.is_none() && !domains.contains(member.domain) { + errors.push(format!("orphaned settings enum domain: {}", member.domain)); + } + let (domain, canonical_member, name) = match mapping { + Some(mapping) => { + if !mapped_sources.insert(( + mapping.source_domain.as_str(), + mapping.source_member.as_str(), + )) { + errors.push(format!( + "duplicate settings enum reconciliation for {}.{}", + mapping.source_domain, mapping.source_member + )); + } + match fixture_members.get(&( + mapping.target_domain.as_str(), + mapping.target_member.as_str(), + )) { + Some(target) if target.name == member.name => { + (target.domain, target.member, target.name) + } + Some(target) => { + errors.push(format!( + "settings enum reconciliation name mismatch {}.{} -> {}.{}: {:?} vs {:?}", + mapping.source_domain, mapping.source_member, + mapping.target_domain, mapping.target_member, member.name, target.name + )); + continue; + } + None => { + errors.push(format!( + "settings enum reconciliation target is missing: {}.{} -> {}.{}", + mapping.source_domain, + mapping.source_member, + mapping.target_domain, + mapping.target_member + )); + continue; + } + } + } + None => (member.domain, member.member, member.name), + }; + if let Some(previous) = names.insert((domain, name), canonical_member) { + if previous != canonical_member { + errors.push(format!( + "conflicting settings enum display name {}.{name:?}: {previous} vs {canonical_member}", + domain + )); + } + } + } + for mapping in mappings { + if !mapped_sources.contains(&( + mapping.source_domain.as_str(), + mapping.source_member.as_str(), + )) { + errors.push(format!( + "orphaned settings enum reconciliation: {}.{}", + mapping.source_domain, mapping.source_member + )); + } } errors } @@ -1382,7 +1526,7 @@ mod tests { #[test] fn enum_projection_conflicts_are_not_hidden_by_lookup_order() { - let errors = validate_enum_projection([&FIXTURE_ENUM_MEMBER, &GENERATED_ENUM_MEMBER]); + let errors = validate_enum_projection([&FIXTURE_ENUM_MEMBER, &GENERATED_ENUM_MEMBER], &[]); assert_eq!(errors.len(), 1); assert!(errors[0].contains("mapRotation.afterGame")); } diff --git a/crates/workshop-rs/src/settings/table.rs b/crates/workshop-rs/src/settings/table.rs index cc0b323..f8fa81c 100644 --- a/crates/workshop-rs/src/settings/table.rs +++ b/crates/workshop-rs/src/settings/table.rs @@ -892,6 +892,16 @@ pub static ENUM_MEMBERS: &[EnumMember] = &[ member: "afterAGame", name: "After A Game", }, + EnumMember { + domain: "mapRotation", + member: "afterMirrorMatch", + name: "After A Mirror Match", + }, + EnumMember { + domain: "mapRotation", + member: "paused", + name: "Paused", + }, EnumMember { domain: "matchVoiceChat", member: "enabled", @@ -907,6 +917,11 @@ pub static ENUM_MEMBERS: &[EnumMember] = &[ member: "afterAGame", name: "After A Game", }, + EnumMember { + domain: "returnToLobby", + member: "afterMirrorMatch", + name: "After A Mirror Match", + }, EnumMember { domain: "gameModeStartTrigger", member: "immediately", @@ -922,6 +937,16 @@ pub static ENUM_MEMBERS: &[EnumMember] = &[ member: "disabled", name: "Disabled", }, + EnumMember { + domain: "spawnHealthPacks", + member: "modeDependent", + name: "Determined By Mode", + }, + EnumMember { + domain: "spawnHealthPacks", + member: "enabled", + name: "Enabled", + }, EnumMember { domain: "roleLimit", member: "2OfEachRolePerTeam", @@ -932,6 +957,11 @@ pub static ENUM_MEMBERS: &[EnumMember] = &[ member: "1Tank2Offense2Support", name: "1 Tank 2 Offense 2 Support", }, + EnumMember { + domain: "roleLimit", + member: "off", + name: "Off", + }, EnumMember { domain: "tankPassiveHealthBonus", member: "alwaysEnabled", @@ -947,6 +977,26 @@ pub static ENUM_MEMBERS: &[EnumMember] = &[ member: "off", name: "Off", }, + EnumMember { + domain: "heroLimit", + member: "1PerTeam", + name: "1 Per Team", + }, + EnumMember { + domain: "heroLimit", + member: "2PerTeam", + name: "2 Per Team", + }, + EnumMember { + domain: "heroLimit", + member: "1PerGame", + name: "1 Per Game", + }, + EnumMember { + domain: "heroLimit", + member: "2PerGame", + name: "2 Per Game", + }, ]; /// Look up a settings leaf entry by its exact path. diff --git a/crates/workshop-rs/tests/settings_pipeline.rs b/crates/workshop-rs/tests/settings_pipeline.rs index cc66a3b..c15681b 100644 --- a/crates/workshop-rs/tests/settings_pipeline.rs +++ b/crates/workshop-rs/tests/settings_pipeline.rs @@ -689,6 +689,30 @@ fn settings_schema_catalog_is_complete_and_conflict_checked() { workshop_rs::settings::schema::validate_catalog().expect("reviewed settings catalog"); } +#[test] +fn reconciled_export_enum_members_remain_writable() { + let catalog = catalog(); + let mut program = parser::parse( + "settings { modes { General { Hero Limit: Off } } }", + &catalog, + &Locale::new("en-US"), + ) + .expect("parse fixture-owned hero limit"); + let definition = definitions_by_id(&SettingId::from("setting.gameMode.heroLimit")) + .find(|definition| definition.path() == "gamemodes.general.heroLimit") + .expect("hero-limit definition"); + definition + .write( + program.settings.as_mut().expect("settings"), + &SettingTarget::Global, + SettingValue::Enum("1PerTeam".to_string()), + ) + .expect("export-backed hero-limit member remains writable"); + + let emitted = emitter::emit(&program, &catalog, &Locale::new("en-US")).expect("emit"); + assert!(emitted.contains("Hero Limit: 1 Per Team")); +} + #[test] fn typed_settings_read_and_write_preserve_unrelated_structure() { let catalog = Catalog::builtin().expect("catalog"); From 50b7a45b6df22b0d534eebd58b8c869728ae3770 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Sat, 29 Aug 2026 01:34:17 +0800 Subject: [PATCH 3/4] test(settings): cover reconciled enum projections --- crates/workshop-rs/src/settings/schema.rs | 57 ++++++++++++++----- crates/workshop-rs/tests/settings_pipeline.rs | 51 ++++++++++------- 2 files changed, 74 insertions(+), 34 deletions(-) diff --git a/crates/workshop-rs/src/settings/schema.rs b/crates/workshop-rs/src/settings/schema.rs index a0535be..6256043 100644 --- a/crates/workshop-rs/src/settings/schema.rs +++ b/crates/workshop-rs/src/settings/schema.rs @@ -1162,9 +1162,8 @@ pub fn validate_catalog() -> Result<(), Vec> { let mut errors = Vec::new(); errors.extend(validate_raw_projection(table::raw_entries())); errors.extend(validate_enum_projection( - table::ENUM_MEMBERS - .iter() - .chain(table::GENERATED_ENUM_MEMBERS.iter()), + table::ENUM_MEMBERS.iter(), + table::GENERATED_ENUM_MEMBERS.iter(), &reconciliation::data().enum_member_mappings, )); let mut paths = HashSet::new(); @@ -1298,7 +1297,8 @@ fn key_kind_matches(kind: KeyKind, expected: &reconciliation::EntryContract) -> /// both stale enum projections and conflicting duplicate spellings that the /// lookup helper would otherwise hide. fn validate_enum_projection( - entries: impl IntoIterator, + fixture_entries: impl IntoIterator, + generated_entries: impl IntoIterator, mappings: &[reconciliation::EnumMemberMapping], ) -> Vec { use std::collections::{HashMap, HashSet}; @@ -1312,12 +1312,7 @@ fn validate_enum_projection( let mut errors = Vec::new(); let mut members = HashMap::new(); let mut names = HashMap::new(); - let mut generated = Vec::new(); - for member in entries { - generated.push(member); - } - let fixture_count = table::ENUM_MEMBERS.len(); - for member in generated.iter().take(fixture_count).copied() { + for member in fixture_entries { if !domains.contains(member.domain) { errors.push(format!("orphaned settings enum domain: {}", member.domain)); } @@ -1344,7 +1339,7 @@ fn validate_enum_projection( .map(|member| ((member.domain, member.member), member)) .collect(); let mut mapped_sources = HashSet::new(); - for member in generated.into_iter().skip(fixture_count) { + for member in generated_entries { let key = (member.domain, member.member); if let Some(previous) = members.insert(key, member.name) { if previous != member.name { @@ -1448,14 +1443,24 @@ mod tests { }; static FIXTURE_ENUM_MEMBER: table::EnumMember = table::EnumMember { domain: "mapRotation", - member: "afterGame", + member: "afterAGame", name: "After A Game", }; static GENERATED_ENUM_MEMBER: table::EnumMember = table::EnumMember { domain: "mapRotation", - member: "afterGame", + member: "afterAGame", name: "After Game", }; + static DISPLAY_NAME_COLLISION: table::EnumMember = table::EnumMember { + domain: "mapRotation", + member: "afterMirrorMatch", + name: "After A Game", + }; + static EXPORT_ENUM_MEMBER: table::EnumMember = table::EnumMember { + domain: "setting_lobby_mapRotation", + member: "afterGame", + name: "After A Game", + }; fn definition(target: TargetPattern) -> SettingDefinition { SettingDefinition { @@ -1526,8 +1531,30 @@ mod tests { #[test] fn enum_projection_conflicts_are_not_hidden_by_lookup_order() { - let errors = validate_enum_projection([&FIXTURE_ENUM_MEMBER, &GENERATED_ENUM_MEMBER], &[]); + let errors = + validate_enum_projection([&FIXTURE_ENUM_MEMBER], [&GENERATED_ENUM_MEMBER], &[]); + assert_eq!(errors.len(), 1); + assert!(errors[0].contains("mapRotation.afterAGame")); + } + + #[test] + fn enum_projection_rejects_display_name_to_identity_collisions() { + let errors = + validate_enum_projection([&FIXTURE_ENUM_MEMBER, &DISPLAY_NAME_COLLISION], [], &[]); assert_eq!(errors.len(), 1); - assert!(errors[0].contains("mapRotation.afterGame")); + assert!(errors[0].contains("conflicting settings enum display name")); + } + + #[test] + fn enum_projection_reconciles_export_members_to_canonical_identities() { + let mappings = [reconciliation::EnumMemberMapping { + source_domain: "setting_lobby_mapRotation".to_string(), + source_member: "afterGame".to_string(), + target_domain: "mapRotation".to_string(), + target_member: "afterAGame".to_string(), + }]; + let errors = + validate_enum_projection([&FIXTURE_ENUM_MEMBER], [&EXPORT_ENUM_MEMBER], &mappings); + assert!(errors.is_empty(), "{errors:?}"); } } diff --git a/crates/workshop-rs/tests/settings_pipeline.rs b/crates/workshop-rs/tests/settings_pipeline.rs index c15681b..6a017d3 100644 --- a/crates/workshop-rs/tests/settings_pipeline.rs +++ b/crates/workshop-rs/tests/settings_pipeline.rs @@ -692,25 +692,38 @@ fn settings_schema_catalog_is_complete_and_conflict_checked() { #[test] fn reconciled_export_enum_members_remain_writable() { let catalog = catalog(); - let mut program = parser::parse( - "settings { modes { General { Hero Limit: Off } } }", - &catalog, - &Locale::new("en-US"), - ) - .expect("parse fixture-owned hero limit"); - let definition = definitions_by_id(&SettingId::from("setting.gameMode.heroLimit")) - .find(|definition| definition.path() == "gamemodes.general.heroLimit") - .expect("hero-limit definition"); - definition - .write( - program.settings.as_mut().expect("settings"), - &SettingTarget::Global, - SettingValue::Enum("1PerTeam".to_string()), - ) - .expect("export-backed hero-limit member remains writable"); - - let emitted = emitter::emit(&program, &catalog, &Locale::new("en-US")).expect("emit"); - assert!(emitted.contains("Hero Limit: 1 Per Team")); + for (source, id, path, member, expected) in [ + ( + "settings { modes { General { Hero Limit: Off } } }", + "setting.gameMode.heroLimit", + "gamemodes.general.heroLimit", + "1PerTeam", + "Hero Limit: 1 Per Team", + ), + ( + "settings { lobby { Map Rotation: After A Game } }", + "setting.lobby.mapRotation", + "lobby.mapRotation", + "afterMirrorMatch", + "Map Rotation: After A Mirror Match", + ), + ] { + let mut program = parser::parse(source, &catalog, &Locale::new("en-US")) + .expect("parse fixture-owned enum setting"); + let definition = definitions_by_id(&SettingId::from(id)) + .find(|definition| definition.path() == path) + .expect("reconciled enum definition"); + definition + .write( + program.settings.as_mut().expect("settings"), + &SettingTarget::Global, + SettingValue::Enum(member.to_string()), + ) + .expect("export-backed enum member remains writable"); + + let emitted = emitter::emit(&program, &catalog, &Locale::new("en-US")).expect("emit"); + assert!(emitted.contains(expected), "{emitted}"); + } } #[test] From 8284a2a0d2e55718c3ffd1c5681dfc431f75ee4c Mon Sep 17 00:00:00 2001 From: Teakowa Date: Sat, 29 Aug 2026 02:46:43 +0800 Subject: [PATCH 4/4] fix(settings): validate reconciliation manifest --- .../src/settings/reconciliation.rs | 113 ++++++++++++++++++ crates/workshop-rs/src/settings/schema.rs | 1 + crates/workshop-rs/src/settings/table.rs | 9 +- 3 files changed, 118 insertions(+), 5 deletions(-) diff --git a/crates/workshop-rs/src/settings/reconciliation.rs b/crates/workshop-rs/src/settings/reconciliation.rs index a79275b..070ff16 100644 --- a/crates/workshop-rs/src/settings/reconciliation.rs +++ b/crates/workshop-rs/src/settings/reconciliation.rs @@ -8,6 +8,8 @@ const DATA: &str = include_str!("data/projection_reconciliation.json"); #[derive(Debug, Deserialize)] pub(crate) struct Reconciliation { + #[serde(rename = "schemaVersion")] + pub(crate) schema_version: u32, #[serde(rename = "entryOverrides")] pub(crate) entry_overrides: Vec, #[serde(rename = "enumMemberMappings")] @@ -46,3 +48,114 @@ pub(crate) fn data() -> &'static Reconciliation { serde_json::from_str(DATA).expect("settings projection reconciliation data is valid JSON") }) } + +/// Reject ambiguous reconciliation rules before callers can use their +/// first-match lookup behavior. +pub(crate) fn validate() -> Vec { + validate_manifest(data()) +} + +fn validate_manifest(reconciliation: &Reconciliation) -> Vec { + use std::collections::HashSet; + + let mut errors = Vec::new(); + if reconciliation.schema_version != 1 { + errors.push(format!( + "unsupported settings reconciliation schema version: {}", + reconciliation.schema_version + )); + } + + let mut entry_paths = HashSet::new(); + for override_ in &reconciliation.entry_overrides { + if !entry_paths.insert(override_.path.as_str()) { + errors.push(format!( + "duplicate settings reconciliation entry override: {}", + override_.path + )); + } + } + + let mut member_sources = HashSet::new(); + for mapping in &reconciliation.enum_member_mappings { + let source = ( + mapping.source_domain.as_str(), + mapping.source_member.as_str(), + ); + if !member_sources.insert(source) { + errors.push(format!( + "duplicate settings enum reconciliation source: {}.{}", + mapping.source_domain, mapping.source_member + )); + } + } + errors +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry_override(path: &str) -> EntryOverride { + EntryOverride { + path: path.to_string(), + fixture: EntryContract { + name: "Fixture".to_string(), + kind: "bool".to_string(), + domain: None, + }, + generated: EntryContract { + name: "Generated".to_string(), + kind: "bool".to_string(), + domain: None, + }, + } + } + + fn mapping(source_member: &str) -> EnumMemberMapping { + EnumMemberMapping { + source_domain: "exportDomain".to_string(), + source_member: source_member.to_string(), + target_domain: "canonicalDomain".to_string(), + target_member: "canonicalMember".to_string(), + } + } + + fn manifest() -> Reconciliation { + Reconciliation { + schema_version: 1, + entry_overrides: Vec::new(), + enum_member_mappings: Vec::new(), + } + } + + #[test] + fn manifest_rejects_duplicate_entry_override_paths() { + let mut reconciliation = manifest(); + reconciliation.entry_overrides = vec![ + entry_override("lobby.mapRotation"), + entry_override("lobby.mapRotation"), + ]; + let errors = validate_manifest(&reconciliation); + assert_eq!(errors.len(), 1); + assert!(errors[0].contains("duplicate settings reconciliation entry override")); + } + + #[test] + fn manifest_rejects_duplicate_enum_member_sources() { + let mut reconciliation = manifest(); + reconciliation.enum_member_mappings = vec![mapping("afterGame"), mapping("afterGame")]; + let errors = validate_manifest(&reconciliation); + assert_eq!(errors.len(), 1); + assert!(errors[0].contains("duplicate settings enum reconciliation source")); + } + + #[test] + fn manifest_rejects_unsupported_schema_versions() { + let mut reconciliation = manifest(); + reconciliation.schema_version = 2; + let errors = validate_manifest(&reconciliation); + assert_eq!(errors.len(), 1); + assert!(errors[0].contains("unsupported settings reconciliation schema version")); + } +} diff --git a/crates/workshop-rs/src/settings/schema.rs b/crates/workshop-rs/src/settings/schema.rs index 6256043..1285a22 100644 --- a/crates/workshop-rs/src/settings/schema.rs +++ b/crates/workshop-rs/src/settings/schema.rs @@ -1160,6 +1160,7 @@ pub fn validate_catalog() -> Result<(), Vec> { use std::collections::{HashMap, HashSet}; let mut errors = Vec::new(); + errors.extend(reconciliation::validate()); errors.extend(validate_raw_projection(table::raw_entries())); errors.extend(validate_enum_projection( table::ENUM_MEMBERS.iter(), diff --git a/crates/workshop-rs/src/settings/table.rs b/crates/workshop-rs/src/settings/table.rs index f8fa81c..cf1de32 100644 --- a/crates/workshop-rs/src/settings/table.rs +++ b/crates/workshop-rs/src/settings/table.rs @@ -881,11 +881,10 @@ pub struct EnumMember { include!("data/generated_entries.rs"); include!("data/generated_hero_settings.rs"); -/// Enum member names per domain. `roleLimit` has exactly one evidenced -/// member ("2OfEachRolePerTeam", pixelart + broken-weapons); "off" appears -/// only in the not-acquired skirmish_elim source and is rejected -/// (settings-unknown-value) until a snapshot evidences it. `heroLimit` "off" -/// is evidenced (santa, clientToServer, parabola, crosshair, inputhud). +/// Fixture-owned canonical enum member names. Additional reviewed +/// Workshop-data export members are retained through +/// `projection_reconciliation.json`, which maps their source identities into +/// these canonical domains without replacing fixture-backed display names. pub static ENUM_MEMBERS: &[EnumMember] = &[ EnumMember { domain: "mapRotation",