diff --git a/gateway/src/stdb/bindings/creature_instruction_type.rs b/gateway/src/stdb/bindings/creature_instruction_type.rs index 8884f38a0..e406e3f50 100644 --- a/gateway/src/stdb/bindings/creature_instruction_type.rs +++ b/gateway/src/stdb/bindings/creature_instruction_type.rs @@ -12,6 +12,7 @@ use super::event_ai_quest_credit_type::EventAiQuestCredit; use super::facing_instruction_type::FacingInstruction; use super::force_despawn_instruction_type::ForceDespawnInstruction; use super::increment_phase_instruction_type::IncrementPhaseInstruction; +use super::instruction_target_type::InstructionTarget; use super::missing_text_template_no_effect_type::MissingTextTemplateNoEffect; use super::movement_operation_type::MovementOperation; use super::notify_encounter_instruction_type::NotifyEncounterInstruction; @@ -95,6 +96,8 @@ pub enum CreatureInstruction { NotifyEncounter(NotifyEncounterInstruction), StartRelay(StartRelayInstruction), + + AttackStart(InstructionTarget), } impl __sdk::InModule for CreatureInstruction { diff --git a/gateway/src/stdb/bindings/game_quest_reward_spell_table.rs b/gateway/src/stdb/bindings/game_quest_reward_spell_table.rs new file mode 100644 index 000000000..c06df5e01 --- /dev/null +++ b/gateway/src/stdb/bindings/game_quest_reward_spell_table.rs @@ -0,0 +1,230 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::quest_reward_spell_type::QuestRewardSpell; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `game_quest_reward_spell`. +/// +/// Obtain a handle from the [`GameQuestRewardSpellTableAccess::game_quest_reward_spell`] method on [`super::RemoteTables`], +/// like `ctx.db.game_quest_reward_spell()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_quest_reward_spell().on_insert(...)`. +pub struct GameQuestRewardSpellTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `game_quest_reward_spell`. +pub struct GameQuestRewardSpellTableAccessor; + +impl __sdk::TableAccessor for GameQuestRewardSpellTableAccessor { + type Row = QuestRewardSpell; + type Handle<'db> = GameQuestRewardSpellTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.game_quest_reward_spell() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `game_quest_reward_spell`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait GameQuestRewardSpellTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`GameQuestRewardSpellTableHandle`], which mediates access to the table `game_quest_reward_spell`. + fn game_quest_reward_spell(&self) -> GameQuestRewardSpellTableHandle<'_>; +} + +impl GameQuestRewardSpellTableAccess for super::RemoteTables { + fn game_quest_reward_spell(&self) -> GameQuestRewardSpellTableHandle<'_> { + GameQuestRewardSpellTableHandle { + imp: self + .imp + .get_table::("game_quest_reward_spell"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct GameQuestRewardSpellInsertCallbackId(__sdk::CallbackId); +pub struct GameQuestRewardSpellDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for GameQuestRewardSpellTableHandle<'ctx> { + type Row = QuestRewardSpell; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for GameQuestRewardSpellTableHandle<'ctx> { + type Row = QuestRewardSpell; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = GameQuestRewardSpellInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameQuestRewardSpellInsertCallbackId { + GameQuestRewardSpellInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameQuestRewardSpellInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = GameQuestRewardSpellDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameQuestRewardSpellDeleteCallbackId { + GameQuestRewardSpellDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameQuestRewardSpellDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for GameQuestRewardSpellTableHandle<'ctx> { + type InsertCallbackId = GameQuestRewardSpellInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameQuestRewardSpellInsertCallbackId { + GameQuestRewardSpellInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameQuestRewardSpellInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for GameQuestRewardSpellTableHandle<'ctx> { + type DeleteCallbackId = GameQuestRewardSpellDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameQuestRewardSpellDeleteCallbackId { + GameQuestRewardSpellDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameQuestRewardSpellDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct GameQuestRewardSpellUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for GameQuestRewardSpellTableHandle<'ctx> { + type UpdateCallbackId = GameQuestRewardSpellUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameQuestRewardSpellUpdateCallbackId { + GameQuestRewardSpellUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameQuestRewardSpellUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for GameQuestRewardSpellTableHandle<'ctx> { + type UpdateCallbackId = GameQuestRewardSpellUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameQuestRewardSpellUpdateCallbackId { + GameQuestRewardSpellUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameQuestRewardSpellUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `quest_entry` unique index on the table `game_quest_reward_spell`, +/// which allows point queries on the field of the same name +/// via the [`GameQuestRewardSpellQuestEntryUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_quest_reward_spell().quest_entry().find(...)`. +pub struct GameQuestRewardSpellQuestEntryUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> GameQuestRewardSpellTableHandle<'ctx> { + /// Get a handle on the `quest_entry` unique index on the table `game_quest_reward_spell`. + pub fn quest_entry(&self) -> GameQuestRewardSpellQuestEntryUnique<'ctx> { + GameQuestRewardSpellQuestEntryUnique { + imp: self.imp.get_unique_constraint::("quest_entry"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> GameQuestRewardSpellQuestEntryUnique<'ctx> { + /// Find the subscribed row whose `quest_entry` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &u32) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("game_quest_reward_spell"); + _table.add_unique_constraint::("quest_entry", |row| &row.quest_entry); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `QuestRewardSpell`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait game_quest_reward_spellQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `QuestRewardSpell`. + fn game_quest_reward_spell(&self) -> __sdk::__query_builder::Table; +} + +impl game_quest_reward_spellQueryTableAccess for __sdk::QueryTableAccessor { + fn game_quest_reward_spell(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("game_quest_reward_spell") + } +} diff --git a/gateway/src/stdb/bindings/mod.rs b/gateway/src/stdb/bindings/mod.rs index 2dd266c44..b08fa61a2 100644 --- a/gateway/src/stdb/bindings/mod.rs +++ b/gateway/src/stdb/bindings/mod.rs @@ -490,6 +490,7 @@ pub mod game_quest_event_requirement_table; pub mod game_quest_objective_table; pub mod game_quest_reward_choice_table; pub mod game_quest_reward_item_table; +pub mod game_quest_reward_spell_table; pub mod game_quest_template_table; pub mod game_quest_text_table; pub mod game_race_info_table; @@ -771,6 +772,7 @@ pub mod quest_event_type; pub mod quest_objective_type; pub mod quest_reward_choice_type; pub mod quest_reward_item_type; +pub mod quest_reward_spell_type; pub mod quest_taken_predicate_type; pub mod quest_template_type; pub mod quest_text_type; @@ -1457,6 +1459,7 @@ pub use game_quest_event_requirement_table::*; pub use game_quest_objective_table::*; pub use game_quest_reward_choice_table::*; pub use game_quest_reward_item_table::*; +pub use game_quest_reward_spell_table::*; pub use game_quest_template_table::*; pub use game_quest_text_table::*; pub use game_race_info_table::*; @@ -1738,6 +1741,7 @@ pub use quest_event_type::QuestEvent; pub use quest_objective_type::QuestObjective; pub use quest_reward_choice_type::QuestRewardChoice; pub use quest_reward_item_type::QuestRewardItem; +pub use quest_reward_spell_type::QuestRewardSpell; pub use quest_taken_predicate_type::QuestTakenPredicate; pub use quest_template_type::QuestTemplate; pub use quest_text_type::QuestText; @@ -6986,6 +6990,7 @@ pub struct DbUpdate { game_quest_objective: __sdk::TableUpdate, game_quest_reward_choice: __sdk::TableUpdate, game_quest_reward_item: __sdk::TableUpdate, + game_quest_reward_spell: __sdk::TableUpdate, game_quest_template: __sdk::TableUpdate, game_quest_text: __sdk::TableUpdate, game_race_info: __sdk::TableUpdate, @@ -7628,6 +7633,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "game_quest_reward_item" => db_update.game_quest_reward_item.append( game_quest_reward_item_table::parse_table_update(table_update)?, ), + "game_quest_reward_spell" => db_update.game_quest_reward_spell.append( + game_quest_reward_spell_table::parse_table_update(table_update)?, + ), "game_quest_template" => db_update .game_quest_template .append(game_quest_template_table::parse_table_update(table_update)?), @@ -8652,6 +8660,12 @@ impl __sdk::DbUpdate for DbUpdate { &self.game_quest_reward_item, ) .with_updates_by_pk(|row| &row.id); + diff.game_quest_reward_spell = cache + .apply_diff_to_table::( + "game_quest_reward_spell", + &self.game_quest_reward_spell, + ) + .with_updates_by_pk(|row| &row.quest_entry); diff.game_quest_template = cache .apply_diff_to_table::("game_quest_template", &self.game_quest_template) .with_updates_by_pk(|row| &row.entry); @@ -9454,6 +9468,9 @@ impl __sdk::DbUpdate for DbUpdate { "game_quest_reward_item" => db_update .game_quest_reward_item .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "game_quest_reward_spell" => db_update + .game_quest_reward_spell + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "game_quest_template" => db_update .game_quest_template .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -10187,6 +10204,9 @@ impl __sdk::DbUpdate for DbUpdate { "game_quest_reward_item" => db_update .game_quest_reward_item .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "game_quest_reward_spell" => db_update + .game_quest_reward_spell + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "game_quest_template" => db_update .game_quest_template .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -10585,6 +10605,7 @@ pub struct AppliedDiff<'r> { game_quest_objective: __sdk::TableAppliedDiff<'r, QuestObjective>, game_quest_reward_choice: __sdk::TableAppliedDiff<'r, QuestRewardChoice>, game_quest_reward_item: __sdk::TableAppliedDiff<'r, QuestRewardItem>, + game_quest_reward_spell: __sdk::TableAppliedDiff<'r, QuestRewardSpell>, game_quest_template: __sdk::TableAppliedDiff<'r, QuestTemplate>, game_quest_text: __sdk::TableAppliedDiff<'r, QuestText>, game_race_info: __sdk::TableAppliedDiff<'r, RaceInfo>, @@ -11478,6 +11499,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.game_quest_reward_item, event, ); + callbacks.invoke_table_row_callbacks::( + "game_quest_reward_spell", + &self.game_quest_reward_spell, + event, + ); callbacks.invoke_table_row_callbacks::( "game_quest_template", &self.game_quest_template, @@ -12638,6 +12664,7 @@ impl __sdk::SpacetimeModule for RemoteModule { game_quest_objective_table::register_table(client_cache); game_quest_reward_choice_table::register_table(client_cache); game_quest_reward_item_table::register_table(client_cache); + game_quest_reward_spell_table::register_table(client_cache); game_quest_template_table::register_table(client_cache); game_quest_text_table::register_table(client_cache); game_race_info_table::register_table(client_cache); @@ -12880,6 +12907,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "game_quest_objective", "game_quest_reward_choice", "game_quest_reward_item", + "game_quest_reward_spell", "game_quest_template", "game_quest_text", "game_race_info", diff --git a/gateway/src/stdb/bindings/quest_reward_spell_type.rs b/gateway/src/stdb/bindings/quest_reward_spell_type.rs new file mode 100644 index 000000000..510bed94d --- /dev/null +++ b/gateway/src/stdb/bindings/quest_reward_spell_type.rs @@ -0,0 +1,52 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct QuestRewardSpell { + pub quest_entry: u32, + pub spell_id: u32, +} + +impl __sdk::InModule for QuestRewardSpell { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `QuestRewardSpell`. +/// +/// Provides typed access to columns for query building. +pub struct QuestRewardSpellCols { + pub quest_entry: __sdk::__query_builder::Col, + pub spell_id: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for QuestRewardSpell { + type Cols = QuestRewardSpellCols; + fn cols(table_name: &'static str) -> Self::Cols { + QuestRewardSpellCols { + quest_entry: __sdk::__query_builder::Col::new(table_name, "quest_entry"), + spell_id: __sdk::__query_builder::Col::new(table_name, "spell_id"), + } + } +} + +/// Indexed column accessor struct for the table `QuestRewardSpell`. +/// +/// Provides typed access to indexed columns for query building. +pub struct QuestRewardSpellIxCols { + pub quest_entry: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for QuestRewardSpell { + type IxCols = QuestRewardSpellIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + QuestRewardSpellIxCols { + quest_entry: __sdk::__query_builder::IxCol::new(table_name, "quest_entry"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for QuestRewardSpell {} diff --git a/importer/manifests/cmangos-classic-z2815.json b/importer/manifests/cmangos-classic-z2815.json index 208db410c..a08ff4436 100644 --- a/importer/manifests/cmangos-classic-z2815.json +++ b/importer/manifests/cmangos-classic-z2815.json @@ -93,6 +93,7 @@ 51, 53, 54, + 55, 56, 57, 58, diff --git a/importer/src/eventai.rs b/importer/src/eventai.rs index 9c5c76bd0..e3106cb42 100644 --- a/importer/src/eventai.rs +++ b/importer/src/eventai.rs @@ -4594,6 +4594,21 @@ fn map_action( ACTION_TEXT | ACTION_TEXT_NEW => { map_speech_action(action, kind, rule_id, slot, broadcasts, relays) } + ACTION_ATTACK_START if action[2..] == [0, 0] => { + let target = map_target(action[1]).map_err(|failure| vec![failure])?; + Ok(NativeAction { + encoded: format!("attack-start:{target}"), + raw_kind: kind, + raw_target: Some(action[1]), + raw_cast_flags: None, + threat_percent: None, + dependencies: Vec::new(), + texts: Vec::new(), + summon_entry: None, + summon_location: None, + normalizations: Vec::new(), + }) + } ACTION_EMOTE => Ok(NativeAction { encoded: format!("emote:{}:self", action[1]), raw_kind: kind, @@ -6898,6 +6913,40 @@ mod tests { assert!(manifest.is_apply_ready(), "{}", manifest.render()); } + #[test] + fn binding_startup_rules_keep_faction_then_attack_the_summoner() { + // classic-db cd0c426a3b2ff56dd518bf009025299468e60fdb startup actions. + let source = parse(&dump(&[ + rule( + 567602, + 5676, + EVENT_TIMER_GENERIC, + 100, + 0, + [1000, 1000, 0, 0, 0, 0], + [[2, 14, 0, 0], [55, 11, 0, 0], [0; 4]], + ), + rule( + 567701, + 5677, + EVENT_SPAWNED, + 100, + 0, + [0; 6], + [[2, 14, 0, 0], [55, 11, 0, 0], [0; 4]], + ), + ])); + let entries = HashSet::from([5676, 5677]); + let plan = source.assemble(&entries, &HashMap::new(), &entries); + assert_eq!(plan.definition_rows.len(), 2); + for row in &plan.definition_rows { + assert!(row.ends_with("faction:14+attack-start:spawner"), "{row}"); + } + let profile = fixture_profile(&plan); + let manifest = plan.compatibility_manifest(&profile, "fixture", LOADER_CONTRACT); + assert!(manifest.is_apply_ready(), "{}", manifest.render()); + } + #[test] fn definitions_keep_subjects_rules_and_instruction_order() { let source = parse(&dump(&[ diff --git a/importer/src/main.rs b/importer/src/main.rs index e5b25f569..1e8b7ebfa 100644 --- a/importer/src/main.rs +++ b/importer/src/main.rs @@ -484,6 +484,8 @@ mod qt { pub const REW_REP_FACTION1: usize = 88; // ..=92 RewRepFaction1..5 (we model 2 slots; +1 = faction2) pub const REW_REP_VALUE1: usize = 93; // ..=97 RewRepValue1..5 (parallel to the faction cols; +1 = value2) pub const REW_OR_REQ_MONEY: usize = 98; // >0 = reward copper, <0 = required copper (clamp ≥ 0) + pub const REW_SPELL: usize = 100; // reward display, also the cast when RewSpellCast is zero + pub const REW_SPELL_CAST: usize = 101; // overrides the reward cast pub const REW_MONEY_MAX_LEVEL: usize = 99; // cmangos RewMoneyMaxLevel (col 99, right after 98); /0.6 = authentic quest XP } mod qr { @@ -653,6 +655,10 @@ fn go_template_row( format!("({entry},{GO_BUTTON},{disp},{n},0,0,0,0,0,{data1},{size})"), None, ), + 8 => ( + format!("({entry},8,{disp},{n},{data0},{data1},0,0,0,0,{size})"), + None, + ), other => ( format!("({entry},{other},{disp},{n},0,0,0,0,0,0,{size})"), None, @@ -2468,6 +2474,7 @@ struct QuestEtl { objectives: Vec, cast_objectives: Vec, reward_items: Vec, + reward_spells: Vec, reward_choices: Vec, relations: Vec, // GAMEOBJECT giver relations (work-item 041: GO 68 "Wanted Poster" starts q176 Wanted: Hogger, @@ -2803,6 +2810,20 @@ fn build_quests( let mut objective_rows: Vec = Vec::new(); let mut cast_objective_rows: Vec = Vec::new(); let mut reward_item_rows: Vec = Vec::new(); + let mut reward_spell_rows = Vec::new(); + // Single LearnSpell wrappers have no other reward behavior to preserve. + let reward_learn_spells: HashMap = parse_table(dump, "spell_template") + .into_iter() + .filter_map(|row| { + let wrapper = field(&row, 0).parse().ok()?; + let effects: Vec<_> = (0..3).filter(|&i| field(&row, 61 + i) != "0").collect(); + if effects.len() != 1 || field(&row, 61 + effects[0]) != "36" { + return None; + } + let learned: u32 = field(&row, 109 + effects[0]).parse().ok()?; + (learned != 0).then_some((wrapper, learned)) + }) + .collect(); let mut reward_choice_rows: Vec = Vec::new(); let mut reward_item_entries: HashSet = HashSet::new(); let mut req_item_entries: HashSet = HashSet::new(); @@ -2822,6 +2843,16 @@ fn build_quests( continue; } valid_quests.insert(entry); + let reward_cast: u32 = field(&row, qt::REW_SPELL_CAST).parse().unwrap_or(0); + let reward_cast = if reward_cast != 0 { + reward_cast + } else { + field(&row, qt::REW_SPELL).parse().unwrap_or(0) + }; + if let Some(learned) = reward_learn_spells.get(&reward_cast) { + reward_spell_rows.push(format!("({entry},{learned})")); + } + let min_level: u32 = field(&row, qt::MIN_LEVEL).parse().unwrap_or(0); let quest_level: i64 = field(&row, qt::QUEST_LEVEL).parse().unwrap_or(0); let money: i64 = field(&row, qt::REW_OR_REQ_MONEY).parse().unwrap_or(0); @@ -3076,6 +3107,7 @@ fn build_quests( objectives: objective_rows, cast_objectives: cast_objective_rows, reward_items: reward_item_rows, + reward_spells: reward_spell_rows, reward_choices: reward_choice_rows, relations: creature_quest_rows, go_relations: go_quest_rows, @@ -4135,14 +4167,9 @@ fn resolve_scope_entries( 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 - // E_SUMMON_PET handler to build the pet entity. Force-include the pet entries so their templates import - // from cmangos creature_template even without a placed spawn. Additive (no spawn is added — pets are - // spawned at runtime by the summon, not seeded). Extend this list as more pets ship (Succubus 1863, - // Felhunter 417). [Tier 3b] - const PET_TEMPLATE_ENTRIES: &[u64] = &[416, 1860]; // Imp (688) + Voidwalker (697). - entries.extend(PET_TEMPLATE_ENTRIES.iter().copied()); + // Spell summons have no placed spawn. Include the pet rewards and both Binding objectives. + const SUMMON_TEMPLATE_ENTRIES: &[u64] = &[416, 1860, 1863, 5676, 5677]; + entries.extend(SUMMON_TEMPLATE_ENTRIES.iter().copied()); // Forced-creature template safety net: the spawn-loop gate above already keeps a force-listed // entry's placed spawns, whether the profile owns it or the legacy flag extends the scope. // But a giver with NO `creature` row (a pure-summon/instanced NPC) produces zero spawns — extend @@ -5160,6 +5187,13 @@ fn push_quest_and_gameobject_statements( stmts.push("DELETE FROM game_quest_objective WHERE id > 0".into()); stmts.push("DELETE FROM game_quest_cast_objective WHERE id > 0".into()); stmts.push("DELETE FROM game_quest_reward_item WHERE id > 0".into()); + stmts.push("DELETE FROM game_quest_reward_spell WHERE quest_entry > 0".into()); + push_insert( + stmts, + "game_quest_reward_spell", + "quest_entry,spell_id", + &quests.reward_spells, + ); 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()); @@ -5830,12 +5864,11 @@ mod tests { "(1731,25,259,'Copper Vein',2770,1,186,300,100,0,0.5)" ); assert_eq!(gather_loot, None); - // QUESTGIVER + an INERT type both fall through to the all-zero catch-all arm (11 cols). An - // absent dump size arrives here as 0 and stays 0. + // A quest giver has no type payload. A spell focus preserves its id and radius. let (qg_row, _) = go_template_row(4000, GO_QUESTGIVER, 50, "Wanted Poster", 1, 2, 0.0); assert_eq!(qg_row, "(4000,2,50,'Wanted Poster',0,0,0,0,0,0,0)"); let (inert_row, _) = go_template_row(5000, 8, 60, "Spell Focus", 3, 4, 1.75); - assert_eq!(inert_row, "(5000,8,60,'Spell Focus',0,0,0,0,0,0,1.75)"); + assert_eq!(inert_row, "(5000,8,60,'Spell Focus',3,4,0,0,0,0,1.75)"); } #[test] @@ -7954,6 +7987,124 @@ mod tests { assert_eq!(factions.get("100"), Some(&"14")); } + #[test] + fn binding_quests_import_source_items_objectives_and_learned_rewards() { + let mut quest_rows = Vec::new(); + let mut wrapper_rows = Vec::new(); + let mut item_rows = Vec::new(); + for (quest, item, objective, wrapper, learned) in [ + (1689, 6928, 5676, 11520, 697), + (1739, 6913, 5677, 11519, 712), + ] { + let mut row = vec!["0".to_string(); 102]; + row[0] = quest.to_string(); + row[30] = "'The Binding'".to_string(); + row[27] = item.to_string(); + row[28] = "1".to_string(); + row[56] = objective.to_string(); + row[60] = "1".to_string(); + row[100] = learned.to_string(); + row[101] = wrapper.to_string(); + quest_rows.push(format!("({})", row.join(","))); + let mut spell = vec!["0".to_string(); 112]; + spell[0] = wrapper.to_string(); + spell[61] = "36".to_string(); + spell[109] = learned.to_string(); + wrapper_rows.push(format!("({})", spell.join(","))); + let mut item_row = vec!["0".to_string(); 128]; + item_row[it::ENTRY] = item.to_string(); + item_row[it::NAME] = "'Binding item'".to_string(); + item_row[it::SPELLID_1] = if item == 6928 { "7728" } else { "8674" }.to_string(); + item_rows.push(format!("({})", item_row.join(","))); + } + let dump = + format!( + "INSERT INTO `creature` VALUES (1,100,0,1,-8949.95,-132.493,83.5312,0,300,300,0,0); \ + INSERT INTO `creature_template` VALUES {}; \ + INSERT INTO `creature_questrelation` VALUES (100,1689),(100,1739); \ + INSERT INTO `quest_template` VALUES {}; \ + INSERT INTO `spell_template` VALUES {}; INSERT INTO `item_template` VALUES {};", + [100, 5676, 5677, 1863].map(|entry| creature_template_row(entry, 0)).join(","), + quest_rows.join(","), + wrapper_rows.join(","), + item_rows.join(",") + ); + let plan = build_dump_plan(&dump, &test_args(), &None, &None).unwrap(); + let rewards = plan + .stmts + .iter() + .find(|s| s.starts_with("INSERT INTO game_quest_reward_spell")) + .unwrap(); + assert!(rewards.contains("(1689,697)")); + assert!(rewards.contains("(1739,712)")); + assert!(!rewards.contains("11520")); + assert!(!rewards.contains("11519")); + let objectives = plan + .stmts + .iter() + .find(|s| s.starts_with("INSERT INTO game_quest_objective")) + .unwrap(); + assert!(objectives.contains(",1689,0,0,5676,1)")); + assert!(objectives.contains(",1739,0,0,5677,1)")); + let items = plan + .stmts + .iter() + .find(|s| s.starts_with("INSERT INTO game_item_template")) + .unwrap(); + assert!(items.contains("(6928,")); + assert!(items.contains("(6913,")); + assert!(items.contains(",7728,0,")); + assert!(items.contains(",8674,0,")); + let creatures = plan + .stmts + .iter() + .find(|s| s.starts_with("INSERT INTO game_creature_template")) + .unwrap(); + for entry in [5676, 5677, 1863] { + assert!(creatures.contains(&format!("({entry},"))); + } + let focus = go_template_row(5_091_040, 8, 1, "Focus", 83, 10, 1.0).0; + assert!(focus.contains(",83,10,0,0,0,0,1)")); + } + + #[test] + fn quest_reward_cast_precedence_and_single_learn_effect_are_preserved() { + let mut quests = Vec::new(); + for (quest, reward, cast) in [ + (5091041, 50941, 0), + (5091042, 50941, 50942), + (5091043, 50941, 50943), + ] { + let mut row = vec!["0".to_string(); 102]; + row[0] = quest.to_string(); + row[30] = "'Reward fixture'".into(); + row[100] = reward.to_string(); + row[101] = cast.to_string(); + quests.push(format!("({})", row.join(","))); + } + let mut spells = Vec::new(); + for (spell, learned, other_effect) in + [(50941, 50944, 0), (50942, 50945, 0), (50943, 50946, 2)] + { + let mut row = vec!["0".to_string(); 112]; + row[0] = spell.to_string(); + row[61] = "36".into(); + row[62] = other_effect.to_string(); + row[109] = learned.to_string(); + spells.push(format!("({})", row.join(","))); + } + let dump = format!("INSERT INTO `creature_questrelation` VALUES (51940,5091041),(51940,5091042),(51940,5091043); INSERT INTO `quest_template` VALUES {}; INSERT INTO `spell_template` VALUES {};", quests.join(","), spells.join(",")); + let empty = std::collections::HashSet::new(); + let quests = build_quests( + &dump, + &std::collections::HashSet::from([51940]), + &empty, + &empty, + &empty, + ); + assert_eq!(quests.reward_spells, ["(5091041,50944)", "(5091042,50945)"]); + } + /// A minimal `quest_template` INSERT tuple wide enough to reach `qt::REW_MONEY_MAX_LEVEL` (col 99): /// entry at col 0, title at col 30, everything else `0`. Mirrors `creature_template_row`'s pattern. fn quest_template_row(entry: u64, title: &str) -> String { diff --git a/importer/src/spell.rs b/importer/src/spell.rs index 927c7fca1..14a6624f3 100644 --- a/importer/src/spell.rs +++ b/importer/src/spell.rs @@ -68,6 +68,18 @@ const E_OPEN_LOCK: u8 = 0x1D; // OPEN LOCK (Pick Lock 1804, work-item 119): gate const E_DUEL: u8 = 0x22; // Duel (raw effect 83): p0 is the duel-flag gameobject template entry const E_DISENCHANT: u8 = 0x18; // DISENCHANT (real Disenchant 13262, work-item 282): gateway-intercepted, routed to the disenchant reducer by kind. Mapped from raw vanilla effect 99 (SPELL_EFFECT_DISENCHANT); no params (the module validates + yields dust by item). Lockstep with the module taxonomy (module/src/spell/taxonomy.rs E_DISENCHANT). const E_DISMOUNT: u8 = 0x23; // remove the target's active land mount (Dazed's mount-removal half): translated from a raw DISPEL_MECHANIC effect (108) whose misc value names the mount mechanic (21) — see `dismount_effect_kind` below. No params. Lockstep with module taxonomy. +const E_SUMMON_HOSTILE: u8 = 0x24; // temporary ownerless summon; p0 = creature entry, p1 = required spell focus, header duration = lifetime + +// cmangos/classic-db cd0c426a3b2ff56dd518bf009025299468e60fdb: +// spell_template 7728/8674 send events 1131/1134; dbscripts_on_event summons +// 5676/5677 for 360000 ms. Their creature_ai_scripts set faction 14 and attack the summoner. +fn binding_summon_entry(spell_id: u32, effect: i32, event: u32, focus: u32) -> Option { + match (spell_id, effect, event, focus) { + (7728, 61, 1131, 83) => Some(5676), + (8674, 61, 1134, 83) => Some(5677), + _ => None, + } +} // aura effects (high bit set) const A_PERIODIC_DAMAGE: u8 = 0x90; @@ -118,7 +130,7 @@ const P_COMBAT_FIELD: u8 = 5; const P_SPEED_KIND: u8 = 6; const P_FLAG: u8 = 7; const P_ITEM_ENTRY: u8 = 8; -const P_ENTRY: u8 = 9; // p0 is a game_creature_template entry (E_SUMMON_PET — the summoned pet's creature entry) +const P_ENTRY: u8 = 9; // p0 is a game_creature_template entry const P_SPELLMOD_OP: u8 = 11; // p0 is a SpellModOp (A_SPELLMOD_*) const P_PCT_MAX_POWER: u8 = 12; // the effect's `amount` is a PERCENT of the caster's max power (Evocation); aura_apply converts it to an absolute per-tick (lockstep with module taxonomy) const P_GAMEOBJECT_ENTRY: u8 = 13; // p0 is a game_gameobject_template entry (E_DUEL) @@ -1407,6 +1419,20 @@ fn push_spell_header_row( proc_chance, proc_charges, }); + if binding_summon_entry( + spell_id, + s.effect[0], + s.effect_misc_value[0], + s.requires_spell_focus.id, + ) + .is_some() + { + let row = spell_rows + .last_mut() + .expect("the spell header was just inserted"); + row.duration_ms = 360_000; + row.cooldown_ms = 180_000; + } cov.spells += 1; // Allowlist diagnostics: one header line per requested spell so the operator sees the full @@ -1514,6 +1540,16 @@ fn push_spell_effect_rows( per_level, period_ms, } = resolve_effect_kind(s, i, header, creature_displays, cov); + let binding_entry = binding_summon_entry( + spell_id, + effect_id, + s.effect_misc_value[i], + s.requires_spell_focus.id, + ); + let (kind, p0, p0_kind) = match binding_entry { + Some(entry) => (E_SUMMON_HOSTILE, entry, P_ENTRY), + None => (kind, p0, p0_kind), + }; 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 → @@ -1557,7 +1593,9 @@ fn push_spell_effect_rows( 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 { + let p1 = if kind == E_SUMMON_HOSTILE { + s.requires_spell_focus.id as i32 + } else 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 @@ -1825,7 +1863,7 @@ fn resolve_effect_target( // 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_SUMMON_PET | E_SUMMON_HOSTILE => T_SELF, E_DUEL => T_TARGET_ANY, E_TAME_CREATURE => T_TARGET_ENEMY, _ => target, @@ -2170,6 +2208,7 @@ fn kind_name(kind: u8) -> &'static str { E_NEXT_SWING => "E_NEXT_SWING", E_SET_STANCE => "E_SET_STANCE", E_SUMMON_PET => "E_SUMMON_PET", + E_SUMMON_HOSTILE => "E_SUMMON_HOSTILE", E_HEAL_MAX_HEALTH => "E_HEAL_MAX_HEALTH", E_TAME_CREATURE => "E_TAME_CREATURE", E_FEED_PET => "E_FEED_PET", @@ -3427,3 +3466,103 @@ mod tests { assert_eq!(aura_interrupt_bits(0, 458), 0); // a mount id is NOT on the force-on list } } + +#[cfg(test)] +mod binding_tests { + use super::*; + use wow_dbc::vanilla_tables::spell::SpellKey; + use wow_dbc::vanilla_tables::spell_cast_times::{SpellCastTimesKey, SpellCastTimesRow}; + use wow_dbc::vanilla_tables::spell_focus_object::SpellFocusObjectKey; + use wow_dbc::vanilla_tables::spell_range::SpellRangeRow; + + fn source_spell(spell_id: u32, event: u32) -> wow_dbc::vanilla_tables::spell::SpellRow { + let mut bytes = b"WDBC".to_vec(); + for value in [1u32, 173, 692, 1] { + bytes.extend(value.to_le_bytes()); + } + bytes.resize(20 + 692 + 1, 0); + let mut row = DbcSpell::read(&mut bytes.as_slice()) + .unwrap() + .rows + .remove(0); + row.id = SpellKey::new(spell_id); + row.requires_spell_focus = SpellFocusObjectKey::new(83); + row.effect = [61, 86, 0]; + row.effect_misc_value = [event, 1, 0]; + row.implicit_target_a = [0, 40, 0]; + row.casting_time_index = SpellCastTimesKey::new(7); + row.speed = f32::from_bits(7); + row + } + + #[test] + fn binding_source_events_import_one_hostile_summon_and_keep_the_focus() { + // Facts from classic-db cd0c426a3b2ff56dd518bf009025299468e60fdb, + // spell_template and dbscripts_on_event. This fixture contains no client archive bytes. + for (spell_id, event, entry) in [(7728, 1131, 5676), (8674, 1134, 5677)] { + let row = source_spell(spell_id, event); + let dbc = SpellDbc { + spells: DbcSpell { rows: vec![] }, + cast_times: SpellCastTimes { + rows: vec![SpellCastTimesRow { + id: SpellCastTimesKey::new(7), + base: 10_000, + per_level_increase: 0, + minimum: 0, + }], + }, + ranges: SpellRange { + rows: vec![SpellRangeRow { + id: SpellRangeKey::new(7), + range_min: 0.0, + range_max: 10.0, + flags: 0, + display_name: row.name.clone(), + display_name_short: row.name.clone(), + }], + }, + durations: SpellDuration { rows: vec![] }, + radii: SpellRadius { rows: vec![] }, + }; + let mut acc = SpellAccumulator::default(); + let allow = std::collections::HashSet::new(); + let header = push_spell_header_row(&row, &dbc, &allow, &mut acc); + push_spell_effect_rows(&row, &header, &dbc, &BTreeMap::new(), &allow, &mut acc); + let summons: Vec<_> = acc + .effect_rows + .iter() + .filter(|e| e.kind == E_SUMMON_HOSTILE) + .collect(); + assert_eq!(summons.len(), 1); + assert_eq!( + ( + summons[0].p0, + summons[0].p0_kind, + summons[0].p1, + summons[0].target + ), + (entry, P_ENTRY, 83, T_SELF) + ); + assert_eq!(acc.spell_rows[0].cast_time_ms, 10_000); + assert_eq!(acc.spell_rows[0].range_yd, 10); + assert_eq!(acc.spell_rows[0].duration_ms, 360_000); + assert_eq!(acc.spell_rows[0].cooldown_ms, 180_000); + assert_eq!(acc.effect_rows[1].kind, E_SCRIPTED); + } + } + + #[test] + fn unrelated_events_and_owned_pet_effects_are_not_binding_summons() { + for (spell, effect, event, focus) in [ + (688, 56, 416, 0), + (7728, 61, 1134, 83), + (8674, 61, 1134, 43), + (999, 61, 1131, 83), + (7728, 86, 1131, 83), + ] { + assert_eq!(binding_summon_entry(spell, effect, event, focus), None); + } + assert_eq!(instant_effect_to_kind(56), E_SUMMON_PET); + assert_eq!(resolve_instant_params(E_SUMMON_PET, 416, 0), (416, P_ENTRY)); + } +} diff --git a/module/src/combat/engage.rs b/module/src/combat/engage.rs index 8886e3028..f1969f7c9 100644 --- a/module/src/combat/engage.rs +++ b/module/src/combat/engage.rs @@ -562,6 +562,18 @@ pub(crate) fn apply_start_attack( // the mount up. No-op for an unmounted attacker, and idempotent on a re-target. crate::mount::dismount(ctx, attacker.guid); + if !attacker.is_player() { + if arm_creature_engagement(ctx, attacker.guid, target_guid, false) { + return Ok(()); + } + if let Some(mut creature) = ctx.db.game_world_entity().guid().find(attacker.guid) { + if creature.target_guid != target_guid { + creature.target_guid = target_guid; + ctx.db.game_world_entity().guid().update(creature); + } + } + } + // Arm the engagement (the tick gates each swing on range + timer). Re-arming retargets. let melee = ctx.db.game_melee_attack(); let row = MeleeAttack { diff --git a/module/src/creatures/cycle/harness.rs b/module/src/creatures/cycle/harness.rs index d2a80e2e2..9ac47be4a 100644 --- a/module/src/creatures/cycle/harness.rs +++ b/module/src/creatures/cycle/harness.rs @@ -2145,6 +2145,20 @@ impl EventAiWorld for Scenario { EngageSink::engage(self, summon_guid, target_guid, Pull::Assisted); } + fn eventai_start_attack(&mut self, creature_guid: u64, target_guid: u64) -> bool { + if let Some(fight) = self + .fights + .borrow_mut() + .iter_mut() + .find(|fight| fight.attacker == creature_guid) + { + fight.victim = target_guid; + return true; + } + EngageSink::engage(self, creature_guid, target_guid, Pull::Noticed); + true + } + fn eventai_remove_aura(&mut self, target_guid: u64, spell_id: u32) -> bool { if self.eventai_unit(target_guid).is_none() { return false; diff --git a/module/src/creatures/eventai/combat.rs b/module/src/creatures/eventai/combat.rs index 538e0acd6..a5a7faca4 100644 --- a/module/src/creatures/eventai/combat.rs +++ b/module/src/creatures/eventai/combat.rs @@ -346,6 +346,23 @@ pub(super) fn execute( choice: u64, ) -> ActionResult { match instruction { + CreatureInstruction::AttackStart(target) => { + let Some(selected) = unit_target(world, context, *target, None, choice) else { + return ActionResult::Refused; + }; + if selected == context.creature_guid + || world + .eventai_unit(context.creature_guid) + .is_none_or(|actor| actor.dead) + { + return ActionResult::Refused; + } + if world.eventai_start_attack(context.creature_guid, selected) { + ActionResult::Applied + } else { + ActionResult::Refused + } + } CreatureInstruction::Emote(emote) => { let Some(target_guid) = unit_target(world, context, emote.target, None, choice) else { return ActionResult::Refused; diff --git a/module/src/creatures/eventai/engine.rs b/module/src/creatures/eventai/engine.rs index 242df81e6..5f7227b12 100644 --- a/module/src/creatures/eventai/engine.rs +++ b/module/src/creatures/eventai/engine.rs @@ -134,6 +134,7 @@ pub(crate) trait EventAiWorld { ); /// The fresh summon joins the fight against `target_guid`. fn eventai_engage_summon(&mut self, summon_guid: u64, target_guid: u64); + fn eventai_start_attack(&mut self, creature_guid: u64, target_guid: u64) -> bool; fn eventai_remove_aura(&mut self, target_guid: u64, spell_id: u32) -> bool; fn eventai_force_despawn(&mut self, creature_guid: u64, delay_ms: u32) -> bool; fn eventai_throw_ai_event( @@ -433,7 +434,8 @@ fn execute_instruction( world.set_eventai_phase(context.creature_guid, phase); ActionResult::Applied } - CreatureInstruction::Emote(_) + CreatureInstruction::AttackStart(_) + | CreatureInstruction::Emote(_) | CreatureInstruction::RandomEmote(_) | CreatureInstruction::FleeForAssist | CreatureInstruction::CallForHelp(_) => { @@ -764,6 +766,7 @@ fn rule_uses_linked_random(rule: &EventAiRule) -> bool { .any(|instruction| match instruction { CreatureInstruction::Speak(speech) => speech.broadcast_ids.len() > 1, CreatureInstruction::Cast(cast) => random_target(cast.target), + CreatureInstruction::AttackStart(target) => random_target(*target), CreatureInstruction::Emote(emote) => random_target(emote.target), CreatureInstruction::RandomEmote(_) => true, CreatureInstruction::Summon(summon) => random_target(summon.target), @@ -834,7 +837,7 @@ impl<'a> DatabaseWorld<'a> { } } -fn unit_of(entity: &WorldEntity) -> EventAiUnit { +pub(super) fn unit_of(entity: &WorldEntity) -> EventAiUnit { EventAiUnit { guid: entity.guid, entry: entity.entry, @@ -1504,6 +1507,10 @@ impl EventAiWorld for DatabaseWorld<'_> { super::mobility::engage_summon(self.ctx, summon_guid, target_guid) } + fn eventai_start_attack(&mut self, creature_guid: u64, target_guid: u64) -> bool { + crate::combat::apply_start_attack(self.ctx, creature_guid, target_guid).is_ok() + } + fn eventai_remove_aura(&mut self, target_guid: u64, spell_id: u32) -> bool { if self .ctx diff --git a/module/src/creatures/eventai/loader.rs b/module/src/creatures/eventai/loader.rs index fb46542d8..0f1c1bf2c 100644 --- a/module/src/creatures/eventai/loader.rs +++ b/module/src/creatures/eventai/loader.rs @@ -578,6 +578,7 @@ fn parse_instruction(encoded: &str) -> Result { emote_ids, })) } + ["attack-start", target] => Ok(CreatureInstruction::AttackStart(parse_target(target)?)), ["flee"] => Ok(CreatureInstruction::FleeForAssist), ["help", radius] => Ok(CreatureInstruction::CallForHelp(CallForHelpInstruction { radius_yd: parse_u32(radius)?, diff --git a/module/src/creatures/eventai/mobility.rs b/module/src/creatures/eventai/mobility.rs index e4bcc14af..41a3092c8 100644 --- a/module/src/creatures/eventai/mobility.rs +++ b/module/src/creatures/eventai/mobility.rs @@ -506,29 +506,27 @@ pub(super) fn place_relay_summon( .game_world_entity() .guid() .find(summoner_guid) - .map(|entity| super::EventAiUnit { - guid: entity.guid, - entry: entity.entry, - x: entity.x, - y: entity.y, - z: entity.z, - map_id: entity.map_id, - instance_id: entity.instance_id, - zone_id: entity.zone_id, - health: entity.health, - max_health: entity.max_health, - power: entity.power, - max_power: entity.max_power, - power_type: (entity.unit_bytes_0 >> 24) as u8, - level: entity.level, - faction_template: entity.faction_template, - dead: entity.dead, - is_player: entity.is_player(), - orientation: entity.orientation, - owner_guid: entity.owner_guid, - }) - .filter(|unit| !unit.is_player && !unit.dead) + .filter(|unit| !unit.is_player() && !unit.dead) .ok_or_else(|| format!("relay summoner {summoner_guid} is unavailable"))?; + let guid = place_temporary_summon(ctx, &summoner, entry, location)?; + if active { + set_active_object(ctx, guid, true)?; + } + if run_by_default { + super::movement::apply_relay_walking(ctx, guid, super::RelayForcedMovement::Run)?; + } + Ok(guid) +} + +/// Place a temporary creature through the shared summon identity, origin and expiry lifecycle. +pub(crate) fn place_temporary_summon( + ctx: &ReducerContext, + caster: &crate::WorldEntity, + entry: u32, + location: SummonLocation, +) -> Result { + let summoner_guid = caster.guid; + let summoner = super::engine::unit_of(caster); if ctx .db .game_creature_template() @@ -536,29 +534,23 @@ pub(super) fn place_relay_summon( .find(entry) .is_none() { - return Err(format!("relay summon template {entry} is missing")); + return Err(format!("summon template {entry} is missing")); } if ![location.x, location.y, location.z, location.orientation] .into_iter() .all(f32::is_finite) { - return Err("relay summon location must be finite".to_string()); + return Err("summon location must be finite".to_string()); } let sequence = claim_summon_sequence(ctx, location.lifetime_ms); - let guid = summon_guid(entry, sequence) - .ok_or_else(|| "relay summon sequence is unavailable".to_string())?; + let guid = + summon_guid(entry, sequence).ok_or_else(|| "summon sequence is unavailable".to_string())?; if ctx.db.game_world_entity().guid().find(guid).is_some() { release_summon_sequence(ctx, sequence); - return Err(format!("relay summon guid {guid} is already live")); + return Err(format!("summon guid {guid} is already live")); } place_summon(ctx, sequence, guid, entry, &location, &summoner); super::edges::eventai_on_summoned(ctx, summoner_guid, guid, entry); - if active { - set_active_object(ctx, guid, true)?; - } - if run_by_default { - super::movement::apply_relay_walking(ctx, guid, super::RelayForcedMovement::Run)?; - } Ok(guid) } diff --git a/module/src/creatures/eventai/mod.rs b/module/src/creatures/eventai/mod.rs index e77fcb93e..e71eb3827 100644 --- a/module/src/creatures/eventai/mod.rs +++ b/module/src/creatures/eventai/mod.rs @@ -40,7 +40,6 @@ pub(crate) use engine::{evaluate, EventAiWorld}; pub(crate) use fixtures::seed_on_aggro_fixtures; #[cfg(feature = "debug_reducers")] pub(crate) use loader::replace_definition_for_debug; -pub(crate) use mobility::summon_life_seq; #[cfg(test)] pub(crate) use mobility::summon_lifetime_after; pub(crate) use mobility::{drop_summon_expiry, ranged_posture, react_state}; @@ -50,6 +49,7 @@ pub use mobility::{ }; #[cfg(feature = "debug_reducers")] pub(crate) use mobility::{mark_summon_origin_for_debug, remove_guardians}; +pub(crate) use mobility::{place_temporary_summon, summon_life_seq}; pub(crate) use model::*; pub use movement::{CreatureAiMovementIntent, CreatureAiMovementPathWaypoint}; use presentation::import_verified_rajaxx_spawn_protection; diff --git a/module/src/creatures/eventai/model.rs b/module/src/creatures/eventai/model.rs index 5f64a8c54..52f254523 100644 --- a/module/src/creatures/eventai/model.rs +++ b/module/src/creatures/eventai/model.rs @@ -616,6 +616,7 @@ pub enum CreatureInstruction { SetFacing(FacingInstruction), NotifyEncounter(NotifyEncounterInstruction), StartRelay(StartRelayInstruction), + AttackStart(InstructionTarget), } #[derive(spacetimedb::SpacetimeType, Clone, Debug, Eq, PartialEq)] diff --git a/module/src/creatures/hostile_summon.rs b/module/src/creatures/hostile_summon.rs new file mode 100644 index 000000000..b6af4222d --- /dev/null +++ b/module/src/creatures/hostile_summon.rs @@ -0,0 +1,91 @@ +use spacetimedb::ReducerContext; + +use crate::spell::{Spell, SpellEffect, P_ENTRY}; +use crate::{ + game_creature_template, game_gameobject, game_gameobject_template, GameObject, WorldEntity, +}; + +/// Resolve the required focus in the caster's partition before a cast spends anything. +pub(crate) fn check_hostile_summon( + ctx: &ReducerContext, + caster: &WorldEntity, + spell: &Spell, + effect: &SpellEffect, +) -> Result { + if effect.p0_kind != P_ENTRY || effect.p0 <= 0 || effect.p1 <= 0 || spell.duration_ms == 0 { + return Err("invalid hostile summon definition".to_string()); + } + if ctx + .db + .game_creature_template() + .entry() + .find(effect.p0 as u32) + .is_none() + { + return Err(format!("summon template {} is missing", effect.p0)); + } + let radius = spell.range_yd.min(100) as f32; + let (gx0, gx1, gy0, gy1) = + lyracore_shared::spatial::covering_cell_box(caster.x, caster.y, radius); + let mut nearest: Option<(f32, GameObject)> = None; + for gx in gx0..=gx1 { + for gy in gy0..=gy1 { + let cell = lyracore_shared::spatial::grid_cell_id(gx, gy); + for focus in + ctx.db + .game_gameobject() + .by_cell() + .filter((caster.map_id, caster.instance_id, cell)) + { + let Some(template) = ctx + .db + .game_gameobject_template() + .entry() + .find(focus.template_entry) + else { + continue; + }; + if template.type_id != 8 || template.data0 != effect.p1 as u32 { + continue; + } + let distance_sq = (focus.x - caster.x).powi(2) + + (focus.y - caster.y).powi(2) + + (focus.z - caster.z).powi(2); + let allowed = radius.min(template.data1 as f32); + if distance_sq <= allowed * allowed + && nearest.as_ref().is_none_or(|(distance, previous)| { + (distance_sq, focus.guid) < (*distance, previous.guid) + }) + { + nearest = Some((distance_sq, focus)); + } + } + } + } + nearest + .map(|(_, focus)| focus) + .ok_or_else(|| "required summoning circle is out of range".to_string()) +} + +pub(crate) fn summon_hostile( + ctx: &ReducerContext, + caster_guid: u64, + spell: &Spell, + effect: &SpellEffect, +) -> Result<(), String> { + let caster = crate::helpers::live_entity(ctx, caster_guid)?; + let focus = check_hostile_summon(ctx, &caster, spell, effect)?; + super::place_temporary_summon( + ctx, + &caster, + effect.p0 as u32, + super::SummonLocation { + x: focus.x, + y: focus.y, + z: focus.z, + orientation: focus.orientation, + lifetime_ms: spell.duration_ms, + }, + )?; + Ok(()) +} diff --git a/module/src/creatures/mod.rs b/module/src/creatures/mod.rs index f627797bf..8181ab1fd 100644 --- a/module/src/creatures/mod.rs +++ b/module/src/creatures/mod.rs @@ -14,6 +14,7 @@ mod ai; pub(crate) mod cycle; mod eventai; +mod hostile_summon; mod hunter_pet; mod pet; mod pet_care; @@ -24,6 +25,7 @@ pub(crate) mod tick; pub use ai::*; pub use eventai::*; +pub(crate) use hostile_summon::{check_hostile_summon, summon_hostile}; pub use hunter_pet::*; pub use pet::*; pub use pet_care::*; diff --git a/module/src/items/ops.rs b/module/src/items/ops.rs index 107529002..03c976a63 100644 --- a/module/src/items/ops.rs +++ b/module/src/items/ops.rs @@ -384,24 +384,21 @@ fn use_spell_for(tmpl: &ItemTemplate) -> Option { (tmpl.spellid_1 != 0 && tmpl.spelltrigger_1 == 0).then_some(tmpl.spellid_1) } -/// Does the on-use `spell_id` leave its item in the bag? The data-driven twin of `spell_restores_power`'s -/// mana gate: `apply_item_use` reads this to skip the stack-consumption every OTHER on-use spell takes. -/// Two kinds qualify today, both keyed on the SPELL's effect kind rather than an item-entry allowlist, so -/// any future recall trinket or mount is reusable with zero code: -/// -/// - `E_RECALL_HOME` (0x1F) — the Hearthstone's shape. A recall trinket is never used up. -/// - `A_MOUNTED` — a mount item, via `mount::spell_is_mount`, the one place that classification lives. -/// Vanilla mounts are permanent bag items, not one-shot potions. -/// -/// Reads `game_spell_effect` by `by_spell` — `true` if ANY effect carries one of the kinds. [entity] +/// Recall items, mounts and hostile summon quest items remain in the bag after use. +/// The effect kind is the authority; the quest item is also needed for retries. fn spell_keeps_item(ctx: &ReducerContext, spell_id: u32) -> bool { - let recalls_home = ctx + let reusable_effect = ctx .db .game_spell_effect() .by_spell() .filter(&spell_id) - .any(|e| e.kind == crate::spell::E_RECALL_HOME); - recalls_home || crate::mount::spell_is_mount(ctx, spell_id) + .any(|e| { + matches!( + e.kind, + crate::spell::E_RECALL_HOME | crate::spell::E_SUMMON_HOSTILE + ) + }); + reusable_effect || crate::mount::spell_is_mount(ctx, spell_id) } /// Is `entry` a BANDAGE AND is its cooldown debuff (`RECENTLY_BANDAGED_SPELL`) currently live on the @@ -529,12 +526,8 @@ pub(crate) fn apply_item_use( "only mana users can use that", )); } - // Consume one unit FIRST — UNLESS the on-use spell is one of the permanent kinds (a recall trinket, - // a mount): `spell_keeps_item` is the data-driven exception to "using an item consumes it", keyed on - // the spell's effect kind, not a hardcoded item entry. Every other on-use spell consumes: the cast is - // the effect, and a failed cast still consumed the item, matching vanilla — a fizzled potion is gone. - // A REFUSED mount consumes nothing either way: the riding/combat/indoor/liquid gates all live in the - // cast core's read-only pre-spend sweep, and this branch never touched the stack to begin with. + // Reusable spells keep the item. Other casts consume one unit on admission; + // a Refusal from begin_cast rolls back this inventory change with the Durable Request. if !spell_keeps_item(ctx, spell_id) { if inst.stack_count > 1 { inst.stack_count -= 1; diff --git a/module/src/quest.rs b/module/src/quest.rs index adb652559..1f05fedac 100644 --- a/module/src/quest.rs +++ b/module/src/quest.rs @@ -302,6 +302,15 @@ pub struct QuestRewardItem { pub count: u32, } +/// A quest reward whose source cast teaches one spell. The importer stores the learned spell, +/// never its teaching wrapper. Non-teaching reward casts are outside this catalogue. +#[table(accessor = game_quest_reward_spell)] +pub struct QuestRewardSpell { + #[primary_key] + pub quest_entry: u32, + pub spell_id: u32, +} + /// A CHOICE reward item (pick-1-of-N) granted on turn-in (cmangos RewChoiceItemId/Count1-6). The player /// picks ONE row by `choice_index`; that single item is granted IN ADDITION to every [`QuestRewardItem`]. /// `choice_index` is 0-based and matches the wire order of the offer/details `choice_item_rewards` array @@ -991,6 +1000,25 @@ pub(crate) fn apply_turn_in_quest( .find(quest_entry) .ok_or_else(|| format!("no such quest {quest_entry}"))?; + let reward_spell = ctx + .db + .game_quest_reward_spell() + .quest_entry() + .find(quest_entry); + if let Some(reward) = &reward_spell { + use crate::game_spell; + if ctx + .db + .game_spell() + .spell_id() + .find(reward.spell_id) + .is_none() + { + return Err(format!("quest reward spell {} is missing", reward.spell_id)); + } + } + let owner_identity = player.owner_identity; + // Consume the COLLECT objectives' required items BEFORE granting rewards — frees the bag // space the reward may need, and is atomic with the grant (any later Err rolls the whole tx back, so a // failed turn-in neither eats the items nor hands out the reward). `quest_is_complete` already verified @@ -1089,6 +1117,10 @@ pub(crate) fn apply_turn_in_quest( tmpl.rew_rep_value_2, ); + if let Some(reward) = reward_spell { + crate::spell::learn_spell(ctx, player_guid, owner_identity, reward.spell_id); + } + // Mark the row rewarded and KEEP it — for every quest, repeatable or not. Deleting it here for a // repeatable quest would erase the only record it was ever completed, which would permanently break // any OTHER quest whose `prev_quest_id` points at this one (quest_is_rewarded reads this row). A @@ -2421,6 +2453,13 @@ pub(crate) fn apply_abandon_quest( if cq.rewarded { return Err("cannot abandon a completed quest".to_string()); } + if let Some(tmpl) = ctx.db.game_quest_template().entry().find(quest_entry) { + if tmpl.src_item != 0 { + let available = crate::items::item_count(ctx, player_guid, tmpl.src_item); + let count = available.min(tmpl.src_item_count.max(1)); + crate::items::remove_items(ctx, player_guid, tmpl.src_item, count)?; + } + } ctx.db.game_character_quest().id().delete(cq.id); Ok(()) } diff --git a/module/src/spell/cast/resolve.rs b/module/src/spell/cast/resolve.rs index 5cf42ae91..5bb2c22b3 100644 --- a/module/src/spell/cast/resolve.rs +++ b/module/src/spell/cast/resolve.rs @@ -771,6 +771,12 @@ fn check_cast_gate_suffix( level: u8, ) -> Result<(), String> { let caster_guid = caster.guid; + for effect in effects + .iter() + .filter(|effect| effect.kind == E_SUMMON_HOSTILE) + { + crate::creatures::check_hostile_summon(ctx, caster, hdr, effect)?; + } // Level gate: a CHARACTER cannot cast a rank above its level. Pairs with the trainer level-gate so a // higher rank is both UNBUYABLE and UNCASTABLE until you level up — the leveling spine. Keyed on the @@ -1196,6 +1202,25 @@ fn begin_cast_with_admission( .find(spell_id) .ok_or_else(|| format!("unknown spell {spell_id}"))?; + let caster = crate::helpers::live_entity(ctx, caster_guid)?; + let mut effects: Vec = ctx + .db + .game_spell_effect() + .by_spell() + .filter(&spell_id) + .collect(); + effects.sort_by_key(|effect| (effect.kind != E_INTERRUPT, effect.effect_index)); + check_cast_gates_with_admission( + ctx, + &caster, + &hdr, + &effects, + target_guid, + spell_id, + level, + admission == CreatureSpellCasterAdmission::DeadCreatureCallback, + )?; + // A NEW cast breaks any channel already in progress (vanilla: casting interrupts your channel). Fired // here at the cast ENTRY so it covers both the instant and the timed branch — and BEFORE the new // channel's own `resolve_cast_at` places its aura, so re-casting Arcane Missiles cleanly restarts it. diff --git a/module/src/spell/cast/targeting.rs b/module/src/spell/cast/targeting.rs index 7f8cddb9b..e9cd1c35b 100644 --- a/module/src/spell/cast/targeting.rs +++ b/module/src/spell/cast/targeting.rs @@ -819,6 +819,12 @@ pub(crate) fn apply_effect( return EffectHit::none(); // an aura effect deals no direct cast damage } match e.kind { + E_SUMMON_HOSTILE => { + if let Err(error) = crate::creatures::summon_hostile(ctx, caster_guid, hdr, e) { + spacetimedb::log::warn!("hostile summon refused: {error}"); + } + EffectHit::none() + } E_DUEL => { if e.p0_kind == P_GAMEOBJECT_ENTRY { crate::duel::request_duel(ctx, caster_guid, target_guid, e.p0.max(0) as u32); diff --git a/module/src/spell/taxonomy.rs b/module/src/spell/taxonomy.rs index fced398b1..8d7b63481 100644 --- a/module/src/spell/taxonomy.rs +++ b/module/src/spell/taxonomy.rs @@ -115,6 +115,8 @@ pub(crate) const E_DUEL: u8 = 0x22; // Duel (raw effect 83): request a server-au /// `DISPEL_MECHANIC` effect whose parameter is the mount mechanic onto this kind, so the runtime never /// implements a generic mechanic dispel and never branches on spell 1604 or a spell name. pub(crate) const E_DISMOUNT: u8 = 0x23; +pub(crate) const E_SUMMON_HOSTILE: u8 = 0x24; // temporary ownerless summon; p0 = creature entry, p1 = required spell focus, header duration = lifetime + pub(crate) const E_POWER_BURN: u8 = 0x19; // drain N mana from the target and deal a fraction of it as damage (Mana Burn): MANA-power-type gate read off the target's `unit_bytes_0` byte 3 (same read as `is_rage_user`) — a rage/energy target is a silent no-op (power AND health untouched), matching vanilla's behaviour of skipping the effect entirely. drained = min(base_points, target.power) (floor-at-available; an empty/low pool just burns less, never fails the cast). damage = drained * p1 / 100 (p1 = the effect's ratio in basis-points — vanilla Mana Burn is EffectMultipleValue=0.5 -> p1=50 -> half the drained mana as Shadow damage); `p1<=0` (unauthored data) defaults to 100 (1:1), so a missing p1 never silently zeroes all burn damage. Dealt via the shared `apply_target_damage` (threat/kill/absorb reuse, no new wire work) // --- aura effects (high bit set) --- @@ -226,7 +228,7 @@ pub(crate) const P_COMBAT_FIELD: u8 = 5; pub(crate) const P_SPEED_KIND: u8 = 6; pub(crate) const P_FLAG: u8 = 7; pub(crate) const P_ITEM_ENTRY: u8 = 8; // p0 is a game_item_template entry (E_CREATE_ITEM) -pub(crate) const P_ENTRY: u8 = 9; // p0 is a game_creature_template entry (E_SUMMON_PET — the summoned pet's creature entry) +pub(crate) const P_ENTRY: u8 = 9; // p0 is a game_creature_template entry pub(crate) const P_ENCHANT_ID: u8 = 10; // p0 is an enchant id (E_ENCHANT_ITEM — the enchant applied to the item) // SpellModOp values the engine consumes (mangos SpellModOp; the rest import fine and stay inert until a seam folds them). pub(crate) const SPELLMOD_OP_DAMAGE: i32 = 0; @@ -396,6 +398,7 @@ pub(crate) const ALL_INSTANT_KINDS: &[u8] = &[ E_NEXT_SWING, E_SET_STANCE, E_SUMMON_PET, + E_SUMMON_HOSTILE, E_HEAL_MAX_HEALTH, E_ENCHANT_ITEM, E_DISENCHANT, diff --git a/module/src/spell/tests.rs b/module/src/spell/tests.rs index a82797d9a..3e5c26f15 100644 --- a/module/src/spell/tests.rs +++ b/module/src/spell/tests.rs @@ -498,7 +498,7 @@ fn kind_wire_values() { fn instant_kind_wire_values_exhaustive() { assert_eq!( ALL_INSTANT_KINDS.len(), - 34, + 35, "an instant kind was added to (or removed from) taxonomy.rs without a matching, deliberate \ change to ALL_INSTANT_KINDS — bump this count only when the taxonomy really changed" ); diff --git a/module/tests/binding_quests.rs b/module/tests/binding_quests.rs new file mode 100644 index 000000000..96d1ceb8b --- /dev/null +++ b/module/tests/binding_quests.rs @@ -0,0 +1,373 @@ +mod support; + +use std::collections::BTreeMap; +use support::{poll_until, Standalone, POLL_TIMEOUT}; + +const PLAYER: &str = "1"; +const FOCUS: u32 = 5_091_040; +const GIVER: u32 = 51_940; +const PET: u32 = 51_943; + +fn copy_template( + node: &Standalone, + table: &str, + source: u32, + entry: u32, + changes: &[(&str, String)], +) { + let mut row = node + .query_rows(&format!("SELECT * FROM {table} WHERE entry = {source}")) + .remove(0); + row.insert("entry".into(), entry.to_string()); + for (key, value) in changes { + row.insert((*key).into(), value.clone()); + } + let columns = match table { + "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", + "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", + _ => panic!("unsupported fixture catalogue {table}"), + }; + let values = columns + .split(',') + .map(|key| { + let value = &row[key]; + if matches!(key, "name" | "subname") { + format!("'{}'", value.replace('\'', "''")) + } else { + value.clone() + } + }) + .collect::>() + .join(","); + node.assert_sql(&format!( + "INSERT INTO {table} ({columns}) VALUES ({values})" + )); +} + +fn insert_spell(node: &Standalone, spell: u32, entry: u32, kind: u8) { + node.assert_sql(&format!("INSERT INTO game_spell (spell_id,name,power_type,cost,cast_time_ms,gcd_ms,cooldown_ms,range_yd,duration_ms,school_mask,dispel_type,mechanic,max_stacks,aura_interrupt,attributes,spell_level,max_level,is_negative,cast_flags,stances,family_name,family_flags,proc_flags,proc_chance,proc_charges) VALUES ({spell},'Binding fixture',0,0,50,0,180000,10,360000,1,0,0,0,0,0,1,0,false,0,0,0,0,0,0,0)")); + let id = u64::from(spell) << 2; + node.assert_sql(&format!("INSERT INTO game_spell_effect (id,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) VALUES ({id},{spell},0,{kind},1,0,0.0,0,0,0.0,0,0,0,{entry},9,83,0,false)")); +} + +fn actor(node: &Standalone) -> BTreeMap { + node.query_rows("SELECT * FROM game_world_entity WHERE guid = 1") + .remove(0) +} + +fn summoned(node: &Standalone, entry: u32) -> Vec> { + node.query_rows(&format!( + "SELECT * FROM game_world_entity WHERE entry = {entry}" + )) +} + +fn assert_refused(node: &Standalone, reducer: &str, args: &[&str]) { + let result = node.call(reducer, args); + assert!(!result.status.success(), "{reducer} unexpectedly succeeded"); +} + +fn load_startup_rule(node: &Standalone, entry: u32, variant: u32) { + let subject = format!("entry:{entry}"); + let event = if variant == 0 { + "timer-generic:1000:1000" + } else { + "spawn:always" + }; + let rules = format!("{entry},{event},100,4294967295,once,all,ordinary,any-posture,faction:14+attack-start:spawner~{},aggro,100,4294967295,repeat:0:0,all,ordinary,any-posture,phase-inc:7", entry + 100); + let mut hasher = blake3::Hasher::new(); + hasher.update(b"lyracore-eventai-definition-v1"); + hasher.update(format!("{subject}@{rules}").as_bytes()); + let revision = u64::from_le_bytes(hasher.finalize().as_bytes()[..8].try_into().unwrap()); + let packed = serde_json::to_string(&format!("{subject}@{revision}@{rules}")).unwrap(); + node.assert_call("import_creature_ai_definitions", &[&packed]); +} + +fn focus_loss_cancels_cast(node: &Standalone, spell: u32, creature: u32, slot: &str, focus: &str) { + node.assert_sql(&format!( + "UPDATE game_spell SET cast_time_ms = 2000 WHERE spell_id = {spell}" + )); + node.assert_call("debug_use_item", &[PLAYER, slot]); + node.assert_sql(&format!( + "UPDATE game_gameobject SET instance_id = 99 WHERE guid = {focus}" + )); + assert!(poll_until(POLL_TIMEOUT, || node + .query_rows("SELECT * FROM game_pending_cast WHERE caster_guid = 1") + .is_empty())); + assert!(summoned(node, creature).is_empty()); + node.assert_sql(&format!( + "UPDATE game_gameobject SET instance_id = 0 WHERE guid = {focus}" + )); + node.assert_sql(&format!( + "UPDATE game_spell SET cast_time_ms = 50 WHERE spell_id = {spell}" + )); +} + +fn abandon_and_reaccept(node: &Standalone, giver: &str, quest: u32, item: u32) { + let quest_arg = quest.to_string(); + node.assert_call("gw_abandon_quest", &[PLAYER, &quest_arg]); + assert!(node + .query_rows(&format!( + "SELECT * FROM game_item_instance WHERE entry = {item}" + )) + .is_empty()); + node.assert_sql(&format!( + "UPDATE game_quest_template SET src_item_count = 0 WHERE entry = {quest}" + )); + node.assert_call("debug_accept_quest", &[PLAYER, giver, &quest_arg]); + node.assert_call("gw_abandon_quest", &[PLAYER, &quest_arg]); + assert!(node + .query_rows(&format!( + "SELECT * FROM game_item_instance WHERE entry = {item}" + )) + .is_empty()); + node.assert_call("debug_accept_quest", &[PLAYER, giver, &quest_arg]); + node.assert_sql(&format!( + "DELETE FROM game_item_instance WHERE entry = {item}" + )); + node.assert_call("gw_abandon_quest", &[PLAYER, &quest_arg]); + node.assert_sql(&format!( + "UPDATE game_quest_template SET src_item_count = 1 WHERE entry = {quest}" + )); + node.assert_call("debug_accept_quest", &[PLAYER, giver, &quest_arg]); +} + +fn circle_radius_refuses_item_use(node: &Standalone, creature: u32, item: u32, slot: &str) { + let inventory = node.query_rows(&format!( + "SELECT * FROM game_item_instance WHERE entry = {item}" + )); + node.assert_sql(&format!( + "UPDATE game_gameobject_template SET data1 = 2 WHERE entry = {FOCUS}" + )); + assert_refused(node, "debug_use_item", &[PLAYER, slot]); + assert!(summoned(node, creature).is_empty()); + assert_eq!( + node.query_rows(&format!( + "SELECT * FROM game_item_instance WHERE entry = {item}" + )), + inventory + ); + node.assert_sql(&format!( + "UPDATE game_gameobject_template SET data1 = 10 WHERE entry = {FOCUS}" + )); +} + +fn assert_first_aggro(node: &Standalone, summon: &BTreeMap) { + assert_eq!(summon["target_guid"], PLAYER); + node.assert_call("gw_attack", &[&summon["guid"], PLAYER]); + assert_eq!( + node.query_rows(&format!( + "SELECT phase FROM game_creature_ai_state WHERE creature_guid = {}", + summon["guid"] + ))[0]["phase"], + "7" + ); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn binding_items_require_the_circle_and_complete_both_quest_variants_without_replacing_a_pet() { + // Reserved fixtures copy the verified 1689/1739 item, objective and reward relationships. + // Casts take 50 ms here; importer tests pin the source event mapping independently. + let mut node = Standalone::start("binding-quests"); + node.publish_module(); + node.assert_call("claim_operator", &[]); + node.assert_sql("UPDATE game_character SET level = 20, class = 9, race = 1 WHERE guid = 1"); + node.assert_call("debug_spawn_player_entity", &[PLAYER]); + node.assert_sql("UPDATE game_world_entity SET level = 20, health = 100000, max_health = 100000 WHERE guid = 1"); + copy_template( + &node, + "game_creature_template", + 51000, + GIVER, + &[("faction_template", "35".into()), ("npc_flags", "2".into())], + ); + node.assert_call("debug_spawn_at_feet", &[PLAYER, &GIVER.to_string(), "1"]); + let giver = summoned(&node, GIVER).remove(0)["guid"].clone(); + copy_template(&node, "game_creature_template", 51000, PET, &[]); + insert_spell(&node, 50_943, PET, 0x15); + node.assert_call("debug_force_cast", &[PLAYER, "50943"]); + let pet = summoned(&node, PET).remove(0); + assert_eq!(pet["owner_guid"], PLAYER); + + let location = actor(&node); + let focus_x = (location["x"].parse::().unwrap() + 3.0).to_string(); + node.assert_call( + "debug_spawn_gameobject", + &[ + "--", + &FOCUS.to_string(), + "8", + "1", + "83", + &location["map_id"], + &focus_x, + &location["y"], + &location["z"], + "10", + "0", + "0", + "0", + ], + ); + let focus_guid = ((0xF110_u64 << 48) | u64::from(FOCUS)).to_string(); + + for (variant, reward) in [(0u32, 50_944), (1, 50_945)] { + let quest = 5_091_041 + variant; + let item = 5_091_041 + variant; + let creature = 51_941 + variant; + let spell = 50_941 + variant; + copy_template( + &node, + "game_creature_template", + 51000, + creature, + &[ + ( + "faction_template", + if variant == 0 { "35" } else { "90" }.into(), + ), + ( + "unit_flags", + if variant == 0 { "512" } else { "33280" }.into(), + ), + ("health", "100000".into()), + ("max_level_health", "100000".into()), + ], + ); + copy_template( + &node, + "game_item_template", + 51, + item, + &[ + ("spellid_1", spell.to_string()), + ("spelltrigger_1", "0".into()), + ("required_level", "1".into()), + ("max_count", "1".into()), + ("max_stack", "1".into()), + ], + ); + load_startup_rule(&node, creature, variant); + insert_spell(&node, spell, creature, 0x24); + insert_spell(&node, reward, PET, 0x15); + node.assert_sql(&format!("INSERT INTO game_spell_learn (id,parent_spell,learn_spell) VALUES ({quest},{reward},50946)")); + node.assert_sql(&format!("INSERT INTO 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) VALUES ({quest},1,20,'Binding fixture',0,0,0,1,256,-61,0,0,0,0,{item},1,false,0,0,0)")); + node.assert_sql(&format!("INSERT INTO game_quest_objective (id,quest_entry,obj_index,kind,target_entry,required_count) VALUES ({quest},{quest},0,0,{creature},1)")); + node.assert_sql(&format!("INSERT INTO game_creature_quest (id,creature_entry,quest_entry,role) VALUES ({},{GIVER},{quest},0),({},{GIVER},{quest},1)", quest * 2, quest * 2 + 1)); + node.assert_sql(&format!( + "INSERT INTO game_quest_reward_spell (quest_entry,spell_id) VALUES ({quest},{reward})" + )); + node.assert_call("debug_accept_quest", &[PLAYER, &giver, &quest.to_string()]); + abandon_and_reaccept(&node, &giver, quest, item); + let inventory = node.query_rows(&format!( + "SELECT * FROM game_item_instance WHERE entry = {item}" + )); + assert_eq!(inventory.len(), 1); + assert_eq!(inventory[0]["stack_count"], "1"); + let slot = inventory[0]["slot"].clone(); + + circle_radius_refuses_item_use(&node, creature, item, &slot); + + node.assert_sql(&format!( + "UPDATE game_gameobject SET instance_id = 99 WHERE guid = {focus_guid}" + )); + assert_refused(&node, "debug_use_item", &[PLAYER, &slot]); + assert!(summoned(&node, creature).is_empty()); + assert_eq!( + node.query_rows(&format!( + "SELECT * FROM game_item_instance WHERE entry = {item}" + )), + inventory + ); + node.assert_sql(&format!( + "UPDATE game_gameobject SET instance_id = 0 WHERE guid = {focus_guid}" + )); + + focus_loss_cancels_cast(&node, spell, creature, &slot, &focus_guid); + node.assert_call("debug_use_item", &[PLAYER, &slot]); + assert!(poll_until(POLL_TIMEOUT, || summoned(&node, creature) + .first() + .is_some_and(|summon| summon["faction_template"] == "14"))); + let summons = summoned(&node, creature); + assert_eq!(summons.len(), 1); + let summon = &summons[0]; + assert_eq!(summon["owner_guid"], "0"); + assert_first_aggro(&node, summon); + assert_eq!(summon["faction_template"], "14"); + assert_eq!(summon["map_id"], location["map_id"]); + assert_eq!(summon["instance_id"], location["instance_id"]); + assert_eq!( + node.query_rows(&format!( + "SELECT * FROM game_item_instance WHERE entry = {item}" + )), + inventory + ); + let melee = node.query_rows(&format!( + "SELECT target_guid FROM game_melee_attack WHERE attacker_guid = {}", + summon["guid"] + )); + assert_eq!(melee[0]["target_guid"], PLAYER); + assert_eq!(summoned(&node, PET)[0]["guid"], pet["guid"]); + assert_eq!(summoned(&node, PET)[0]["owner_guid"], PLAYER); + + assert_refused(&node, "debug_use_item", &[PLAYER, &slot]); + assert_eq!(summoned(&node, creature).len(), 1); + assert_eq!( + node.query_rows(&format!( + "SELECT * FROM game_item_instance WHERE entry = {item}" + )), + inventory + ); + node.assert_sql(&format!( + "UPDATE game_world_entity SET health = 1 WHERE guid = {}", + summon["guid"] + )); + node.assert_call("gw_attack", &[PLAYER, &summon["guid"]]); + assert!(poll_until(POLL_TIMEOUT, || summoned(&node, creature) + .first() + .is_some_and(|summon| summon["dead"] == "true"))); + let progress = node.query_rows(&format!( + "SELECT * FROM game_character_quest WHERE quest_entry = {quest}" + )); + assert_eq!(progress[0]["counts"], "1"); + assert_refused(&node, "debug_kill_creature", &[PLAYER, &summon["guid"]]); + assert_eq!( + node.query_rows(&format!( + "SELECT * FROM game_character_quest WHERE quest_entry = {quest}" + )), + progress + ); + node.assert_call( + "debug_turn_in_quest", + &[PLAYER, &giver, &quest.to_string(), "0"], + ); + assert_eq!( + node.query_rows(&format!( + "SELECT * FROM game_item_instance WHERE entry = {item}" + )), + inventory + ); + assert_eq!( + node.query_rows(&format!( + "SELECT spell_id FROM game_player_spell WHERE spell_id = {reward}" + )) + .len(), + 1 + ); + assert!(node + .query_rows("SELECT spell_id FROM game_player_spell WHERE spell_id = 50946") + .is_empty()); + assert_refused( + &node, + "debug_turn_in_quest", + &[PLAYER, &giver, &quest.to_string(), "0"], + ); + assert_eq!( + node.query_rows(&format!( + "SELECT spell_id FROM game_player_spell WHERE spell_id = {reward}" + )) + .len(), + 1 + ); + } +}