diff --git a/Cargo.toml b/Cargo.toml index ab74ae8e3..542d34776 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,12 @@ homepage = "https://github.com/LyraCoreProject/LyraCore" # either repository — so the whole workspace is publish-gated here rather than crate by crate. publish = false +# A function above the threshold in `clippy.toml` no longer fits in one head. Existing warnings +# mark untouched long functions; changed code stays below the 200-line ceiling. Lower it as those +# functions shrink. +[workspace.lints.clippy] +too_many_lines = "warn" + [workspace.dependencies] lyracore-shared = { path = "crates/lyracore-shared" } lyracore-package-delta = { path = "crates/lyracore-package-delta" } diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 000000000..04a404c3f --- /dev/null +++ b/clippy.toml @@ -0,0 +1,5 @@ +# `too_many_lines` (warn, workspace-wide) holds a ceiling rather than describing today's code: the +# default 100 fires on 140 pre-existing functions and buries every real finding. 200 is the size the +# split of the four longest production functions targeted, so only untouched code exceeds it. Lower +# it as those shrink. +too-many-lines-threshold = 200 diff --git a/crates/lyracore-package-delta/Cargo.toml b/crates/lyracore-package-delta/Cargo.toml index 560e71c42..c288dee23 100644 --- a/crates/lyracore-package-delta/Cargo.toml +++ b/crates/lyracore-package-delta/Cargo.toml @@ -23,3 +23,6 @@ serde_json = "1" [[bin]] name = "lyracore-delta-check" path = "src/bin/lyracore-delta-check.rs" + +[lints] +workspace = true diff --git a/crates/lyracore-shared/Cargo.toml b/crates/lyracore-shared/Cargo.toml index 252de8ec1..96a6cfd38 100644 --- a/crates/lyracore-shared/Cargo.toml +++ b/crates/lyracore-shared/Cargo.toml @@ -12,3 +12,6 @@ description = "LyraCore: protocol-agnostic constants and helpers shared by the S # Intentionally NO external dependencies: this crate is consumed both by the wasm module # and the native gateway, so it must stay portable and trivially compilable. [dependencies] + +[lints] +workspace = true diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml index 80183720b..d5b6a9402 100644 --- a/gateway/Cargo.toml +++ b/gateway/Cargo.toml @@ -71,3 +71,6 @@ dhat-heap = ["dep:dhat"] # drift tripwire. [target.'cfg(target_os = "linux")'.dev-dependencies] lyracore-module = { path = "../module" } + +[lints] +workspace = true diff --git a/importer/Cargo.toml b/importer/Cargo.toml index a97f64df7..02def9237 100644 --- a/importer/Cargo.toml +++ b/importer/Cargo.toml @@ -39,3 +39,6 @@ wow_dbc = { version = "0.3", features = ["vanilla"] } # Those types live in wow_world_base; wow_dbc 0.3 pins it to =0.2.0, so we depend on the SAME version # (same type instance) to name the variants by hand. Pure parser, ships ZERO Blizzard data. wow_world_base = { version = "=0.2.0", features = ["vanilla"] } + +[lints] +workspace = true diff --git a/importer/src/eventai.rs b/importer/src/eventai.rs index 0a19cd3e8..aab610b86 100644 --- a/importer/src/eventai.rs +++ b/importer/src/eventai.rs @@ -4570,6 +4570,135 @@ fn map_action( normalizations: mapped.normalizations, }); } + match kind { + ACTION_TEXT | ACTION_TEXT_NEW => { + map_speech_action(action, kind, rule_id, slot, broadcasts, relays) + } + ACTION_EMOTE => Ok(NativeAction { + encoded: format!("emote:{}:self", action[1]), + raw_kind: kind, + raw_target: None, + raw_cast_flags: None, + threat_percent: None, + dependencies: Vec::new(), + texts: Vec::new(), + summon_entry: None, + summon_location: None, + normalizations: Vec::new(), + }), + ACTION_RANDOM_EMOTE => Ok(NativeAction { + encoded: format!( + "random-emote:{}.{}.{}", + action[1] as i32, action[2] as i32, action[3] as i32 + ), + raw_kind: kind, + raw_target: None, + raw_cast_flags: None, + threat_percent: None, + dependencies: Vec::new(), + texts: Vec::new(), + summon_entry: None, + summon_location: None, + normalizations: Vec::new(), + }), + ACTION_CAST | ACTION_THREAT_SINGLE | ACTION_THREAT_ALL_PCT | ACTION_REMOVE_AURA => { + map_combat_action(action, kind, rule_id, slot, relays) + } + ACTION_SET_PHASE + | ACTION_INCREMENT_PHASE + | ACTION_RANDOM_PHASE + | ACTION_RANDOM_PHASE_RANGE => map_phase_action(action, kind), + ACTION_FLEE_FOR_ASSIST => Ok(NativeAction { + encoded: "flee".to_string(), + raw_kind: kind, + raw_target: None, + raw_cast_flags: None, + threat_percent: None, + dependencies: Vec::new(), + texts: Vec::new(), + summon_entry: None, + summon_location: None, + normalizations: Vec::new(), + }), + ACTION_CALL_FOR_HELP => Ok(NativeAction { + encoded: format!("help:{}", action[1]), + raw_kind: kind, + raw_target: None, + raw_cast_flags: None, + threat_percent: None, + dependencies: Vec::new(), + texts: Vec::new(), + summon_entry: None, + summon_location: None, + normalizations: Vec::new(), + }), + ACTION_SPAWN | ACTION_SUMMON_ID => map_summon_action( + action, + kind, + rule_id, + slot, + summon_locations, + importable_templates, + ), + ACTION_COMBAT_MOVEMENT + | ACTION_EVADE + | ACTION_RANGED_MOVEMENT + | ACTION_CHANGE_MOVEMENT + | ACTION_PAUSE_WAYPOINTS + | ACTION_SET_RANGED_MODE + | ACTION_SET_WALK + | ACTION_SET_FACING + | ACTION_SET_IMMOBILIZED + | ACTION_SET_FOLLOW_MOVEMENT + | ACTION_RETREAT => map_movement_action(action, kind), + ACTION_DIE + | ACTION_FORCE_DESPAWN + | ACTION_THROW_AI_EVENT + | ACTION_SET_STAND_STATE + | ACTION_SET_REACT_STATE + | ACTION_DESPAWN_GUARDIANS + | ACTION_SET_DEATH_PREVENTION => { + map_state_action(action, kind, rule_id, slot, importable_templates) + } + ACTION_SET_INSTANCE_DATA | ACTION_START_RELAY => map_encounter_action( + action, + kind, + raw_subject, + rule_id, + slot, + source_maps, + relays, + ), + ACTION_QUEST_EVENT | ACTION_KILLED_MONSTER => map_quest_action( + action, + kind, + rule_id, + slot, + quest_entries, + importable_templates, + ), + value => unsupported_action(value), + } +} + +/// The source action fell through every parameter shape this loader accepts. +fn unsupported_action(kind: u32) -> Result> { + Err(vec![MappingFailure::source( + "action", + u64::from(kind), + "unsupported_action", + )]) +} + +/// Speech: the legacy multi-text action and its newer single-text/template twin. +fn map_speech_action( + action: [u32; 4], + kind: u32, + rule_id: u64, + slot: usize, + broadcasts: &BTreeMap, + relays: &RelaySource, +) -> Result> { match kind { ACTION_TEXT => { let texts = action[1..] @@ -4729,33 +4858,19 @@ fn map_action( normalizations: Vec::new(), }) } - ACTION_EMOTE => Ok(NativeAction { - encoded: format!("emote:{}:self", action[1]), - raw_kind: kind, - raw_target: None, - raw_cast_flags: None, - threat_percent: None, - dependencies: Vec::new(), - texts: Vec::new(), - summon_entry: None, - summon_location: None, - normalizations: Vec::new(), - }), - ACTION_RANDOM_EMOTE => Ok(NativeAction { - encoded: format!( - "random-emote:{}.{}.{}", - action[1] as i32, action[2] as i32, action[3] as i32 - ), - raw_kind: kind, - raw_target: None, - raw_cast_flags: None, - threat_percent: None, - dependencies: Vec::new(), - texts: Vec::new(), - summon_entry: None, - summon_location: None, - normalizations: Vec::new(), - }), + value => unsupported_action(value), + } +} + +/// Combat: casting a spell, moving threat, and stripping an aura. +fn map_combat_action( + action: [u32; 4], + kind: u32, + rule_id: u64, + slot: usize, + relays: &RelaySource, +) -> Result> { + match kind { ACTION_CAST => { if action[1] == 0 { return Err(vec![MappingFailure::source( @@ -4832,6 +4947,45 @@ fn map_action( normalizations: Vec::new(), }) } + ACTION_REMOVE_AURA if action[2] != 0 && action[3] == 0 => { + let target = map_target(action[1]).map_err(|failure| vec![failure])?; + if !relays.spell_ids.contains(&action[2]) { + return Err(vec![MappingFailure::dependency( + "spell_template", + u64::from(action[2]), + "missing", + format!( + "rule:{rule_id} -> action:{slot} -> spell_template:{}", + action[2] + ), + )]); + } + Ok(NativeAction { + encoded: format!("remove-aura:{}:{target}", action[2]), + raw_kind: kind, + raw_target: Some(action[1]), + raw_cast_flags: None, + threat_percent: None, + dependencies: vec![Dependency { + kind: "spell_template", + path: format!( + "rule:{rule_id} -> action:{slot} -> spell_template:{}", + action[2] + ), + }], + texts: Vec::new(), + summon_entry: None, + summon_location: None, + normalizations: Vec::new(), + }) + } + value => unsupported_action(value), + } +} + +/// Phase: the four ways a rule moves its subject's phase mask. +fn map_phase_action(action: [u32; 4], kind: u32) -> Result> { + match kind { ACTION_SET_PHASE if action[1] < 32 => Ok(NativeAction { encoded: format!("phase:{}", action[1]), raw_kind: kind, @@ -4895,30 +5049,28 @@ fn map_action( normalizations, }) } - ACTION_FLEE_FOR_ASSIST => Ok(NativeAction { - encoded: "flee".to_string(), - raw_kind: kind, - raw_target: None, - raw_cast_flags: None, - threat_percent: None, - dependencies: Vec::new(), - texts: Vec::new(), - summon_entry: None, - summon_location: None, - normalizations: Vec::new(), - }), - ACTION_CALL_FOR_HELP => Ok(NativeAction { - encoded: format!("help:{}", action[1]), - raw_kind: kind, - raw_target: None, - raw_cast_flags: None, - threat_percent: None, - dependencies: Vec::new(), - texts: Vec::new(), - summon_entry: None, - summon_location: None, - normalizations: Vec::new(), - }), + ACTION_SET_PHASE + | ACTION_INCREMENT_PHASE + | ACTION_RANDOM_PHASE + | ACTION_RANDOM_PHASE_RANGE => Err(vec![MappingFailure::source( + "action", + u64::from(action[1]), + format!("invalid_phase_{}", action[1]), + )]), + value => unsupported_action(value), + } +} + +/// Summons: the placement-free spawn and the `creature_ai_summons` location-keyed summon. +fn map_summon_action( + action: [u32; 4], + kind: u32, + rule_id: u64, + slot: usize, + summon_locations: &BTreeMap, + importable_templates: &HashSet, +) -> Result> { + match kind { ACTION_SPAWN => { let entry = u64::from(action[1]); let mut failures = Vec::new(); @@ -4968,38 +5120,77 @@ fn map_action( normalizations: Vec::new(), }) } - ACTION_REMOVE_AURA if action[2] != 0 && action[3] == 0 => { - let target = map_target(action[1]).map_err(|failure| vec![failure])?; - if !relays.spell_ids.contains(&action[2]) { - return Err(vec![MappingFailure::dependency( - "spell_template", - u64::from(action[2]), + ACTION_SUMMON_ID => { + let entry = action[1] as u64; + let mut failures = Vec::new(); + if !importable_templates.contains(&entry) { + failures.push(MappingFailure::dependency( + "summon_creature", + entry, + "missing", + format!("rule:{rule_id} -> action:{slot} -> creature_template:{entry}"), + )); + } + if !summon_locations.contains_key(&action[3]) { + failures.push(MappingFailure::dependency( + "summon_location", + u64::from(action[3]), "missing", format!( - "rule:{rule_id} -> action:{slot} -> spell_template:{}", - action[2] + "rule:{rule_id} -> action:{slot} -> creature_ai_summons:{}", + action[3] ), - )]); + )); + } + let target = match map_target(action[2]) { + Ok(target) => Some(target), + Err(failure) => { + failures.push(failure); + None + } + }; + if !failures.is_empty() { + return Err(failures); } Ok(NativeAction { - encoded: format!("remove-aura:{}:{target}", action[2]), + encoded: format!( + "summon:{}:{}:{}", + action[1], + action[3], + target.expect("a summon without mapping failures has a target") + ), raw_kind: kind, - raw_target: Some(action[1]), + raw_target: Some(action[2]), raw_cast_flags: None, threat_percent: None, - dependencies: vec![Dependency { - kind: "spell_template", - path: format!( - "rule:{rule_id} -> action:{slot} -> spell_template:{}", - action[2] - ), - }], texts: Vec::new(), - summon_entry: None, - summon_location: None, + summon_entry: Some(entry), + summon_location: Some(action[3]), + dependencies: vec![ + Dependency { + kind: "summon_creature", + path: format!( + "rule:{rule_id} -> action:{slot} -> creature_template:{entry}" + ), + }, + Dependency { + kind: "summon_location", + path: format!( + "rule:{rule_id} -> action:{slot} -> creature_ai_summons:{}", + action[3] + ), + }, + ], normalizations: Vec::new(), }) } + value => unsupported_action(value), + } +} + +/// Movement: every action that changes how the subject moves or faces. +fn map_movement_action(action: [u32; 4], kind: u32) -> Result> { + match kind { ACTION_COMBAT_MOVEMENT if action[2] == 0 && action[3] == 0 => Ok(movement_action( kind, format!("combat-movement:{}", action[1] != 0), @@ -5095,70 +5286,19 @@ fn map_action( u64::from(kind), "unsupported_retreat", )]), - ACTION_SUMMON_ID => { - let entry = action[1] as u64; - let mut failures = Vec::new(); - if !importable_templates.contains(&entry) { - failures.push(MappingFailure::dependency( - "summon_creature", - entry, - "missing", - format!("rule:{rule_id} -> action:{slot} -> creature_template:{entry}"), - )); - } - if !summon_locations.contains_key(&action[3]) { - failures.push(MappingFailure::dependency( - "summon_location", - u64::from(action[3]), - "missing", - format!( - "rule:{rule_id} -> action:{slot} -> creature_ai_summons:{}", - action[3] - ), - )); - } - let target = match map_target(action[2]) { - Ok(target) => Some(target), - Err(failure) => { - failures.push(failure); - None - } - }; - if !failures.is_empty() { - return Err(failures); - } - Ok(NativeAction { - encoded: format!( - "summon:{}:{}:{}", - action[1], - action[3], - target.expect("a summon without mapping failures has a target") - ), - raw_kind: kind, - raw_target: Some(action[2]), - raw_cast_flags: None, - threat_percent: None, - texts: Vec::new(), - summon_entry: Some(entry), - summon_location: Some(action[3]), - dependencies: vec![ - Dependency { - kind: "summon_creature", - path: format!( - "rule:{rule_id} -> action:{slot} -> creature_template:{entry}" - ), - }, - Dependency { - kind: "summon_location", - path: format!( - "rule:{rule_id} -> action:{slot} -> creature_ai_summons:{}", - action[3] - ), - }, - ], - normalizations: Vec::new(), - }) - } + value => unsupported_action(value), + } +} + +/// Unit state: dying, despawning, stand/react state, and the AI event throw. +fn map_state_action( + action: [u32; 4], + kind: u32, + rule_id: u64, + slot: usize, + importable_templates: &HashSet, +) -> Result> { + match kind { ACTION_DIE if action[1..] == [0, 0, 0] => Ok(NativeAction { encoded: "force-death".to_string(), raw_kind: kind, @@ -5281,6 +5421,31 @@ fn map_action( normalizations: Vec::new(), }) } + ACTION_DIE => Err(vec![MappingFailure::source( + "action", + ACTION_DIE as u64, + "invalid_force_death_parameters", + )]), + ACTION_SET_DEATH_PREVENTION => Err(vec![MappingFailure::source( + "death_prevention_state", + u64::from(action[1]), + "invalid_death_prevention_state", + )]), + value => unsupported_action(value), + } +} + +/// Encounter Signals and Relay Definitions: the two actions that cross an Encounter Binding. +fn map_encounter_action( + action: [u32; 4], + kind: u32, + raw_subject: i32, + rule_id: u64, + slot: usize, + source_maps: &HashMap>, + relays: &RelaySource, +) -> Result> { + match kind { ACTION_SET_INSTANCE_DATA => { let entry = u32::try_from(raw_subject).map_err(|_| { vec![MappingFailure::dependency( @@ -5384,16 +5549,20 @@ fn map_action( normalizations, }) } - ACTION_DIE => Err(vec![MappingFailure::source( - "action", - ACTION_DIE as u64, - "invalid_force_death_parameters", - )]), - ACTION_SET_DEATH_PREVENTION => Err(vec![MappingFailure::source( - "death_prevention_state", - u64::from(action[1]), - "invalid_death_prevention_state", - )]), + value => unsupported_action(value), + } +} + +/// Quest credit: the explicit quest event and the kill credit that stands in for one. +fn map_quest_action( + action: [u32; 4], + kind: u32, + rule_id: u64, + slot: usize, + quest_entries: &HashSet, + importable_templates: &HashSet, +) -> Result> { + match kind { ACTION_QUEST_EVENT => { let recipient: &'static str = match (action[2], action[3]) { (TARGET_ACTION_INVOKER, 0) => Ok("selected-character"), @@ -5454,19 +5623,7 @@ fn map_action( } kill_credit_action(action, rule_id, slot, importable_templates) } - ACTION_SET_PHASE - | ACTION_INCREMENT_PHASE - | ACTION_RANDOM_PHASE - | ACTION_RANDOM_PHASE_RANGE => Err(vec![MappingFailure::source( - "action", - u64::from(action[1]), - format!("invalid_phase_{}", action[1]), - )]), - value => Err(vec![MappingFailure::source( - "action", - value as u64, - "unsupported_action", - )]), + value => unsupported_action(value), } } diff --git a/importer/src/main.rs b/importer/src/main.rs index afed5b578..95da7b4a5 100644 --- a/importer/src/main.rs +++ b/importer/src/main.rs @@ -3711,8 +3711,6 @@ fn template_is_importable(row: &[String]) -> bool { field(row, ct::UNIT_FLAGS).parse::().unwrap_or(0) & UNIT_FLAG_NOT_SELECTABLE == 0 } -// ETL row shape: one `game_creature_spawn` row per tuple, in the packed-payload column order. -#[allow(clippy::type_complexity)] /// Build the full `--dump` ETL plan (work-item 216's `--family` split lives entirely in here, via /// `family_active` gates around each family's DELETE+INSERT block): parses the cmangos dump, filters /// the content slice, maps every family's rows, and returns the ready-to-apply plan. Pure w.r.t. the @@ -3740,6 +3738,256 @@ fn build_dump_plan( eprintln!(" whole map: {map}"); } + let spawns = collect_creature_spawns(dump, &scope); + let ScopeEntries { + entries, + eventai, + eventai_manifest, + } = resolve_scope_entries(dump, args, &scope, &spawns)?; + let CreatureTemplates { + templates, + creature_loot_ids, + creature_pickpocket_ids, + creature_skin_ids, + excluded_entries, + class_trainer_count, + } = build_creature_templates(dump, &entries, display_scales); + let (creature_casts, creature_rotation_rows) = build_creature_cast_rows(dump, &entries); + let SpawnPayload { + guid_of, + packed_spawns, + excluded_count, + } = build_packed_spawn_payload(&spawns, &excluded_entries); + let waypoints = build_waypoint_rows(dump, &spawns, &guid_of); + + eprintln!( + "mapped: {} templates ({class_trainer_count} class-gated trainers), {} spawns ({} excluded), {} waypoints", + templates.len(), + spawns.len() - excluded_count, + excluded_count, + waypoints.len() + ); + + let go_spawns = collect_gameobject_spawns(dump, &scope); + let (go_template_rows, go_trap_rows, chest_loot_ids_used) = + gameobject_template_rows(&go_spawns); + let trainer_rows = build_trainer_spell_rows(dump, &entries, profession_tier_values); + + // Quests: map quest_template + the giver-relation tables (creature AND gameobject) for quests given + // by an in-slice giver. Built BEFORE the item ETL so the reward item entries can be folded into the + // item-template load (a turn-in's reward grant joins game_item_template). + // Obtainability gate (ZERO-STUCK-QUESTS): the set of items that drop from in-slice creatures, so + // build_quests only emits a COLLECT objective for a ReqItem that is obtainable (drops OR is the + // quest's SrcItem) — else the quest auto-completes instead of soft-locking. + // 042: obtainable = creature drops ∪ in-slice GO (chest) loot ∪ in-slice vendor stock. The old + // creature-drops-only gate skipped GO/vendor-sourced ReqItems entirely → an EMPTY objective set → + // quest_is_complete() == true on accept → free turn-in reward (q3904 grapes / q5545 wood via GO + // loot, q3861 kodo feed via vendor — all three obtainable in-slice now, verified live). + let mut obtainable_items = creature_drop_item_set(dump, &creature_loot_ids); + obtainable_items.extend(gameobject_loot_item_set(dump, &go_spawns.used_go)); + obtainable_items.extend(vendor_stock_item_set(dump, &entries)); + let quests = build_quests( + dump, + &entries, + &go_spawns.used_go, + &go_spawns.goober_entries, + &obtainable_items, + ); + eprintln!( + "mapped: {} quests, {} text, {} objectives, {} cast objectives, {} reward items, {} choice rewards, \ + {} creature giver relations, {} gameobject giver relations, {} chained (next_quest_id>0) [V], \ + {} timed (limit_time>0) [V]", + quests.templates.len(), quests.texts.len(), quests.objectives.len(), + quests.cast_objectives.len(), quests.reward_items.len(), quests.reward_choices.len(), quests.relations.len(), + quests.go_relations.len(), quests.chained_count, quests.timed_count, + ); + + // P4: real item templates + creature loot for the imported creatures (clear+reload, like above). + // game_item_template is wiped+reloaded with the loot-referenced set ∪ the starter loadout ∪ quest + // reward items ∪ quest COLLECT (ReqItem) items (so the login grant keeps a real weapon, turn-ins can + // deliver, AND every collect-quest item has a template); game_creature_loot is fully importer-owned. + let extra_item_entries: std::collections::HashSet = quests + .reward_item_entries + .iter() + .chain(quests.req_item_entries.iter()) + .copied() + .collect(); + let (item_rows, loot_rows, vendor_rows) = + build_items_and_loot(dump, &creature_loot_ids, &entries, &extra_item_entries); + eprintln!( + "mapped: {} item_templates, {} creature_loot rows, {} npc_vendor rows ({} creatures with loot)", + item_rows.len(), loot_rows.len(), vendor_rows.len(), creature_loot_ids.len() + ); + let LootFamilyRows { + pickpocket_rows, + skinning_rows, + gameobject_loot_rows, + fishing_rows, + } = build_loot_family_rows( + dump, + &creature_pickpocket_ids, + &creature_skin_ids, + &chest_loot_ids_used, + ); + // Gossip menus + NPC text + menu options (work-item 217): map each in-box gossip NPC's + // creature_entry to the npc_text id shown in SMSG_GOSSIP_MESSAGE, import the resolved greeting + // strings (all 8 weighted slots, not just the first), and import its clickable menu options + // (vendor/innkeeper/trainer/plain-gossip, quest-status conditions folded). Four-table clear+reload. + let gossip = build_gossip_sql(dump, &entries); + + let content = MappedContent { + templates: &templates, + waypoints: &waypoints, + item_rows: &item_rows, + loot_rows: &loot_rows, + vendor_rows: &vendor_rows, + pickpocket_rows: &pickpocket_rows, + skinning_rows: &skinning_rows, + gameobject_loot_rows: &gameobject_loot_rows, + fishing_rows: &fishing_rows, + gossip: &gossip, + quests: &quests, + go_template_rows: &go_template_rows, + go_trap_rows: &go_trap_rows, + trainer_rows: &trainer_rows, + creature_casts: &creature_casts, + creature_rotation_rows: &creature_rotation_rows, + eventai: &eventai, + }; + // 5) static content (no Timestamp columns) → plain SQL clear+reload, family by family, in the + // order the tables must land. The spawns + live-entity reset are NOT here — they go through + // the `import_creature_spawns` reducer (step 6), which clears every CREATURE entity + spawn + // and loads the new spawns with a valid `ctx.timestamp`. + let mut stmts: Vec = Vec::new(); + push_world_content_statements(args, content, &mut stmts); + push_quest_and_gameobject_statements(args, content, &mut stmts); + push_creature_behaviour_statements(args, content, &mut stmts); + let (globals_row_count, spellmeta_row_count) = push_global_statements(args, dump, &mut stmts)?; + + // The spawn payload can hold ~2k rows for a full zone — more than one `spacetime call` string arg + // takes — so split it into batches. Batch 0 goes to `import_creature_spawns` (clears the live + // roster + threat ONCE, then loads); every later batch goes to `import_creature_spawns_append` + // (load only), so the clear happens exactly once and every batch survives. + let spawn_rows: Vec<&str> = packed_spawns.split(';').filter(|r| !r.is_empty()).collect(); + let batches: Vec = spawn_rows + .chunks(SPAWN_BATCH) + .map(|c| c.join(";")) + .collect(); + let go_batches: Vec = go_spawns + .go_packed_rows + .chunks(SPAWN_BATCH) + .map(|c| c.join(";")) + .collect(); + + let stamps = family_stamps(args, content, globals_row_count, spellmeta_row_count); + + let eventai_definition_count = eventai.definition_count(); + let eventai_instruction_count = eventai.instruction_count(); + let eventai_definition_batches = eventai.definition_batches; + let eventai_relay_definition_count = eventai.relay_definition_rows.len() as u64; + let eventai_relay_definition_batches = eventai.relay_definition_batches; + + Ok(DumpPlan { + stmts, + spawn_row_count: spawn_rows.len(), + go_row_count: go_spawns.go_packed_rows.len(), + spawn_batches: batches, + go_batches, + eventai_definition_batches, + eventai_relay_definition_batches, + eventai_definition_count, + eventai_relay_definition_count, + eventai_instruction_count, + eventai_manifest, + stamps, + }) +} + +/// ETL row shape: one `game_creature_spawn` row per tuple, in the packed-payload column order — +/// db_guid, entry, x, y, z, orientation, movement_type, respawn_secs, map. +type CreatureSpawnRow = (u64, u64, f64, f64, f64, f64, u8, u32, i64); + +/// The creature template entries this run imports, with the EventAI plan that grew them. +struct ScopeEntries { + entries: std::collections::HashSet, + eventai: eventai::EventAiPlan, + /// The rendered Compatibility Manifest — `None` when the creature-ai family is not part of the run. + eventai_manifest: Option, +} + +/// The `game_creature_template` rows plus the per-entry loot ids and exclusions every later family reads. +struct CreatureTemplates { + templates: Vec, + creature_loot_ids: std::collections::HashMap, + creature_pickpocket_ids: std::collections::HashMap, + creature_skin_ids: std::collections::HashMap, + excluded_entries: std::collections::HashSet, + class_trainer_count: usize, +} + +/// The packed `import_creature_spawns` payload and the db_guid → world guid map the waypoints need. +struct SpawnPayload { + guid_of: std::collections::HashMap, + packed_spawns: String, + excluded_count: usize, +} + +/// One imported `gameobject_template`, reduced to the columns the per-type row builder needs. +struct GoMeta { + stored_type: u8, + disp: u32, + name: String, + data0: u32, + data1: u32, + trap_spell_id: u32, + trap_cooldown_secs: u32, + size: f32, +} + +/// The gameobject spawns the World Import Scope keeps, with the template metadata they resolved against. +struct GameobjectSpawns { + go_meta: std::collections::HashMap, + dropped_type25: Vec, + goober_entries: std::collections::HashSet, + go_packed_rows: Vec, + used_go: std::collections::HashSet, +} + +/// The four loot families that hang off an imported creature or chest. +struct LootFamilyRows { + pickpocket_rows: Vec, + skinning_rows: Vec, + gameobject_loot_rows: Vec, + fishing_rows: Vec, +} + +/// Every mapped row set the static-content SQL blocks and the provenance stamps read. Borrowed, so +/// the push helpers below stay a view over `build_dump_plan`'s own values. +#[derive(Clone, Copy)] +struct MappedContent<'a> { + templates: &'a [String], + waypoints: &'a [String], + item_rows: &'a [String], + loot_rows: &'a [String], + vendor_rows: &'a [String], + pickpocket_rows: &'a [String], + skinning_rows: &'a [String], + gameobject_loot_rows: &'a [String], + fishing_rows: &'a [String], + gossip: &'a GossipEtl, + quests: &'a QuestEtl, + go_template_rows: &'a [String], + go_trap_rows: &'a [String], + trainer_rows: &'a [String], + creature_casts: &'a [String], + creature_rotation_rows: &'a [String], + eventai: &'a eventai::EventAiPlan, +} + +/// Every creature spawn the World Import Scope keeps: the cmangos `creature` rows inside the scope, +/// with id=0 pool slots resolved through `creature_spawn_entry` and `spawn_group` MaxCount caps +/// applied. +fn collect_creature_spawns(dump: &str, scope: &WorldImportScope) -> Vec { // 0b) creature_spawn_entry: resolves id=0 pool slots to concrete creature_template entries. // cmangos places id=0 in `creature` for spawn-pool points; the real entry is in creature_spawn_entry. // Each guid may map to multiple entries (random pick at runtime). We pick the first for determinism. @@ -3760,7 +4008,7 @@ fn build_dump_plan( // 1) spawns in range → the content slice. // db_guid, entry, x,y,z,o, movement_type, respawn_secs, map (map END-appended, work-item 226 — // an --include-map row carries its OWN map into the packed payload, not args.map). - let mut spawns: Vec<(u64, u64, f64, f64, f64, f64, u8, u32, i64)> = Vec::new(); + let mut spawns: Vec = Vec::new(); let mut pool_resolved = 0u32; let mut pool_skipped = 0u32; for row in parse_table(dump, "creature") { @@ -3772,7 +4020,7 @@ fn build_dump_plan( let raw_entry: u64 = field(&row, cr::ID).parse().unwrap_or(0); // A forced entry bypasses bounded geometry but not the scope's map fence. Keeping the spawn // makes its entry flow into every creature-scoped family below. - if !creature_row_kept_in_scope(&scope, map, x, y, z, raw_entry) { + if !creature_row_kept_in_scope(scope, map, x, y, z, raw_entry) { continue; } // cmangos uses id=0 for random spawn-POOL slots; the concrete entry is in creature_spawn_entry. @@ -3871,6 +4119,18 @@ fn build_dump_plan( } } + spawns +} + +/// The creature template entries this run imports, and the EventAI plan built against them. Accepted +/// EventAI summons force their summoned templates into the entry set, to a fixpoint, so a summoned-only +/// creature still gets a template. +fn resolve_scope_entries( + dump: &str, + args: &Args, + scope: &WorldImportScope, + spawns: &[CreatureSpawnRow], +) -> Result { let mut entries: std::collections::HashSet = spawns.iter().map(|s| s.1).collect(); // PET/SUMMON templates (Tier 3b): a summoned pet (Warlock's Imp = entry 416) has NO world spawn, so it // is never in the geographic slice above — yet its game_creature_template row MUST exist for the @@ -3953,6 +4213,20 @@ fn build_dump_plan( entries.len() ); + Ok(ScopeEntries { + entries, + eventai, + eventai_manifest, + }) +} + +/// The `game_creature_template` rows for the scope's entries. Also collects the loot ids and the +/// excluded (`UNIT_FLAG_NOT_SELECTABLE`) entries the spawn payload and the loot families read. +fn build_creature_templates( + dump: &str, + entries: &std::collections::HashSet, + display_scales: &Option>, +) -> CreatureTemplates { // 2) templates for those entries. let mut templates: Vec = Vec::new(); // entry → LootId for the imported creatures; drives the item + loot ETL below (0 = no loot table). @@ -4021,7 +4295,7 @@ fn build_dump_plan( } }; let faction_template: u32 = field(&row, ct::FACTION).parse().unwrap_or(0); - // Per-creature melee damage (parity #7): cmangos MinMeleeDmg/MaxMeleeDmg are floats; round to int + // Per-creature melee damage: cmangos MinMeleeDmg/MaxMeleeDmg are floats; round to int // (vanilla creature swings are whole-number ranges in practice). 0 stays 0 → the module's // swing_range_ctx falls back to the flat CREATURE_MELEE range for that creature. let dmg_min: u32 = field(&row, ct::MIN_MELEE_DMG) @@ -4075,6 +4349,21 @@ fn build_dump_plan( )); } + CreatureTemplates { + templates, + creature_loot_ids, + creature_pickpocket_ids, + creature_skin_ids, + excluded_entries, + class_trainer_count, + } +} + +/// The legacy single-spell cast rows and the multi-spell rotation rows for the scope's casters. +fn build_creature_cast_rows( + dump: &str, + entries: &std::collections::HashSet, +) -> (Vec, Vec) { // 2b) caster-mob cast rows (game_creature_cast, LEGACY) + rotation rows (game_creature_spell, rank 20). // // LEGACY (game_creature_cast): one spell per entry (spell1), used by pass_cast as a fallback ONLY when @@ -4161,6 +4450,14 @@ fn build_dump_plan( .count(), ); + (creature_casts, creature_rotation_rows) +} + +/// The packed spawn payload the `import_creature_spawns` reducer loads. +fn build_packed_spawn_payload( + spawns: &[CreatureSpawnRow], + excluded_entries: &std::collections::HashSet, +) -> SpawnPayload { // 3) spawn payload for the `import_creature_spawns` reducer — NOT SQL. `game_creature_spawn` has // Timestamp columns (respawn_at/despawn_at) and SpacetimeDB 2.5 SQL has no Timestamp literal, // so spawns load through a reducer (which stamps `ctx.timestamp`). Rows `;`-separated, fields @@ -4200,6 +4497,20 @@ fn build_dump_plan( .collect::>() .join(";"); + SpawnPayload { + guid_of, + packed_spawns, + excluded_count, + } +} + +/// The `game_creature_waypoint` rows for the imported spawns: guid-keyed routes first, then the +/// entry-keyed template routes expanded onto every waypoint-typed spawn without one. +fn build_waypoint_rows( + dump: &str, + spawns: &[CreatureSpawnRow], + guid_of: &std::collections::HashMap, +) -> Vec { // 4) waypoints for the imported spawns (id assigned by a counter — clear+reload is idempotent). let mut waypoints: Vec = Vec::new(); let mut wp_id: u64 = 1; @@ -4255,7 +4566,7 @@ fn build_dump_plan( } let mut tmpl_spawns = 0usize; if !tmpl_paths.is_empty() { - for (db_guid, entry, _x, _y, _z, _o, mt, _rs, _map) in &spawns { + for (db_guid, entry, _x, _y, _z, _o, mt, _rs, _map) in spawns { if *mt != 2 || direct_wp_guids.contains(db_guid) { continue; // not waypoint-typed, or already routed by direct rows } @@ -4278,35 +4589,12 @@ fn build_dump_plan( } } - eprintln!( - "mapped: {} templates ({class_trainer_count} class-gated trainers), {} spawns ({} excluded), {} waypoints", - templates.len(), - spawns.len() - excluded_count, - excluded_count, - waypoints.len() - ); - - // 5) static content (no Timestamp columns) → plain SQL clear+reload. The spawns + live-entity - // reset are NOT here — they go through the `import_creature_spawns` reducer (step 6), which - // clears every CREATURE entity + spawn and loads the new spawns with a valid `ctx.timestamp`. - // So we only clear/reload templates + waypoints; the reducer owns the rest. - // family gate (work-item 216): `--family creatures` reloads this block (+ the spawn/waypoint - // reducer batches below) alone; every other family's block skips it. `--family` absent → every - // `family_active` call below is unconditionally true, so the full run is byte-identical to - // pre-216 behavior — see the full-run parity fixture test. - let mut stmts: Vec = Vec::new(); - if family_active(args, "creatures") { - stmts.push("DELETE FROM game_creature_waypoint WHERE id > 0".into()); - stmts.push("DELETE FROM game_creature_template WHERE entry > 0".into()); - push_insert(&mut stmts, "game_creature_template", "entry,name,subname,display_id,level,health,faction_template,npc_flags,unit_flags,creature_type,creature_family,type_flags,rank,scale,base_attack_time_ms,money_min,money_max,max_level,max_level_health,aggro_range,damage_min,damage_max,armor,pickpocket_loot_id,skin_loot_id,trainer_type,trainer_class", &templates); - push_insert( - &mut stmts, - "game_creature_waypoint", - "id,creature_guid,x,y,z", - &waypoints, - ); - } + waypoints +} +/// The gameobject spawns the World Import Scope keeps, packed for the `import_gameobjects` reducer, +/// with the classified template metadata they resolved against. +fn collect_gameobject_spawns(dump: &str, scope: &WorldImportScope) -> GameobjectSpawns { // Gameobjects (work-item 211 widened import): EVERY in-scope cmangos `gameobject_template` type // now imports (template + spawn) — previously only an allowlisted subset (GOOBER/curated GATHER/ // curated CHEST/QUESTGIVER) did. `classify_go_type` decides the stored `type_id` (or drops the row @@ -4314,16 +4602,6 @@ fn build_dump_plan( // load via the import_gameobjects reducer (game_gameobject has a Timestamp); the packed spawn row // carries an `initial_state` field (work-item 211) so a DOOR/BUTTON can spawn already-open. let go_tmpls = parse_table(dump, "gameobject_template"); - struct GoMeta { - stored_type: u8, - disp: u32, - name: String, - data0: u32, - data1: u32, - trap_spell_id: u32, - trap_cooldown_secs: u32, - size: f32, - } let mut dropped_type25: Vec = Vec::new(); let go_meta: std::collections::HashMap = go_tmpls .iter() @@ -4400,7 +4678,7 @@ fn build_dump_plan( } let db_guid: u64 = field(&row, go::GUID).parse().unwrap_or(0); let o: f64 = field(&row, go::O).parse().unwrap_or(0.0); - // The spawn quaternion (#515) — carried verbatim; 0,0,0,0 (a row with no rotation columns, + // The spawn quaternion — carried verbatim; 0,0,0,0 (a row with no rotation columns, // or a genuinely-identity spawn) is a valid packed value, not a parse failure, so the // fallback is `0.0` exactly like every other numeric field this loop parses. let rot0: f64 = field(&row, go::ROT0).parse().unwrap_or(0.0); @@ -4434,6 +4712,26 @@ fn build_dump_plan( let count = go_whole_map_counts.get(&map).copied().unwrap_or(0); eprintln!("filter: whole map {map} → {count} gameobject spawns"); } + + GameobjectSpawns { + go_meta, + dropped_type25, + goober_entries, + go_packed_rows, + used_go, + } +} + +/// The `game_gameobject_template` + `game_gameobject_trap` rows for the spawned gameobjects, and the +/// CHEST loot ids they reference. +fn gameobject_template_rows(spawns: &GameobjectSpawns) -> (Vec, Vec, Vec) { + let GameobjectSpawns { + go_meta, + dropped_type25, + go_packed_rows, + used_go, + .. + } = spawns; // Template rows for the GOs actually spawned, split by type (`go_template_row`) — a SQL INSERT must // name EVERY column (#[default] is NOT applied on INSERT, only on migration — slice-5 data-loss // lesson). Also builds the per-type coverage histogram + the CHEST lootId set (widened to EVERY @@ -4508,6 +4806,16 @@ fn build_dump_plan( ); } + (go_template_rows, go_trap_rows, chest_loot_ids_used) +} + +/// The `game_trainer_spell` rows for the scope's trainers: every cmangos offering, plus the +/// synthesized profession learn-rows. +fn build_trainer_spell_rows( + dump: &str, + entries: &std::collections::HashSet, + profession_tier_values: &Option>, +) -> Vec { // Trainer spell lists: npc_trainer rows for the SPAWNED trainer creatures → game_trainer_spell // (cmangos npc_trainer is all DIRECT entries — no template indirection). Lights up the trainer system. // Two kinds of row are emitted: @@ -4583,64 +4891,17 @@ fn build_dump_plan( trainer_rows.len(), trainer_lines.len(), ); - // Quests: map quest_template + the giver-relation tables (creature AND gameobject) for quests given - // by an in-slice giver. Built BEFORE the item ETL so the reward item entries can be folded into the - // item-template load (a turn-in's reward grant joins game_item_template). - // Obtainability gate (ZERO-STUCK-QUESTS): the set of items that drop from in-slice creatures, so - // build_quests only emits a COLLECT objective for a ReqItem that is obtainable (drops OR is the - // quest's SrcItem) — else the quest auto-completes instead of soft-locking. - // 042: obtainable = creature drops ∪ in-slice GO (chest) loot ∪ in-slice vendor stock. The old - // creature-drops-only gate skipped GO/vendor-sourced ReqItems entirely → an EMPTY objective set → - // quest_is_complete() == true on accept → free turn-in reward (q3904 grapes / q5545 wood via GO - // loot, q3861 kodo feed via vendor — all three obtainable in-slice now, verified live). - let mut obtainable_items = creature_drop_item_set(dump, &creature_loot_ids); - obtainable_items.extend(gameobject_loot_item_set(dump, &used_go)); - obtainable_items.extend(vendor_stock_item_set(dump, &entries)); - let quests = build_quests(dump, &entries, &used_go, &goober_entries, &obtainable_items); - eprintln!( - "mapped: {} quests, {} text, {} objectives, {} cast objectives, {} reward items, {} choice rewards, \ - {} creature giver relations, {} gameobject giver relations, {} chained (next_quest_id>0) [V], \ - {} timed (limit_time>0) [V]", - quests.templates.len(), quests.texts.len(), quests.objectives.len(), - quests.cast_objectives.len(), quests.reward_items.len(), quests.reward_choices.len(), quests.relations.len(), - quests.go_relations.len(), quests.chained_count, quests.timed_count, - ); - - // P4: real item templates + creature loot for the imported creatures (clear+reload, like above). - // game_item_template is wiped+reloaded with the loot-referenced set ∪ the starter loadout ∪ quest - // reward items ∪ quest COLLECT (ReqItem) items (so the login grant keeps a real weapon, turn-ins can - // deliver, AND every collect-quest item has a template); game_creature_loot is fully importer-owned. - let extra_item_entries: std::collections::HashSet = quests - .reward_item_entries - .iter() - .chain(quests.req_item_entries.iter()) - .copied() - .collect(); - let (item_rows, loot_rows, vendor_rows) = - build_items_and_loot(dump, &creature_loot_ids, &entries, &extra_item_entries); - eprintln!( - "mapped: {} item_templates, {} creature_loot rows, {} npc_vendor rows ({} creatures with loot)", - item_rows.len(), loot_rows.len(), vendor_rows.len(), creature_loot_ids.len() - ); - if family_active(args, "items") { - stmts.push("DELETE FROM game_item_template WHERE entry > 0".into()); - stmts.push("DELETE FROM game_creature_loot WHERE id > 0".into()); - stmts.push("DELETE FROM game_npc_vendor WHERE id > 0".into()); - push_insert(&mut stmts, "game_item_template", "entry,class,subclass,name,display_id,quality,inventory_type,item_level,required_level,max_durability,buy_price,sell_price,max_stack,damage_min,damage_max,delay_ms,stat_strength,stat_agility,stat_stamina,stat_intellect,stat_spirit,stat_crit,stat_hit,stat_armor,block_value,restores_power,spellid_1,spelltrigger_1,spellid_2,spelltrigger_2,container_slots,sheath,bonding,holy_res,fire_res,nature_res,frost_res,shadow_res,arcane_res,spellid_3,spelltrigger_3,spellid_4,spelltrigger_4,spellid_5,spelltrigger_5,required_skill,required_skill_rank,required_reputation_faction,required_reputation_rank,max_count,item_flags,page_text,start_quest,bag_family,buy_count,food_type,allowed_class,allowed_race", &item_rows); - push_insert( - &mut stmts, - "game_creature_loot", - "id,creature_entry,item_entry,chance_bp,count,group_id,quest_only", - &loot_rows, - ); - push_insert( - &mut stmts, - "game_npc_vendor", - "id,creature_entry,item_entry,slot,max_count", - &vendor_rows, - ); - } + trainer_rows +} +/// The pickpocket / skinning / gameobject(chest) / fishing loot rows, all off one parse of the +/// shared reference-pool table. +fn build_loot_family_rows( + dump: &str, + creature_pickpocket_ids: &std::collections::HashMap, + creature_skin_ids: &std::collections::HashMap, + chest_loot_ids_used: &[u32], +) -> LootFamilyRows { // Loot-family completeness (work-item 210): pickpocket / skinning / gameobject(chest) / fishing. // `refs` (the reference_loot_template pool map) is parsed ONCE here and shared by all four — // `parse_creature_drops` above already parsed its own copy for the creature family; a second parse @@ -4691,7 +4952,7 @@ fn build_dump_plan( // above). Scoped to the lootIds actually used by CHESTs spawned in this box (`chest_loot_ids_used`) // — GATHER nodes never touch this table (they stay on the hardcoded GATHER_NODES grant). let drops_by_goloot = parse_loot_family(dump, "gameobject_loot_template", &refs); - let mut go_loot_ids_used: Vec = chest_loot_ids_used.clone(); + let mut go_loot_ids_used: Vec = chest_loot_ids_used.to_vec(); go_loot_ids_used.sort_unstable(); go_loot_ids_used.dedup(); let mut gameobject_loot_rows: Vec = Vec::new(); @@ -4743,42 +5004,92 @@ fn build_dump_plan( fishing_rows.len(), drops_by_zone.len(), ); + + LootFamilyRows { + pickpocket_rows, + skinning_rows, + gameobject_loot_rows, + fishing_rows, + } +} + +/// The creature / item / loot / gossip clear+reload blocks, each behind its own family gate. +fn push_world_content_statements(args: &Args, content: MappedContent, stmts: &mut Vec) { + let MappedContent { + templates, + waypoints, + item_rows, + loot_rows, + vendor_rows, + pickpocket_rows, + skinning_rows, + gameobject_loot_rows, + fishing_rows, + gossip, + .. + } = content; + if family_active(args, "creatures") { + stmts.push("DELETE FROM game_creature_waypoint WHERE id > 0".into()); + stmts.push("DELETE FROM game_creature_template WHERE entry > 0".into()); + push_insert(stmts, "game_creature_template", "entry,name,subname,display_id,level,health,faction_template,npc_flags,unit_flags,creature_type,creature_family,type_flags,rank,scale,base_attack_time_ms,money_min,money_max,max_level,max_level_health,aggro_range,damage_min,damage_max,armor,pickpocket_loot_id,skin_loot_id,trainer_type,trainer_class", templates); + push_insert( + stmts, + "game_creature_waypoint", + "id,creature_guid,x,y,z", + waypoints, + ); + } + + if family_active(args, "items") { + stmts.push("DELETE FROM game_item_template WHERE entry > 0".into()); + stmts.push("DELETE FROM game_creature_loot WHERE id > 0".into()); + stmts.push("DELETE FROM game_npc_vendor WHERE id > 0".into()); + push_insert(stmts, "game_item_template", "entry,class,subclass,name,display_id,quality,inventory_type,item_level,required_level,max_durability,buy_price,sell_price,max_stack,damage_min,damage_max,delay_ms,stat_strength,stat_agility,stat_stamina,stat_intellect,stat_spirit,stat_crit,stat_hit,stat_armor,block_value,restores_power,spellid_1,spelltrigger_1,spellid_2,spelltrigger_2,container_slots,sheath,bonding,holy_res,fire_res,nature_res,frost_res,shadow_res,arcane_res,spellid_3,spelltrigger_3,spellid_4,spelltrigger_4,spellid_5,spelltrigger_5,required_skill,required_skill_rank,required_reputation_faction,required_reputation_rank,max_count,item_flags,page_text,start_quest,bag_family,buy_count,food_type,allowed_class,allowed_race", item_rows); + push_insert( + stmts, + "game_creature_loot", + "id,creature_entry,item_entry,chance_bp,count,group_id,quest_only", + loot_rows, + ); + push_insert( + stmts, + "game_npc_vendor", + "id,creature_entry,item_entry,slot,max_count", + vendor_rows, + ); + } + if family_active(args, "loot") { stmts.push("DELETE FROM game_pickpocket_loot WHERE id > 0".into()); stmts.push("DELETE FROM game_skinning_loot WHERE id > 0".into()); stmts.push("DELETE FROM game_gameobject_loot WHERE id > 0".into()); stmts.push("DELETE FROM game_fishing_loot WHERE id > 0".into()); push_insert( - &mut stmts, + stmts, "game_pickpocket_loot", "id,creature_entry,item_entry,chance_bp,count,group_id,quest_only", - &pickpocket_rows, + pickpocket_rows, ); push_insert( - &mut stmts, + stmts, "game_skinning_loot", "id,skin_loot_id,item_entry,chance_bp,count,group_id", - &skinning_rows, + skinning_rows, ); push_insert( - &mut stmts, + stmts, "game_gameobject_loot", "id,loot_id,item_entry,chance_bp,count,group_id,quest_only", - &gameobject_loot_rows, + gameobject_loot_rows, ); push_insert( - &mut stmts, + stmts, "game_fishing_loot", "id,zone_id,item_entry,chance_bp,count,group_id", - &fishing_rows, + fishing_rows, ); } - // Gossip menus + NPC text + menu options (work-item 217): map each in-box gossip NPC's - // creature_entry to the npc_text id shown in SMSG_GOSSIP_MESSAGE, import the resolved greeting - // strings (all 8 weighted slots, not just the first), and import its clickable menu options - // (vendor/innkeeper/trainer/plain-gossip, quest-status conditions folded). Four-table clear+reload. - let gossip = build_gossip_sql(dump, &entries); if family_active(args, "gossip") { // RESERVED RANGES (283): the ETL owns only the low id bands (it emits dense low ids); a // PACKAGE that mints its own gossip (e.g. the dynamic-events guard) uses the high bands — @@ -4791,38 +5102,52 @@ fn build_dump_plan( stmts.push("DELETE FROM game_npc_text_slot WHERE id >= 0 AND id < 50000".into()); stmts.push("DELETE FROM game_gossip_option WHERE row_id >= 0 AND row_id < 50000".into()); push_insert( - &mut stmts, + stmts, "game_gossip_menu", "entry,text_id", &gossip.menu_rows, ); push_insert( - &mut stmts, + stmts, "game_npc_text", "text_id,text", &gossip.npc_text_rows, ); push_insert( - &mut stmts, + stmts, "game_npc_text_slot", "id,text_id,slot_index,text_male,text_female,probability", &gossip.npc_text_slot_rows, ); - push_insert(&mut stmts, "game_gossip_option", "row_id,entry,option_index,icon,text,action,action_menu_id,cond_type,cond_value1,cond_value2", &gossip.option_rows); + push_insert(stmts, "game_gossip_option", "row_id,entry,option_index,icon,text,action,action_menu_id,cond_type,cond_value1,cond_value2", &gossip.option_rows); push_insert( - &mut stmts, + stmts, "game_gossip_menu_profile", "menu_id,text_id", &gossip.profile_rows, ); push_insert( - &mut stmts, + stmts, "game_gossip_menu_profile_option", "row_id,menu_id,option_index,icon,text,action,action_menu_id,cond_type,cond_value1,cond_value2", &gossip.profile_option_rows, ); } +} +/// The quest / gameobject-template / trainer clear+reload blocks, each behind its own family gate. +fn push_quest_and_gameobject_statements( + args: &Args, + content: MappedContent, + stmts: &mut Vec, +) { + let MappedContent { + quests, + go_template_rows, + go_trap_rows, + trainer_rows, + .. + } = content; // Quests: clear+reload the static quest tables (header / body text / objectives / cast objectives / // reward items / creature giver relations / gameobject giver relations). game_character_quest // (per-player progress) is born in the accept reducer, never here. @@ -4835,45 +5160,45 @@ fn build_dump_plan( stmts.push("DELETE FROM game_quest_reward_choice WHERE id > 0".into()); stmts.push("DELETE FROM game_creature_quest WHERE id > 0".into()); stmts.push("DELETE FROM game_gameobject_quest WHERE id > 0".into()); - push_insert(&mut stmts, "game_quest_template", "entry,min_level,quest_level,title,reward_money,reward_xp,prev_quest_id,required_races,required_classes,zone_or_sort,rew_rep_faction_1,rew_rep_value_1,rew_rep_faction_2,rew_rep_value_2,src_item,src_item_count,repeatable,next_quest_id,limit_time,reward_money_max_level", &quests.templates); + push_insert(stmts, "game_quest_template", "entry,min_level,quest_level,title,reward_money,reward_xp,prev_quest_id,required_races,required_classes,zone_or_sort,rew_rep_faction_1,rew_rep_value_1,rew_rep_faction_2,rew_rep_value_2,src_item,src_item_count,repeatable,next_quest_id,limit_time,reward_money_max_level", &quests.templates); push_insert( - &mut stmts, + stmts, "game_quest_text", "quest_entry,details,objectives,offer_reward_text,request_items_text", &quests.texts, ); push_insert( - &mut stmts, + stmts, "game_quest_objective", "id,quest_entry,obj_index,kind,target_entry,required_count", &quests.objectives, ); push_insert( - &mut stmts, + stmts, "game_quest_cast_objective", "id,quest_entry,obj_index,spell_id", &quests.cast_objectives, ); push_insert( - &mut stmts, + stmts, "game_quest_reward_item", "id,quest_entry,item_entry,count", &quests.reward_items, ); push_insert( - &mut stmts, + stmts, "game_quest_reward_choice", "id,quest_entry,choice_index,item_entry,count", &quests.reward_choices, ); push_insert( - &mut stmts, + stmts, "game_creature_quest", "id,creature_entry,quest_entry,role", &quests.relations, ); push_insert( - &mut stmts, + stmts, "game_gameobject_quest", "id,go_entry,quest_entry,role", &quests.go_relations, @@ -4885,34 +5210,47 @@ fn build_dump_plan( if family_active(args, "gameobjects") { stmts.push("DELETE FROM game_gameobject_trap WHERE entry > 0".into()); stmts.push("DELETE FROM game_gameobject_template WHERE entry > 0".into()); - push_insert(&mut stmts, "game_gameobject_template", "entry,type_id,display_id,name,data0,data1,gather_skill_line,respawn_secs,gather_gray,lock_id,size", &go_template_rows); + push_insert(stmts, "game_gameobject_template", "entry,type_id,display_id,name,data0,data1,gather_skill_line,respawn_secs,gather_gray,lock_id,size", go_template_rows); push_insert( - &mut stmts, + stmts, "game_gameobject_trap", "entry,spell_id,cooldown_secs", - &go_trap_rows, + go_trap_rows, ); } // Trainer spell lists (family "trainers"). if family_active(args, "trainers") { stmts.push("DELETE FROM game_trainer_spell WHERE id > 0".into()); push_insert( - &mut stmts, + stmts, "game_trainer_spell", "id,trainer_entry,spell_id,cost,required_level,learn_skill_line,learn_skill_cap", - &trainer_rows, + trainer_rows, ); } +} +/// The caster-mob rotation and EventAI clear+reload blocks, each behind its own family gate. +fn push_creature_behaviour_statements( + args: &Args, + content: MappedContent, + stmts: &mut Vec, +) { + let MappedContent { + creature_casts, + creature_rotation_rows, + eventai, + .. + } = content; // Caster-mob cast + rotation rows (family "casts"). The primary-key is creature_entry, so clear ALL // rows then reload the in-box set (importer-owned). if family_active(args, "casts") { stmts.push("DELETE FROM game_creature_cast WHERE creature_entry > 0".into()); push_insert( - &mut stmts, + stmts, "game_creature_cast", "creature_entry,spell_id", - &creature_casts, + creature_casts, ); // Rotation rows (game_creature_spell, rank 20): multi-spell casters use these instead of (or in // addition to) game_creature_cast. Clear ALL rows first (same pattern as game_creature_cast: the @@ -4920,10 +5258,10 @@ fn build_dump_plan( // creature_rotation_rows assembled above. stmts.push("DELETE FROM game_creature_spell WHERE id > 0".into()); push_insert( - &mut stmts, + stmts, "game_creature_spell", "id,creature_entry,spell_id,priority,condition,condition_value", - &creature_rotation_rows, + creature_rotation_rows, ); } @@ -4932,25 +5270,28 @@ fn build_dump_plan( stmts.push("DELETE FROM game_creature_ai_summon WHERE id > 0".into()); stmts.push("DELETE FROM game_quest_event_requirement WHERE id > 0".into()); push_insert( - &mut stmts, + stmts, "game_creature_ai_broadcast_text", "id,male_text,female_text,chat_type,language_id,emote_delay_1_ms,emote_id_1,emote_delay_2_ms,emote_id_2,emote_delay_3_ms,emote_id_3", &eventai.broadcast_rows, ); push_insert( - &mut stmts, + stmts, "game_creature_ai_summon", "id,x,y,z,orientation,lifetime_ms", &eventai.summon_rows, ); push_insert( - &mut stmts, + stmts, "game_quest_event_requirement", "id,quest_entry", &eventai.quest_event_requirement_rows(), ); } +} +/// The two global (not box-scoped) families. Returns their row counts for the provenance stamps. +fn push_global_statements(args: &Args, dump: &str, stmts: &mut Vec) -> Result<(u64, u64)> { // P3: the class/level stat curve + P1: all-(race,class) start positions + 209: graveyard-zone // links (all global, plain SQL — not box-scoped) + work-item 212's createinfo spells/actions/items // + work-item 225's areatrigger-teleport dungeon portals. @@ -5014,20 +5355,34 @@ fn build_dump_plan( stmts.extend(spell_proc_event_sql); } - // The spawn payload can hold ~2k rows for a full zone — more than one `spacetime call` string arg - // takes — so split it into batches. Batch 0 goes to `import_creature_spawns` (clears the live - // roster + threat ONCE, then loads); every later batch goes to `import_creature_spawns_append` - // (load only), so the clear happens exactly once and every batch survives. - let spawn_rows: Vec<&str> = packed_spawns.split(';').filter(|r| !r.is_empty()).collect(); - let batches: Vec = spawn_rows - .chunks(SPAWN_BATCH) - .map(|c| c.join(";")) - .collect(); - let go_batches: Vec = go_packed_rows - .chunks(SPAWN_BATCH) - .map(|c| c.join(";")) - .collect(); + Ok((globals_row_count, spellmeta_row_count)) +} +/// One provenance stamp per family that actually had its block pushed — mirroring exactly the +/// `family_active` gates the push helpers used. +fn family_stamps( + args: &Args, + content: MappedContent, + globals_row_count: u64, + spellmeta_row_count: u64, +) -> Vec<(&'static str, u64)> { + let MappedContent { + templates, + item_rows, + pickpocket_rows, + skinning_rows, + gameobject_loot_rows, + fishing_rows, + gossip, + quests, + go_template_rows, + go_trap_rows, + trainer_rows, + creature_casts, + creature_rotation_rows, + eventai, + .. + } = content; // Provenance stamps (work-item 216): one entry per family that ACTUALLY had its block pushed // above — mirrors exactly the `family_active` gates this function used, so a stamp only ever // corresponds to data this run's plan actually carries. @@ -5086,26 +5441,7 @@ fn build_dump_plan( stamps.push(("spellmeta", spellmeta_row_count)); } - let eventai_definition_count = eventai.definition_count(); - let eventai_instruction_count = eventai.instruction_count(); - let eventai_definition_batches = eventai.definition_batches; - let eventai_relay_definition_count = eventai.relay_definition_rows.len() as u64; - let eventai_relay_definition_batches = eventai.relay_definition_batches; - - Ok(DumpPlan { - stmts, - spawn_row_count: spawn_rows.len(), - go_row_count: go_packed_rows.len(), - spawn_batches: batches, - go_batches, - eventai_definition_batches, - eventai_relay_definition_batches, - eventai_definition_count, - eventai_relay_definition_count, - eventai_instruction_count, - eventai_manifest, - stamps, - }) + stamps } fn main() -> Result<()> { diff --git a/importer/src/spell.rs b/importer/src/spell.rs index 90af969de..927c7fca1 100644 --- a/importer/src/spell.rs +++ b/importer/src/spell.rs @@ -1101,35 +1101,8 @@ fn build_spell_rows( trainers: &[(u32, Vec)], ) -> Result<(SpellRows, Coverage, Vec)> { let allow: std::collections::HashSet = only.iter().copied().collect(); - // spell_id → its DBC-derived spell_level, captured during the header build so the trainer rows below - // get a required_level straight from Spell.dbc (the single firewall-clean source — no cmangos value). - let mut spell_levels: BTreeMap = BTreeMap::new(); - // wrapper spell_id -> its trigger RANK id (first nonzero trigger_spell). A LearnSpell wrapper's own - // spell_level is 0; its real level/cost live on the rank it teaches — used by the trainer offerings. - let mut wrapper_to_rank: BTreeMap = BTreeMap::new(); - let mut chain = open_chain(data_dir)?; - eprintln!("spells: opened MPQ chain from {}", data_dir.display()); - - let spells: DbcSpell = read_table(&mut chain)?; - let cast_times: SpellCastTimes = read_table(&mut chain)?; - let ranges: SpellRange = read_table(&mut chain)?; - let durations: SpellDuration = read_table(&mut chain)?; - let radii: SpellRadius = read_table(&mut chain)?; - eprintln!( - "spells: parsed Spell({}) + SpellCastTimes({}) + SpellRange({}) + SpellDuration({}) + SpellRadius({})", - spells.rows().len(), - cast_times.rows().len(), - ranges.rows().len(), - durations.rows().len(), - radii.rows().len(), - ); - - let mut cov = Coverage::default(); - let mut spell_rows: Vec = Vec::new(); - let mut effect_rows: Vec = Vec::new(); - let mut reagent_rows: Vec = Vec::new(); - let mut eventai_metadata_rows: Vec = Vec::new(); - let mut samples: Vec = Vec::new(); + let dbc = open_spell_tables(data_dir)?; + let mut acc = SpellAccumulator::default(); // Mount creature-template display resolution (entry → display id): the `--dbc --spells` CLI path // is DBC-only (no cmangos creature_template dump is loaded alongside it), so this is empty today — // every real classic mount spell resolves correctly regardless (see `mount_display_p0`), since its @@ -1137,7 +1110,7 @@ fn build_spell_rows( // this map later is then a one-line change here, not a rewrite of the resolution logic itself. let creature_displays: BTreeMap = BTreeMap::new(); - for s in spells.rows() { + for s in dbc.spells.rows() { let spell_id = s.id.id; if spell_id == 0 { continue; // the 0 placeholder @@ -1158,585 +1131,782 @@ fn build_spell_rows( continue; } let id = ((spell_id as u64) << 3) | slot as u64; - reagent_rows.push(format!("({id},{spell_id},{item},{count})")); + acc.reagent_rows + .push(format!("({id},{spell_id},{item},{count})")); } - // --- header (game_spell) --- - // - // ⚠ wow_dbc 0.3 vanilla `Spell` SCHEMA BUG — off-by-one field NAMES from column 21 on. - // The crate's vanilla `SpellRow` is missing the `InterruptFlags` column (real col 21), so from - // there every field is NAMED as the NEXT real column. The BYTES are read sequentially and are - // correct (read position N == real column N); only the wow_dbc field *name* on each is wrong. - // It resyncs before the effect arrays (those read correctly). So to get a logically-correct - // value we read the wow_dbc field whose READ POSITION matches the real column: - // real powerType = s.mana_cost (col 31) - // real manaCost = s.mana_cost_per_level (col 32) - // real DurationIdx = s.power_type (col 30) - // real spellLevel = s.duration.id (col 29) - // real maxLevel = s.base_level (col 27) - // real rangeIndex = s.speed (int bytes read as f32 → recover via .to_bits()) (col 36) - // real stackAmount = s.totem[0] (col 39) - // real AuraIntFlags = s.channel_interrupt_flags (col 22) - // real procFlags = s.proc_chance (col 24) - // real procChance = s.proc_charges (col 25) - // real procCharges = s.max_level (col 26) - // VERIFIED by `--only` dry-run against known values: Fireball powerType=0(mana)/30 mana/4s DoT/ - // 35yd; Battle Shout rage/10/120s; Slam rage/15/L30/instant; Sunder rage/15/L10/30s/STACK=5; - // Frost Armor (168) procFlags=0x28/procChance=100/procCharges=0; Lightning Shield (324) - // procChance=100/procCharges=3 (its real procFlags is a richer taken-hit mask, not 0x28 — every - // "taken" bit, matching its real behavior of zapping back at any attack, not just melee). - // (cast_time/cooldown/gcd/school/dispel/mechanic/attributes/effects are pre-col-21 or post-resync - // → read directly.) Rage costs are stored ×10 in BOTH the DBC and our power bar, mana ×1 in both, - // so `manaCost` imports with no scaling. - let name = s.name.en_gb.clone(); - let power_type = s.mana_cost as u8; // real PowerType: 0 mana / 1 rage / 3 energy / 255 health - let cost = s.mana_cost_per_level.max(0) as u32; // real ManaCost (rage already ×10) - let cast_time_ms = cast_times - .get(s.casting_time_index) - .map(|r| r.base.max(0) as u32) - .unwrap_or(0); - // RecoveryTime is the spell-specific cooldown; CategoryRecoveryTime is the CATEGORY cooldown - // (e.g. Hammer of Justice 853 = 60s, Divine Protection 498 = 5min). Both may carry the real - // per-spell cooldown depending on how the DBC authored the spell, so take the max of both so - // neither path is silently dropped. gcd_ms is computed below after `attributes` is known. - let cooldown_ms = s.recovery_time.max(0).max(s.category_recovery_time.max(0)) as u32; - let range_yd = ranges - .get(SpellRangeKey::new(s.speed.to_bits())) // real rangeIndex (see schema-bug note) - .map(|r| r.range_max.max(0.0) as u32) - .unwrap_or(0); - // DBC duration -1 means INFINITE (toggle auras like Devotion Aura 465, Battle Stance, etc.). - // Collapse to u32::MAX as a sentinel; cast.rs converts that to Timestamp(i64::MAX) so the - // reaper's `expires_at <= now` filter never matches (i64::MAX ≈ 9.2e18 µs >> 2026 epoch). - // Any non-negative duration value is taken as-is (milliseconds). A missing DurationIndex - // entry falls back to 0, which expires immediately (fine — those spells have no aura effect). - let duration_ms = durations - .get(SpellDurationKey::new(s.power_type.max(0) as u32)) // real DurationIndex - .map(|r| { - if r.duration == -1 { - u32::MAX - } else { - r.duration.max(0) as u32 - } - }) - .unwrap_or(0); - // Spell.dbc `school` is a Resistances.dbc INDEX (0=phys,1=holy,2=fire,3=nature,4=frost, - // 5=shadow,6=arcane), NOT a bitmask — our `school_mask` is the bitmask (1< Result { + let mut chain = open_chain(data_dir)?; + eprintln!("spells: opened MPQ chain from {}", data_dir.display()); + + let spells: DbcSpell = read_table(&mut chain)?; + let cast_times: SpellCastTimes = read_table(&mut chain)?; + let ranges: SpellRange = read_table(&mut chain)?; + let durations: SpellDuration = read_table(&mut chain)?; + let radii: SpellRadius = read_table(&mut chain)?; + eprintln!( + "spells: parsed Spell({}) + SpellCastTimes({}) + SpellRange({}) + SpellDuration({}) + SpellRadius({})", + spells.rows().len(), + cast_times.rows().len(), + ranges.rows().len(), + durations.rows().len(), + radii.rows().len(), + ); + + Ok(SpellDbc { + spells, + cast_times, + ranges, + durations, + radii, + }) +} + +/// The row sets and diagnostics one DBC sweep accumulates. +#[derive(Default)] +struct SpellAccumulator { + cov: Coverage, + spell_rows: Vec, + effect_rows: Vec, + reagent_rows: Vec, + eventai_metadata_rows: Vec, + samples: Vec, + /// spell_id → its DBC-derived spell_level, for the trainer offerings' required_level. + spell_levels: BTreeMap, + /// wrapper spell_id → the trigger RANK it teaches; the rank carries the real level and cost. + wrapper_to_rank: BTreeMap, +} + +/// The header values the effect mapping below reads back off the spell it belongs to. +struct SpellHeader { + name: String, + school_mask: u8, + is_negative: bool, + channeled: bool, +} + +/// One effect's resolved kind and magnitudes, after every curated reclassify and p0 fix-up. +struct ResolvedEffect { + kind: u8, + p0: i32, + p0_kind: u8, + base_points: i32, + die_sides: i32, + per_level: f32, + period_ms: u32, +} + +/// Derive one spell's `game_spell` header row and push it, returning the values the effect mapping +/// reads back. +fn push_spell_header_row( + s: &wow_dbc::vanilla_tables::spell::SpellRow, + dbc: &SpellDbc, + allow: &std::collections::HashSet, + acc: &mut SpellAccumulator, +) -> SpellHeader { + let SpellAccumulator { + cov, + spell_rows, + eventai_metadata_rows, + samples, + spell_levels, + .. + } = acc; + let spell_id = s.id.id; + // --- header (game_spell) --- + // + // ⚠ wow_dbc 0.3 vanilla `Spell` SCHEMA BUG — off-by-one field NAMES from column 21 on. + // The crate's vanilla `SpellRow` is missing the `InterruptFlags` column (real col 21), so from + // there every field is NAMED as the NEXT real column. The BYTES are read sequentially and are + // correct (read position N == real column N); only the wow_dbc field *name* on each is wrong. + // It resyncs before the effect arrays (those read correctly). So to get a logically-correct + // value we read the wow_dbc field whose READ POSITION matches the real column: + // real powerType = s.mana_cost (col 31) + // real manaCost = s.mana_cost_per_level (col 32) + // real DurationIdx = s.power_type (col 30) + // real spellLevel = s.duration.id (col 29) + // real maxLevel = s.base_level (col 27) + // real rangeIndex = s.speed (int bytes read as f32 → recover via .to_bits()) (col 36) + // real stackAmount = s.totem[0] (col 39) + // real AuraIntFlags = s.channel_interrupt_flags (col 22) + // real procFlags = s.proc_chance (col 24) + // real procChance = s.proc_charges (col 25) + // real procCharges = s.max_level (col 26) + // VERIFIED by `--only` dry-run against known values: Fireball powerType=0(mana)/30 mana/4s DoT/ + // 35yd; Battle Shout rage/10/120s; Slam rage/15/L30/instant; Sunder rage/15/L10/30s/STACK=5; + // Frost Armor (168) procFlags=0x28/procChance=100/procCharges=0; Lightning Shield (324) + // procChance=100/procCharges=3 (its real procFlags is a richer taken-hit mask, not 0x28 — every + // "taken" bit, matching its real behavior of zapping back at any attack, not just melee). + // (cast_time/cooldown/gcd/school/dispel/mechanic/attributes/effects are pre-col-21 or post-resync + // → read directly.) Rage costs are stored ×10 in BOTH the DBC and our power bar, mana ×1 in both, + // so `manaCost` imports with no scaling. + let name = s.name.en_gb.clone(); + let power_type = s.mana_cost as u8; // real PowerType: 0 mana / 1 rage / 3 energy / 255 health + let cost = s.mana_cost_per_level.max(0) as u32; // real ManaCost (rage already ×10) + let cast_time_ms = dbc + .cast_times + .get(s.casting_time_index) + .map(|r| r.base.max(0) as u32) + .unwrap_or(0); + // RecoveryTime is the spell-specific cooldown; CategoryRecoveryTime is the CATEGORY cooldown + // (e.g. Hammer of Justice 853 = 60s, Divine Protection 498 = 5min). Both may carry the real + // per-spell cooldown depending on how the DBC authored the spell, so take the max of both so + // neither path is silently dropped. gcd_ms is computed below after `attributes` is known. + let cooldown_ms = s.recovery_time.max(0).max(s.category_recovery_time.max(0)) as u32; + let range_yd = dbc + .ranges + .get(SpellRangeKey::new(s.speed.to_bits())) // real rangeIndex (see schema-bug note) + .map(|r| r.range_max.max(0.0) as u32) + .unwrap_or(0); + // DBC duration -1 means INFINITE (toggle auras like Devotion Aura 465, Battle Stance, etc.). + // Collapse to u32::MAX as a sentinel; cast.rs converts that to Timestamp(i64::MAX) so the + // reaper's `expires_at <= now` filter never matches (i64::MAX ≈ 9.2e18 µs >> 2026 epoch). + // Any non-negative duration value is taken as-is (milliseconds). A missing DurationIndex + // entry falls back to 0, which expires immediately (fine — those spells have no aura effect). + let duration_ms = dbc + .durations + .get(SpellDurationKey::new(s.power_type.max(0) as u32)) // real DurationIndex + .map(|r| { + if r.duration == -1 { + u32::MAX + } else { + r.duration.max(0) as u32 + } + }) + .unwrap_or(0); + // Spell.dbc `school` is a Resistances.dbc INDEX (0=phys,1=holy,2=fire,3=nature,4=frost, + // 5=shadow,6=arcane), NOT a bitmask — our `school_mask` is the bitmask (1<(), )); + } + + SpellHeader { + name, + school_mask, + is_negative, + channeled, + } +} + +/// Whether a spell reads as a debuff. Vanilla has no buff/debuff flag, so derive it from the +/// effects: damage, a DoT, CC, or a stat-reducing modifier aura on a non-self target. +fn spell_is_negative(s: &wow_dbc::vanilla_tables::spell::SpellRow) -> bool { + // Polarity: ATTR bit PASSIVE-or-not isn't a buff/debuff flag; vanilla marks debuffs with + // AttributesEx? Negative/CANT_CANCEL flags that we don't model — derive heuristically from the + // FIRST effect's target/aura (a CC/damage-on-enemy effect ⇒ negative). Refined below per-effect. + let mut is_negative = false; + for i in 0..3 { + let eff = s.effect[i]; + if eff == 0 { + continue; + } + let aura = s.effect_aura[i]; + // damage / DoT / CC / a debuff-shaped aura on a non-self target ⇒ negative + if matches!(eff, 2 | 17 | 58 | 121 | 31) { + is_negative = true; } + if matches!( + aura, + AuraMod::PeriodicDamage + | AuraMod::PeriodicLeech + | AuraMod::ModStun + | AuraMod::ModRoot + | AuraMod::ModFear + | AuraMod::ModConfuse + | AuraMod::ModDecreaseSpeed + | AuraMod::ModSilence + | AuraMod::ModDamageTaken + ) { + is_negative = true; + } + // A modifier aura that REDUCES a stat / resistance / combat field (negative base points) is a + // DEBUFF — e.g. Sunder Armor (−armor via ModResistance), Demoralizing Shout (−AP via + // ModAttackPower). The variant alone can't tell buff from debuff (the SAME AuraMod does both; + // the sign of the magnitude decides), so the earlier variant-only list misses these and they'd + // wrongly resolve onto an ALLY (see `is_reducing_modifier_aura`). + if is_reducing_modifier_aura(eff, aura, s.effect_base_points[i]) { + is_negative = true; + } + } + is_negative +} - // --- effects (game_spell_effect) --- - // Track which effect_index slots the DBC populates, so a SYNTHETIC effect (an ADDED A_CONTROL / - // A_MOD_SPEED) can take the first FREE slot — keeping the deterministic id `(spell_id<<2)|index` - // unique and within the 2-bit (0..3) effect-index space. - let mut used_slots = [false; 4]; - // `i` is the DBC EFFECT INDEX, not merely a position in `used_slots`: it addresses four - // parallel DBC arrays (`effect`, `effect_aura`, `effect_mechanic`, `effect_item_type`) AND - // is packed into the deterministic row id `(spell_id << 2) | index`. Iterating one of those - // arrays instead (what `clippy::needless_range_loop` asks for) would hide that. - #[allow(clippy::needless_range_loop)] - for i in 0..3 { - let effect_id = s.effect[i]; - if effect_id == 0 { - continue; // sparse effect slots are common - } - used_slots[i] = true; - let effect_index = i as u8; - let aura = s.effect_aura[i]; - - let (kind, (p0, p0_kind)) = if is_aura_effect(effect_id) || aura != AuraMod::None { - let k = aura_mod_to_kind(aura); - if k == E_SCRIPTED { - *cov.unmapped_aura.entry(format!("{aura:?}")).or_default() += 1; - } - (k, resolve_aura_params(k, aura, s.effect_misc_value[i])) - } else { - let k = instant_effect_to_kind(effect_id); - if k == E_SCRIPTED { - *cov.unmapped_effect.entry(effect_id).or_default() += 1; - } +/// Derive and push one spell's `game_spell_effect` rows, including the synthetic additions that +/// cover CC/speed/seal data the DBC lacks. +fn push_spell_effect_rows( + s: &wow_dbc::vanilla_tables::spell::SpellRow, + header: &SpellHeader, + dbc: &SpellDbc, + creature_displays: &BTreeMap, + allow: &std::collections::HashSet, + acc: &mut SpellAccumulator, +) { + let SpellAccumulator { + cov, + effect_rows, + samples, + wrapper_to_rank, + .. + } = acc; + let spell_id = s.id.id; + let name = header.name.as_str(); + // --- effects (game_spell_effect) --- + // Track which effect_index slots the DBC populates, so a SYNTHETIC effect (an ADDED A_CONTROL / + // A_MOD_SPEED) can take the first FREE slot — keeping the deterministic id `(spell_id<<2)|index` + // unique and within the 2-bit (0..3) effect-index space. + let mut used_slots = [false; 4]; + // `i` is the DBC EFFECT INDEX, not merely a position in `used_slots`: it addresses four + // parallel DBC arrays (`effect`, `effect_aura`, `effect_mechanic`, `effect_item_type`) AND + // is packed into the deterministic row id `(spell_id << 2) | index`. Iterating one of those + // arrays instead (what `clippy::needless_range_loop` asks for) would hide that. + #[allow(clippy::needless_range_loop)] + for i in 0..3 { + let effect_id = s.effect[i]; + if effect_id == 0 { + continue; // sparse effect slots are common + } + used_slots[i] = true; + let effect_index = i as u8; + let ResolvedEffect { + kind, + p0, + p0_kind, + base_points, + die_sides, + per_level, + period_ms, + } = resolve_effect_kind(s, i, header, creature_displays, cov); + let target = resolve_effect_target(s, i, kind, header); + // Evocation (12051): a channeled 8s self-buff that restores a PERCENT of max mana every 2s + // (~60% over the channel). Its DBC effects are inert markers (a +1500% ModPowerRegenPercent → + // A_FLAG, and a second no-op) that restore no real number. Reclassify the FIRST effect + // (effect_index 1) to a GENERIC A_PERIODIC_ENERGIZE self-tick: period 2000ms, amount 15 (a + // PERCENT — p0_kind P_PCT_MAX_POWER makes aura_apply convert it to an absolute per-tick off the + // caster's max mana), MANA power type (p0=0), self-targeted. The header's CHANNELED flag + // (is_channeled by name) holds the caster 8s; break_channel tears this aura down on move/cast/CC + // (the widened periodic-energize filter). The second no-op effect stays inert. Keyed on name + + // effect index, never engine code. (Mirrors the Consecration/Human-Spirit by-name effect fixes.) + let (kind, period_ms, base_points, target, p0, p0_kind) = + if name == "Evocation" && effect_index == 1 { ( - k, - resolve_instant_params(k, s.effect_misc_value[i], s.effect_item_type[i]), + A_PERIODIC_ENERGIZE, + 2000u32, + 15i32, + T_SELF, + 0i32, + P_PCT_MAX_POWER, ) - }; - // Curated correction (Spell.sql analog): reclassify the known script-effect-as-generic spells. - let kind = correct_script_effect_kind(&name, kind); - - // DISMOUNT reclassify (data-driven, never by name/id): a raw DISPEL_MECHANIC effect whose - // misc value names the mount mechanic becomes E_DISMOUNT. Runs on the RAW effect id (not - // the resolved kind), so it fires regardless of what instant_effect_to_kind mapped 108 to. - let kind = dismount_effect_kind(effect_id, s.effect_misc_value[i]).unwrap_or(kind); - - // STANCE p0 remap: a reclassified E_SET_STANCE effect arrives with p0 = the vanilla form id - // (from the ModShapeshift misc value). Remap it to our 0-based stance id via `form_to_stance` - // (Battle 17→0 / Defensive 18→1 / Berserker 19→2 / Bear 5→3 / Cat 1→4 / Dire Bear 8→5) so the - // cast handler writes the right WorldEntity.stance. Mirrors the COMBAT_DODGE ×100 base-point - // rescale below — a kind-keyed p0 fix-up after the reclassify, never a spell id. Non-stance - // effects keep their resolved p0. - let p0 = stance_p0(kind, p0); - - // MOUNT DISPLAY resolution: an A_MOUNTED effect's p0 already defaults to the raw misc value - // (resolve_aura_params) — resolve the rarer creature-template indirection here, mirroring - // the stance/CREATE_ITEM kind-keyed p0 fix-ups above. - let p0 = mount_display_p0(kind, p0, &creature_displays); - - // PROC_DAMAGE school: an A_PROC_DAMAGE effect deals its frozen amount as damage of the - // proc spell's own school — mirrors the mount/stance p0 fix-ups above. - let (p0, p0_kind) = proc_damage_school_p0(kind, p0, p0_kind, school_mask); - - // CREATE_ITEM p0 injection (Healthstone): the by-name reclassify above made these E_CREATE_ITEM, - // but their DBC effect_item_type is 0 (the mangos script hardcodes the item), so the resolved p0 - // is 0 (and p0_kind is P_NONE, since the kind was E_SCRIPTED when resolve_instant_params ran). - // Inject the per-rank Healthstone item template entry + P_ITEM_ENTRY here — the same kind-keyed, - // name-keyed p0 fix-up as the E_SET_STANCE remap above, never disturbing a real effect_item_type - // p0 (the arm only fires for these names, whose native p0 is already 0). count=1 rides base_points. - // The module's E_CREATE_ITEM handler reads only p0; the p0_kind set is for data parity with - // natively-mapped Conjure Water/Food (which carry P_ITEM_ENTRY). - let (p0, p0_kind) = if kind == E_CREATE_ITEM { - let item = match name.as_str() { - "Create Healthstone (Minor)" => Some(5512), - "Create Healthstone (Lesser)" => Some(5511), - "Create Healthstone" => Some(5509), - "Create Healthstone (Greater)" => Some(5510), - "Create Healthstone (Major)" => Some(9421), - _ => None, // a native CreateItem (Conjure Water/Food) keeps its effect_item_type (p0, p0_kind) - }; - match item { - Some(entry) => (entry, P_ITEM_ENTRY), - None => (p0, p0_kind), - } } else { - (p0, p0_kind) + (kind, period_ms, base_points, target, p0, p0_kind) }; + let radius_yd = if s.effect_radius[i] > 0 { + dbc.radii + .get(SpellRadiusKey::new(s.effect_radius[i])) + .map(|r| r.radius) + .unwrap_or(0.0) + } else { + 0.0 + }; + let chain_targets = s.effect_chain_target[i].clamp(0, 255) as u8; + let trigger_spell = s.effect_trigger_spell[i]; + // Record wrapper -> rank: a LearnSpell wrapper's first nonzero trigger is the castable rank it + // teaches (resolves the wrapper's trainer-offering level/cost to the rank, below). + // `is_wrapper_rank_trigger` excludes every OTHER kind that rides the same `trigger_spell` + // column for a non-rank payload (a channel's missile, a Proc's trigger, a plain instant + // trigger) — see its own doc comment. This list must stay in lockstep with + // `resolve_learn_target` (module/src/trainer.rs). + if trigger_spell != 0 && is_wrapper_rank_trigger(kind) { + wrapper_to_rank.entry(spell_id).or_insert(trigger_spell); + } + let effect_mechanic = s.effect_mechanic[i] as u8; + let p1 = if kind == E_POWER_BURN { + power_burn_ratio_bp(s.effect_multiple_values[i]) + } else if kind == A_SPELLMOD_FLAT || kind == A_SPELLMOD_PCT { + // 264: the affected-spell FAMILY MASK (DBC EffectItemType) — matched at fold time + // against the cast header's family_flags. + s.effect_item_type[i] + } else { + power_word_shield_p1_override(spell_id, name, kind, 0i32) + }; + let script_id = 0u32; + // [093] data-driven "this energize enters/holds combat": set on Bloodrage (cast 2687 + trickle + // 29131, BOTH named "Bloodrage") so the E_ENERGIZE / A_PERIODIC_ENERGIZE arms read the flag, not + // a spell id. Any energize can opt in by adding the name. + let enters_combat = name == "Bloodrage"; - let base_points = dbc_flat_amount(s.effect_base_points[i]); // DBC +1 convention (mounted-speed 59/99 → 60/100 falls out of this for free) - // Avoidance-chance combat fields are basis-points in our engine but PERCENT in the DBC, so - // scale ×100 (Evasion's +50% dodge → +5000 bp). COMBAT_THREAT is a signed percent in both - // (the threat fold divides by 100), so it is NOT scaled. - let base_points = if kind == A_MOD_COMBAT && p0 == COMBAT_DODGE { - base_points * 100 - } else { - base_points - }; - let die_sides = s.effect_die_sides[i]; - let per_level = s.effect_real_points_per_level[i]; - // EffectAmplitude is an INTEGER ms in the real DBC, but wow_dbc 0.3 vanilla mis-declares it as - // f32 — so a value like 3000 arrives as the denormal 4.204e-42 and `as u32` would truncate to - // 0 (silently making EVERY imported DoT/HoT never tick). Recover the integer via `.to_bits()`, - // the SAME float-misdeclaration workaround the importer already uses for rangeIndex. Without - // this, Garrote's bleed (and Rend/SW:Pain/Corruption/Curse of Agony) sit dormant and expire. - let period_ms = s.effect_amplitude[i].to_bits(); // amplitude is an INTEGER ms misdeclared as f32 by wow_dbc - // ModRegen (Demon Skin/Armor's health-per-5, work-item 024) is force-ticked every 5000ms by - // vanilla regardless of the DBC's own EffectAmplitude (a behaviour the reference cores show too) — apply that - // override here so the reclassified A_PERIODIC_HEAL effect actually schedules a tick even if - // Spell.dbc carries 0/garbage amplitude for this aura kind. - let period_ms = if kind == A_PERIODIC_HEAL && aura == AuraMod::ModRegen { - 5000 - } else { - period_ms - }; - // CHANNEL reclassify: on a channeled spell, the periodic-trigger effect (a PeriodicTriggerSpell / - // raw TriggerSpell mapped to E_TRIGGER, with a tick period) IS the channel tick — reclassify it to - // A_PERIODIC_TRIGGER so `tick_auras` fires its `trigger_spell` (the missile) each period at the - // channel target. Gated on the channeled header bit + a real period, so a non-channel periodic - // trigger (a proc) is untouched. Keyed on the kind + period, never a spell id; the channel target - // + the trigger spell id are frozen onto the aura at cast (aura_apply). [import] - let kind = if channeled && kind == E_TRIGGER && period_ms > 0 { - A_PERIODIC_TRIGGER - } else { - kind - }; - // GROUND-AoE (118): a ground-persistent A_PERIODIC_DAMAGE is a FIXED-POSITION area, not a unit - // DoT. The DBC encodes it as A_PERIODIC_DAMAGE with a dynobj/self target that resolves WRONG — - // Consecration → T_SELF would DoT the paladin himself. Reclassify BY NAME to E_PERSISTENT_AREA - // (the Charge/Blink name-rescue precedent) so it spawns a game_ground_area whose own - // tick_ground_areas damages hostiles inside. Consecration is caster-anchored; Flamestrike (262) - // is the first CLICKED-GROUND one — the 118 phase-2 dest plumbing (6067df1) anchors the area at - // the click when the cast carries a DEST_LOCATION block, so the same kind serves both. - // Blizzard/Rain of Fire remain un-rescued (channeled patches — their channel/tick interplay is - // its own follow-up; leaving them A_PERIODIC_DAMAGE keeps them out of the curated kit). - let kind = if kind == A_PERIODIC_DAMAGE - && matches!(name.as_str(), "Consecration" | "Flamestrike") - { - E_PERSISTENT_AREA - } else { - kind - }; - // The Human Spirit: wow_dbc mis-decodes this racial's effect as a FLAT all-stat ModStat, but - // Classic's actual effect is "Mod Stat - %" = +5% SPIRIT (verified vs - // wowhead.com/classic/spell=20598). Force the percent kind + the Spirit stat by name; the - // decoded base_points (5) already carries the 5%. The A_MOD_STAT_PCT recompute fold then makes - // Spirit = round(base * 1.05), Spirit-only. [104] - let (kind, p0, p0_kind) = if name == "The Human Spirit" { - (A_MOD_STAT_PCT, 4, P_STAT_ID) // 4 = Spirit (UNIT_FIELD_STAT4) - } else { - (kind, p0, p0_kind) - }; - let target = resolve_target(s.implicit_target_a[i], is_negative); - // Charge/Judgement/Pick Pocket are inherently ENEMY-targeted; Resurrection is inherently - // ALLY-targeted (a dead friend) — the raw DBC implicit target reads wrong for all of these. - // E_TAUNT joined 2026-07-19 (266): Taunt/Growl carry implicit target 6|25 whose polarity - // fallback read ALLY (they're not is_negative), so the faction gate refused every yank. - let target = match kind { - E_CHARGE | E_JUDGEMENT | E_PICKPOCKET | E_INTERRUPT | E_NEXT_SWING | E_TAUNT => { - T_TARGET_ENEMY - } - E_RESURRECT => T_TARGET_ALLY, - // Feint's threat drop acts on the CASTER as source — force T_SELF so it self-targets - // (the handler reads caster_guid) and the faction gate never trips (self-cast bypass). - E_REDUCE_THREAT => T_SELF, - // Blink (116): a self-cast forward teleport — the handler reads caster_guid only and - // ignores any resolved target, so force T_SELF (fires once, bypasses the faction gate). - E_BLINK => T_SELF, - // Ground-AoE (118): anchor at the CASTER (Consecration is caster-centered) — force T_SELF so - // select_targets yields the caster once (the handler stamps the area at that position) and the - // faction gate is bypassed. A clicked-ground variant anchors at the dest coords instead. - E_PERSISTENT_AREA => T_SELF, - // Summon (Summon Imp): the pet is summoned at the CASTER — the handler reads caster_guid and - // ignores the resolved target. Force T_SELF so `select_targets` yields the caster (the - // summon fires exactly once) AND the faction gate is bypassed (a self-cast imposes no - // faction constraint), so casting it while an enemy is selected still summons the pet. - E_SUMMON_PET => T_SELF, - E_DUEL => T_TARGET_ANY, - E_TAME_CREATURE => T_TARGET_ENEMY, - _ => target, - }; - // Slice and Dice (a combo FINISHER) is cast AT the enemy you built combo on (to read + spend - // it); its inert marker effect reads ally-typed in the DBC, which makes the faction gate reject - // the enemy cast. Force the marker enemy-targeted — the self-haste effect (T_SELF) is untouched. - let target = if name == "Slice and Dice" && target == T_TARGET_ALLY { - T_TARGET_ENEMY - } else { - target - }; - // Mind Soothe (453, reduces a hostile creature's aggro radius) and Disarm (676, strips the - // enemy's weapon) are ENEMY debuffs, but their DBC implicit target reads ally/self-typed - // (Mind Soothe → T_SELF/T_TARGET_ALLY; Disarm imports as target=2 = T_TARGET_ALLY), so the - // faction gate would refuse the hostile cast + `select_targets` wouldn't reach the foe. Force - // both onto the enemy. Keyed on name (the Slice and Dice precedent above). - let target = if matches!(name.as_str(), "Mind Soothe" | "Disarm") { - T_TARGET_ENEMY - } else { - target - }; - // Thunder Clap / Frost Nova are enemy PBAoEs (negative=true) but their DBC implicit - // target reads as a friendly-party code -> mapped T_TARGET_ALLY, so the AoE fan-out - // never fires and the faction gate refuses casting them at a hostile. Force all - // effects to T_AREA_ENEMY so they splash nearby hostiles. (Frost Nova: both effects - // (E_DAMAGE + A_CONTROL M_ROOT) carry target=2 and radius=10 in the DBC; the - // PBAoE fan-out + root already exist in the engine — this is purely a data fix.) - let target = if name == "Thunder Clap" || name == "Frost Nova" { - T_AREA_ENEMY - } else { - target - }; - // Flamestrike (262): the INITIAL-impact nuke (its E_DAMAGE effect) fans out around the - // CLICK — the 118 phase-2 select_targets anchors an area target on the cast's dest when - // one is present. Scoped to E_DAMAGE only: forcing all effects (the Thunder Clap shape) - // would drag the PATCH effect to T_AREA_ENEMY and spawn one ground area per hostile. - let target = if name == "Flamestrike" && kind == E_DAMAGE { - T_AREA_ENEMY - } else { - target - }; - // Battle Shout is a party PBAoE buff (EFFECT_APPLY_AREA_AURA_PARTY in the DBC, - // 30yd radius) but its implicit_target_a=20 (TARGET_UNIT_PARTY_CASTER) maps to - // T_TARGET_ALLY (single ally), so only one party member is buffed in a group. - // Force T_AREA_ALLY so the fan-out engine splashes all nearby allies at the - // DBC-imported radius (30yd). Mirrors the Thunder Clap precedent above. - let target = if name == "Battle Shout" { - T_AREA_ALLY - } else { - target - }; - // Arcane Intellect (1459) imports with implicit_target_a=0 → T_SELF, but it is - // a friendly single-target buff (targets yourself OR an ally). Force T_TARGET_ALLY - // so the faction gate allows casting on a friendly target; the engine falls back to - // the caster when no friendly target is selected (same behaviour as Resurrection). - // Same DBC-collapse trap hits the paladin/priest friendly single-target kit — Holy - // Light (635/639), Lay on Hands (633), Blessing of Might (19740) and Purify (1152) all - // import with implicit_target_a=0 (see work-item 007, archived), so they share this override. - let target = friendly_self_or_ally_target_override(&name, target); - // CHANNEL self-marker: a channeled spell carries an inert A_FLAG "you are channeling" marker - // (Arcane Missiles eff2) that the DBC reads as ALLY-targeted — but the channel is cast AT an - // ENEMY, so an ally-typed effect would make the faction gate REJECT the enemy cast (the same - // trap fixed for Slice and Dice / Thunder Clap). Force the marker to T_SELF: it's a caster-side - // flag, so self-targeting it both lands it correctly AND removes it from the faction gate's - // hits_ally/hits_enemy scan (a self-cast effect imposes no faction constraint). Generic over the - // channeled bit + the A_FLAG kind, never a spell id; the channel's A_PERIODIC_TRIGGER tick effect - // is already T_SELF, so only the stray marker is corrected. - let target = if channeled && kind == A_FLAG { - T_SELF - } else { - target - }; - // Evocation (12051): a channeled 8s self-buff that restores a PERCENT of max mana every 2s - // (~60% over the channel). Its DBC effects are inert markers (a +1500% ModPowerRegenPercent → - // A_FLAG, and a second no-op) that restore no real number. Reclassify the FIRST effect - // (effect_index 1) to a GENERIC A_PERIODIC_ENERGIZE self-tick: period 2000ms, amount 15 (a - // PERCENT — p0_kind P_PCT_MAX_POWER makes aura_apply convert it to an absolute per-tick off the - // caster's max mana), MANA power type (p0=0), self-targeted. The header's CHANNELED flag - // (is_channeled by name) holds the caster 8s; break_channel tears this aura down on move/cast/CC - // (the widened periodic-energize filter). The second no-op effect stays inert. Keyed on name + - // effect index, never engine code. (Mirrors the Consecration/Human-Spirit by-name effect fixes.) - let (kind, period_ms, base_points, target, p0, p0_kind) = - if name == "Evocation" && effect_index == 1 { - ( - A_PERIODIC_ENERGIZE, - 2000u32, - 15i32, - T_SELF, - 0i32, - P_PCT_MAX_POWER, - ) - } else { - (kind, period_ms, base_points, target, p0, p0_kind) - }; - let radius_yd = if s.effect_radius[i] > 0 { - radii - .get(SpellRadiusKey::new(s.effect_radius[i])) - .map(|r| r.radius) - .unwrap_or(0.0) - } else { - 0.0 - }; - let chain_targets = s.effect_chain_target[i].clamp(0, 255) as u8; - let trigger_spell = s.effect_trigger_spell[i]; - // Record wrapper -> rank: a LearnSpell wrapper's first nonzero trigger is the castable rank it - // teaches (resolves the wrapper's trainer-offering level/cost to the rank, below). - // `is_wrapper_rank_trigger` excludes every OTHER kind that rides the same `trigger_spell` - // column for a non-rank payload (a channel's missile, a Proc's trigger, a plain instant - // trigger) — see its own doc comment. This list must stay in lockstep with - // `resolve_learn_target` (module/src/trainer.rs). - if trigger_spell != 0 && is_wrapper_rank_trigger(kind) { - wrapper_to_rank.entry(spell_id).or_insert(trigger_spell); - } - let effect_mechanic = s.effect_mechanic[i] as u8; - let p1 = if kind == E_POWER_BURN { - power_burn_ratio_bp(s.effect_multiple_values[i]) - } else if kind == A_SPELLMOD_FLAT || kind == A_SPELLMOD_PCT { - // 264: the affected-spell FAMILY MASK (DBC EffectItemType) — matched at fold time - // against the cast header's family_flags. - s.effect_item_type[i] - } else { - power_word_shield_p1_override(spell_id, &name, kind, 0i32) - }; - let script_id = 0u32; - // [093] data-driven "this energize enters/holds combat": set on Bloodrage (cast 2687 + trickle - // 29131, BOTH named "Bloodrage") so the E_ENERGIZE / A_PERIODIC_ENERGIZE arms read the flag, not - // a spell id. Any energize can opt in by adding the name. - let enters_combat = name == "Bloodrage"; - - effect_rows.push(SpellEffectRow { - spell_id, - effect_index, - kind, - base_points, - die_sides, - per_level, - period_ms, - target, - radius_yd, - chain_targets, - trigger_spell, - effect_mechanic, - p0, - p0_kind, - p1, - script_id, - enters_combat, - }); - - cov.effects += 1; - *cov.by_kind.entry(kind).or_default() += 1; - if kind == E_SCRIPTED { - cov.scripted += 1; - } else { - cov.real += 1; - } + effect_rows.push(SpellEffectRow { + spell_id, + effect_index, + kind, + base_points, + die_sides, + per_level, + period_ms, + target, + radius_yd, + chain_targets, + trigger_spell, + effect_mechanic, + p0, + p0_kind, + p1, + script_id, + enters_combat, + }); + + cov.effects += 1; + *cov.by_kind.entry(kind).or_default() += 1; + if kind == E_SCRIPTED { + cov.scripted += 1; + } else { + cov.real += 1; + } - if !allow.is_empty() { - // Allowlist mode: log EVERY effect (incl. E_SCRIPTED no-ops) so an unmapped ability is - // visible, not silently dropped — the operator decides whether it's usable as-is. - samples.push(format!( + if !allow.is_empty() { + // Allowlist mode: log EVERY effect (incl. E_SCRIPTED no-ops) so an unmapped ability is + // visible, not silently dropped — the operator decides whether it's usable as-is. + samples.push(format!( " eff{effect_index}: {kn} (0x{kind:02X}) base={base_points} die={die_sides} period_ms={period_ms} target={target} p0={p0} p0_kind={p0_kind} trigger={trigger_spell}", kn = kind_name(kind), )); - } else if kind != E_SCRIPTED && samples.len() < 8 { - // Full-import mode: keep a few human-readable samples for the dry-run (first 8 REAL effects). - samples.push(format!( + } else if kind != E_SCRIPTED && samples.len() < 8 { + // Full-import mode: keep a few human-readable samples for the dry-run (first 8 REAL effects). + samples.push(format!( " spell {spell_id} '{}' eff{effect_index}: kind=0x{kind:02X} base={base_points} period_ms={period_ms} target={target} p0={p0} p0_kind={p0_kind}", name.chars().take(28).collect::(), )); - } } + } - // --- SYNTHETIC effect ADDITIONS (the curated correction for CC/speed/seal data the DBC lacks) --- - // A synthetic A_CONTROL (Gouge) / A_MOD_SPEED (Stealth) / A_SEAL (Seal of the Crusader) is ADDED at - // the FIRST free effect_index (the lowest open slot, keeping the deterministic id - // `(spell_id<<2)|index` unique + in the 0..3 range). At most ONE synthetic per spell (Gouge/Stealth/ - // SoC are distinct ids), so a single free slot suffices. These are REAL kinds → counted toward - // coverage like any mapped effect. - let synth_slot = used_slots.iter().position(|&u| !u).unwrap_or(3) as u8; - if let Some(synth) = synthetic_control_effect(spell_id, &name, synth_slot) - .or_else(|| synthetic_stealth_slow_effect(spell_id, &name, synth_slot)) - .or_else(|| synthetic_seal_effect(spell_id, &name, synth_slot)) - { - cov.effects += 1; - cov.real += 1; - *cov.by_kind.entry(synth.kind).or_default() += 1; - if !allow.is_empty() { - samples.push(format!(" [synthetic+curated] {}", synth.sql_values())); - } - effect_rows.push(synth); + // --- SYNTHETIC effect ADDITIONS (the curated correction for CC/speed/seal data the DBC lacks) --- + // A synthetic A_CONTROL (Gouge) / A_MOD_SPEED (Stealth) / A_SEAL (Seal of the Crusader) is ADDED at + // the FIRST free effect_index (the lowest open slot, keeping the deterministic id + // `(spell_id<<2)|index` unique + in the 0..3 range). At most ONE synthetic per spell (Gouge/Stealth/ + // SoC are distinct ids), so a single free slot suffices. These are REAL kinds → counted toward + // coverage like any mapped effect. + let synth_slot = used_slots.iter().position(|&u| !u).unwrap_or(3) as u8; + if let Some(synth) = synthetic_control_effect(spell_id, name, synth_slot) + .or_else(|| synthetic_stealth_slow_effect(spell_id, name, synth_slot)) + .or_else(|| synthetic_seal_effect(spell_id, name, synth_slot)) + { + cov.effects += 1; + cov.real += 1; + *cov.by_kind.entry(synth.kind).or_default() += 1; + if !allow.is_empty() { + samples.push(format!(" [synthetic+curated] {}", synth.sql_values())); + } + effect_rows.push(synth); + } +} + +/// Resolve one DBC effect slot to our kind + magnitudes: the aura/instant mapping, then every +/// curated reclassify and p0 fix-up that rides on it. +fn resolve_effect_kind( + s: &wow_dbc::vanilla_tables::spell::SpellRow, + i: usize, + header: &SpellHeader, + creature_displays: &BTreeMap, + cov: &mut Coverage, +) -> ResolvedEffect { + let effect_id = s.effect[i]; + let aura = s.effect_aura[i]; + let name = header.name.as_str(); + let school_mask = header.school_mask; + let channeled = header.channeled; + let (kind, (p0, p0_kind)) = if is_aura_effect(effect_id) || aura != AuraMod::None { + let k = aura_mod_to_kind(aura); + if k == E_SCRIPTED { + *cov.unmapped_aura.entry(format!("{aura:?}")).or_default() += 1; + } + (k, resolve_aura_params(k, aura, s.effect_misc_value[i])) + } else { + let k = instant_effect_to_kind(effect_id); + if k == E_SCRIPTED { + *cov.unmapped_effect.entry(effect_id).or_default() += 1; } + ( + k, + resolve_instant_params(k, s.effect_misc_value[i], s.effect_item_type[i]), + ) + }; + // Curated correction (Spell.sql analog): reclassify the known script-effect-as-generic spells. + let kind = correct_script_effect_kind(name, kind); + + // DISMOUNT reclassify (data-driven, never by name/id): a raw DISPEL_MECHANIC effect whose + // misc value names the mount mechanic becomes E_DISMOUNT. Runs on the RAW effect id (not + // the resolved kind), so it fires regardless of what instant_effect_to_kind mapped 108 to. + let kind = dismount_effect_kind(effect_id, s.effect_misc_value[i]).unwrap_or(kind); + + // STANCE p0 remap: a reclassified E_SET_STANCE effect arrives with p0 = the vanilla form id + // (from the ModShapeshift misc value). Remap it to our 0-based stance id via `form_to_stance` + // (Battle 17→0 / Defensive 18→1 / Berserker 19→2 / Bear 5→3 / Cat 1→4 / Dire Bear 8→5) so the + // cast handler writes the right WorldEntity.stance. Mirrors the COMBAT_DODGE ×100 base-point + // rescale below — a kind-keyed p0 fix-up after the reclassify, never a spell id. Non-stance + // effects keep their resolved p0. + let p0 = stance_p0(kind, p0); + + // MOUNT DISPLAY resolution: an A_MOUNTED effect's p0 already defaults to the raw misc value + // (resolve_aura_params) — resolve the rarer creature-template indirection here, mirroring + // the stance/CREATE_ITEM kind-keyed p0 fix-ups above. + let p0 = mount_display_p0(kind, p0, creature_displays); + + // PROC_DAMAGE school: an A_PROC_DAMAGE effect deals its frozen amount as damage of the + // proc spell's own school — mirrors the mount/stance p0 fix-ups above. + let (p0, p0_kind) = proc_damage_school_p0(kind, p0, p0_kind, school_mask); + + // CREATE_ITEM p0 injection (Healthstone): the by-name reclassify above made these E_CREATE_ITEM, + // but their DBC effect_item_type is 0 (the mangos script hardcodes the item), so the resolved p0 + // is 0 (and p0_kind is P_NONE, since the kind was E_SCRIPTED when resolve_instant_params ran). + // Inject the per-rank Healthstone item template entry + P_ITEM_ENTRY here — the same kind-keyed, + // name-keyed p0 fix-up as the E_SET_STANCE remap above, never disturbing a real effect_item_type + // p0 (the arm only fires for these names, whose native p0 is already 0). count=1 rides base_points. + // The module's E_CREATE_ITEM handler reads only p0; the p0_kind set is for data parity with + // natively-mapped Conjure Water/Food (which carry P_ITEM_ENTRY). + let (p0, p0_kind) = if kind == E_CREATE_ITEM { + let item = match name { + "Create Healthstone (Minor)" => Some(5512), + "Create Healthstone (Lesser)" => Some(5511), + "Create Healthstone" => Some(5509), + "Create Healthstone (Greater)" => Some(5510), + "Create Healthstone (Major)" => Some(9421), + _ => None, // a native CreateItem (Conjure Water/Food) keeps its effect_item_type (p0, p0_kind) + }; + match item { + Some(entry) => (entry, P_ITEM_ENTRY), + None => (p0, p0_kind), + } + } else { + (p0, p0_kind) + }; + + let base_points = dbc_flat_amount(s.effect_base_points[i]); // DBC +1 convention (mounted-speed 59/99 → 60/100 falls out of this for free) + // Avoidance-chance combat fields are basis-points in our engine but PERCENT in the DBC, so + // scale ×100 (Evasion's +50% dodge → +5000 bp). COMBAT_THREAT is a signed percent in both + // (the threat fold divides by 100), so it is NOT scaled. + let base_points = if kind == A_MOD_COMBAT && p0 == COMBAT_DODGE { + base_points * 100 + } else { + base_points + }; + let die_sides = s.effect_die_sides[i]; + let per_level = s.effect_real_points_per_level[i]; + // EffectAmplitude is an INTEGER ms in the real DBC, but wow_dbc 0.3 vanilla mis-declares it as + // f32 — so a value like 3000 arrives as the denormal 4.204e-42 and `as u32` would truncate to + // 0 (silently making EVERY imported DoT/HoT never tick). Recover the integer via `.to_bits()`, + // the SAME float-misdeclaration workaround the importer already uses for rangeIndex. Without + // this, Garrote's bleed (and Rend/SW:Pain/Corruption/Curse of Agony) sit dormant and expire. + let period_ms = s.effect_amplitude[i].to_bits(); // amplitude is an INTEGER ms misdeclared as f32 by wow_dbc + // ModRegen (Demon Skin/Armor's health-per-5, work-item 024) is force-ticked every 5000ms by + // vanilla regardless of the DBC's own EffectAmplitude (a behaviour the reference cores show too) — apply that + // override here so the reclassified A_PERIODIC_HEAL effect actually schedules a tick even if + // Spell.dbc carries 0/garbage amplitude for this aura kind. + let period_ms = if kind == A_PERIODIC_HEAL && aura == AuraMod::ModRegen { + 5000 + } else { + period_ms + }; + // CHANNEL reclassify: on a channeled spell, the periodic-trigger effect (a PeriodicTriggerSpell / + // raw TriggerSpell mapped to E_TRIGGER, with a tick period) IS the channel tick — reclassify it to + // A_PERIODIC_TRIGGER so `tick_auras` fires its `trigger_spell` (the missile) each period at the + // channel target. Gated on the channeled header bit + a real period, so a non-channel periodic + // trigger (a proc) is untouched. Keyed on the kind + period, never a spell id; the channel target + // + the trigger spell id are frozen onto the aura at cast (aura_apply). [import] + let kind = if channeled && kind == E_TRIGGER && period_ms > 0 { + A_PERIODIC_TRIGGER + } else { + kind + }; + // GROUND-AoE (118): a ground-persistent A_PERIODIC_DAMAGE is a FIXED-POSITION area, not a unit + // DoT. The DBC encodes it as A_PERIODIC_DAMAGE with a dynobj/self target that resolves WRONG — + // Consecration → T_SELF would DoT the paladin himself. Reclassify BY NAME to E_PERSISTENT_AREA + // (the Charge/Blink name-rescue precedent) so it spawns a game_ground_area whose own + // tick_ground_areas damages hostiles inside. Consecration is caster-anchored; Flamestrike (262) + // is the first CLICKED-GROUND one — the 118 phase-2 dest plumbing (6067df1) anchors the area at + // the click when the cast carries a DEST_LOCATION block, so the same kind serves both. + // Blizzard/Rain of Fire remain un-rescued (channeled patches — their channel/tick interplay is + // its own follow-up; leaving them A_PERIODIC_DAMAGE keeps them out of the curated kit). + let kind = if kind == A_PERIODIC_DAMAGE && matches!(name, "Consecration" | "Flamestrike") { + E_PERSISTENT_AREA + } else { + kind + }; + // The Human Spirit: wow_dbc mis-decodes this racial's effect as a FLAT all-stat ModStat, but + // Classic's actual effect is "Mod Stat - %" = +5% SPIRIT (verified vs + // wowhead.com/classic/spell=20598). Force the percent kind + the Spirit stat by name; the + // decoded base_points (5) already carries the 5%. The A_MOD_STAT_PCT recompute fold then makes + // Spirit = round(base * 1.05), Spirit-only. [104] + let (kind, p0, p0_kind) = if name == "The Human Spirit" { + (A_MOD_STAT_PCT, 4, P_STAT_ID) // 4 = Spirit (UNIT_FIELD_STAT4) + } else { + (kind, p0, p0_kind) + }; + + ResolvedEffect { + kind, + p0, + p0_kind, + base_points, + die_sides, + per_level, + period_ms, } +} +/// Resolve one effect's target: the DBC implicit target, then the curated by-name overrides that +/// correct the ones vanilla's data reads wrong. +fn resolve_effect_target( + s: &wow_dbc::vanilla_tables::spell::SpellRow, + i: usize, + kind: u8, + header: &SpellHeader, +) -> u8 { + let name = header.name.as_str(); + let is_negative = header.is_negative; + let channeled = header.channeled; + let target = resolve_target(s.implicit_target_a[i], is_negative); + // Charge/Judgement/Pick Pocket are inherently ENEMY-targeted; Resurrection is inherently + // ALLY-targeted (a dead friend) — the raw DBC implicit target reads wrong for all of these. + // E_TAUNT joined 2026-07-19 (266): Taunt/Growl carry implicit target 6|25 whose polarity + // fallback read ALLY (they're not is_negative), so the faction gate refused every yank. + let target = match kind { + E_CHARGE | E_JUDGEMENT | E_PICKPOCKET | E_INTERRUPT | E_NEXT_SWING | E_TAUNT => { + T_TARGET_ENEMY + } + E_RESURRECT => T_TARGET_ALLY, + // Feint's threat drop acts on the CASTER as source — force T_SELF so it self-targets + // (the handler reads caster_guid) and the faction gate never trips (self-cast bypass). + E_REDUCE_THREAT => T_SELF, + // Blink (116): a self-cast forward teleport — the handler reads caster_guid only and + // ignores any resolved target, so force T_SELF (fires once, bypasses the faction gate). + E_BLINK => T_SELF, + // Ground-AoE (118): anchor at the CASTER (Consecration is caster-centered) — force T_SELF so + // select_targets yields the caster once (the handler stamps the area at that position) and the + // faction gate is bypassed. A clicked-ground variant anchors at the dest coords instead. + E_PERSISTENT_AREA => T_SELF, + // Summon (Summon Imp): the pet is summoned at the CASTER — the handler reads caster_guid and + // ignores the resolved target. Force T_SELF so `select_targets` yields the caster (the + // summon fires exactly once) AND the faction gate is bypassed (a self-cast imposes no + // faction constraint), so casting it while an enemy is selected still summons the pet. + E_SUMMON_PET => T_SELF, + E_DUEL => T_TARGET_ANY, + E_TAME_CREATURE => T_TARGET_ENEMY, + _ => target, + }; + // Slice and Dice (a combo FINISHER) is cast AT the enemy you built combo on (to read + spend + // it); its inert marker effect reads ally-typed in the DBC, which makes the faction gate reject + // the enemy cast. Force the marker enemy-targeted — the self-haste effect (T_SELF) is untouched. + let target = if name == "Slice and Dice" && target == T_TARGET_ALLY { + T_TARGET_ENEMY + } else { + target + }; + // Mind Soothe (453, reduces a hostile creature's aggro radius) and Disarm (676, strips the + // enemy's weapon) are ENEMY debuffs, but their DBC implicit target reads ally/self-typed + // (Mind Soothe → T_SELF/T_TARGET_ALLY; Disarm imports as target=2 = T_TARGET_ALLY), so the + // faction gate would refuse the hostile cast + `select_targets` wouldn't reach the foe. Force + // both onto the enemy. Keyed on name (the Slice and Dice precedent above). + let target = if matches!(name, "Mind Soothe" | "Disarm") { + T_TARGET_ENEMY + } else { + target + }; + // Thunder Clap / Frost Nova are enemy PBAoEs (negative=true) but their DBC implicit + // target reads as a friendly-party code -> mapped T_TARGET_ALLY, so the AoE fan-out + // never fires and the faction gate refuses casting them at a hostile. Force all + // effects to T_AREA_ENEMY so they splash nearby hostiles. (Frost Nova: both effects + // (E_DAMAGE + A_CONTROL M_ROOT) carry target=2 and radius=10 in the DBC; the + // PBAoE fan-out + root already exist in the engine — this is purely a data fix.) + let target = if name == "Thunder Clap" || name == "Frost Nova" { + T_AREA_ENEMY + } else { + target + }; + // Flamestrike (262): the INITIAL-impact nuke (its E_DAMAGE effect) fans out around the + // CLICK — the 118 phase-2 select_targets anchors an area target on the cast's dest when + // one is present. Scoped to E_DAMAGE only: forcing all effects (the Thunder Clap shape) + // would drag the PATCH effect to T_AREA_ENEMY and spawn one ground area per hostile. + let target = if name == "Flamestrike" && kind == E_DAMAGE { + T_AREA_ENEMY + } else { + target + }; + // Battle Shout is a party PBAoE buff (EFFECT_APPLY_AREA_AURA_PARTY in the DBC, + // 30yd radius) but its implicit_target_a=20 (TARGET_UNIT_PARTY_CASTER) maps to + // T_TARGET_ALLY (single ally), so only one party member is buffed in a group. + // Force T_AREA_ALLY so the fan-out engine splashes all nearby allies at the + // DBC-imported radius (30yd). Mirrors the Thunder Clap precedent above. + let target = if name == "Battle Shout" { + T_AREA_ALLY + } else { + target + }; + // Arcane Intellect (1459) imports with implicit_target_a=0 → T_SELF, but it is + // a friendly single-target buff (targets yourself OR an ally). Force T_TARGET_ALLY + // so the faction gate allows casting on a friendly target; the engine falls back to + // the caster when no friendly target is selected (same behaviour as Resurrection). + // Same DBC-collapse trap hits the paladin/priest friendly single-target kit — Holy + // Light (635/639), Lay on Hands (633), Blessing of Might (19740) and Purify (1152) all + // import with implicit_target_a=0 (see work-item 007, archived), so they share this override. + let target = friendly_self_or_ally_target_override(name, target); + // CHANNEL self-marker: a channeled spell carries an inert A_FLAG "you are channeling" marker + // (Arcane Missiles eff2) that the DBC reads as ALLY-targeted — but the channel is cast AT an + // ENEMY, so an ally-typed effect would make the faction gate REJECT the enemy cast (the same + // trap fixed for Slice and Dice / Thunder Clap). Force the marker to T_SELF: it's a caster-side + // flag, so self-targeting it both lands it correctly AND removes it from the faction gate's + // hits_ally/hits_enemy scan (a self-cast effect imposes no faction constraint). Generic over the + // channeled bit + the A_FLAG kind, never a spell id; the channel's A_PERIODIC_TRIGGER tick effect + // is already T_SELF, so only the stray marker is corrected. + if channeled && kind == A_FLAG { + T_SELF + } else { + target + } +} + +/// The `game_trainer_spell` rows for each `--trainer =` binding. +fn trainer_offering_rows( + trainers: &[(u32, Vec)], + spell_levels: &BTreeMap, + wrapper_to_rank: &BTreeMap, +) -> Vec { // --- trainer offerings (game_trainer_spell) — FIREWALL-CLEAN --- // For each `--trainer =` binding, emit one row per offered spell. required_level comes // straight from the DBC `spell_level` captured above; cost from our own level-keyed formula @@ -1794,18 +1964,7 @@ fn build_spell_rows( ); } - Ok(( - SpellRows { - headers: spell_rows, - effects: effect_rows, - fishing: fishing_marker_rows(), - reagents: reagent_rows, - eventai_metadata: eventai_metadata_rows, - trainers: trainer_rows, - }, - cov, - samples, - )) + trainer_rows } /// Fishing (060): the E_FISH marker effect rows for the three tier ids (skill 356 — 7620/7731/7732, diff --git a/module/Cargo.toml b/module/Cargo.toml index f7dac90e6..9f6e3c085 100644 --- a/module/Cargo.toml +++ b/module/Cargo.toml @@ -49,3 +49,6 @@ getrandom = "0.3" # parameter has to arrive as a JSON string literal rather than as bare text — this encodes them. # Already in the lockfile through `lyracore-package-delta`, so it adds nothing to the tree. serde_json = "1" + +[lints] +workspace = true diff --git a/module/src/spell/cast/targeting.rs b/module/src/spell/cast/targeting.rs index 6d6ba361e..ea5578934 100644 --- a/module/src/spell/cast/targeting.rs +++ b/module/src/spell/cast/targeting.rs @@ -766,19 +766,6 @@ pub(crate) fn apply_effect( // never start a proc. triggered: bool, ) -> EffectHit { - // The hit a damage arm below hands to the shared pipeline: this spell, and the proc event its - // source raises — or Triggered, raising nothing, when the whole cast was one. - let hit_of = |source: crate::combat::HitSource, crit: bool| { - crate::combat::Hit::spell( - if triggered { - crate::combat::HitSource::Triggered - } else { - source - }, - hdr.spell_id, - crit, - ) - }; if e.kind & KIND_AURA_BIT != 0 { // CC IMMUNITY: an `A_CONTROL` crowd-control effect (a stun/root/…) is REFUSED if the target is // immune to that mechanic (an active `A_IMMUNITY(mechanic)` aura). The effect simply doesn't land @@ -809,7 +796,7 @@ pub(crate) fn apply_effect( level, points, ); - // Linked-debuff APPLY (Weakened Soul, #013): the twin of the refusal gate in `resolve_cast_at` — a + // Linked-debuff APPLY (Weakened Soul): the twin of the refusal gate in `resolve_cast_at` — a // nonzero `e.p1` names a SEPARATE spell to also apply on `target_guid` right after this aura lands // (PW:Shield's absorb effect names Weakened Soul). Generic over the kind/spell id: any aura effect // can opt in via its `p1`, the engine never names one. No-op if the linked spell/effect isn't loaded @@ -837,6 +824,94 @@ pub(crate) fn apply_effect( } EffectHit::none() } + E_DAMAGE | E_WEAPON_STRIKE | E_JUDGEMENT | E_FINISHER_DAMAGE => apply_damage_effect( + ctx, + e, + hdr, + caster_guid, + target_guid, + level, + points, + triggered, + ), + E_HEAL | E_HEAL_MAX_HEALTH | E_ENERGIZE | E_CONVERT_RESOURCE | E_POWER_BURN + | E_ADD_COMBO => { + apply_restore_effect(ctx, e, hdr, caster_guid, target_guid, points, triggered) + } + E_TAUNT | E_INTERRUPT | E_REDUCE_THREAT | E_NEXT_SWING => { + apply_threat_effect(ctx, e, hdr, caster_guid, target_guid, points) + } + E_CHARGE | E_BLINK | E_RECALL_HOME | E_DISMOUNT | E_PERSISTENT_AREA => { + apply_reposition_effect(ctx, e, hdr, caster_guid, target_guid, points, dest) + } + E_CREATE_ITEM | E_PICKPOCKET => { + apply_inventory_effect(ctx, e, caster_guid, target_guid, points) + } + E_RESURRECT | E_SUMMON_PET | E_TAME_CREATURE | E_SET_STANCE => { + apply_character_effect(ctx, e, caster_guid, target_guid, points) + } + E_TRIGGER | E_SCRIPTED => apply_delegating_effect(ctx, e, caster_guid, target_guid, level), + E_DISPEL => { + // The dispelled CATEGORY: the effect's `p0` (the authored override) if set, else the + // dispelling spell's header `dispel_type`. 0 = strip every foreign aura (the baseline — + // Dispel Magic 527 has p0=0 + dispel_type=0, so it strips everything). + let category = if e.p0 != 0 { + e.p0 as u8 + } else { + hdr.dispel_type + }; + let removed = dispel_target(ctx, target_guid, category); + log::info!("dispel (cat {category}) on {target_guid} removed {removed} debuff(s)"); + EffectHit::none() + } + other => { + // Unmapped residue: a graceful no-op + log (the import safety net — casts and does + // nothing, vs doing the wrong thing). Spells that LOOK scripted in the raw DBC but are + // really a known effect (Holy Light → a heal, Life Tap → a resource convert, Charge → a rush) + // are reclassified to a generic kind at IMPORT time (the importer's curated correction), so + // they arrive here as E_HEAL / E_CONVERT_RESOURCE / E_CHARGE and never reach this arm. + log::info!( + "spell {} effect kind 0x{other:02x} not implemented (graceful no-op)", + e.spell_id + ); + EffectHit::none() + } + } +} + +/// The hit a damage arm hands to the shared pipeline: this spell, and the proc event its source +/// raises — or Triggered, raising nothing, when the whole cast was one. +fn hit_of( + hdr: &Spell, + triggered: bool, + source: crate::combat::HitSource, + crit: bool, +) -> crate::combat::Hit { + crate::combat::Hit::spell( + if triggered { + crate::combat::HitSource::Triggered + } else { + source + }, + hdr.spell_id, + crit, + ) +} + +#[allow(clippy::too_many_arguments)] +/// The damaging effects: the spell nuke, the weapon-derived strike, the judgement, and the +/// combo-point finisher. Each returns the post-mitigation figures the caller relays. +fn apply_damage_effect( + ctx: &ReducerContext, + e: &SpellEffect, + hdr: &Spell, + caster_guid: u64, + target_guid: u64, + level: u8, + points: i32, + triggered: bool, +) -> EffectHit { + match e.kind { E_DAMAGE => { // Binary spell MISS: a level-derived chance the spell fully misses (4% vs equal, up to // 17% vs +3) — a clean 0-damage outcome with NO threat/scaling. Rolled BEFORE everything else, @@ -928,7 +1003,7 @@ pub(crate) fn apply_effect( target_guid, caster_guid, after_resist, - hit_of(crate::combat::HitSource::Spell, is_crit), + hit_of(hdr, triggered, crate::combat::HitSource::Spell, is_crit), ); EffectHit { dealt, @@ -960,13 +1035,68 @@ pub(crate) fn apply_effect( target_guid, caster_guid, dmg as i32, - hit_of(crate::combat::HitSource::MeleeSpell, crit), + hit_of(hdr, triggered, crate::combat::HitSource::MeleeSpell, crit), + ); + EffectHit::dmg(dealt, absorbed) + } else { + EffectHit::none() + } + } + E_JUDGEMENT => { + // Unleash the caster's active SEAL: a one-time holy hit on the target derived from the seal + // value, then consume the seal. Generic — reads the A_SEAL aura, never a spell id. No seal → + // no-op. (Damage = the seal coefficient; the exact vanilla Judgement-of-Righteousness formula + // is a tuning refinement.) + let seal = seal_amount(ctx, caster_guid); + if seal > 0 { + let (dealt, absorbed) = apply_target_damage( + ctx, + target_guid, + caster_guid, + seal, + hit_of(hdr, triggered, crate::combat::HitSource::Spell, false), ); + remove_seal_auras(ctx, caster_guid); EffectHit::dmg(dealt, absorbed) } else { EffectHit::none() } } + E_FINISHER_DAMAGE => { + // Rogue FINISHER: damage scales with the combo points built on this target (base_points is the + // PER-POINT damage), then spend them. Generic — reads game_combo_point, never a spell id. + let combo = crate::combo::combo_points(ctx, caster_guid, target_guid); + let dmg = crate::combo::finisher_damage(points, combo); + let hit = if dmg > 0 { + let (dealt, absorbed) = apply_target_damage( + ctx, + target_guid, + caster_guid, + dmg, + hit_of(hdr, triggered, crate::combat::HitSource::MeleeSpell, false), + ); + EffectHit::dmg(dealt, absorbed) + } else { + EffectHit::none() + }; + crate::combo::spend_combo(ctx, caster_guid, target_guid); + hit + } + _ => EffectHit::none(), + } +} + +/// The restoring effects: heals, energize, the resource convert, the power burn, and combo points. +fn apply_restore_effect( + ctx: &ReducerContext, + e: &SpellEffect, + hdr: &Spell, + caster_guid: u64, + target_guid: u64, + points: i32, + triggered: bool, +) -> EffectHit { + match e.kind { E_HEAL => EffectHit::heal(apply_heal(ctx, hdr, caster_guid, target_guid, points)), E_HEAL_MAX_HEALTH => { // Lay on Hands: heal the target to FULL max health regardless of `points` (the DBC @@ -983,19 +1113,6 @@ pub(crate) fn apply_effect( } EffectHit::none() } - E_DISPEL => { - // The dispelled CATEGORY: the effect's `p0` (the authored override) if set, else the - // dispelling spell's header `dispel_type`. 0 = strip every foreign aura (the baseline — - // Dispel Magic 527 has p0=0 + dispel_type=0, so it strips everything). - let category = if e.p0 != 0 { - e.p0 as u8 - } else { - hdr.dispel_type - }; - let removed = dispel_target(ctx, target_guid, category); - log::info!("dispel (cat {category}) on {target_guid} removed {removed} debuff(s)"); - EffectHit::none() - } E_ENERGIZE => { if let Some(mut t) = ctx.db.game_world_entity().guid().find(target_guid) { t.power = energized_value(t.power, t.max_power, points); @@ -1008,6 +1125,56 @@ pub(crate) fn apply_effect( } EffectHit::none() } + E_CONVERT_RESOURCE => { + convert_health_to_power(ctx, caster_guid, points); + EffectHit::none() + } + E_POWER_BURN => { + // MANA-only gate: the vanilla effect reads the target's power type and returns a + // silent no-op on anything else (rage/energy) — the cast still resolves, it just does + // nothing on this target. Read straight off `unit_bytes_0` byte 3, same as `is_rage_user`. + let Some(mut t) = ctx.db.game_world_entity().guid().find(target_guid) else { + return EffectHit::none(); + }; + if ((t.unit_bytes_0 >> 24) & 0xFF) as u8 != lyracore_shared::packing::power_type::MANA { + return EffectHit::none(); + } + let drained = mana_burn_drain(t.power, points); + if drained == 0 { + return EffectHit::none(); + } + t.power -= drained; + ctx.db.game_world_entity().guid().update(t); + let damage = mana_burn_damage(drained, e.p1); + let (dealt, absorbed) = apply_target_damage( + ctx, + target_guid, + caster_guid, + damage as i32, + hit_of(hdr, triggered, crate::combat::HitSource::Spell, false), + ); + EffectHit::dmg(dealt, absorbed) + } + E_ADD_COMBO => { + // Rogue GENERATOR: +1 combo point on the target (capped at 5); the finisher reads it. Generic + // over game_combo_point, never a spell id. + crate::combo::add_combo_point(ctx, caster_guid, target_guid); + EffectHit::none() + } + _ => EffectHit::none(), + } +} + +/// The threat-table effects: taunt, interrupt, the threat wipe, and the queued next swing. +fn apply_threat_effect( + ctx: &ReducerContext, + e: &SpellEffect, + hdr: &Spell, + caster_guid: u64, + target_guid: u64, + points: i32, +) -> EffectHit { + match e.kind { E_TAUNT => { // Taunt: top the caster's threat on the target CREATURE so the retarget pass switches it to // the caster (the threat-yank). Only meaningful on a creature target — a no-op against a @@ -1024,72 +1191,68 @@ pub(crate) fn apply_effect( } EffectHit::none() } - E_CREATE_ITEM => { - // Conjure / quest item: create `points` of item `e.p0` (a game_item_template entry) in the - // CASTER's inventory — vanilla CreateItem always gives to the caster, not the explicit target. - // Reuses the shared `items::grant_item` (free backpack slot, one stack). A failure (inventory - // full, or the item template not loaded — the importer P4 dependency) is logged, NOT - // propagated: the cast still completes (graceful, like the other handlers). `points` is the - // DBC count (already +1-adjusted at import), floored at 1. - let entry = e.p0.max(0) as u32; - let count = points.max(1) as u32; - if entry == 0 { - log::info!( - "spell {} E_CREATE_ITEM with no item entry (p0=0) — skipped", - e.spell_id - ); - } else if let Err(err) = crate::items::grant_item(ctx, caster_guid, entry, count) { - log::info!( - "spell {} E_CREATE_ITEM: could not grant {count}x item {entry} to {caster_guid}: {err}", - e.spell_id - ); - // CRAFT (282): the reagents were already consumed (the gate in resolve_cast_at), so a - // failed product grant (full bag) must NOT eat them — refund EVERY reagent (vanilla refunds - // an unstorable craft). A conjure/quest CreateItem has no reagents → this loop is empty. - for (item, cnt) in recipe_reagents(ctx, e.spell_id) { - let _ = crate::items::grant_item(ctx, caster_guid, item, cnt); - } - } else if let Some((line, green, gray)) = recipe_skill(ctx, e.spell_id) { - // CRAFT skill-up (282) — the profession-loop's third leg, co-located with the product grant. - // Reaching here means the craft fully succeeded → climb the recipe's OWN profession line one - // step toward its cap, with the REAL difficulty band (green floor .. gray ceiling) from - // game_skill_ability. A no-op if the line isn't learned or is at cap; a degenerate/zero band - // ⇒ always +1 (skillup_chance_bp). Non-craft E_CREATE_ITEM (conjure) has no profession - // ability → None → skipped. - crate::skill::gain_profession_skill(ctx, caster_guid, line, green, gray); - } + E_INTERRUPT => { + // Kick: cancel the TARGET's in-progress (timed) cast — delete its game_pending_cast row(s) so + // the spell never resolves — AND lock the interrupted cast's SCHOOL (the Kick silence: + // `interrupt_cast_and_lock` writes a game_school_lockout row the cast gate reads). Generic over + // the kind, never a spell id; the importer reclassifies the raw vanilla InterruptCast effect + // (68) to E_INTERRUPT. Safe no-op if the target isn't mid-cast (nothing to cancel/lock). Uses + // the lock wrapper (not the bare `interrupt_cast`) so ONLY a Kick locks the school — a CC that + // happens to interrupt a cast (stun/poly/fear) stays a pure cancel. The lockout window comes + // from `game_spell.duration_ms` (the SPELL HEADER row, NOT `game_spell_effect` — SpellEffect + // has no duration_ms column). Vanilla values: Kick = 5000 ms, Counterspell = 8000 ms, + // Pummel = 4000 ms. To change a lockout window, edit `game_spell.duration_ms` on the header + // row; there is no effect-level duration field. Falls back to 5 s when the header carries 0. + let lockout_ms = if hdr.duration_ms > 0 { + hdr.duration_ms as i64 + } else { + 5_000 + }; + interrupt_cast_and_lock(ctx, target_guid, lockout_ms); EffectHit::none() } - E_TRIGGER => { - // Cast the linked spell NOW at the SAME target, routed through the shared cast core so the - // triggered spell runs its OWN effect list (a proc / "cast X which casts Y" chain). Guards: - // `trigger_spell == 0` is no-op (unset link); a spell triggering ITSELF (`trigger_spell == - // e.spell_id`) is skipped to avoid unbounded recursion. The trigger reuses the caster + - // level; its own GCD/cost/range gates apply — a rejected trigger is logged, never panics. - // The triggered spell routes through its OWN resolve_cast_at, which emits its OWN cast-GO event - // (with its OWN damage log) — so its damage is NOT folded into this parent cast's row (no - // double-count / double-log). Return 0 here. - if e.trigger_spell != 0 && e.trigger_spell != e.spell_id { - if let Err(err) = resolve_cast_at( - ctx, - caster_guid, - e.trigger_spell, - level, - target_guid, - false, - false, - None, - ) { - log::info!( - "spell {} triggered {} which did not resolve: {}", - e.spell_id, - e.trigger_spell, - err - ); - } + E_REDUCE_THREAT => { + // Feint: a one-time drop of the CASTER's CURRENT threat by the effect MAGNITUDE on EVERY + // creature it is a source on (floored at 0) — the rogue slides down each mob's aggro list and + // the retarget pass re-points them at the new top source (the tank). The vanilla data encodes + // Feint's threat drop as a NEGATIVE Threat-effect base (e.g. −150), so the reduction amount is + // `|points|` — `reduce_threat_from_source` then SUBTRACTS that positive magnitude. DISTINCT + // from Fade (an A_MOD_COMBAT(COMBAT_THREAT) percent on FUTURE threat). The effect is + // self-sourced — `target_guid` is ignored (the importer forces it T_SELF, so + // `target_guid == caster_guid` here anyway). Generic over the kind, never a spell id. + crate::threat::reduce_threat_from_source(ctx, caster_guid, (points as i64).abs()); + EffectHit::none() + } + E_NEXT_SWING => { + // On-next-swing QUEUE (Heroic Strike / Cleave): instead of hitting NOW, queue this spell onto the + // CASTER's next melee swing. Set `next_swing_spell`; the swing path (combat::resolve_swing) reads + // the queued spell's E_NEXT_SWING base_points as bonus damage on a LANDED swing and clears the + // field. The rage is VALIDATED at press but CHARGED at the swing-fire (resolve_swing, 114) — + // vanilla defers the deduction; a fizzle (rage dropped below cost before the swing) un-queues + // without firing. A missed swing still charges + spends the queue (vanilla). Generic over the kind, never a spell id; + // the importer reclassifies Heroic Strike/Cleave's weapon-damage E_DAMAGE to E_NEXT_SWING by name. + // No-op if the caster left the world. + if let Some(mut c) = ctx.db.game_world_entity().guid().find(caster_guid) { + c.next_swing_spell = e.spell_id; + ctx.db.game_world_entity().guid().update(c); } EffectHit::none() } + _ => EffectHit::none(), + } +} + +/// The effects that move a unit or anchor a patch of ground. +fn apply_reposition_effect( + ctx: &ReducerContext, + e: &SpellEffect, + hdr: &Spell, + caster_guid: u64, + target_guid: u64, + points: i32, + dest: Option<(f32, f32, f32)>, +) -> EffectHit { + match e.kind { E_CHARGE => { charge_to_target(ctx, caster_guid, target_guid); EffectHit::none() @@ -1101,7 +1264,7 @@ pub(crate) fn apply_effect( EffectHit::none() } E_RECALL_HOME => { - // Hearthstone (#387): recall the caster to its bound home, always into instance 0 (the open + // Hearthstone: recall the caster to its bound home, always into instance 0 (the open // world) even from inside a dungeon — `recall_to_home` resolves the durable char row's // home_map/x/y/z itself, so no target/effect params are read here. crate::world::recall_to_home(ctx, caster_guid); @@ -1125,111 +1288,52 @@ pub(crate) fn apply_effect( create_ground_area(ctx, e, hdr, caster_guid, target_guid, points, dest); EffectHit::none() } - E_CONVERT_RESOURCE => { - convert_health_to_power(ctx, caster_guid, points); - EffectHit::none() - } - E_POWER_BURN => { - // MANA-only gate: the vanilla effect reads the target's power type and returns a - // silent no-op on anything else (rage/energy) — the cast still resolves, it just does - // nothing on this target. Read straight off `unit_bytes_0` byte 3, same as `is_rage_user`. - let Some(mut t) = ctx.db.game_world_entity().guid().find(target_guid) else { - return EffectHit::none(); - }; - if ((t.unit_bytes_0 >> 24) & 0xFF) as u8 != lyracore_shared::packing::power_type::MANA { - return EffectHit::none(); - } - let drained = mana_burn_drain(t.power, points); - if drained == 0 { - return EffectHit::none(); - } - t.power -= drained; - ctx.db.game_world_entity().guid().update(t); - let damage = mana_burn_damage(drained, e.p1); - let (dealt, absorbed) = apply_target_damage( - ctx, - target_guid, - caster_guid, - damage as i32, - hit_of(crate::combat::HitSource::Spell, false), - ); - EffectHit::dmg(dealt, absorbed) - } - E_JUDGEMENT => { - // Unleash the caster's active SEAL: a one-time holy hit on the target derived from the seal - // value, then consume the seal. Generic — reads the A_SEAL aura, never a spell id. No seal → - // no-op. (Damage = the seal coefficient; the exact vanilla Judgement-of-Righteousness formula - // is a tuning refinement.) - let seal = seal_amount(ctx, caster_guid); - if seal > 0 { - let (dealt, absorbed) = apply_target_damage( - ctx, - target_guid, - caster_guid, - seal, - hit_of(crate::combat::HitSource::Spell, false), - ); - remove_seal_auras(ctx, caster_guid); - EffectHit::dmg(dealt, absorbed) - } else { - EffectHit::none() - } - } - E_ADD_COMBO => { - // Rogue GENERATOR: +1 combo point on the target (capped at 5); the finisher reads it. Generic - // over game_combo_point, never a spell id. - crate::combo::add_combo_point(ctx, caster_guid, target_guid); - EffectHit::none() - } - E_FINISHER_DAMAGE => { - // Rogue FINISHER: damage scales with the combo points built on this target (base_points is the - // PER-POINT damage), then spend them. Generic — reads game_combo_point, never a spell id. - let combo = crate::combo::combo_points(ctx, caster_guid, target_guid); - let dmg = crate::combo::finisher_damage(points, combo); - let hit = if dmg > 0 { - let (dealt, absorbed) = apply_target_damage( - ctx, - target_guid, - caster_guid, - dmg, - hit_of(crate::combat::HitSource::MeleeSpell, false), + _ => EffectHit::none(), + } +} + +/// The inventory effects: conjuring an item and pickpocketing one. +fn apply_inventory_effect( + ctx: &ReducerContext, + e: &SpellEffect, + caster_guid: u64, + target_guid: u64, + points: i32, +) -> EffectHit { + match e.kind { + E_CREATE_ITEM => { + // Conjure / quest item: create `points` of item `e.p0` (a game_item_template entry) in the + // CASTER's inventory — vanilla CreateItem always gives to the caster, not the explicit target. + // Reuses the shared `items::grant_item` (free backpack slot, one stack). A failure (inventory + // full, or the item template not loaded — the importer P4 dependency) is logged, NOT + // propagated: the cast still completes (graceful, like the other handlers). `points` is the + // DBC count (already +1-adjusted at import), floored at 1. + let entry = e.p0.max(0) as u32; + let count = points.max(1) as u32; + if entry == 0 { + log::info!( + "spell {} E_CREATE_ITEM with no item entry (p0=0) — skipped", + e.spell_id ); - EffectHit::dmg(dealt, absorbed) - } else { - EffectHit::none() - }; - crate::combo::spend_combo(ctx, caster_guid, target_guid); - hit - } - E_RESURRECT => { - // Offer a revive to a DEAD ally: emit SMSG_RESURRECT_REQUEST and wait for the target's - // CMSG_RESURRECT_RESPONSE accept — vanilla never revives a player without their consent. - // No-op on a living target (nothing to offer) and on a non-player target (creatures have no - // client to prompt; a future "always accept" NPC-rez path is out of scope). The actual revive - // (restore `points`% of max hp/power + clear the death/ghost state, mirroring corpse.rs - // `reclaim_corpse`) lives in `resurrect_response`, gated on accept. - if let Some(t) = ctx.db.game_world_entity().guid().find(target_guid) { - if t.dead && t.is_player() { - let caster_name = ctx - .db - .game_character() - .guid() - .find(caster_guid) - .map(|c| c.name) - .unwrap_or_default(); - ctx.db - .game_resurrect_request() - .target_guid() - .delete(target_guid); - ctx.db.game_resurrect_request().insert(ResurrectRequest { - target_guid, - target_identity: t.owner_identity, - caster_guid, - caster_name, - points, - created_at: ctx.timestamp, - }); + } else if let Err(err) = crate::items::grant_item(ctx, caster_guid, entry, count) { + log::info!( + "spell {} E_CREATE_ITEM: could not grant {count}x item {entry} to {caster_guid}: {err}", + e.spell_id + ); + // CRAFT (282): the reagents were already consumed (the gate in resolve_cast_at), so a + // failed product grant (full bag) must NOT eat them — refund EVERY reagent (vanilla refunds + // an unstorable craft). A conjure/quest CreateItem has no reagents → this loop is empty. + for (item, cnt) in recipe_reagents(ctx, e.spell_id) { + let _ = crate::items::grant_item(ctx, caster_guid, item, cnt); } + } else if let Some((line, green, gray)) = recipe_skill(ctx, e.spell_id) { + // CRAFT skill-up (282) — the profession-loop's third leg, co-located with the product grant. + // Reaching here means the craft fully succeeded → climb the recipe's OWN profession line one + // step toward its cap, with the REAL difficulty band (green floor .. gray ceiling) from + // game_skill_ability. A no-op if the line isn't learned or is at cap; a degenerate/zero band + // ⇒ always +1 (skillup_chance_bp). Non-craft E_CREATE_ITEM (conjure) has no profession + // ability → None → skipped. + crate::skill::gain_profession_skill(ctx, caster_guid, line, green, gray); } EffectHit::none() } @@ -1270,50 +1374,126 @@ pub(crate) fn apply_effect( } EffectHit::none() } - E_INTERRUPT => { - // Kick: cancel the TARGET's in-progress (timed) cast — delete its game_pending_cast row(s) so - // the spell never resolves — AND lock the interrupted cast's SCHOOL (the Kick silence: - // `interrupt_cast_and_lock` writes a game_school_lockout row the cast gate reads). Generic over - // the kind, never a spell id; the importer reclassifies the raw vanilla InterruptCast effect - // (68) to E_INTERRUPT. Safe no-op if the target isn't mid-cast (nothing to cancel/lock). Uses - // the lock wrapper (not the bare `interrupt_cast`) so ONLY a Kick locks the school — a CC that - // happens to interrupt a cast (stun/poly/fear) stays a pure cancel. The lockout window comes - // from `game_spell.duration_ms` (the SPELL HEADER row, NOT `game_spell_effect` — SpellEffect - // has no duration_ms column). Vanilla values: Kick = 5000 ms, Counterspell = 8000 ms, - // Pummel = 4000 ms. To change a lockout window, edit `game_spell.duration_ms` on the header - // row; there is no effect-level duration field. Falls back to 5 s when the header carries 0. - let lockout_ms = if hdr.duration_ms > 0 { - hdr.duration_ms as i64 - } else { - 5_000 - }; - interrupt_cast_and_lock(ctx, target_guid, lockout_ms); + _ => EffectHit::none(), + } +} + +/// The effects that hand the work to another engine path: a triggered cast, and a Runtime Script. +fn apply_delegating_effect( + ctx: &ReducerContext, + e: &SpellEffect, + caster_guid: u64, + target_guid: u64, + level: u8, +) -> EffectHit { + match e.kind { + E_TRIGGER => { + // Cast the linked spell NOW at the SAME target, routed through the shared cast core so the + // triggered spell runs its OWN effect list (a proc / "cast X which casts Y" chain). Guards: + // `trigger_spell == 0` is no-op (unset link); a spell triggering ITSELF (`trigger_spell == + // e.spell_id`) is skipped to avoid unbounded recursion. The trigger reuses the caster + + // level; its own GCD/cost/range gates apply — a rejected trigger is logged, never panics. + // The triggered spell routes through its OWN resolve_cast_at, which emits its OWN cast-GO event + // (with its OWN damage log) — so its damage is NOT folded into this parent cast's row (no + // double-count / double-log). Return 0 here. + if e.trigger_spell != 0 && e.trigger_spell != e.spell_id { + if let Err(err) = resolve_cast_at( + ctx, + caster_guid, + e.trigger_spell, + level, + target_guid, + false, + false, + None, + ) { + log::info!( + "spell {} triggered {} which did not resolve: {}", + e.spell_id, + e.trigger_spell, + err + ); + } + } EffectHit::none() } - E_REDUCE_THREAT => { - // Feint: a one-time drop of the CASTER's CURRENT threat by the effect MAGNITUDE on EVERY - // creature it is a source on (floored at 0) — the rogue slides down each mob's aggro list and - // the retarget pass re-points them at the new top source (the tank). The vanilla data encodes - // Feint's threat drop as a NEGATIVE Threat-effect base (e.g. −150), so the reduction amount is - // `|points|` — `reduce_threat_from_source` then SUBTRACTS that positive magnitude. DISTINCT - // from Fade (an A_MOD_COMBAT(COMBAT_THREAT) percent on FUTURE threat). The effect is - // self-sourced — `target_guid` is ignored (the importer forces it T_SELF, so - // `target_guid == caster_guid` here anyway). Generic over the kind, never a spell id. - crate::threat::reduce_threat_from_source(ctx, caster_guid, (points as i64).abs()); + E_SCRIPTED => { + // script_id == 0 is the vanilla no-op — every seed/imported spell (including the + // importer's fallback for a raw effect it cannot map to a known kind) carries it, and + // this arm changes nothing for them. + if e.script_id != 0 { + // The precast gate (`check_cast_gate_suffix`) already refused the whole cast if + // this script was missing or disabled — this is the SAME lookup, re-run because + // the row can change between the gate and here (an Operator disables it mid-cast, + // or an earlier effect in this same cast removed it). A miss here fails only THIS + // effect: no panic, no late refusal — the cost is already spent and the cast's + // other effects still apply. + if let Some(script) = ctx.db.game_script().script_id().find(e.script_id) { + if script.enabled { + // Label carries the spell + effect identity a Script Diagnostic names on + // failure, and what `event.name` reads as inside the script. Distinct from + // an Event Binding's event name (there is no binding here — script_id + // names the script outright). + let event = crate::runtime_script::ScriptEvent { + name: format!("spell_effect:{}:{}", e.spell_id, e.effect_index), + actor: crate::runtime_script::EntityView::read(ctx, caster_guid), + target: crate::runtime_script::EntityView::read(ctx, target_guid), + }; + crate::runtime_script::invoke_by_identity( + ctx, + crate::runtime_script::RuntimeScript { + name: &script.name, + source: &script.source, + }, + &event, + ); + } + } + } EffectHit::none() } - E_NEXT_SWING => { - // On-next-swing QUEUE (Heroic Strike / Cleave): instead of hitting NOW, queue this spell onto the - // CASTER's next melee swing. Set `next_swing_spell`; the swing path (combat::resolve_swing) reads - // the queued spell's E_NEXT_SWING base_points as bonus damage on a LANDED swing and clears the - // field. The rage is VALIDATED at press but CHARGED at the swing-fire (resolve_swing, 114) — - // vanilla defers the deduction; a fizzle (rage dropped below cost before the swing) un-queues - // without firing. A missed swing still charges + spends the queue (vanilla). Generic over the kind, never a spell id; - // the importer reclassifies Heroic Strike/Cleave's weapon-damage E_DAMAGE to E_NEXT_SWING by name. - // No-op if the caster left the world. - if let Some(mut c) = ctx.db.game_world_entity().guid().find(caster_guid) { - c.next_swing_spell = e.spell_id; - ctx.db.game_world_entity().guid().update(c); + _ => EffectHit::none(), + } +} + +/// The effects that change what a character IS: resurrected, accompanied by a pet, or in a stance. +fn apply_character_effect( + ctx: &ReducerContext, + e: &SpellEffect, + caster_guid: u64, + target_guid: u64, + points: i32, +) -> EffectHit { + match e.kind { + E_RESURRECT => { + // Offer a revive to a DEAD ally: emit SMSG_RESURRECT_REQUEST and wait for the target's + // CMSG_RESURRECT_RESPONSE accept — vanilla never revives a player without their consent. + // No-op on a living target (nothing to offer) and on a non-player target (creatures have no + // client to prompt; a future "always accept" NPC-rez path is out of scope). The actual revive + // (restore `points`% of max hp/power + clear the death/ghost state, mirroring corpse.rs + // `reclaim_corpse`) lives in `resurrect_response`, gated on accept. + if let Some(t) = ctx.db.game_world_entity().guid().find(target_guid) { + if t.dead && t.is_player() { + let caster_name = ctx + .db + .game_character() + .guid() + .find(caster_guid) + .map(|c| c.name) + .unwrap_or_default(); + ctx.db + .game_resurrect_request() + .target_guid() + .delete(target_guid); + ctx.db.game_resurrect_request().insert(ResurrectRequest { + target_guid, + target_identity: t.owner_identity, + caster_guid, + caster_name, + points, + created_at: ctx.timestamp, + }); + } } EffectHit::none() } @@ -1381,53 +1561,7 @@ pub(crate) fn apply_effect( } EffectHit::none() } - E_SCRIPTED => { - // script_id == 0 is the vanilla no-op — every seed/imported spell (including the - // importer's fallback for a raw effect it cannot map to a known kind) carries it, and - // this arm changes nothing for them. - if e.script_id != 0 { - // The precast gate (`check_cast_gate_suffix`) already refused the whole cast if - // this script was missing or disabled — this is the SAME lookup, re-run because - // the row can change between the gate and here (an Operator disables it mid-cast, - // or an earlier effect in this same cast removed it). A miss here fails only THIS - // effect: no panic, no late refusal — the cost is already spent and the cast's - // other effects still apply. - if let Some(script) = ctx.db.game_script().script_id().find(e.script_id) { - if script.enabled { - // Label carries the spell + effect identity a Script Diagnostic names on - // failure, and what `event.name` reads as inside the script. Distinct from - // an Event Binding's event name (there is no binding here — script_id - // names the script outright). - let event = crate::runtime_script::ScriptEvent { - name: format!("spell_effect:{}:{}", e.spell_id, e.effect_index), - actor: crate::runtime_script::EntityView::read(ctx, caster_guid), - target: crate::runtime_script::EntityView::read(ctx, target_guid), - }; - crate::runtime_script::invoke_by_identity( - ctx, - crate::runtime_script::RuntimeScript { - name: &script.name, - source: &script.source, - }, - &event, - ); - } - } - } - EffectHit::none() - } - other => { - // Unmapped residue: a graceful no-op + log (the import safety net — casts and does - // nothing, vs doing the wrong thing). Spells that LOOK scripted in the raw DBC but are - // really a known effect (Holy Light → a heal, Life Tap → a resource convert, Charge → a rush) - // are reclassified to a generic kind at IMPORT time (the importer's curated correction), so - // they arrive here as E_HEAL / E_CONVERT_RESOURCE / E_CHARGE and never reach this arm. - log::info!( - "spell {} effect kind 0x{other:02x} not implemented (graceful no-op)", - e.spell_id - ); - EffectHit::none() - } + _ => EffectHit::none(), } } @@ -1997,7 +2131,10 @@ mod effect_handler_tests { fn thirty_two_buffs_full_refuses_the_thirty_third_leaving_the_thirty_two_untouched() { let buffs_full: Vec<(u8, u32)> = (0u8..32).map(|slot| (slot, slot as u32 + 2000)).collect(); assert_eq!(buffs_full.len(), 32); - assert_eq!(super::pick_aura_slot(&buffs_full, 8888, false, 32, 48), None); + assert_eq!( + super::pick_aura_slot(&buffs_full, 8888, false, 32, 48), + None + ); for &(slot, spell_id) in &buffs_full { assert_eq!( super::pick_aura_slot(&buffs_full, spell_id, false, 32, 48), @@ -2028,7 +2165,11 @@ mod effect_handler_tests { // Both ranges full simultaneously: a fresh debuff is refused (debuff range is what's full for // it) and a fresh buff is refused (buff range is what's full for it) — each by its OWN range, // not by the other polarity's occupancy. - let both_full: Vec<(u8, u32)> = buffs_full.iter().chain(debuffs_full.iter()).copied().collect(); + let both_full: Vec<(u8, u32)> = buffs_full + .iter() + .chain(debuffs_full.iter()) + .copied() + .collect(); assert_eq!(super::pick_aura_slot(&both_full, 9999, true, 32, 48), None); assert_eq!(super::pick_aura_slot(&both_full, 8888, false, 32, 48), None); }