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