diff --git a/README.md b/README.md index a550e02..ef70ed3 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,19 @@ Settings-bearing programs are parsed into the canonical WIR settings carrier and emitted by the library. Locale detection is available via `workshop_rs::detect`. +### Rule event contract + +The public WIR event contract is locale- and provider-independent. It includes +`Event::Global`, `Event::EachPlayer`, filtered `Event::EachPlayerWithFilters`, +nine filtered `Event::Player` identities (`PlayerEventKind`), and +`Event::Subroutine`. Filtered events carry a canonical `EventTeam` and an +`EventTarget` (`All`, Workshop slot `0..=11`, or a canonical hero id). +Raw Workshop player events require both their team and player filters; +parameterless `Ongoing - Each Player` remains supported for existing programs. +Event identities and filter members are checked against the catalog before a +program is accepted for canonical emission, so source-language providers can +consume this public model without defining a second event table. + ## CLI usage ```sh @@ -105,11 +118,11 @@ the canonical form with a fresh digest (byte-idempotent). See ## Locale status -* `en-US`: complete declared surface (341/341 canonical entries), corpus +* `en-US`: complete declared surface (366/366 canonical entries), corpus round-trips and settings emission tested. -* `zh-CN`: the reviewed export-backed corpus covers **341/341** canonical - entries (structural 11/11, actions 60/60, values 77/77, events 3/3, - operators 14/14, enum members 176/176). The declared surface is complete; +* `zh-CN`: the reviewed export-backed corpus covers **366/366** canonical + entries (structural 11/11, actions 60/60, values 77/77, events 12/12, + operators 14/14, enum members 192/192). The declared surface is complete; settings data covers labels 19/19 and all other declared settings sections. The corpus is reproducible with the user-provided export (not committed): diff --git a/crates/workshop-rs-cli/tests/cli.rs b/crates/workshop-rs-cli/tests/cli.rs index 7f5fde8..33c0ead 100644 --- a/crates/workshop-rs-cli/tests/cli.rs +++ b/crates/workshop-rs-cli/tests/cli.rs @@ -50,8 +50,8 @@ fn locales_lists_declared_locales_with_coverage() { let stdout = String::from_utf8(output.stdout).unwrap(); let lines: Vec<&str> = stdout.lines().collect(); assert_eq!(lines.len(), 2); - assert!(lines[0].starts_with("en-us 341/341"), "{stdout}"); - assert!(lines[1].starts_with("zh-cn 341/341"), "{stdout}"); + assert!(lines[0].starts_with("en-us 366/366"), "{stdout}"); + assert!(lines[1].starts_with("zh-cn 366/366"), "{stdout}"); } #[test] diff --git a/crates/workshop-rs/src/bin/workshop-catalog-gen.rs b/crates/workshop-rs/src/bin/workshop-catalog-gen.rs index 8c90711..f48f140 100644 --- a/crates/workshop-rs/src/bin/workshop-catalog-gen.rs +++ b/crates/workshop-rs/src/bin/workshop-catalog-gen.rs @@ -221,7 +221,7 @@ mod corpus { } /// An exact-en-US-spelling index over a slice of the export. - #[derive(Debug, Default)] + #[derive(Debug, Clone, Default)] struct Index { by_en: HashMap>, } @@ -303,6 +303,28 @@ mod corpus { index } + /// Build an index from a nested `data..
` table with + /// direct en-US/zh-CN fields. + fn nested_data_index(export: &Value, parent: &str, section: &str) -> Index { + let mut index = Index::default(); + let Some(entries) = export + .get("data") + .and_then(|data| data.get(parent)) + .and_then(|parent| parent.get(section)) + .and_then(Value::as_object) + else { + return index; + }; + for (id, entry) in entries { + let en = entry.get("en-US").and_then(Value::as_str); + let zh = entry.get("zh-CN").and_then(Value::as_str); + if let (Some(en), Some(zh)) = (en, zh) { + index.add(&format!("data.{parent}.{section}.{id}"), en, zh); + } + } + index + } + /// Resolve a user-confirmed legacy identity whose export wording differs /// from the canonical English spelling. The export entry is accepted only /// when its category, identity, GUID, and both locale values match the @@ -565,6 +587,26 @@ mod corpus { let actions = localized_index(&export, &["actions."]); let values = localized_index(&export, &["values."]); let events = localized_index(&export, &["other.events."]); + let event_teams = { + let mut index = localized_index(&export, &["other.eventTeams."]); + merge_index( + &mut index, + nested_data_index(&export, "other", "eventTeams"), + ); + index + }; + let event_players = { + let mut index = localized_index(&export, &["other.eventPlayers.", "other.eventSlots."]); + merge_index( + &mut index, + nested_data_index(&export, "other", "eventPlayers"), + ); + merge_index( + &mut index, + nested_data_index(&export, "other", "eventSlots"), + ); + index + }; let operators = localized_index(&export, &["values.", "constants.__Operation__."]); let maps = { let mut index = localized_index(&export, &["maps."]); @@ -582,6 +624,8 @@ mod corpus { let mut constants_by_domain: HashMap = HashMap::new(); for domain in enum_domains(&catalog)? { let export_domain = match domain.as_str() { + "EventTeam" => "__event_team__", + "EventPlayer" => "__event_player__", "Color" => "ColorLiteral", "Team" => "TeamLiteral", "Button" => "ButtonLiteral", @@ -592,7 +636,12 @@ mod corpus { other => other, }; let prefix = format!("constants.{export_domain}."); - constants_by_domain.insert(domain.to_string(), localized_index(&export, &[&prefix])); + let index = match domain.as_str() { + "EventTeam" => event_teams.clone(), + "EventPlayer" => event_players.clone(), + _ => localized_index(&export, &[&prefix]), + }; + constants_by_domain.insert(domain.to_string(), index); } let mut matches: Vec = Vec::new(); @@ -951,7 +1000,7 @@ mod corpus { "commitDate": meta.get("commitDate").and_then(Value::as_str).unwrap_or(""), "fetchedAt": meta.get("fetchedAt").and_then(Value::as_str).unwrap_or(""), }, - "method": "exact en-US spelling match between the catalog aliases and the export's localized index (actions/values/events/operators/constants/maps/heroes), plus confirmed legacy identity/GUID mappings for global stop-chasing, force hero/throttle, Set Player Allowed Heroes, and bare comparison-symbol entries; zh-CN is taken from the same export entry; entries without an accepted match, or whose export candidates disagree on zh-CN, are excluded with a recorded reason and keep fail-explicit behavior (ADR-0001 Decision 7)", + "method": "exact en-US spelling match between the catalog aliases and the export's localized index (actions/values/events/operators/constants/event filters/maps/heroes), plus confirmed legacy identity/GUID mappings for global stop-chasing, force hero/throttle, Set Player Allowed Heroes, and bare comparison-symbol entries; zh-CN is taken from the same export entry; entries without an accepted match, or whose export candidates disagree on zh-CN, are excluded with a recorded reason and keep fail-explicit behavior (ADR-0001 Decision 7)", "sourceReview": "reviewed: workshop-rs commits its own mapping data; the user-provided JSON is build input only and is not redistributed", "coverage": Value::Object(coverage_all), "matches": matches_json, diff --git a/crates/workshop-rs/src/catalog/data/catalog.json b/crates/workshop-rs/src/catalog/data/catalog.json index 925af58..2c47ba9 100644 --- a/crates/workshop-rs/src/catalog/data/catalog.json +++ b/crates/workshop-rs/src/catalog/data/catalog.json @@ -828,7 +828,7 @@ "params": [] } ], - "digest": "1f84204bfd6bfd0dcaabc275185d907764e03fae0fbde54f28e162aed9935961", + "digest": "48b3a07188f7137a8a81e51fc33c06681da5ca5d30b79ee636b0147eb14dfc6d", "enums": [ { "domain": "Color", @@ -1200,6 +1200,128 @@ } ] }, + { + "domain": "EventTeam", + "members": [ + { + "aliases": { + "en-US": "All", + "zh-CN": "双方" + }, + "id": "ALL" + }, + { + "aliases": { + "en-US": "Team 1", + "zh-CN": "队伍1" + }, + "id": "TEAM_1" + }, + { + "aliases": { + "en-US": "Team 2", + "zh-CN": "队伍2" + }, + "id": "TEAM_2" + } + ] + }, + { + "domain": "EventPlayer", + "members": [ + { + "aliases": { + "en-US": "All", + "zh-CN": "全部" + }, + "id": "ALL" + }, + { + "aliases": { + "en-US": "Slot 0", + "zh-CN": "栏位 0" + }, + "id": "SLOT_0" + }, + { + "aliases": { + "en-US": "Slot 1", + "zh-CN": "栏位 1" + }, + "id": "SLOT_1" + }, + { + "aliases": { + "en-US": "Slot 2", + "zh-CN": "栏位 2" + }, + "id": "SLOT_2" + }, + { + "aliases": { + "en-US": "Slot 3", + "zh-CN": "栏位 3" + }, + "id": "SLOT_3" + }, + { + "aliases": { + "en-US": "Slot 4", + "zh-CN": "栏位 4" + }, + "id": "SLOT_4" + }, + { + "aliases": { + "en-US": "Slot 5", + "zh-CN": "栏位 5" + }, + "id": "SLOT_5" + }, + { + "aliases": { + "en-US": "Slot 6", + "zh-CN": "栏位 6" + }, + "id": "SLOT_6" + }, + { + "aliases": { + "en-US": "Slot 7", + "zh-CN": "栏位 7" + }, + "id": "SLOT_7" + }, + { + "aliases": { + "en-US": "Slot 8", + "zh-CN": "栏位 8" + }, + "id": "SLOT_8" + }, + { + "aliases": { + "en-US": "Slot 9", + "zh-CN": "栏位 9" + }, + "id": "SLOT_9" + }, + { + "aliases": { + "en-US": "Slot 10", + "zh-CN": "栏位 10" + }, + "id": "SLOT_10" + }, + { + "aliases": { + "en-US": "Slot 11", + "zh-CN": "栏位 11" + }, + "id": "SLOT_11" + } + ] + }, { "domain": "Invis", "members": [ @@ -2206,7 +2328,150 @@ "en-US": "Ongoing - Each Player", "zh-CN": "持续 - 每名玩家" }, - "id": "eachPlayer" + "id": "eachPlayer", + "paramDomains": [ + "EventTeam", + null + ], + "params": [ + "Team", + "Player" + ] + }, + { + "aliases": { + "en-US": "Player Dealt Damage", + "zh-CN": "玩家造成伤害" + }, + "id": "playerDealtDamage", + "paramDomains": [ + "EventTeam", + null + ], + "params": [ + "Team", + "Player" + ] + }, + { + "aliases": { + "en-US": "Player Dealt Final Blow", + "zh-CN": "玩家造成最后一击" + }, + "id": "playerDealtFinalBlow", + "paramDomains": [ + "EventTeam", + null + ], + "params": [ + "Team", + "Player" + ] + }, + { + "aliases": { + "en-US": "Player Dealt Healing", + "zh-CN": "玩家造成治疗" + }, + "id": "playerDealtHealing", + "paramDomains": [ + "EventTeam", + null + ], + "params": [ + "Team", + "Player" + ] + }, + { + "aliases": { + "en-US": "Player Died", + "zh-CN": "玩家阵亡" + }, + "id": "playerDied", + "paramDomains": [ + "EventTeam", + null + ], + "params": [ + "Team", + "Player" + ] + }, + { + "aliases": { + "en-US": "Player Earned Elimination", + "zh-CN": "玩家参与消灭" + }, + "id": "playerEarnedElimination", + "paramDomains": [ + "EventTeam", + null + ], + "params": [ + "Team", + "Player" + ] + }, + { + "aliases": { + "en-US": "Player Joined Match", + "zh-CN": "玩家加入比赛" + }, + "id": "playerJoined", + "paramDomains": [ + "EventTeam", + null + ], + "params": [ + "Team", + "Player" + ] + }, + { + "aliases": { + "en-US": "Player Left Match", + "zh-CN": "玩家离开比赛" + }, + "id": "playerLeft", + "paramDomains": [ + "EventTeam", + null + ], + "params": [ + "Team", + "Player" + ] + }, + { + "aliases": { + "en-US": "Player Received Healing", + "zh-CN": "玩家受到治疗" + }, + "id": "playerReceivedHealing", + "paramDomains": [ + "EventTeam", + null + ], + "params": [ + "Team", + "Player" + ] + }, + { + "aliases": { + "en-US": "Player Took Damage", + "zh-CN": "玩家受到伤害" + }, + "id": "playerTookDamage", + "paramDomains": [ + "EventTeam", + null + ], + "params": [ + "Team", + "Player" + ] }, { "aliases": { @@ -2325,7 +2590,7 @@ "generatorVersion": "0.1.0", "license": "MIT (WrightKit-authored data, ownership transfer to workshop-rs per wright#136 direction; see docs/provenance.md)", "reviewed": true, - "source": "en-US spellings transcribed from the compatibility corpus workshop snapshots and the M5 support matrix; squareRoot spelling from the Workshop emission surface for .opy sqrt(); receiver-call action/value spellings (setMoveSpeed, isAlive, getPosition, getHealth, teleport, setMaxHealth, setHealth, setAimSpeed, setGravity, setDamageDealt, setDamageReceived, setUltCharge) from the pinned en-US emission surface for .opy eventPlayer.(...) forms, evidenced by the synthetic/receiver-calls corpus fixture (#104); ChaseTimeReeval and ChaseRateReeval member spellings (None, Destination and Duration, Destination and Rate) reference-validated against the pinned enum blocks and emission (#105); chaseOverTime (Chase Global Variable Over Time), isGameInProgress (Is Game In Progress), getPlayersInRadius (Players Within Radius), worldVector (World Vector Of), getThrottle (Throttle Of), setInvisibility (Set Invisible) with the Invis domain, setStatusEffect (Set Status) with the Status domain, the Transform domain, and the LosCheck domain transcribed from the en-US emission surface for the OPY semantic manifest probes (#109); chaseAtRate (Chase Global Variable At Rate), chasePlayerVariableAtRate (Chase Player Variable At Rate), and chasePlayerVariableOverTime (Chase Player Variable Over Time) transcribed from the en-US emission surface for the chase keyword-argument probes (#110); OSTW exercised builtin params/spellings and enum domain/member data transcribed from pinned reference probe emissions and the protect-ban entry-point reachable closure (#118); migrated to workshop-rs from the Wright-authored catalog on 2026-08-16 as the canonical Workshop catalog; the chase-family expected enum domains migrate the Wright-authored semantic manifest probe data (#109/#110) into the canonical catalog so the standalone core resolves the shared bare None member without any Wright tooling dependency; zh-CN aliases are generated from the user-provided workshop-data JSON; confirmed identity/GUID mappings cover global stop-chasing, force hero/throttle, Set Player Allowed Heroes, the four bare comparison symbols, and three enum aliases; legacy/provider syntax sugar is intentionally outside the declared catalog surface; 341/341 canonical entries are covered with no exclusions" + "source": "en-US spellings transcribed from the compatibility corpus workshop snapshots and the M5 support matrix; squareRoot spelling from the Workshop emission surface for .opy sqrt(); receiver-call action/value spellings (setMoveSpeed, isAlive, getPosition, getHealth, teleport, setMaxHealth, setHealth, setAimSpeed, setGravity, setDamageDealt, setDamageReceived, setUltCharge) from the pinned en-US emission surface for .opy eventPlayer.(...) forms, evidenced by the synthetic/receiver-calls corpus fixture (#104); ChaseTimeReeval and ChaseRateReeval member spellings (None, Destination and Duration, Destination and Rate) reference-validated against the pinned enum blocks and emission (#105); chaseOverTime (Chase Global Variable Over Time), isGameInProgress (Is Game In Progress), getPlayersInRadius (Players Within Radius), worldVector (World Vector Of), getThrottle (Throttle Of), setInvisibility (Set Invisible) with the Invis domain, setStatusEffect (Set Status) with the Status domain, the Transform domain, and the LosCheck domain transcribed from the en-US emission surface for the OPY semantic manifest probes (#109); chaseAtRate (Chase Global Variable At Rate), chasePlayerVariableAtRate (Chase Player Variable At Rate), and chasePlayerVariableOverTime (Chase Player Variable Over Time) transcribed from the en-US emission surface for the chase keyword-argument probes (#110); OSTW exercised builtin params/spellings and enum domain/member data transcribed from pinned reference probe emissions and the protect-ban entry-point reachable closure (#118); rule-event identities and EventTeam/EventPlayer filter aliases are transcribed from the user-provided workshop-data export and its Workshop event annotations, cross-checked against documented raw Workshop event forms; migrated to workshop-rs from the Wright-authored catalog on 2026-08-16 as the canonical Workshop catalog; the chase-family expected enum domains migrate the Wright-authored semantic manifest probe data (#109/#110) into the canonical catalog so the standalone core resolves the shared bare None member without any Wright tooling dependency; zh-CN aliases are generated from the user-provided workshop-data JSON; confirmed identity/GUID mappings cover global stop-chasing, force hero/throttle, Set Player Allowed Heroes, the four bare comparison symbols, and three enum aliases; legacy/provider syntax sugar is intentionally outside the declared catalog surface; 366/366 canonical entries are covered with no exclusions" }, "schemaVersion": 1, "structural": [ diff --git a/crates/workshop-rs/src/catalog/mod.rs b/crates/workshop-rs/src/catalog/mod.rs index 26dca5e..8f2808f 100644 --- a/crates/workshop-rs/src/catalog/mod.rs +++ b/crates/workshop-rs/src/catalog/mod.rs @@ -94,7 +94,11 @@ pub struct CatalogEntry { pub params: Vec, /// The canonical enum domain expected at each parameter position, when /// the parameter takes an enumerated value (parallel to `params`). - /// `None` for non-enum parameters and for undocumented parameters. + /// `None` for non-enum parameters and for parameters whose accepted + /// values span multiple canonical domains. In particular, a filtered + /// rule event's `Player` parameter accepts `EventPlayer` members or + /// canonical `Hero` members; the WIR [`crate::wir::EventTarget`] carries + /// that union explicitly. pub param_domains: Vec>, /// Default value per parameter position (parallel to `params`), /// resolved when a call omits the argument. See the catalog data diff --git a/crates/workshop-rs/src/emitter.rs b/crates/workshop-rs/src/emitter.rs index 1e7ca9c..3620eb6 100644 --- a/crates/workshop-rs/src/emitter.rs +++ b/crates/workshop-rs/src/emitter.rs @@ -382,10 +382,17 @@ impl Emitter<'_> { wir::Event::EachPlayer => { let spelling = self.spelling(Kind::Event, "eachPlayer")?; self.line(2, &format!("{spelling};"))?; - // The default eachPlayer parameter ("All", corpus-evidenced) - // is a locale-invariant keyword in the declared surface. - self.line(2, "All;")?; - self.line(2, "All;")?; + self.event_filters(wir::EventTeam::All, &wir::EventTarget::All)?; + } + wir::Event::EachPlayerWithFilters { team, target } => { + let spelling = self.spelling(Kind::Event, "eachPlayer")?; + self.line(2, &format!("{spelling};"))?; + self.event_filters(*team, target)?; + } + wir::Event::Player { kind, team, target } => { + let spelling = self.spelling(Kind::Event, kind.catalog_id())?; + self.line(2, &format!("{spelling};"))?; + self.event_filters(*team, target)?; } wir::Event::Subroutine(subroutine) => { let spelling = self.spelling(Kind::Event, "subroutine")?; @@ -437,6 +444,25 @@ impl Emitter<'_> { Ok(()) } + fn event_filters(&mut self, team: wir::EventTeam, target: &wir::EventTarget) -> Result<()> { + let team = match team { + wir::EventTeam::All => "ALL", + wir::EventTeam::Team1 => "TEAM_1", + wir::EventTeam::Team2 => "TEAM_2", + }; + let team = self.enum_spelling("EventTeam", team)?; + self.line(2, &format!("{team};"))?; + let target = match target { + wir::EventTarget::All => self.enum_spelling("EventPlayer", "ALL")?, + wir::EventTarget::Slot(slot) => { + self.enum_spelling("EventPlayer", &format!("SLOT_{slot}"))? + } + wir::EventTarget::Hero(hero) => self.enum_spelling("Hero", hero)?, + }; + self.line(2, &format!("{target};"))?; + Ok(()) + } + /// Emit one rule action; `rule_final` marks the last action of the rule, /// for which an `if`/`if-else` closes without the trailing `End;` /// (the pinned oracle's spelling, #87). diff --git a/crates/workshop-rs/src/parser.rs b/crates/workshop-rs/src/parser.rs index 4216d06..349e9c5 100644 --- a/crates/workshop-rs/src/parser.rs +++ b/crates/workshop-rs/src/parser.rs @@ -12,7 +12,9 @@ use crate::settings::table::{self, KeyKind, PathPart}; use crate::settings::{Settings, SettingsListElement, SettingsNode}; use crate::signatures::{ExpectedDomain, NoExpectedDomain}; use crate::source::{Position, SourceFile, Span}; -use crate::wir::{self, Action, Event, ModifyOp, Value, ValueNode}; +use crate::wir::{ + self, Action, Event, EventTarget, EventTeam, ModifyOp, PlayerEventKind, Value, ValueNode, +}; use crate::catalog::{Catalog, Kind, Locale}; use crate::error::{Result, WorkshopError}; @@ -665,20 +667,39 @@ impl Parser<'_> { span: None, })?; match entry.id.as_str() { - "global" => Ok(Event::Global), + "global" => { + if lines[1..].iter().any(|line| !line.trim().is_empty()) { + return Err(self.unsupported_event_parameters("global")); + } + Ok(Event::Global) + } "eachPlayer" => { - for sub in &lines[1..] { - let sub = sub.trim(); - if !sub.is_empty() && sub != "All" { - return Err(WorkshopError::Unsupported { - message: format!("unsupported 'eachPlayer' event parameter '{sub}'"), - span: None, - }); - } + if lines[1..].iter().all(|line| line.trim().is_empty()) { + return Ok(Event::EachPlayer); } - Ok(Event::EachPlayer) + let (team, target) = self.event_filters(&lines, "eachPlayer", true)?; + Ok(Event::EachPlayerWithFilters { team, target }) } + "playerDealtDamage" => self.player_event(&lines, PlayerEventKind::DealtDamage), + "playerDealtFinalBlow" => self.player_event(&lines, PlayerEventKind::DealtFinalBlow), + "playerDealtHealing" => self.player_event(&lines, PlayerEventKind::DealtHealing), + "playerDied" => self.player_event(&lines, PlayerEventKind::Died), + "playerEarnedElimination" => { + self.player_event(&lines, PlayerEventKind::EarnedElimination) + } + "playerJoined" => self.player_event(&lines, PlayerEventKind::Joined), + "playerLeft" => self.player_event(&lines, PlayerEventKind::Left), + "playerReceivedHealing" => self.player_event(&lines, PlayerEventKind::ReceivedHealing), + "playerTookDamage" => self.player_event(&lines, PlayerEventKind::TookDamage), "subroutine" => { + if lines + .get(2..) + .unwrap_or(&[]) + .iter() + .any(|line| !line.trim().is_empty()) + { + return Err(self.unsupported_event_parameters("subroutine")); + } let Some(sub_name) = lines.get(1).map(|s| s.trim()) else { return Err(self.malformed( "subroutine event requires a subroutine name", @@ -695,6 +716,88 @@ impl Parser<'_> { } } + fn player_event(&self, lines: &[String], kind: PlayerEventKind) -> Result { + let (team, target) = self.event_filters(lines, kind.catalog_id(), false)?; + Ok(Event::Player { kind, team, target }) + } + + fn event_filters( + &self, + lines: &[String], + event_id: &str, + allow_empty: bool, + ) -> Result<(EventTeam, EventTarget)> { + let parameters: Vec<&str> = lines[1..] + .iter() + .map(String::as_str) + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect(); + if parameters.is_empty() { + if allow_empty { + return Ok((EventTeam::All, EventTarget::All)); + } + return Err(WorkshopError::Malformed { + message: format!("event '{event_id}' requires team and player parameters"), + span: None, + }); + } + if parameters.len() != 2 { + if event_id == "eachPlayer" { + return Err(WorkshopError::Unsupported { + message: format!("event '{event_id}' requires both team and player parameters"), + span: None, + }); + } + return Err(WorkshopError::Malformed { + message: format!("event '{event_id}' requires team and player parameters"), + span: None, + }); + } + let team_member = self + .catalog + .resolve_enum_member("EventTeam", &self.locale, parameters[0]) + .map(|(_, member)| member); + let team = match team_member.as_deref() { + Some("ALL") => EventTeam::All, + Some("TEAM_1") => EventTeam::Team1, + Some("TEAM_2") => EventTeam::Team2, + _ => return Err(self.unknown("event team", parameters[0])), + }; + let target = if let Some((_, member)) = + self.catalog + .resolve_enum_member("EventPlayer", &self.locale, parameters[1]) + { + if member == "ALL" { + EventTarget::All + } else if let Some(slot) = member.strip_prefix("SLOT_") { + let slot = slot + .parse::() + .map_err(|_| self.unknown("event player", parameters[1]))?; + EventTarget::Slot(slot) + } else { + return Err(self.unknown("event player", parameters[1])); + } + } else if let Some((_, hero)) = self + .catalog + .bare_member_matches(&self.locale, parameters[1]) + .into_iter() + .find(|(domain, _)| domain == "Hero") + { + EventTarget::Hero(hero) + } else { + return Err(self.unknown("event player", parameters[1])); + }; + Ok((team, target)) + } + + fn unsupported_event_parameters(&self, event_id: &str) -> WorkshopError { + WorkshopError::Unsupported { + message: format!("event '{event_id}' does not accept parameters"), + span: None, + } + } + fn conditions_section(&mut self) -> Result> { self.expect_word("conditions")?; self.expect(TokenKind::LBrace, "expected '{' after 'conditions'")?; @@ -1473,7 +1576,16 @@ impl Parser<'_> { start: Position, end: Position, ) -> Result { - let matches: Vec<(String, String)> = self.catalog.bare_member_matches(&self.locale, phrase); + // Event filter domains are resolved by the event parser and are not + // value-argument domains. Excluding them here keeps their spellings + // from making unrelated bare value arguments ambiguous (for example, + // `All` in `Set Invisible(..., All)`). + let matches: Vec<(String, String)> = self + .catalog + .bare_member_matches(&self.locale, phrase) + .into_iter() + .filter(|(domain, _)| domain != "EventTeam" && domain != "EventPlayer") + .collect(); if matches.len() == 1 { return Ok(self.target.values.push(ValueNode::new( Value::Enum { diff --git a/crates/workshop-rs/src/roundtrip.rs b/crates/workshop-rs/src/roundtrip.rs index 0cd54a1..e8915d2 100644 --- a/crates/workshop-rs/src/roundtrip.rs +++ b/crates/workshop-rs/src/roundtrip.rs @@ -283,6 +283,32 @@ fn event_equivalent( match (left, right) { (wir::Event::Global, wir::Event::Global) => true, (wir::Event::EachPlayer, wir::Event::EachPlayer) => true, + (wir::Event::EachPlayer, wir::Event::EachPlayerWithFilters { team, target }) + | (wir::Event::EachPlayerWithFilters { team, target }, wir::Event::EachPlayer) => { + *team == wir::EventTeam::All && *target == wir::EventTarget::All + } + ( + wir::Event::EachPlayerWithFilters { + team: team_a, + target: target_a, + }, + wir::Event::EachPlayerWithFilters { + team: team_b, + target: target_b, + }, + ) => team_a == team_b && target_a == target_b, + ( + wir::Event::Player { + kind: kind_a, + team: team_a, + target: target_a, + }, + wir::Event::Player { + kind: kind_b, + team: team_b, + target: target_b, + }, + ) => kind_a == kind_b && team_a == team_b && target_a == target_b, (wir::Event::Subroutine(sa), wir::Event::Subroutine(sb)) => { let name_a = a.subroutines.get(*sa).map(|s| s.name.as_str()); let name_b = b.subroutines.get(*sb).map(|s| s.name.as_str()); diff --git a/crates/workshop-rs/src/validate.rs b/crates/workshop-rs/src/validate.rs index 54702da..c15c4f4 100644 --- a/crates/workshop-rs/src/validate.rs +++ b/crates/workshop-rs/src/validate.rs @@ -20,6 +20,7 @@ pub fn validate_canonical_ids(program: &wir::Program, catalog: &Catalog) -> Resu let Some(rule_data) = program.rules.get(rule) else { continue; }; + validate_event(&rule_data.event, rule_data.span, catalog, &mut errors); for action in &rule_data.actions { validate_action(program, catalog, *action, &mut errors); } @@ -30,6 +31,78 @@ pub fn validate_canonical_ids(program: &wir::Program, catalog: &Catalog) -> Resu errors.into_iter().next().map_or(Ok(()), Err) } +fn validate_event( + event: &wir::Event, + span: Option, + catalog: &Catalog, + errors: &mut Vec, +) { + let (id, filters) = match event { + wir::Event::Global => ("global", None), + wir::Event::EachPlayer => ("eachPlayer", None), + wir::Event::EachPlayerWithFilters { team, target } => ("eachPlayer", Some((*team, target))), + wir::Event::Player { kind, team, target } => (kind.catalog_id(), Some((*team, target))), + wir::Event::Subroutine(_) => ("subroutine", None), + }; + if catalog.entry(Kind::Event, id).is_none() { + errors.push(WorkshopError::Unknown { + kind: "event", + spelling: id.to_string(), + locale: crate::catalog::Locale::new("en-US"), + span, + }); + return; + } + let Some((team, target)) = filters else { + return; + }; + let en = crate::catalog::Locale::new("en-US"); + let team_member = match team { + wir::EventTeam::All => "ALL", + wir::EventTeam::Team1 => "TEAM_1", + wir::EventTeam::Team2 => "TEAM_2", + }; + if catalog + .enum_spelling("EventTeam", &en, team_member) + .is_none() + { + errors.push(WorkshopError::Unknown { + kind: "event team", + spelling: team_member.to_string(), + locale: en.clone(), + span, + }); + } + let target_member = match target { + wir::EventTarget::All => Some("ALL".to_string()), + wir::EventTarget::Slot(slot) => Some(format!("SLOT_{slot}")), + wir::EventTarget::Hero(hero) => { + if catalog.enum_spelling("Hero", &en, hero).is_none() { + errors.push(WorkshopError::Unknown { + kind: "event player", + spelling: hero.clone(), + locale: en.clone(), + span, + }); + } + None + } + }; + if let Some(target_member) = target_member { + if catalog + .enum_spelling("EventPlayer", &en, &target_member) + .is_none() + { + errors.push(WorkshopError::Unknown { + kind: "event player", + spelling: target_member, + locale: en, + span, + }); + } + } +} + fn validate_action( program: &wir::Program, catalog: &Catalog, diff --git a/crates/workshop-rs/src/wir/dump.rs b/crates/workshop-rs/src/wir/dump.rs index e608b01..9387a7f 100644 --- a/crates/workshop-rs/src/wir/dump.rs +++ b/crates/workshop-rs/src/wir/dump.rs @@ -2,7 +2,7 @@ use crate::source::Span; -use super::{Action, Event, Program, Value}; +use super::{Action, Event, EventTarget, EventTeam, PlayerEventKind, Program, Value}; /// Render a deterministic, human-readable dump of the workshop program. pub(crate) fn dump(program: &Program) -> String { @@ -63,6 +63,19 @@ fn render_event(program: &Program, event: &Event, out: &mut String, level: usize match event { Event::Global => out.push_str(&format!("{}event Global\n", indent(level))), Event::EachPlayer => out.push_str(&format!("{}event EachPlayer\n", indent(level))), + Event::EachPlayerWithFilters { team, target } => out.push_str(&format!( + "{}event EachPlayer team={} target={}\n", + indent(level), + event_team_name(*team), + event_target_name(target) + )), + Event::Player { kind, team, target } => out.push_str(&format!( + "{}event {} team={} target={}\n", + indent(level), + player_event_name(*kind), + event_team_name(*team), + event_target_name(target) + )), Event::Subroutine(subroutine) => { let name = program .subroutines @@ -78,6 +91,36 @@ fn render_event(program: &Program, event: &Event, out: &mut String, level: usize } } +fn event_team_name(team: EventTeam) -> &'static str { + match team { + EventTeam::All => "All", + EventTeam::Team1 => "Team1", + EventTeam::Team2 => "Team2", + } +} + +fn event_target_name(target: &EventTarget) -> String { + match target { + EventTarget::All => "All".to_string(), + EventTarget::Slot(slot) => format!("Slot{slot}"), + EventTarget::Hero(hero) => format!("Hero({hero})"), + } +} + +fn player_event_name(kind: PlayerEventKind) -> &'static str { + match kind { + PlayerEventKind::DealtDamage => "PlayerDealtDamage", + PlayerEventKind::DealtFinalBlow => "PlayerDealtFinalBlow", + PlayerEventKind::DealtHealing => "PlayerDealtHealing", + PlayerEventKind::Died => "PlayerDied", + PlayerEventKind::EarnedElimination => "PlayerEarnedElimination", + PlayerEventKind::Joined => "PlayerJoined", + PlayerEventKind::Left => "PlayerLeft", + PlayerEventKind::ReceivedHealing => "PlayerReceivedHealing", + PlayerEventKind::TookDamage => "PlayerTookDamage", + } +} + fn render_action(program: &Program, id: super::ActionId, out: &mut String, level: usize) { let Some(action) = program.actions.get(id) else { out.push_str(&format!("{}\n", indent(level))); diff --git a/crates/workshop-rs/src/wir/mod.rs b/crates/workshop-rs/src/wir/mod.rs index 1eeaf7a..73e9d9d 100644 --- a/crates/workshop-rs/src/wir/mod.rs +++ b/crates/workshop-rs/src/wir/mod.rs @@ -123,6 +123,53 @@ pub struct Rule { pub actions: Vec, } +/// The team filter attached to a player-scoped Workshop event. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EventTeam { + All, + Team1, + Team2, +} + +/// The player filter attached to a player-scoped Workshop event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EventTarget { + All, + Slot(u8), + Hero(String), +} + +/// A non-ongoing player event identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlayerEventKind { + DealtDamage, + DealtFinalBlow, + DealtHealing, + Died, + EarnedElimination, + Joined, + Left, + ReceivedHealing, + TookDamage, +} + +impl PlayerEventKind { + /// The locale-independent catalog identity for this event. + pub fn catalog_id(self) -> &'static str { + match self { + PlayerEventKind::DealtDamage => "playerDealtDamage", + PlayerEventKind::DealtFinalBlow => "playerDealtFinalBlow", + PlayerEventKind::DealtHealing => "playerDealtHealing", + PlayerEventKind::Died => "playerDied", + PlayerEventKind::EarnedElimination => "playerEarnedElimination", + PlayerEventKind::Joined => "playerJoined", + PlayerEventKind::Left => "playerLeft", + PlayerEventKind::ReceivedHealing => "playerReceivedHealing", + PlayerEventKind::TookDamage => "playerTookDamage", + } + } +} + /// A workshop event. #[derive(Debug, Clone)] pub enum Event { @@ -130,6 +177,17 @@ pub enum Event { Global, /// `Ongoing - Each Player` (from `@Event eachPlayer`). EachPlayer, + /// `Ongoing - Each Player` with its canonical team/player filters. + EachPlayerWithFilters { + team: EventTeam, + target: EventTarget, + }, + /// A player-scoped Workshop event with canonical filters. + Player { + kind: PlayerEventKind, + team: EventTeam, + target: EventTarget, + }, /// A subroutine body (`def name():`), referencing the subroutine. Subroutine(SubroutineId), } diff --git a/crates/workshop-rs/src/wir/validate.rs b/crates/workshop-rs/src/wir/validate.rs index b54f3f9..4f86548 100644 --- a/crates/workshop-rs/src/wir/validate.rs +++ b/crates/workshop-rs/src/wir/validate.rs @@ -4,7 +4,7 @@ use crate::settings::{Settings as IrSettings, SettingsNode as IrSettingsNode}; use crate::source::Span; use crate::wir::error::IrError; -use super::{Action, Event, Program, Rule, Value}; +use super::{Action, Event, EventTarget, Program, Rule, Value}; /// Validate every ID resolves and every span is valid, returning the first /// violation. @@ -34,6 +34,19 @@ fn check_rule(program: &Program, rule: &Rule) -> Result<(), IrError> { return Err(dangling("subroutine", subroutine.index())); } } + let target = match &rule.event { + Event::EachPlayerWithFilters { target, .. } | Event::Player { target, .. } => Some(target), + _ => None, + }; + if let Some(EventTarget::Slot(slot)) = target { + if *slot > 11 { + return Err(IrError::Invalid { + code: "invalid-event-slot", + message: format!("event slot {slot} is outside the canonical 0..=11 range"), + span: rule.span, + }); + } + } for condition in &rule.conditions { check_value(program, *condition)?; } diff --git a/crates/workshop-rs/tests/catalog.rs b/crates/workshop-rs/tests/catalog.rs index 32afde3..4d2c595 100644 --- a/crates/workshop-rs/tests/catalog.rs +++ b/crates/workshop-rs/tests/catalog.rs @@ -28,7 +28,7 @@ fn builtin_catalog_loads_and_declares_en_us_and_zh_cn() { // zh-CN is the evidence-backed corpus locale; its exclusions remain // explicitly unmapped and therefore still fail closed. assert!(catalog.supports(&Locale::new("zh-CN"))); - assert_eq!(catalog.locale_coverage(&Locale::new("zh-CN")).mapped, 341); + assert_eq!(catalog.locale_coverage(&Locale::new("zh-CN")).mapped, 366); assert_eq!( catalog.locale_coverage(&Locale::new("zh-CN")).total, catalog.locale_coverage(&en()).total diff --git a/crates/workshop-rs/tests/corpus.rs b/crates/workshop-rs/tests/corpus.rs index ab00116..cc33d55 100644 --- a/crates/workshop-rs/tests/corpus.rs +++ b/crates/workshop-rs/tests/corpus.rs @@ -41,9 +41,9 @@ fn manifest_pins_the_export_and_exact_match_coverage() { manifest["source"]["commit"], "d854bf01fc7bbf3b2169f67408c07a8da8989ad6" ); - assert_eq!(manifest["coverage"]["total"]["matched"], 341); - assert_eq!(manifest["coverage"]["total"]["total"], 341); - assert_eq!(manifest["matches"].as_array().unwrap().len(), 341); + assert_eq!(manifest["coverage"]["total"]["matched"], 366); + assert_eq!(manifest["coverage"]["total"]["total"], 366); + assert_eq!(manifest["matches"].as_array().unwrap().len(), 366); assert_eq!(manifest["excluded"].as_array().unwrap().len(), 0); for (kind, id, source, zh_cn) in [ ( diff --git a/crates/workshop-rs/tests/identity.rs b/crates/workshop-rs/tests/identity.rs index 51728c0..4cf40e4 100644 --- a/crates/workshop-rs/tests/identity.rs +++ b/crates/workshop-rs/tests/identity.rs @@ -12,7 +12,7 @@ use workshop_rs::catalog::{Catalog, Locale}; /// (`workshop-catalog-gen build`) recomputes it and the pin is updated /// deliberately together with the data. const PINNED_CATALOG_DIGEST: &str = - "1f84204bfd6bfd0dcaabc275185d907764e03fae0fbde54f28e162aed9935961"; + "48b3a07188f7137a8a81e51fc33c06681da5ca5d30b79ee636b0147eb14dfc6d"; #[test] fn committed_catalog_digest_is_pinned() { @@ -71,11 +71,11 @@ fn locale_coverage_is_exact_and_primary_is_complete() { let en = catalog.locale_coverage(&Locale::new("en-US")); assert_eq!(en.mapped, en.total, "the primary locale is complete"); assert_eq!( - en.mapped, 341, - "declared en-US surface (165 entries + 176 members)" + en.mapped, 366, + "declared en-US surface (174 entries + 192 members)" ); let zh = catalog.locale_coverage(&Locale::new("zh-CN")); - assert_eq!(zh.mapped, 341, "zh-CN corpus coverage is pinned"); + assert_eq!(zh.mapped, 366, "zh-CN corpus coverage is pinned"); assert_eq!(zh.total, en.total); let all = catalog.locale_coverage_all(); assert_eq!(all.len(), 2); diff --git a/crates/workshop-rs/tests/locale.rs b/crates/workshop-rs/tests/locale.rs index dd209ab..3035220 100644 --- a/crates/workshop-rs/tests/locale.rs +++ b/crates/workshop-rs/tests/locale.rs @@ -3,7 +3,7 @@ //! target-locale mappings fail explicitly by default; fallback is opt-in and //! visible; settings follow the same contract. //! -//! The committed catalog declares an evidence-backed `zh-CN` corpus (341/341). +//! The committed catalog declares an evidence-backed `zh-CN` corpus (366/366). //! This suite pins both successful corpus conversion and the fail-explicit //! behavior for an unsupported undeclared target locale. diff --git a/crates/workshop-rs/tests/parser.rs b/crates/workshop-rs/tests/parser.rs index 8cfc451..1516d88 100644 --- a/crates/workshop-rs/tests/parser.rs +++ b/crates/workshop-rs/tests/parser.rs @@ -138,6 +138,8 @@ fn parsed_events_are_canonical() { .map(|rule| match &rule.event { wir::Event::Global => "global".to_string(), wir::Event::EachPlayer => "eachPlayer".to_string(), + wir::Event::EachPlayerWithFilters { .. } => "eachPlayer".to_string(), + wir::Event::Player { kind, .. } => kind.catalog_id().to_string(), wir::Event::Subroutine(subroutine) => format!( "subroutine:{}", program.subroutines.get(*subroutine).unwrap().name @@ -384,6 +386,14 @@ fn cross_domain_member_spelling_collisions_are_the_documented_inventory() { assert_eq!( collisions, vec![ + ( + "All".to_string(), + vec![ + "EventTeam".to_string(), + "EventPlayer".to_string(), + "Invis".to_string() + ] + ), ( "None".to_string(), vec![ @@ -394,11 +404,19 @@ fn cross_domain_member_spelling_collisions_are_the_documented_inventory() { ), ( "Team 1".to_string(), - vec!["Color".to_string(), "Team".to_string()] + vec![ + "Color".to_string(), + "Team".to_string(), + "EventTeam".to_string() + ] ), ( "Team 2".to_string(), - vec!["Color".to_string(), "Team".to_string()] + vec![ + "Color".to_string(), + "Team".to_string(), + "EventTeam".to_string() + ] ), ( "Up".to_string(), diff --git a/crates/workshop-rs/tests/rule_events.rs b/crates/workshop-rs/tests/rule_events.rs new file mode 100644 index 0000000..7ddff69 --- /dev/null +++ b/crates/workshop-rs/tests/rule_events.rs @@ -0,0 +1,374 @@ +use workshop_rs::catalog::{Catalog, Kind, Locale}; +use workshop_rs::wir::{self, EventTarget, EventTeam, PlayerEventKind}; +use workshop_rs::{emitter, parser, roundtrip, validate}; + +const RULE_EVENTS: &str = r#" +rule ("each player filtered") { + event { + Ongoing - Each Player; + Team 1; + Slot 3; + } + actions { + Disable Inspector Recording; + } +} + +rule ("dealt damage") { + event { + Player Dealt Damage; + All; + Ana; + } + actions { + Disable Inspector Recording; + } +} + +rule ("dealt final blow") { + event { + Player Dealt Final Blow; + Team 2; + All; + } + actions { + Disable Inspector Recording; + } +} + +rule ("dealt healing") { + event { + Player Dealt Healing; + All; + Slot 0; + } + actions { + Disable Inspector Recording; + } +} + +rule ("died") { + event { + Player Died; + Team 1; + All; + } + actions { + Disable Inspector Recording; + } +} + +rule ("earned elimination") { + event { + Player Earned Elimination; + All; + Slot 11; + } + actions { + Disable Inspector Recording; + } +} + +rule ("joined") { + event { + Player Joined Match; + Team 2; + Ana; + } + actions { + Disable Inspector Recording; + } +} + +rule ("left") { + event { + Player Left Match; + All; + All; + } + actions { + Disable Inspector Recording; + } +} + +rule ("received healing") { + event { + Player Received Healing; + Team 1; + Slot 4; + } + actions { + Disable Inspector Recording; + } +} + +rule ("took damage") { + event { + Player Took Damage; + All; + All; + } + actions { + Disable Inspector Recording; + } +} +"#; + +const LEGACY_EACH_PLAYER: &str = r#" +rule ("legacy each player") { + event { + Ongoing - Each Player; + } + actions { + Disable Inspector Recording; + } +} +"#; + +const EXPLICIT_EACH_PLAYER: &str = r#" +rule ("legacy each player") { + event { + Ongoing - Each Player; + All; + All; + } + actions { + Disable Inspector Recording; + } +} +"#; + +fn catalog() -> Catalog { + Catalog::builtin().expect("built-in catalog") +} + +fn en() -> Locale { + Locale::new("en-US") +} + +fn zh() -> Locale { + Locale::new("zh-CN") +} + +#[test] +fn all_player_events_parse_validate_emit_and_round_trip() { + let catalog = catalog(); + let program = parser::parse_with_context(RULE_EVENTS, &catalog, &en(), &catalog) + .expect("all canonical player events parse"); + program.validate().expect("event filters validate"); + validate::validate_canonical_ids(&program, &catalog).expect("event catalog ids validate"); + + let player_kinds: Vec<_> = program + .rules + .iter() + .filter_map(|rule| match &rule.event { + wir::Event::Player { kind, .. } => Some(*kind), + _ => None, + }) + .collect(); + assert_eq!( + player_kinds, + vec![ + PlayerEventKind::DealtDamage, + PlayerEventKind::DealtFinalBlow, + PlayerEventKind::DealtHealing, + PlayerEventKind::Died, + PlayerEventKind::EarnedElimination, + PlayerEventKind::Joined, + PlayerEventKind::Left, + PlayerEventKind::ReceivedHealing, + PlayerEventKind::TookDamage, + ] + ); + assert!(matches!( + program.rules.get(wir::RuleId::from_index(0)).unwrap().event, + wir::Event::EachPlayerWithFilters { + team: EventTeam::Team1, + target: EventTarget::Slot(3), + } + )); + + let en_text = emitter::emit(&program, &catalog, &en()).expect("en-US event emission"); + let reparsed_en = parser::parse_with_context(&en_text, &catalog, &en(), &catalog) + .expect("en-US event reparse"); + assert!(roundtrip::equivalent(&program, &reparsed_en)); + + let zh_text = emitter::emit(&program, &catalog, &zh()).expect("zh-CN event emission"); + assert!(zh_text.contains("持续 - 每名玩家")); + assert!(zh_text.contains("玩家造成伤害")); + assert!(zh_text.contains("队伍1;")); + assert!(zh_text.contains("栏位 3;")); + let reparsed_zh = parser::parse_with_context(&zh_text, &catalog, &zh(), &catalog) + .expect("zh-CN event reparse"); + assert!(roundtrip::equivalent(&program, &reparsed_zh)); +} + +#[test] +fn legacy_each_player_emits_all_filters_and_round_trips_in_supported_locales() { + let catalog = catalog(); + let legacy = parser::parse_with_context(LEGACY_EACH_PLAYER, &catalog, &en(), &catalog) + .expect("legacy parameterless eachPlayer parses"); + assert!(matches!( + legacy + .rules + .get(wir::RuleId::from_index(0)) + .map(|rule| &rule.event), + Some(wir::Event::EachPlayer) + )); + legacy.validate().expect("legacy event validates"); + validate::validate_canonical_ids(&legacy, &catalog).expect("legacy event catalog id validates"); + + let explicit = parser::parse_with_context(EXPLICIT_EACH_PLAYER, &catalog, &en(), &catalog) + .expect("explicit All/All eachPlayer parses"); + assert!(matches!( + explicit + .rules + .get(wir::RuleId::from_index(0)) + .map(|rule| &rule.event), + Some(wir::Event::EachPlayerWithFilters { + team: EventTeam::All, + target: EventTarget::All, + }) + )); + assert!(roundtrip::equivalent(&legacy, &explicit)); + + let en_text = emitter::emit(&legacy, &catalog, &en()).expect("legacy event emits in en-US"); + assert!(en_text.contains("Ongoing - Each Player;\n All;\n All;")); + let reparsed_en = parser::parse_with_context(&en_text, &catalog, &en(), &catalog) + .expect("legacy en-US emission reparses"); + assert!(roundtrip::equivalent(&legacy, &reparsed_en)); + + let zh_text = emitter::emit(&legacy, &catalog, &zh()).expect("legacy event emits in zh-CN"); + assert!(zh_text.contains("持续 - 每名玩家;\n 双方;\n 全部;")); + let reparsed_zh = parser::parse_with_context(&zh_text, &catalog, &zh(), &catalog) + .expect("legacy zh-CN emission reparses"); + assert!(roundtrip::equivalent(&legacy, &reparsed_zh)); +} + +#[test] +fn event_catalog_declares_parameter_and_filter_provenance_surface() { + let catalog = catalog(); + for event_id in [ + "eachPlayer", + "playerDealtDamage", + "playerDealtFinalBlow", + "playerDealtHealing", + "playerDied", + "playerEarnedElimination", + "playerJoined", + "playerLeft", + "playerReceivedHealing", + "playerTookDamage", + ] { + let event = catalog + .entry(Kind::Event, event_id) + .unwrap_or_else(|| panic!("{event_id} catalog entry")); + assert_eq!(event.params, vec!["Team", "Player"]); + assert_eq!( + event.param_domains, + vec![Some("EventTeam".to_string()), None] + ); + } + assert!(catalog.enum_domain("EventPlayer").is_some()); + assert!(catalog.enum_domain("Hero").is_some()); + assert_eq!( + catalog.enum_spelling("EventTeam", &zh(), "ALL"), + Some("双方") + ); + assert_eq!( + catalog.enum_spelling("EventPlayer", &zh(), "SLOT_3"), + Some("栏位 3") + ); +} + +#[test] +fn invalid_event_filter_is_rejected_and_invalid_slot_fails_wir_validation() { + let catalog = catalog(); + let invalid_text = RULE_EVENTS.replace("Slot 3;", "Unknown Player;"); + let error = parser::parse_with_context(&invalid_text, &catalog, &en(), &catalog) + .expect_err("unknown event player filter must fail"); + assert!(error.to_string().contains("unknown event player")); + + let mut program = wir::Program::default(); + program + .files + .push(workshop_rs::source::SourceFile::new("events.ws")); + program.rules.push(wir::Rule { + name: "invalid slot".into(), + span: None, + name_span: None, + disabled: false, + event: wir::Event::Player { + kind: PlayerEventKind::Died, + team: EventTeam::All, + target: EventTarget::Slot(12), + }, + conditions: vec![], + actions: vec![], + }); + let error = program + .validate() + .expect_err("slot 12 is outside Workshop range"); + assert_eq!(error.code(), "invalid-event-slot"); +} + +#[test] +fn player_event_without_catalog_filters_is_rejected() { + let catalog = catalog(); + let text = r#" +rule ("missing filters") { + event { + Player Died; + } +} +"#; + let error = parser::parse_with_context(text, &catalog, &en(), &catalog) + .expect_err("player events require both canonical filters"); + assert!( + error + .to_string() + .contains("requires team and player parameters") + ); + + let empty_parameter = r#" +rule ("empty filter") { + event { + Player Died; + ; + } +} +"#; + parser::parse_with_context(empty_parameter, &catalog, &en(), &catalog) + .expect_err("an empty player filter must not be silently discarded"); +} + +#[test] +fn canonical_validation_checks_rule_event_identities() { + let catalog = Catalog::load( + r#"{ + "schemaVersion": 1, + "locales": ["en-US"], + "target": {"game": "Overwatch", "format": "Workshop", "surface": "test"}, + "provenance": {"generator": "test", "generatorVersion": "1", "source": "test", "license": "MIT", "reviewed": true}, + "events": [{"id": "global", "aliases": {"en-US": "Ongoing - Global"}}] + }"#, + ) + .expect("minimal event catalog"); + let mut program = wir::Program::default(); + program.rules.push(wir::Rule { + name: "undeclared event".into(), + span: None, + name_span: None, + disabled: false, + event: wir::Event::Player { + kind: PlayerEventKind::Died, + team: EventTeam::All, + target: EventTarget::All, + }, + conditions: vec![], + actions: vec![], + }); + let error = validate::validate_canonical_ids(&program, &catalog) + .expect_err("event identities must be catalog-backed"); + assert!(error.to_string().contains("playerDied"), "{error}"); +} diff --git a/docs/provenance.md b/docs/provenance.md index 118878a..ad8f776 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -27,18 +27,19 @@ license, reviewed) is embedded in the dataset itself and surfaced by | en-US spellings of the M5 P0 surface | Transcribed from the Wright compatibility corpus workshop snapshots (pinned OverPy 9.7.10 en-US reference emissions) and the Wright M5 support matrix — classes 1/5 (reproducible behavior; upstream reference emission). | | `squareRoot`, receiver-call action/value spellings (`setMoveSpeed`, `isAlive`, …) | Pinned OverPy 9.7.10 en-US emission surface for the `.opy` forms (class 5). | | Chase family spellings (`Chase Global Variable Over Time`, `Chase Player Variable At Rate`, …) and their expected enum domains (`ChaseTimeReeval`, `ChaseRateReeval`) | Wright-authored OPY semantic manifest probe data (#109/#110), migrated into the canonical catalog so the standalone core resolves ambiguous bare members without any Wright tooling dependency (classes 1/5; canonical signature data is catalog-owned per ADR-0001 Decision 1). | +| Rule event identities and filters (`global`, `eachPlayer`, the nine player events, `subroutine`, `EventTeam`, and `EventPlayer`) | User-provided Workshop export at commit `d854bf01fc7bbf3b2169f67408c07a8da8989ad6` (`other.events`, `other.eventTeams`, `other.eventPlayers`, and computed `other.eventSlots`), cross-checked against documented raw Workshop event blocks; the canonical WIR keeps existing parameterless `eachPlayer` input and requires the evidenced team/player filters for other filtered events. The `Player` filter's accepted union (`EventPlayer` slot/all or a canonical `Hero`) is represented explicitly by `EventTarget` (classes 1/2). | | OSTW-exercised params/spellings and enum domains (CreateEffect, Workshop Setting, Hero/Map/Button/Icon/Operation/Rounding/InworldTextRev, …) | Pinned OSTW v3.4.0 reference probe emissions (P4/P5/P6/P6b) and the protect-ban entry-point reachable closure (class 5). | | Parameter metadata (`paramDomains`, `paramDefaults`) | Pinned-reference probe evidence (classes 1/5), never copied from upstream game data. | | Settings emission table (`src/settings/table.rs`) | Observed from pinned OverPy 9.7.10 en-US output of the oracle-success settings programs (classes 1/5); provenance is recorded in the table header itself. | ### Locale coverage -* `en-US` is the primary locale and is complete (341/341 canonical entries: - 165 builtins + 176 enum members). The committed catalog validates that the +* `en-US` is the primary locale and is complete (366/366 canonical entries: + 174 builtins + 192 enum members). The committed catalog validates that the primary locale is complete. -* `zh-CN` has an evidence-backed corpus of **341/341** canonical entries: - structural 11/11, actions 60/60, values 77/77, events 3/3, operators 14/14, - and enum members 176/176. The reproducible manifest is +* `zh-CN` has an evidence-backed corpus of **366/366** canonical entries: + structural 11/11, actions 60/60, values 77/77, events 12/12, operators 14/14, + and enum members 192/192. The reproducible manifest is `tools/corpus/zh-cn-corpus.json`; it records exact en-US spelling matches, every exclusion, and the export provenance. The source is the user-provided `workshop-data/workshop-data.json` export at commit diff --git a/tools/corpus/zh-cn-corpus.json b/tools/corpus/zh-cn-corpus.json index c6e235c..bcd9a98 100644 --- a/tools/corpus/zh-cn-corpus.json +++ b/tools/corpus/zh-cn-corpus.json @@ -5,12 +5,12 @@ "total": 60 }, "enums": { - "matched": 176, - "total": 176 + "matched": 192, + "total": 192 }, "events": { - "matched": 3, - "total": 3 + "matched": 12, + "total": 12 }, "operators": { "matched": 14, @@ -21,8 +21,8 @@ "total": 11 }, "total": { - "matched": 341, - "total": 341 + "matched": 366, + "total": 366 }, "values": { "matched": 77, @@ -988,6 +988,154 @@ ], "zh-CN": "可见,位置和半径" }, + { + "en-US": "All", + "id": "EventPlayer.ALL", + "kind": "enum member", + "sources": [ + "other.eventPlayers.all", + "data.other.eventPlayers.all" + ], + "zh-CN": "全部" + }, + { + "en-US": "Slot 0", + "id": "EventPlayer.SLOT_0", + "kind": "enum member", + "sources": [ + "data.other.eventSlots.0" + ], + "zh-CN": "栏位 0" + }, + { + "en-US": "Slot 1", + "id": "EventPlayer.SLOT_1", + "kind": "enum member", + "sources": [ + "data.other.eventSlots.1" + ], + "zh-CN": "栏位 1" + }, + { + "en-US": "Slot 10", + "id": "EventPlayer.SLOT_10", + "kind": "enum member", + "sources": [ + "data.other.eventSlots.10" + ], + "zh-CN": "栏位 10" + }, + { + "en-US": "Slot 11", + "id": "EventPlayer.SLOT_11", + "kind": "enum member", + "sources": [ + "data.other.eventSlots.11" + ], + "zh-CN": "栏位 11" + }, + { + "en-US": "Slot 2", + "id": "EventPlayer.SLOT_2", + "kind": "enum member", + "sources": [ + "data.other.eventSlots.2" + ], + "zh-CN": "栏位 2" + }, + { + "en-US": "Slot 3", + "id": "EventPlayer.SLOT_3", + "kind": "enum member", + "sources": [ + "data.other.eventSlots.3" + ], + "zh-CN": "栏位 3" + }, + { + "en-US": "Slot 4", + "id": "EventPlayer.SLOT_4", + "kind": "enum member", + "sources": [ + "data.other.eventSlots.4" + ], + "zh-CN": "栏位 4" + }, + { + "en-US": "Slot 5", + "id": "EventPlayer.SLOT_5", + "kind": "enum member", + "sources": [ + "data.other.eventSlots.5" + ], + "zh-CN": "栏位 5" + }, + { + "en-US": "Slot 6", + "id": "EventPlayer.SLOT_6", + "kind": "enum member", + "sources": [ + "data.other.eventSlots.6" + ], + "zh-CN": "栏位 6" + }, + { + "en-US": "Slot 7", + "id": "EventPlayer.SLOT_7", + "kind": "enum member", + "sources": [ + "data.other.eventSlots.7" + ], + "zh-CN": "栏位 7" + }, + { + "en-US": "Slot 8", + "id": "EventPlayer.SLOT_8", + "kind": "enum member", + "sources": [ + "data.other.eventSlots.8" + ], + "zh-CN": "栏位 8" + }, + { + "en-US": "Slot 9", + "id": "EventPlayer.SLOT_9", + "kind": "enum member", + "sources": [ + "data.other.eventSlots.9" + ], + "zh-CN": "栏位 9" + }, + { + "en-US": "All", + "id": "EventTeam.ALL", + "kind": "enum member", + "sources": [ + "other.eventTeams.all", + "data.other.eventTeams.all" + ], + "zh-CN": "双方" + }, + { + "en-US": "Team 1", + "id": "EventTeam.TEAM_1", + "kind": "enum member", + "sources": [ + "other.eventTeams.1", + "data.other.eventTeams.1" + ], + "zh-CN": "队伍1" + }, + { + "en-US": "Team 2", + "id": "EventTeam.TEAM_2", + "kind": "enum member", + "sources": [ + "other.eventTeams.2", + "data.other.eventTeams.2" + ], + "zh-CN": "队伍2" + }, { "en-US": "Ana", "id": "Hero.ANA", @@ -2249,6 +2397,87 @@ ], "zh-CN": "持续 - 全局" }, + { + "en-US": "Player Dealt Damage", + "id": "playerDealtDamage", + "kind": "event", + "sources": [ + "other.events.playerDealtDamage" + ], + "zh-CN": "玩家造成伤害" + }, + { + "en-US": "Player Dealt Final Blow", + "id": "playerDealtFinalBlow", + "kind": "event", + "sources": [ + "other.events.playerDealtFinalBlow" + ], + "zh-CN": "玩家造成最后一击" + }, + { + "en-US": "Player Dealt Healing", + "id": "playerDealtHealing", + "kind": "event", + "sources": [ + "other.events.playerDealtHealing" + ], + "zh-CN": "玩家造成治疗" + }, + { + "en-US": "Player Died", + "id": "playerDied", + "kind": "event", + "sources": [ + "other.events.playerDied" + ], + "zh-CN": "玩家阵亡" + }, + { + "en-US": "Player Earned Elimination", + "id": "playerEarnedElimination", + "kind": "event", + "sources": [ + "other.events.playerEarnedElimination" + ], + "zh-CN": "玩家参与消灭" + }, + { + "en-US": "Player Joined Match", + "id": "playerJoined", + "kind": "event", + "sources": [ + "other.events.playerJoined" + ], + "zh-CN": "玩家加入比赛" + }, + { + "en-US": "Player Left Match", + "id": "playerLeft", + "kind": "event", + "sources": [ + "other.events.playerLeft" + ], + "zh-CN": "玩家离开比赛" + }, + { + "en-US": "Player Received Healing", + "id": "playerReceivedHealing", + "kind": "event", + "sources": [ + "other.events.playerReceivedHealing" + ], + "zh-CN": "玩家受到治疗" + }, + { + "en-US": "Player Took Damage", + "id": "playerTookDamage", + "kind": "event", + "sources": [ + "other.events.playerTookDamage" + ], + "zh-CN": "玩家受到伤害" + }, { "en-US": "Subroutine", "id": "subroutine", @@ -3184,7 +3413,7 @@ "zh-CN": "地图矢量" } ], - "method": "exact en-US spelling match between the catalog aliases and the export's localized index (actions/values/events/operators/constants/maps/heroes), plus confirmed legacy identity/GUID mappings for global stop-chasing, force hero/throttle, Set Player Allowed Heroes, and bare comparison-symbol entries; zh-CN is taken from the same export entry; entries without an accepted match, or whose export candidates disagree on zh-CN, are excluded with a recorded reason and keep fail-explicit behavior (ADR-0001 Decision 7)", + "method": "exact en-US spelling match between the catalog aliases and the export's localized index (actions/values/events/operators/constants/event filters/maps/heroes), plus confirmed legacy identity/GUID mappings for global stop-chasing, force hero/throttle, Set Player Allowed Heroes, and bare comparison-symbol entries; zh-CN is taken from the same export entry; entries without an accepted match, or whose export candidates disagree on zh-CN, are excluded with a recorded reason and keep fail-explicit behavior (ADR-0001 Decision 7)", "schemaVersion": 1, "source": { "commit": "d854bf01fc7bbf3b2169f67408c07a8da8989ad6",