1use super::super::reconciliation;
2use super::super::table::{self, KeyKind, TableEntry};
3use super::*;
4
5pub fn definitions() -> impl Iterator<Item = SettingDefinition> {
10 table::entries().map(SettingDefinition::from_entry)
11}
12
13pub fn definition(path: &[PathPart<'_>]) -> Option<SettingDefinition> {
15 table::lookup(path).map(SettingDefinition::from_entry)
16}
17
18pub fn definitions_by_id(id: &SettingId) -> impl Iterator<Item = SettingDefinition> {
25 definitions().filter(move |definition| definition.id() == Some(id))
26}
27
28impl SettingDefinition {
29 fn from_entry(entry: &TableEntry) -> Self {
30 let scope = scope_for(entry.path);
31 let key = entry
32 .path
33 .last()
34 .and_then(|part| match part {
35 PathPart::Part(key) => Some(*key),
36 _ => None,
37 })
38 .unwrap_or("");
39 let target = target_for(entry.path);
40 let path = table::path_string(entry.path);
41 let domain = domain_for(entry.kind);
42 let identity = canonical_id(scope, key, entry.path)
43 .map(SettingIdentity::Known)
44 .unwrap_or(SettingIdentity::Unknown);
45 Self {
46 identity,
47 scope,
48 path,
49 path_parts: entry.path,
50 key,
51 target,
52 domain,
53 enum_domain: match entry.kind {
54 KeyKind::BoolEnum(domain) | KeyKind::Enum(domain) => Some(domain),
55 _ => None,
56 },
57 presentation: SettingPresentation {
58 english_name: entry.workshop_name,
59 locale_section: "labels",
60 },
61 source: SettingSource {
62 kind: if table::is_generated_entry(entry) {
63 SettingSourceKind::WorkshopDataExport
64 } else {
65 SettingSourceKind::RawWorkshopFixture
66 },
67 source: if table::is_generated_entry(entry) {
68 "workshop-data/workshop-data.json"
69 } else {
70 "pinned raw Workshop settings fixtures"
71 },
72 reviewed: true,
73 },
74 }
75 }
76}
77
78fn scope_for(path: &[PathPart<'_>]) -> SettingScope {
79 match path.first() {
80 Some(PathPart::Part("main")) => SettingScope::Main,
81 Some(PathPart::Part("lobby")) => SettingScope::Lobby,
82 Some(PathPart::Part("gamemodes")) => SettingScope::GameModes,
83 Some(PathPart::Part("heroes")) => SettingScope::Heroes,
84 Some(PathPart::Part("extensions")) => SettingScope::Extensions,
85 Some(PathPart::Part("workshop")) => SettingScope::Workshop,
86 _ => SettingScope::Unknown,
87 }
88}
89
90fn target_for(path: &[PathPart<'_>]) -> TargetPattern {
91 match path {
92 [PathPart::Part("gamemodes"), PathPart::Part("general"), ..] => TargetPattern::Global,
93 [PathPart::Part("gamemodes"), PathPart::Part(mode), ..] => {
94 TargetPattern::Mode(Some((*mode).to_string()))
95 }
96 [PathPart::Part("gamemodes"), ..] => TargetPattern::Mode(None),
97 [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero, ..] => {
98 target_for_hero(path, None)
99 }
100 [
101 PathPart::Part("heroes"),
102 PathPart::Part(team),
103 PathPart::Hero,
104 ..,
105 ] => target_for_hero(path, Some((*team).to_string())),
106 [PathPart::Part("heroes"), PathPart::Team, ..] => target_for_team(path, None),
107 [PathPart::Part("heroes"), PathPart::Part(team), ..] => {
108 target_for_team(path, Some((*team).to_string()))
109 }
110 [
111 PathPart::Part("main" | "lobby" | "extensions" | "workshop"),
112 ..,
113 ] => TargetPattern::Global,
114 _ => TargetPattern::Unknown,
115 }
116}
117
118fn target_for_team(path: &[PathPart<'_>], team: Option<String>) -> TargetPattern {
119 match semantic_ability_slot_for_path(path) {
120 Some(slot) => TargetPattern::TeamAbility {
121 team,
122 slot: LogicalSlot::new(slot),
123 variant: None,
124 },
125 None => TargetPattern::Team(team),
126 }
127}
128
129fn target_for_hero(path: &[PathPart<'_>], team: Option<String>) -> TargetPattern {
130 let slot = semantic_ability_slot_for_path(path).map(str::to_string);
131 match slot {
132 Some(slot) => TargetPattern::HeroAbility {
133 team,
134 hero: None,
135 slot: LogicalSlot::new(slot),
136 variant: None,
137 },
138 None => TargetPattern::Hero { team, hero: None },
139 }
140}
141
142fn semantic_ability_slot_for_path(path: &[PathPart<'_>]) -> Option<&'static str> {
143 match path.last() {
144 Some(PathPart::Part("enablePrimaryFire")) => Some("primaryFire"),
145 Some(PathPart::Part("enableGenericSecondaryFire")) => Some("secondaryFire"),
146 Some(PathPart::Part("enablePassiveUnlimitedFuel")) => Some("passive"),
147 Some(PathPart::Part("enablePrimaryFireFreezeStack")) => Some("primaryFire"),
148 Some(PathPart::Part(key)) if key.starts_with("ability1") => Some("ability1"),
149 Some(PathPart::Part(key)) if key.starts_with("ability2") => Some("ability2"),
150 Some(PathPart::Part(key)) if key.starts_with("ability3") => Some("ability3"),
151 Some(PathPart::Part(key)) if key.starts_with("secondaryFire") => Some("secondaryFire"),
152 _ => table::ability_slot_for_path(path),
153 }
154}
155
156fn domain_for(kind: KeyKind) -> SettingValueDomain {
157 match kind {
158 KeyKind::Flag => SettingValueDomain::PresenceOnly,
159 KeyKind::String => SettingValueDomain::String,
160 KeyKind::Bool | KeyKind::YesNo | KeyKind::BoolEnum(_) => SettingValueDomain::Boolean,
161 KeyKind::Number => SettingValueDomain::Number(NumericBounds::unknown()),
162 KeyKind::Percent => SettingValueDomain::Percent(NumericBounds::unknown()),
163 KeyKind::Enum(domain) => SettingValueDomain::Enum {
164 domain: domain.to_string(),
165 },
166 KeyKind::ListMap => SettingValueDomain::MapList,
167 KeyKind::ListHero => SettingValueDomain::HeroList,
168 }
169}
170
171fn canonical_id(scope: SettingScope, key: &str, path: &[PathPart<'_>]) -> Option<SettingId> {
172 let prefix = match scope {
173 SettingScope::Main => "main",
174 SettingScope::Lobby => "lobby",
175 SettingScope::GameModes => "gameMode",
176 SettingScope::Heroes => "hero",
177 SettingScope::Extensions => "extension",
178 SettingScope::Workshop => "workshop",
179 SettingScope::Unknown => "unknown",
180 };
181 if matches!(scope, SettingScope::Unknown) {
182 return None;
183 }
184 let concept = canonical_concept(key, path)?;
185 Some(SettingId::new(format!("setting.{prefix}.{concept}")))
186}
187
188fn canonical_concept(key: &str, path: &[PathPart<'_>]) -> Option<String> {
192 let key = key.trim_end_matches('%');
193 Some(match key {
194 "health" => "health".to_string(),
195 "damageDealt" | "damageReceived" | "healingDealt" | "healingReceived" => key.to_string(),
196 "passiveUltGen" => "ultimateGeneration.passive".to_string(),
197 "combatUltGen" => "ultimateGeneration.combat".to_string(),
198 "ultGen" => "ultimateGeneration".to_string(),
199 "enableUlt" => "ability.enabled".to_string(),
200 "enablePrimaryFire"
201 | "enableSecondaryFire"
202 | "enableGenericSecondaryFire"
203 | "enableAbility1"
204 | "enableAbility2"
205 | "enableAbility3" => "ability.enabled".to_string(),
206 "enableAutomaticFire" => "primaryFire.automaticFireEnabled".to_string(),
207 "enableScoping" => "primaryFire.scopingEnabled".to_string(),
208 "enablePassiveUnlimitedFuel" => "passive.unlimitedFuelEnabled".to_string(),
209 "enablePrimaryFireFreezeStack" => "primaryFire.freezeStackEnabled".to_string(),
210 "setValidControlPoints" | "firstActiveControlPoint" => path
211 .iter()
212 .filter_map(|part| match part {
213 PathPart::Part(name) if *name != "gamemodes" && *name != key => Some(*name),
214 _ => None,
215 })
216 .next()
217 .map(|mode| format!("{key}.{mode}"))?,
218 _ => key.to_string(),
219 })
220}
221
222pub fn validate_catalog() -> Result<(), Vec<String>> {
225 use std::collections::{HashMap, HashSet};
226
227 let mut errors = Vec::new();
228 errors.extend(reconciliation::validate());
229 errors.extend(validate_raw_projection(table::raw_entries()));
230 errors.extend(validate_enum_projection(
231 table::ENUM_MEMBERS.iter(),
232 table::GENERATED_ENUM_MEMBERS.iter(),
233 &reconciliation::data().enum_member_mappings,
234 ));
235 let mut paths = HashSet::new();
236 let mut concepts: HashMap<(String, SettingTargetKind, String), SettingValueDomain> =
237 HashMap::new();
238 let mut concept_keys: HashMap<(String, SettingTargetKind), String> = HashMap::new();
239
240 for definition in definitions() {
241 if !paths.insert(definition.path.clone()) {
242 errors.push(format!("duplicate settings path: {}", definition.path));
243 }
244 if definition.scope == SettingScope::Unknown {
245 errors.push(format!("unknown settings scope: {}", definition.path));
246 }
247 let Some(id) = definition.id() else {
248 errors.push(format!(
249 "missing canonical settings identity: {}",
250 definition.path
251 ));
252 continue;
253 };
254 if !definition.source.reviewed {
255 errors.push(format!(
256 "unreviewed settings definition: {}",
257 definition.path
258 ));
259 }
260 if definition.presentation.english_name.is_empty() {
261 errors.push(format!(
262 "missing settings presentation: {}",
263 definition.path
264 ));
265 }
266 let target_kind = definition.target_kind();
267 let semantic_key = semantic_identity_key(definition.key);
268 let collision_key = (id.as_str().to_string(), target_kind.clone());
269 if let Some(previous_key) = concept_keys.insert(collision_key, semantic_key.clone()) {
270 if previous_key != semantic_key {
271 errors.push(format!(
272 "conflicting settings concepts for {id}: {previous_key} vs {semantic_key}"
273 ));
274 }
275 }
276 let key = (id.as_str().to_string(), target_kind, semantic_key);
277 if let Some(previous) = concepts.insert(key, definition.domain.clone()) {
278 if previous != definition.domain {
279 errors.push(format!("conflicting settings domains for {id}"));
280 }
281 }
282 }
283 if errors.is_empty() {
284 Ok(())
285 } else {
286 Err(errors)
287 }
288}
289
290pub(super) fn validate_raw_projection(
294 entries: impl IntoIterator<Item = table::ProjectedEntry>,
295) -> Vec<String> {
296 use std::collections::HashMap;
297
298 let mut errors = Vec::new();
299 let mut paths = HashMap::new();
300 for projected in entries {
301 let entry = projected.entry;
302 if let Some(previous) = paths.insert(entry.path, projected) {
303 if previous.entry != entry
304 && !reconciled_entry_override(
305 table::path_string(entry.path).as_str(),
306 previous,
307 projected,
308 )
309 {
310 errors.push(format!(
311 "conflicting duplicate settings path between {} and {}: {}",
312 previous.source.label(),
313 projected.source.label(),
314 table::path_string(entry.path),
315 ));
316 }
317 }
318 }
319 errors
320}
321
322fn reconciled_entry_override(
323 path: &str,
324 fixture: table::ProjectedEntry,
325 generated: table::ProjectedEntry,
326) -> bool {
327 use table::ProjectionSource::{FixtureTable, WorkshopDataExport};
328
329 let (fixture, generated) = match (fixture.source, generated.source) {
330 (FixtureTable, WorkshopDataExport) => (fixture.entry, generated.entry),
331 (WorkshopDataExport, FixtureTable) => (generated.entry, fixture.entry),
332 _ => return false,
333 };
334 reconciliation::data()
335 .entry_overrides
336 .iter()
337 .find(|override_| override_.path == path)
338 .is_some_and(|override_| {
339 entry_contract_matches(fixture, &override_.fixture)
340 && entry_contract_matches(generated, &override_.generated)
341 })
342}
343
344fn entry_contract_matches(entry: &TableEntry, expected: &reconciliation::EntryContract) -> bool {
345 entry.workshop_name == expected.name && key_kind_matches(entry.kind, expected)
346}
347
348fn key_kind_matches(kind: KeyKind, expected: &reconciliation::EntryContract) -> bool {
349 match (kind, expected.kind.as_str(), expected.domain.as_deref()) {
350 (KeyKind::Flag, "flag", None)
351 | (KeyKind::String, "string", None)
352 | (KeyKind::Bool, "bool", None)
353 | (KeyKind::YesNo, "yesNo", None)
354 | (KeyKind::Number, "number", None)
355 | (KeyKind::Percent, "percent", None)
356 | (KeyKind::ListMap, "mapList", None)
357 | (KeyKind::ListHero, "heroList", None) => true,
358 (KeyKind::BoolEnum(actual), "boolEnum", Some(expected)) => actual == expected,
359 (KeyKind::Enum(actual), "enum", Some(expected)) => actual == expected,
360 _ => false,
361 }
362}
363
364pub(super) fn validate_enum_projection(
368 fixture_entries: impl IntoIterator<Item = &'static table::EnumMember>,
369 generated_entries: impl IntoIterator<Item = &'static table::EnumMember>,
370 mappings: &[reconciliation::EnumMemberMapping],
371) -> Vec<String> {
372 use std::collections::{HashMap, HashSet};
373
374 let domains: HashSet<_> = table::entries()
375 .filter_map(|entry| match entry.kind {
376 KeyKind::Enum(domain) | KeyKind::BoolEnum(domain) => Some(domain),
377 _ => None,
378 })
379 .collect();
380 let mut errors = Vec::new();
381 let mut members = HashMap::new();
382 let mut names = HashMap::new();
383 for member in fixture_entries {
384 if !domains.contains(member.domain) {
385 errors.push(format!("orphaned settings enum domain: {}", member.domain));
386 }
387 let key = (member.domain, member.member);
388 if let Some(previous) = members.insert(key, member.name) {
389 if previous != member.name {
390 errors.push(format!(
391 "conflicting settings enum member {}.{}: {previous:?} vs {:?}",
392 member.domain, member.member, member.name
393 ));
394 }
395 }
396 if let Some(previous) = names.insert((member.domain, member.name), member.member) {
397 if previous != member.member {
398 errors.push(format!(
399 "conflicting settings enum display name {}.{:?}: {previous} vs {}",
400 member.domain, member.name, member.member
401 ));
402 }
403 }
404 }
405 let fixture_members: HashMap<_, _> = table::ENUM_MEMBERS
406 .iter()
407 .map(|member| ((member.domain, member.member), member))
408 .collect();
409 let mut mapped_sources = HashSet::new();
410 for member in generated_entries {
411 let key = (member.domain, member.member);
412 if let Some(previous) = members.insert(key, member.name) {
413 if previous != member.name {
414 errors.push(format!(
415 "conflicting settings enum member {}.{}: {previous:?} vs {:?}",
416 member.domain, member.member, member.name
417 ));
418 }
419 }
420 let mapping = mappings.iter().find(|mapping| {
421 mapping.source_domain == member.domain && mapping.source_member == member.member
422 });
423 if mapping.is_none() && !domains.contains(member.domain) {
424 errors.push(format!("orphaned settings enum domain: {}", member.domain));
425 }
426 let (domain, canonical_member, name) = match mapping {
427 Some(mapping) => {
428 if !mapped_sources.insert((
429 mapping.source_domain.as_str(),
430 mapping.source_member.as_str(),
431 )) {
432 errors.push(format!(
433 "duplicate settings enum reconciliation for {}.{}",
434 mapping.source_domain, mapping.source_member
435 ));
436 }
437 match fixture_members.get(&(
438 mapping.target_domain.as_str(),
439 mapping.target_member.as_str(),
440 )) {
441 Some(target) if target.name == member.name => {
442 (target.domain, target.member, target.name)
443 }
444 Some(target) => {
445 errors.push(format!(
446 "settings enum reconciliation name mismatch {}.{} -> {}.{}: {:?} vs {:?}",
447 mapping.source_domain, mapping.source_member,
448 mapping.target_domain, mapping.target_member, member.name, target.name
449 ));
450 continue;
451 }
452 None => {
453 errors.push(format!(
454 "settings enum reconciliation target is missing: {}.{} -> {}.{}",
455 mapping.source_domain,
456 mapping.source_member,
457 mapping.target_domain,
458 mapping.target_member
459 ));
460 continue;
461 }
462 }
463 }
464 None => (member.domain, member.member, member.name),
465 };
466 if let Some(previous) = names.insert((domain, name), canonical_member) {
467 if previous != canonical_member {
468 errors.push(format!(
469 "conflicting settings enum display name {}.{name:?}: {previous} vs {canonical_member}",
470 domain
471 ));
472 }
473 }
474 }
475 for mapping in mappings {
476 if !mapped_sources.contains(&(
477 mapping.source_domain.as_str(),
478 mapping.source_member.as_str(),
479 )) {
480 errors.push(format!(
481 "orphaned settings enum reconciliation: {}.{}",
482 mapping.source_domain, mapping.source_member
483 ));
484 }
485 }
486 errors
487}
488
489fn semantic_identity_key(key: &str) -> String {
490 match key {
491 "enableSecondaryFire" | "enableGenericSecondaryFire" => "enableSecondaryFire".to_string(),
492 _ => key.to_string(),
493 }
494}