diff --git a/modules/AccountBound b/modules/AccountBound deleted file mode 160000 index f7ba75b14..000000000 --- a/modules/AccountBound +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f7ba75b14bdf04f4a4e711f0b6f71a0589ea4649 diff --git a/modules/AccountBound/.gitattributes b/modules/AccountBound/.gitattributes new file mode 100644 index 000000000..da82fdb51 --- /dev/null +++ b/modules/AccountBound/.gitattributes @@ -0,0 +1,6 @@ +* text=auto +*.cpp text eol=lf +*.h text eol=lf +*.md text eol=lf +*.dist text eol=lf +*.patch text eol=lf diff --git a/modules/AccountBound/.gitignore b/modules/AccountBound/.gitignore new file mode 100644 index 000000000..e08c9fe0e --- /dev/null +++ b/modules/AccountBound/.gitignore @@ -0,0 +1,15 @@ +# Build and editor artifacts +.vs/ +.vscode/ +build/ +cmake-build-*/ +*.user +*.suo +*.VC.db +*.VC.VC.opendb +*.obj +*.pdb +*.log + +# Active server config +conf/AccountBound.conf diff --git a/modules/AccountBound/CHANGELOG.md b/modules/AccountBound/CHANGELOG.md new file mode 100644 index 000000000..a008131c3 --- /dev/null +++ b/modules/AccountBound/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to AccountBound are documented in this file. + +## 1.1.0 - 2026-06-06 + +- Changed account sharing to silent database-only synchronization. +- Removed live `learnSpell()` calls for mounts and companion pets. +- Removed live replay of achievements, titles, reputations and friend lists. +- Added `SyncOnCreate` support to every shared category. +- Enabled startup friend backfill by default. +- Removed the optional achievement live-sync core patch. + +## 1.0.0 - 2026-06-06 + +- Initial public release. +- Added account-wide achievements. +- Added account-wide mounts with riding, class and faction checks. +- Added account-wide vanity companion pets. +- Added account-wide titles. +- Added monotonic account-wide reputation sharing. +- Added account-wide profession ranks, progress and recipes. +- Added configurable support for all 11 primary professions. +- Added account-wide friend lists and notes. +- Added per-category switches, filters and startup backfill. +- Added compatibility with stock AzerothCore and mod-playerbots. +- Added optional instant achievement import patch. diff --git a/modules/AccountBound/LICENSE b/modules/AccountBound/LICENSE new file mode 100644 index 000000000..ecbc05937 --- /dev/null +++ b/modules/AccountBound/LICENSE @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. \ No newline at end of file diff --git a/modules/AccountBound/README.md b/modules/AccountBound/README.md new file mode 100644 index 000000000..2c11ce474 --- /dev/null +++ b/modules/AccountBound/README.md @@ -0,0 +1,482 @@ +# AccountBound + +AccountBound is an AzerothCore module for WotLK 3.3.5a that shares selected +collections and progression systems between eligible characters on the same +game account. + +It combines achievements, mounts, companion pets, titles, reputations, +professions and friends into one module with one configuration file. Every +category can be enabled, disabled and filtered independently. + +## Features + +- Account-wide achievements with faction conversion support. +- Account-wide mounts with riding, class and faction requirement checks. +- Account-wide vanity companion pets. +- Account-wide titles with faction conversion support. +- Account-wide reputations that only copy higher standings. +- Account-wide profession ranks, skill progress and recipes. +- Configurable primary-profession limit up to all 11 WotLK professions. +- Account-wide friend lists and friend notes. +- Per-category startup backfill for existing realms. +- Silent SQL synchronization without learned-spell or collection spam. +- Character creation seeding before the character's first login. +- Per-category ID allow lists and block lists. +- One master switch and one module configuration file. +- Uses AzerothCore's existing character tables. +- No custom SQL tables are required. + +## Compatibility + +- AzerothCore WotLK 3.3.5a +- AzerothCore `master` +- `mod-playerbots/azerothcore-wotlk` +- Built as a normal source module under `modules/` +- Tested with a Release worldserver build on Windows + +The module uses hooks currently available in AzerothCore. Stock AzerothCore +does not require a core patch. + +## Silent Database Synchronization + +AccountBound writes shared data directly to AzerothCore's existing character +tables. It does not call `learnSpell()` or replay achievements, titles, +reputation gains or friend notifications during login. + +The synchronization lifecycle is: + +1. `StartupBackfill` migrates characters that already exist before players can + log in. +2. `SyncOnCreate` seeds a new character after its initial database save and + before its first login. +3. Normal gameplay hooks persist newly earned data to the other characters on + the account. +4. The target character loads the already-persisted rows during its next normal + login. + +This prevents login spam. If a requirement changes while a character is +already online, such as gaining a higher riding rank, the newly eligible rows +are stored immediately and become visible after one relog. + +## Installation + +Clone the repository into the AzerothCore modules directory: + +```bash +cd azerothcore-wotlk/modules +git clone https://github.com/AlsoNotMehh/AccountBound.git +``` + +Re-run CMake and rebuild `worldserver`. + +Copy: + +```text +modules/AccountBound/conf/AccountBound.conf.dist +``` + +to: + +```text +configs/modules/AccountBound.conf +``` + +Restart `worldserver`. + +The startup log should contain: + +```text +Using modules configuration: +> AccountBound.conf +``` + +## Quick Config + +The master switch controls the entire module: + +```ini +AccountBound.Enable = 1 +``` + +Each system has an independent switch: + +```ini +AccountBound.Achievements.Enable = 1 +AccountBound.Mounts.Enable = 1 +AccountBound.Pets.Enable = 1 +AccountBound.Titles.Enable = 1 +AccountBound.Reputations.Enable = 1 +AccountBound.Professions.Enable = 1 +AccountBound.Friends.Enable = 1 +``` + +Set any category to `0` to keep it character-specific. + +## Shared Data + +| Category | Shared data | +| --- | --- | +| Achievements | Completed achievements and completion date | +| Mounts | Learned mount spells | +| Pets | Vanity companion and minipet spells | +| Titles | Known character titles | +| Reputations | Highest eligible standing and reputation flags | +| Professions | Ranks, current/max skill and learned recipes | +| Friends | Friend list entries and notes | + +The module intentionally does not share quests, inventory, gold, currencies, +honor, arena rating, instance lockouts, cooldowns, talents, glyphs, action bars, +mail, auctions or hunter stable pets. + +Those systems contain character-specific progression, ownership or class data +and should not be merged automatically. + +## ID Filters + +Achievements, mounts, pets, titles and reputations support: + +```ini +AccountBound.CATEGORY.AllowList = all +AccountBound.CATEGORY.BlockList = "" +``` + +Replace `CATEGORY` with: + +```text +Achievements +Mounts +Pets +Titles +Reputations +``` + +`AllowList = all` permits every valid entry. + +`AllowList = none` permits no entries. + +A comma-separated allow list shares only those IDs: + +```ini +AccountBound.Mounts.AllowList = 458,470,6648 +``` + +The block list always wins: + +```ini +AccountBound.Mounts.AllowList = all +AccountBound.Mounts.BlockList = 48778,54729 +``` + +Converted Horde or Alliance equivalents must also pass the configured filter. + +## Achievements + +Recommended defaults: + +```ini +AccountBound.Achievements.Enable = 1 +AccountBound.Achievements.SyncOnCreate = 1 +AccountBound.Achievements.SyncRealmFirst = 0 +AccountBound.Achievements.SyncHidden = 0 +AccountBound.Achievements.ConvertFactionSpecific = 1 +``` + +Realm First achievements are excluded by default because they represent a +specific character or realm race. + +Faction-specific achievements are converted with AzerothCore's faction-change +mapping when `ConvertFactionSpecific = 1`. + +Achievements are inserted into `character_achievement`. They are never replayed +through the live achievement reward path, so the module does not resend +announcements, mail, items or title notifications. + +## Mounts + +Recommended defaults: + +```ini +AccountBound.Mounts.Enable = 1 +AccountBound.Mounts.SyncOnCreate = 1 +AccountBound.Mounts.RespectFactionRestrictions = 1 +AccountBound.Mounts.ConvertFactionSpecific = 1 +AccountBound.Mounts.RequireRiding = 1 +AccountBound.Mounts.RequireClass = 1 +``` + +The module discovers mount spells from spell data and item templates. + +It can enforce: + +- Riding skill requirements. +- Class restrictions for class mounts. +- Horde and Alliance restrictions. +- Same-faction restrictions for class mounts. + +Invalid shared mount rows can be cleaned at startup: + +```ini +AccountBound.Mounts.CleanupInvalid = 1 +``` + +## Companion Pets + +The pets category shares vanity companions only: + +```ini +AccountBound.Pets.SyncOnCreate = 1 +AccountBound.Pets.IncludeCompanionSkillLine = 1 +AccountBound.Pets.IncludeMinipetSummons = 1 +``` + +It detects WotLK companion skill-line spells and custom summons using +`SUMMON_TYPE_MINIPET`. + +Hunter stable pets, permanent combat pets and pet talent data are not shared. + +## Titles + +Recommended defaults: + +```ini +AccountBound.Titles.Enable = 1 +AccountBound.Titles.SyncOnCreate = 1 +AccountBound.Titles.SyncOnSave = 1 +AccountBound.Titles.SyncRealmFirst = 0 +AccountBound.Titles.ConvertFactionSpecific = 1 +``` + +Realm First reward titles are excluded by default. Faction-specific titles are +converted using AzerothCore's faction-change title mappings. + +## Reputations + +Recommended public-realm defaults: + +```ini +AccountBound.Reputations.Enable = 1 +AccountBound.Reputations.SyncOnCreate = 1 +AccountBound.Reputations.SyncOnChange = 1 +AccountBound.Reputations.SameFactionOnly = 1 +AccountBound.Reputations.ConvertFactionSpecific = 1 +``` + +Reputation sync is monotonic: it copies a standing only when it improves the +target character. It never lowers another character's standing. + +Base reputation differences for race and class are respected. Cross-faction +sharing can convert supported faction pairs. + +## Professions + +The default configuration allows all 11 WotLK primary professions: + +```ini +AccountBound.Professions.MaxPrimaryProfessions = 11 +AccountBound.Professions.Primary.AllowList = all +AccountBound.Professions.Primary.BlockList = "" +``` + +Accepted English profession names: + +```text +alchemy, blacksmithing, enchanting, engineering, herbalism, +inscription, jewelcrafting, leatherworking, mining, skinning, tailoring +``` + +Accepted Spanish aliases: + +```text +alquimia, herreria, encantamiento, ingenieria, herboristeria, +inscripcion, joyeria, peleteria, mineria, desuello, sastreria +``` + +Skill IDs are also accepted: + +```text +171,164,333,202,182,773,755,165,186,393,197 +``` + +Example: + +```ini +AccountBound.Professions.Primary.AllowList = alchemy, enchanting, tailoring +AccountBound.Professions.MaxPrimaryProfessions = 3 +``` + +Strict allow-list enforcement is disabled by default: + +```ini +AccountBound.Professions.Primary.EnforceAllowedList = 0 +``` + +Warning: enabling it can remove blocked professions from existing characters. + +Account-wide profession settings: + +```ini +AccountBound.Professions.SyncOnCreate = 1 +AccountBound.Professions.SyncSkillProgress = 1 +AccountBound.Professions.SyncProfessionRanks = 1 +AccountBound.Professions.SyncRecipes = 1 +AccountBound.Professions.SyncSkillGrantedSpells = 1 +AccountBound.Professions.IncludeSecondaryProfessions = 1 +AccountBound.Professions.RequireRecipeSkill = 1 +``` + +Custom recipe spells can be explicitly allowed or blocked: + +```ini +AccountBound.Professions.SpellAllowList = 900001,900002 +AccountBound.Professions.SpellBlockList = 20219,20222 +``` + +## Friends + +The friends category mirrors the normal character friend list across every +character on an account: + +```ini +AccountBound.Friends.SyncOnCreate = 1 +AccountBound.Friends.SyncOnlineChanges = 1 +AccountBound.Friends.SyncIntervalSeconds = 3 +``` + +Friend notes are shared. Characters belonging to the same account are removed +from the friend list to avoid self-friend entries. + +Cross-faction friends are allowed by default: + +```ini +AccountBound.Friends.SameFactionOnly = 0 +``` + +## Startup Backfill + +Every progression category has a `StartupBackfill` setting. + +Example: + +```ini +AccountBound.Achievements.StartupBackfill = 1 +AccountBound.Mounts.StartupBackfill = 1 +AccountBound.Pets.StartupBackfill = 1 +AccountBound.Titles.StartupBackfill = 1 +AccountBound.Reputations.StartupBackfill = 1 +AccountBound.Professions.StartupBackfill = 1 +AccountBound.Friends.StartupBackfill = 1 +``` + +For an existing realm: + +1. Back up the characters database. +2. Enable the categories you want. +3. Enable their startup backfill options. +4. Start `worldserver` once. +5. Wait for every AccountBound startup message. +6. Stop `worldserver`. +7. Set the startup backfill options back to `0`. +8. Start the server normally. + +Backfill uses idempotent inserts or monotonic updates, but it can touch many +rows on a large realm. + +Version 1.1 replaces the old `SyncOnLogin` options with `SyncOnCreate`. +Existing custom configuration files should be updated to use the new keys. + +## Safe Public-Realm Defaults + +```ini +AccountBound.Enable = 1 + +AccountBound.Achievements.SyncRealmFirst = 0 +AccountBound.Achievements.SyncHidden = 0 + +AccountBound.Mounts.RespectFactionRestrictions = 1 +AccountBound.Mounts.RequireRiding = 1 +AccountBound.Mounts.RequireClass = 1 + +AccountBound.Titles.SyncRealmFirst = 0 + +AccountBound.Reputations.SameFactionOnly = 1 +AccountBound.Reputations.SyncUnpairedCrossFaction = 0 + +AccountBound.Professions.Primary.EnforceAllowedList = 0 +AccountBound.Professions.RequireRecipeSkill = 1 + +AccountBound.Friends.StartupBackfill = 1 +``` + +After the initial migration, keep every broad `StartupBackfill` option disabled. + +## Database Tables Used + +The module uses normal AzerothCore tables: + +```text +characters +character_achievement +character_spell +character_skills +character_reputation +character_social +``` + +No custom tables or SQL installation files are required. + +## Troubleshooting + +If the module is not loaded: + +- Confirm the folder is `modules/AccountBound`. +- Re-run CMake after adding the module. +- Rebuild `worldserver`. +- Confirm `configs/modules/AccountBound.conf` exists. +- Confirm `AccountBound.Enable = 1`. + +If a mount is not shared: + +- Confirm the spell passes `AllowList` and `BlockList`. +- Check riding, class and faction requirements. +- Check `RequireRiding`, `RequireClass` and `RespectFactionRestrictions`. + +If a profession recipe is not shared: + +- Confirm the profession is allowed. +- Confirm `SyncRecipes = 1`. +- Confirm the target has sufficient skill when `RequireRecipeSkill = 1`. +- Add custom recipes to `SpellAllowList`. + +If data was inserted while the target character was already online: + +- Relog once so AzerothCore reloads the updated character tables. + +If startup takes longer than normal: + +- Disable broad `StartupBackfill` options after the initial migration. + +## Files + +```text +AccountBound/ + conf/ + AccountBound.conf.dist + src/ + AccountBound.h + Achievements.cpp + Friends.cpp + Mounts.cpp + Pets.cpp + Professions.cpp + Reputations.cpp + Titles.cpp + loader.cpp + CHANGELOG.md + LICENSE + README.md +``` + +## License + +AccountBound is distributed under GPL-2.0 to match the AzerothCore core +license. diff --git a/modules/AccountBound/conf/AccountBound.conf.dist b/modules/AccountBound/conf/AccountBound.conf.dist new file mode 100644 index 000000000..a20c3518d --- /dev/null +++ b/modules/AccountBound/conf/AccountBound.conf.dist @@ -0,0 +1,111 @@ +# +# AccountBound +# One configurable account-wide module for AzerothCore WotLK. +# + +# Master switch for every category below. +AccountBound.Enable = 1 + +# +# Achievements +# +AccountBound.Achievements.Enable = 0 +AccountBound.Achievements.StartupBackfill = 1 +AccountBound.Achievements.SameFactionOnly = 1 +AccountBound.Achievements.SyncOnCreate = 1 +AccountBound.Achievements.SyncRealmFirst = 0 +AccountBound.Achievements.SyncHidden = 0 +AccountBound.Achievements.ConvertFactionSpecific = 1 +AccountBound.Achievements.SyncUnpairedFactionSpecific = 0 +AccountBound.Achievements.AllowList = all +AccountBound.Achievements.BlockList = "" + +# +# Mounts +# +AccountBound.Mounts.Enable = 1 +AccountBound.Mounts.StartupBackfill = 1 +AccountBound.Mounts.SyncOnCreate = 1 +AccountBound.Mounts.RespectFactionRestrictions = 1 +AccountBound.Mounts.ConvertFactionSpecific = 1 +AccountBound.Mounts.RequireRiding = 1 +AccountBound.Mounts.SyncClassMounts = 1 +AccountBound.Mounts.RequireClass = 1 +AccountBound.Mounts.ClassMountsSameFactionOnly = 1 +AccountBound.Mounts.CleanupInvalid = 1 +AccountBound.Mounts.BackfillOnRidingSkillChange = 1 +AccountBound.Mounts.AllowList = all +AccountBound.Mounts.BlockList = "" + +# +# Companion pets +# This intentionally shares vanity companions, not hunter stable pets. +# +AccountBound.Pets.Enable = 1 +AccountBound.Pets.StartupBackfill = 1 +AccountBound.Pets.SyncOnCreate = 1 +AccountBound.Pets.SameFactionOnly = 0 +AccountBound.Pets.IncludeCompanionSkillLine = 1 +AccountBound.Pets.IncludeMinipetSummons = 1 +AccountBound.Pets.AllowList = all +AccountBound.Pets.BlockList = "" + +# +# Titles +# +AccountBound.Titles.Enable = 0 +AccountBound.Titles.StartupBackfill = 1 +AccountBound.Titles.SyncOnCreate = 1 +AccountBound.Titles.SyncOnSave = 1 +AccountBound.Titles.SyncRealmFirst = 0 +AccountBound.Titles.ConvertFactionSpecific = 1 +AccountBound.Titles.AllowList = all +AccountBound.Titles.BlockList = "" + +# +# Reputations +# Values only move upward. Faction-specific equivalents are converted. +# +AccountBound.Reputations.Enable = 0 +AccountBound.Reputations.StartupBackfill = 1 +AccountBound.Reputations.SyncOnCreate = 1 +AccountBound.Reputations.SyncOnChange = 1 +AccountBound.Reputations.SameFactionOnly = 1 +AccountBound.Reputations.ConvertFactionSpecific = 1 +AccountBound.Reputations.SyncUnpairedCrossFaction = 0 +AccountBound.Reputations.AllowList = all +AccountBound.Reputations.BlockList = "" + +# +# Professions +# Supports English/Spanish names or skill IDs in the primary lists. +# +AccountBound.Professions.Enable = 0 +AccountBound.Professions.MaxPrimaryProfessions = 11 +AccountBound.Professions.Primary.AllowList = all +AccountBound.Professions.Primary.BlockList = "" +AccountBound.Professions.Primary.CountOnlyAllowed = 1 +AccountBound.Professions.Primary.EnforceAllowedList = 0 +AccountBound.Professions.MaxSkillValue = 450 +AccountBound.Professions.StartupBackfill = 1 +AccountBound.Professions.SyncOnCreate = 1 +AccountBound.Professions.SyncOnLearn = 1 +AccountBound.Professions.IncludeSecondaryProfessions = 1 +AccountBound.Professions.SyncSkillProgress = 1 +AccountBound.Professions.SyncProfessionRanks = 1 +AccountBound.Professions.SyncRecipes = 1 +AccountBound.Professions.SyncSkillGrantedSpells = 1 +AccountBound.Professions.RequireRecipeSkill = 1 +AccountBound.Professions.SpellAllowList = "" +AccountBound.Professions.SpellBlockList = "" + +# +# Friends +# Mirrors the normal friend list and notes across characters on the account. +# +AccountBound.Friends.Enable = 1 +AccountBound.Friends.SyncOnCreate = 1 +AccountBound.Friends.SyncOnlineChanges = 1 +AccountBound.Friends.SameFactionOnly = 0 +AccountBound.Friends.SyncIntervalSeconds = 3 +AccountBound.Friends.StartupBackfill = 1 diff --git a/modules/AccountBound/src/AccountBound.h b/modules/AccountBound/src/AccountBound.h new file mode 100644 index 000000000..0ec9507d9 --- /dev/null +++ b/modules/AccountBound/src/AccountBound.h @@ -0,0 +1,94 @@ +#ifndef ACCOUNT_BOUND_H +#define ACCOUNT_BOUND_H + +#include "Config.h" +#include "Log.h" + +#include +#include +#include +#include +#include + +namespace AccountBound +{ +struct IdFilter +{ + bool AllowAll = true; + std::unordered_set Allowed; + std::unordered_set Blocked; + + bool Allows(uint32 id) const + { + return id && !Blocked.contains(id) && (AllowAll || Allowed.contains(id)); + } +}; + +inline std::string Trim(std::string_view value) +{ + std::size_t first = 0; + while (first < value.size() && std::isspace(static_cast(value[first]))) + ++first; + + std::size_t last = value.size(); + while (last > first && std::isspace(static_cast(value[last - 1]))) + --last; + + return std::string(value.substr(first, last - first)); +} + +inline void ParseIds(std::string const& raw, std::unordered_set& destination) +{ + std::size_t start = 0; + while (start <= raw.size()) + { + std::size_t end = raw.find(',', start); + if (end == std::string::npos) + end = raw.size(); + + std::string token = Trim(std::string_view(raw).substr(start, end - start)); + if (!token.empty()) + { + uint32 id = 0; + auto const [ptr, error] = std::from_chars(token.data(), token.data() + token.size(), id); + if (error == std::errc() && ptr == token.data() + token.size() && id) + destination.insert(id); + else + LOG_WARN("module.accountbound", "AccountBound: ignored invalid ID '{}' in an AllowList or BlockList.", token); + } + + if (end == raw.size()) + break; + + start = end + 1; + } +} + +inline bool IsCategoryEnabled(std::string_view category, bool defaultValue = true) +{ + if (!sConfigMgr->GetOption("AccountBound.Enable", true)) + return false; + + return sConfigMgr->GetOption( + "AccountBound." + std::string(category) + ".Enable", defaultValue); +} + +inline IdFilter LoadIdFilter(std::string_view category) +{ + IdFilter filter; + std::string const prefix = "AccountBound." + std::string(category); + std::string const allowList = Trim(sConfigMgr->GetOption(prefix + ".AllowList", "all")); + + if (!allowList.empty() && allowList != "all" && allowList != "ALL" && allowList != "*") + { + filter.AllowAll = false; + if (allowList != "none" && allowList != "NONE") + ParseIds(allowList, filter.Allowed); + } + + ParseIds(sConfigMgr->GetOption(prefix + ".BlockList", ""), filter.Blocked); + return filter; +} +} + +#endif diff --git a/modules/AccountBound/src/Achievements.cpp b/modules/AccountBound/src/Achievements.cpp new file mode 100644 index 000000000..19145d878 --- /dev/null +++ b/modules/AccountBound/src/Achievements.cpp @@ -0,0 +1,378 @@ +/* + * Account-bound achievements for AzerothCore. + */ + +#include "AccountBound.h" +#include "Config.h" +#include "DatabaseEnv.h" +#include "DBCEnums.h" +#include "DBCStores.h" +#include "GameTime.h" +#include "Log.h" +#include "ObjectMgr.h" +#include "Player.h" +#include "PlayerScript.h" +#include "StringFormat.h" +#include "WorldScript.h" + +#include +#include +#include +#include + +namespace +{ +struct ModuleConfig +{ + bool Enabled = true; + bool StartupBackfill = true; + bool SameFactionOnly = false; + bool SyncOnCreate = true; + bool SyncRealmFirst = false; + bool SyncHidden = false; + bool ConvertFactionSpecific = true; + bool SyncUnpairedFactionSpecific = false; + AccountBound::IdFilter Filter; +}; + +struct CharacterInfo +{ + uint32 Guid = 0; + uint32 AccountId = 0; + uint8 Race = 0; +}; + +ModuleConfig Config; + +bool CanShareBetweenRaces(uint8 sourceRace, uint8 targetRace) +{ + return !Config.SameFactionOnly || Player::TeamIdForRace(sourceRace) == Player::TeamIdForRace(targetRace); +} + +bool IsRealmFirstAchievement(AchievementEntry const* achievement) +{ + return achievement && (achievement->flags & (ACHIEVEMENT_FLAG_REALM_FIRST_REACH | ACHIEVEMENT_FLAG_REALM_FIRST_KILL)); +} + +bool IsAchievementAllowed(AchievementEntry const* achievement) +{ + if (!achievement || (achievement->flags & ACHIEVEMENT_FLAG_COUNTER)) + return false; + + if (!Config.Filter.Allows(achievement->ID)) + return false; + + if (!Config.SyncRealmFirst && IsRealmFirstAchievement(achievement)) + return false; + + if (!Config.SyncHidden && (achievement->flags & ACHIEVEMENT_FLAG_HIDDEN)) + return false; + + return true; +} + +uint32 GetFactionAchievementForTeam(uint32 achievementId, TeamId targetTeam) +{ + AchievementEntry const* achievement = sAchievementStore.LookupEntry(achievementId); + if (!IsAchievementAllowed(achievement)) + return 0; + + if (achievement->requiredFaction == ACHIEVEMENT_FACTION_ANY) + return achievementId; + + if (achievement->requiredFaction == ACHIEVEMENT_FACTION_ALLIANCE && targetTeam == TEAM_ALLIANCE) + return achievementId; + + if (achievement->requiredFaction == ACHIEVEMENT_FACTION_HORDE && targetTeam == TEAM_HORDE) + return achievementId; + + if (!Config.ConvertFactionSpecific) + return Config.SyncUnpairedFactionSpecific ? achievementId : 0; + + for (auto const& [allianceAchievement, hordeAchievement] : sObjectMgr->FactionChangeAchievements) + { + uint32 mappedAchievement = 0; + + if (achievementId == allianceAchievement && targetTeam == TEAM_HORDE) + mappedAchievement = hordeAchievement; + else if (achievementId == hordeAchievement && targetTeam == TEAM_ALLIANCE) + mappedAchievement = allianceAchievement; + + if (!mappedAchievement) + continue; + + AchievementEntry const* mappedEntry = sAchievementStore.LookupEntry(mappedAchievement); + return IsAchievementAllowed(mappedEntry) ? mappedAchievement : 0; + } + + return Config.SyncUnpairedFactionSpecific ? achievementId : 0; +} + +uint32 GetAchievementForRace(uint32 achievementId, uint8 race) +{ + return GetFactionAchievementForTeam(achievementId, Player::TeamIdForRace(race)); +} + +void AppendOrCommit(CharacterDatabaseTransaction& trans, std::string_view sql) +{ + if (!trans) + trans = CharacterDatabase.BeginTransaction(); + + trans->Append(sql); + + if (trans->GetSize() >= 1000) + { + CharacterDatabase.DirectCommitTransaction(trans); + trans = CharacterDatabase.BeginTransaction(); + } +} + +void CommitIfNeeded(CharacterDatabaseTransaction& trans) +{ + if (trans && trans->GetSize()) + CharacterDatabase.DirectCommitTransaction(trans); +} + +void InsertAchievementForCharacter(CharacterDatabaseTransaction& trans, uint32 targetGuid, uint32 achievementId, uint32 date) +{ + AppendOrCommit(trans, Acore::StringFormat( + "INSERT IGNORE INTO character_achievement (guid, achievement, date) VALUES ({}, {}, {})", + targetGuid, achievementId, date)); +} + +std::vector LoadAccountCharacters(uint32 accountId) +{ + std::vector characters; + QueryResult result = CharacterDatabase.Query( + "SELECT guid, account, race FROM characters WHERE account = {}", accountId); + + if (!result) + return characters; + + do + { + Field* fields = result->Fetch(); + characters.push_back({ + fields[0].Get(), + fields[1].Get(), + fields[2].Get() + }); + } while (result->NextRow()); + + return characters; +} + +void SyncAchievementToAccount(Player* player, uint32 achievementId, uint32 date) +{ + if (!Config.Enabled || !player) + return; + + uint8 const sourceRace = player->getRace(true); + std::vector characters = LoadAccountCharacters(player->GetSession()->GetAccountId()); + CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction(); + uint32 synced = 0; + + for (CharacterInfo const& target : characters) + { + if (target.Guid == player->GetGUID().GetCounter() || !CanShareBetweenRaces(sourceRace, target.Race)) + continue; + + uint32 const targetAchievementId = GetAchievementForRace(achievementId, target.Race); + if (!targetAchievementId) + continue; + + InsertAchievementForCharacter(trans, target.Guid, targetAchievementId, date); + ++synced; + } + + CommitIfNeeded(trans); + + if (synced) + LOG_DEBUG("module.accountboundachievements", + "AccountBoundAchievements: synced achievement {} from player {} to {} account character(s).", + achievementId, player->GetGUID().GetCounter(), synced); +} + +void BackfillAchievementsForCharacter(Player* player) +{ + if (!Config.Enabled || !player) + return; + + uint32 const accountId = player->GetSession()->GetAccountId(); + uint32 const targetGuid = player->GetGUID().GetCounter(); + uint8 const targetRace = player->getRace(true); + QueryResult result = CharacterDatabase.Query( + "SELECT ca.achievement, MIN(ca.date), c.race " + "FROM character_achievement ca " + "INNER JOIN characters c ON c.guid = ca.guid " + "WHERE c.account = {} AND c.guid <> {} " + "GROUP BY ca.achievement, c.race " + "ORDER BY MIN(ca.date)", + accountId, targetGuid); + + if (!result) + return; + + CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction(); + uint32 inserted = 0; + + do + { + Field* fields = result->Fetch(); + uint32 const achievementId = fields[0].Get(); + uint32 const date = fields[1].Get(); + uint8 const sourceRace = fields[2].Get(); + + if (!CanShareBetweenRaces(sourceRace, targetRace)) + continue; + + uint32 const targetAchievementId = GetAchievementForRace(achievementId, targetRace); + if (!targetAchievementId) + continue; + + InsertAchievementForCharacter(trans, targetGuid, targetAchievementId, date); + ++inserted; + } while (result->NextRow()); + + CommitIfNeeded(trans); + + if (inserted) + LOG_INFO("module.accountboundachievements", + "AccountBoundAchievements: seeded {} achievement row(s) for new character {}.", + inserted, targetGuid); +} + +void BackfillAllAchievements() +{ + if (!Config.Enabled || !Config.StartupBackfill) + return; + + QueryResult charactersResult = CharacterDatabase.Query( + "SELECT guid, account, race FROM characters WHERE account <> 0"); + + if (!charactersResult) + return; + + std::unordered_map> charactersByAccount; + do + { + Field* fields = charactersResult->Fetch(); + CharacterInfo character = { + fields[0].Get(), + fields[1].Get(), + fields[2].Get() + }; + charactersByAccount[character.AccountId].push_back(character); + } while (charactersResult->NextRow()); + + QueryResult achievementsResult = CharacterDatabase.Query( + "SELECT c.account, c.race, ca.achievement, MIN(ca.date) " + "FROM character_achievement ca " + "INNER JOIN characters c ON c.guid = ca.guid " + "WHERE c.account <> 0 " + "GROUP BY c.account, c.race, ca.achievement " + "ORDER BY MIN(ca.date)"); + + if (!achievementsResult) + return; + + CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction(); + uint64 inserted = 0; + + do + { + Field* fields = achievementsResult->Fetch(); + uint32 const accountId = fields[0].Get(); + uint8 const sourceRace = fields[1].Get(); + uint32 const achievementId = fields[2].Get(); + uint32 const date = fields[3].Get(); + auto accountItr = charactersByAccount.find(accountId); + + if (accountItr == charactersByAccount.end()) + continue; + + for (CharacterInfo const& target : accountItr->second) + { + if (!CanShareBetweenRaces(sourceRace, target.Race)) + continue; + + uint32 const targetAchievementId = GetAchievementForRace(achievementId, target.Race); + if (!targetAchievementId) + continue; + + InsertAchievementForCharacter(trans, target.Guid, targetAchievementId, date); + ++inserted; + } + } while (achievementsResult->NextRow()); + + CommitIfNeeded(trans); + LOG_INFO("module.accountboundachievements", + "AccountBoundAchievements: startup backfill queued {} achievement row(s).", inserted); +} + +void LoadModuleConfig() +{ + Config.Enabled = AccountBound::IsCategoryEnabled("Achievements"); + Config.StartupBackfill = sConfigMgr->GetOption("AccountBound.Achievements.StartupBackfill", true); + Config.SameFactionOnly = sConfigMgr->GetOption("AccountBound.Achievements.SameFactionOnly", false); + Config.SyncOnCreate = sConfigMgr->GetOption("AccountBound.Achievements.SyncOnCreate", true); + Config.SyncRealmFirst = sConfigMgr->GetOption("AccountBound.Achievements.SyncRealmFirst", false); + Config.SyncHidden = sConfigMgr->GetOption("AccountBound.Achievements.SyncHidden", false); + Config.ConvertFactionSpecific = sConfigMgr->GetOption("AccountBound.Achievements.ConvertFactionSpecific", true); + Config.SyncUnpairedFactionSpecific = sConfigMgr->GetOption("AccountBound.Achievements.SyncUnpairedFactionSpecific", false); + Config.Filter = AccountBound::LoadIdFilter("Achievements"); +} +} + +class AccountBoundAchievementsWorldScript : public WorldScript +{ +public: + AccountBoundAchievementsWorldScript() : WorldScript("AccountBoundAchievementsWorldScript", { + WORLDHOOK_ON_AFTER_CONFIG_LOAD, + WORLDHOOK_ON_STARTUP + }) { } + + void OnAfterConfigLoad(bool reload) override + { + LoadModuleConfig(); + LOG_INFO("module.accountboundachievements", + "AccountBoundAchievements: {}. CreateSync={}, StartupBackfill={}, SameFactionOnly={}, RealmFirst={}.", + Config.Enabled ? (reload ? "configuration reloaded" : "configuration loaded") : "disabled", + Config.Enabled && Config.SyncOnCreate ? "on" : "off", + Config.Enabled && Config.StartupBackfill ? "on" : "off", + Config.Enabled && Config.SameFactionOnly ? "on" : "off", + Config.Enabled && Config.SyncRealmFirst ? "on" : "off"); + } + + void OnStartup() override + { + BackfillAllAchievements(); + } +}; + +class AccountBoundAchievementsPlayerScript : public PlayerScript +{ +public: + AccountBoundAchievementsPlayerScript() : PlayerScript("AccountBoundAchievementsPlayerScript", { + PLAYERHOOK_ON_CREATE, + PLAYERHOOK_ON_ACHI_COMPLETE + }) { } + + void OnPlayerCreate(Player* player) override + { + if (Config.SyncOnCreate) + BackfillAchievementsForCharacter(player); + } + + void OnPlayerAchievementComplete(Player* player, AchievementEntry const* achievement) override + { + if (achievement) + SyncAchievementToAccount(player, achievement->ID, uint32(GameTime::GetGameTime().count())); + } +}; + +void AddAccountBoundAchievementsScripts() +{ + new AccountBoundAchievementsWorldScript(); + new AccountBoundAchievementsPlayerScript(); +} diff --git a/modules/AccountBound/src/Friends.cpp b/modules/AccountBound/src/Friends.cpp new file mode 100644 index 000000000..62a9e5605 --- /dev/null +++ b/modules/AccountBound/src/Friends.cpp @@ -0,0 +1,526 @@ +/* + * AccountWideFriends module for AzerothCore. + */ + +#include "AccountBound.h" +#include "Config.h" +#include "DatabaseEnv.h" +#include "Log.h" +#include "Player.h" +#include "PlayerScript.h" +#include "SocialMgr.h" +#include "WorldScript.h" +#include "WorldSession.h" + +#include +#include +#include +#include +#include + +namespace +{ +constexpr uint32 FriendFlag = SOCIAL_FLAG_FRIEND; +constexpr uint32 WithoutFriendFlagMask = 0xFE; + +struct ModuleConfig +{ + bool Enabled = true; + bool SyncOnCreate = true; + bool SyncOnlineChanges = true; + bool StartupBackfill = true; + bool SameFactionOnly = false; + uint32 SyncIntervalMs = 3000; +}; + +struct CharacterInfo +{ + uint32 Guid = 0; + uint8 Race = 0; +}; + +using FriendMap = std::unordered_map; + +ModuleConfig Config; +std::unordered_map UpdateTimersByCharacter; +std::unordered_map CachedFriendsByCharacter; + +bool IsEnabled() +{ + return Config.Enabled; +} + +std::string EscapeSqlString(std::string text) +{ + CharacterDatabase.EscapeString(text); + return text; +} + +bool CanShareBetweenRaces(uint8 ownerRace, uint8 friendRace) +{ + return !Config.SameFactionOnly || Player::TeamIdForRace(ownerRace) == Player::TeamIdForRace(friendRace); +} + +std::vector LoadAccountCharacterInfos(uint32 accountId) +{ + std::vector characters; + + QueryResult result = CharacterDatabase.Query( + "SELECT guid, race FROM characters WHERE account = {}", accountId); + + if (!result) + return characters; + + do + { + Field* fields = result->Fetch(); + characters.push_back({ fields[0].Get(), fields[1].Get() }); + } while (result->NextRow()); + + return characters; +} + +std::vector LoadAccountCharacters(uint32 accountId) +{ + std::vector characterGuids; + + for (CharacterInfo const& character : LoadAccountCharacterInfos(accountId)) + characterGuids.push_back(character.Guid); + + return characterGuids; +} + +std::unordered_set MakeGuidSet(std::vector const& guids) +{ + return { guids.begin(), guids.end() }; +} + +std::unordered_set MakeGuidSet(std::vector const& characters) +{ + std::unordered_set guids; + + for (CharacterInfo const& character : characters) + guids.insert(character.Guid); + + return guids; +} + +uint8 LoadCharacterRace(uint32 characterGuid) +{ + QueryResult result = CharacterDatabase.Query( + "SELECT race FROM characters WHERE guid = {}", characterGuid); + + if (!result) + return 0; + + return result->Fetch()[0].Get(); +} + +bool CanCharacterShareWithFriend(CharacterInfo const& owner, uint32 friendGuid) +{ + uint8 const friendRace = LoadCharacterRace(friendGuid); + return friendRace && CanShareBetweenRaces(owner.Race, friendRace); +} + +void RemoveFriendFromCharacter(uint32 ownerGuid, uint32 friendGuid) +{ + CharacterDatabase.DirectExecute( + "UPDATE character_social SET flags = flags & {} " + "WHERE guid = {} AND friend = {} AND (flags & {}) <> 0", + WithoutFriendFlagMask, ownerGuid, friendGuid, FriendFlag); + + CharacterDatabase.DirectExecute( + "DELETE FROM character_social WHERE guid = {} AND friend = {} AND flags = 0", + ownerGuid, friendGuid); +} + +FriendMap LoadCharacterFriends(uint32 characterGuid) +{ + FriendMap friends; + + QueryResult result = CharacterDatabase.Query( + "SELECT social.friend, COALESCE(social.note, ''), owner.race, friend_char.race " + "FROM character_social social " + "INNER JOIN characters owner ON owner.guid = social.guid " + "INNER JOIN characters friend_char ON friend_char.guid = social.friend " + "WHERE social.guid = {} AND owner.account <> friend_char.account AND (social.flags & {}) <> 0", + characterGuid, FriendFlag); + + if (!result) + return friends; + + do + { + Field* fields = result->Fetch(); + uint32 const friendGuid = fields[0].Get(); + uint8 const ownerRace = fields[2].Get(); + uint8 const friendRace = fields[3].Get(); + + if (friendGuid && friendGuid != characterGuid && CanShareBetweenRaces(ownerRace, friendRace)) + friends[friendGuid] = fields[1].Get(); + } while (result->NextRow()); + + return friends; +} + +FriendMap LoadAccountFriendUnion(uint32 accountId) +{ + FriendMap friends; + std::vector accountCharacters = LoadAccountCharacters(accountId); + std::unordered_set ownCharacters = MakeGuidSet(accountCharacters); + + QueryResult result = CharacterDatabase.Query( + "SELECT social.friend, COALESCE(social.note, '') " + "FROM character_social social " + "INNER JOIN characters owner ON owner.guid = social.guid " + "WHERE owner.account = {} AND (social.flags & {}) <> 0 " + "ORDER BY social.note <> '' DESC", + accountId, FriendFlag); + + if (!result) + return friends; + + do + { + Field* fields = result->Fetch(); + uint32 const friendGuid = fields[0].Get(); + + if (!friendGuid || ownCharacters.contains(friendGuid)) + continue; + + std::string const note = fields[1].Get(); + auto [itr, inserted] = friends.emplace(friendGuid, note); + + if (!inserted && itr->second.empty() && !note.empty()) + itr->second = note; + } while (result->NextRow()); + + return friends; +} + +void InsertOrUpdateFriendForCharacter(uint32 ownerGuid, uint32 friendGuid, std::string const& note, bool forceNote) +{ + if (!ownerGuid || !friendGuid || ownerGuid == friendGuid) + return; + + std::string safeNote = note.substr(0, 48); + safeNote = EscapeSqlString(safeNote); + + if (forceNote) + { + CharacterDatabase.DirectExecute( + "INSERT INTO character_social (guid, friend, flags, note) VALUES ({}, {}, {}, '{}') " + "ON DUPLICATE KEY UPDATE flags = flags | {}, note = '{}'", + ownerGuid, friendGuid, FriendFlag, safeNote, FriendFlag, safeNote); + return; + } + + CharacterDatabase.DirectExecute( + "INSERT INTO character_social (guid, friend, flags, note) VALUES ({}, {}, {}, '{}') " + "ON DUPLICATE KEY UPDATE flags = flags | {}, note = IF(VALUES(note) <> '', VALUES(note), note)", + ownerGuid, friendGuid, FriendFlag, safeNote, FriendFlag); +} + +uint32 SyncFriendToCharacters( + std::vector const& accountCharacters, + std::unordered_set const& ownCharacters, + uint32 friendGuid, + std::string const& note, + bool forceNote) +{ + if (!friendGuid || ownCharacters.contains(friendGuid)) + return 0; + + uint32 synced = 0; + + for (CharacterInfo const& owner : accountCharacters) + { + if (owner.Guid == friendGuid || !CanCharacterShareWithFriend(owner, friendGuid)) + continue; + + InsertOrUpdateFriendForCharacter(owner.Guid, friendGuid, note, forceNote); + ++synced; + } + + return synced; +} + +uint32 SyncFriendToAccount(uint32 accountId, uint32 friendGuid, std::string const& note, bool forceNote = false) +{ + std::vector accountCharacters = LoadAccountCharacterInfos(accountId); + std::unordered_set ownCharacters = MakeGuidSet(accountCharacters); + + return SyncFriendToCharacters(accountCharacters, ownCharacters, friendGuid, note, forceNote); +} + +void RemoveInvalidFactionFriends(uint32 accountId) +{ + if (!Config.SameFactionOnly || !accountId) + return; + + QueryResult result = CharacterDatabase.Query( + "SELECT social.guid, social.friend, owner.race, friend_char.race " + "FROM character_social social " + "INNER JOIN characters owner ON owner.guid = social.guid " + "INNER JOIN characters friend_char ON friend_char.guid = social.friend " + "WHERE owner.account = {} AND owner.account <> friend_char.account AND (social.flags & {}) <> 0", + accountId, FriendFlag); + + if (!result) + return; + + do + { + Field* fields = result->Fetch(); + uint32 const ownerGuid = fields[0].Get(); + uint32 const friendGuid = fields[1].Get(); + uint8 const ownerRace = fields[2].Get(); + uint8 const friendRace = fields[3].Get(); + + if (!CanShareBetweenRaces(ownerRace, friendRace)) + RemoveFriendFromCharacter(ownerGuid, friendGuid); + } while (result->NextRow()); +} + +void RemoveOwnAccountFriends(uint32 accountId) +{ + if (!accountId) + return; + + CharacterDatabase.DirectExecute( + "UPDATE character_social social " + "INNER JOIN characters owner ON owner.guid = social.guid " + "INNER JOIN characters friend_char ON friend_char.guid = social.friend " + "SET social.flags = social.flags & {} " + "WHERE owner.account = {} AND friend_char.account = {} AND (social.flags & {}) <> 0", + WithoutFriendFlagMask, accountId, accountId, FriendFlag); + + CharacterDatabase.DirectExecute( + "DELETE social FROM character_social social " + "INNER JOIN characters owner ON owner.guid = social.guid " + "INNER JOIN characters friend_char ON friend_char.guid = social.friend " + "WHERE owner.account = {} AND friend_char.account = {} AND social.flags = 0", + accountId, accountId); +} + +uint32 SyncAccountFriends(uint32 accountId) +{ + RemoveOwnAccountFriends(accountId); + RemoveInvalidFactionFriends(accountId); + + std::vector accountCharacters = LoadAccountCharacterInfos(accountId); + + if (accountCharacters.empty()) + return 0; + + std::unordered_set ownCharacters = MakeGuidSet(accountCharacters); + FriendMap accountFriends = LoadAccountFriendUnion(accountId); + uint32 synced = 0; + + for (auto const& [friendGuid, note] : accountFriends) + synced += SyncFriendToCharacters(accountCharacters, ownCharacters, friendGuid, note, false); + + return synced; +} + +void RemoveFriendFromAccount(uint32 accountId, uint32 friendGuid) +{ + if (!accountId || !friendGuid) + return; + + CharacterDatabase.DirectExecute( + "UPDATE character_social social " + "INNER JOIN characters owner ON owner.guid = social.guid " + "SET social.flags = social.flags & {} " + "WHERE owner.account = {} AND social.friend = {} AND (social.flags & {}) <> 0", + WithoutFriendFlagMask, accountId, friendGuid, FriendFlag); + + CharacterDatabase.DirectExecute( + "DELETE social FROM character_social social " + "INNER JOIN characters owner ON owner.guid = social.guid " + "WHERE owner.account = {} AND social.friend = {} AND social.flags = 0", + accountId, friendGuid); +} + +void CacheCharacterFriends(uint32 characterGuid) +{ + CachedFriendsByCharacter[characterGuid] = LoadCharacterFriends(characterGuid); +} + +void DetectAndSyncOnlineChanges(Player* player) +{ + if (!player || !player->GetSession()) + return; + + uint32 const accountId = player->GetSession()->GetAccountId(); + uint32 const characterGuid = player->GetGUID().GetCounter(); + RemoveOwnAccountFriends(accountId); + RemoveInvalidFactionFriends(accountId); + FriendMap currentFriends = LoadCharacterFriends(characterGuid); + + auto cacheItr = CachedFriendsByCharacter.find(characterGuid); + if (cacheItr == CachedFriendsByCharacter.end()) + { + CachedFriendsByCharacter[characterGuid] = std::move(currentFriends); + return; + } + + FriendMap const& cachedFriends = cacheItr->second; + bool changed = false; + + for (auto const& [friendGuid, note] : currentFriends) + { + auto cachedFriend = cachedFriends.find(friendGuid); + + if (cachedFriend == cachedFriends.end()) + { + SyncFriendToAccount(accountId, friendGuid, note); + changed = true; + continue; + } + + if (cachedFriend->second != note) + { + SyncFriendToAccount(accountId, friendGuid, note, true); + changed = true; + } + } + + for (auto const& cachedFriendEntry : cachedFriends) + { + uint32 const friendGuid = cachedFriendEntry.first; + + if (!currentFriends.contains(friendGuid)) + { + RemoveFriendFromAccount(accountId, friendGuid); + changed = true; + } + } + + if (changed) + LOG_DEBUG("module.accountwidefriends", "AccountWideFriends: synced friend changes for account {} from character {}.", accountId, characterGuid); + + CacheCharacterFriends(characterGuid); +} + +void BackfillAllAccounts() +{ + QueryResult result = CharacterDatabase.Query( + "SELECT DISTINCT account FROM characters WHERE account <> 0"); + + if (!result) + return; + + uint32 accountCount = 0; + uint32 syncCount = 0; + + do + { + Field* fields = result->Fetch(); + uint32 const accountId = fields[0].Get(); + + syncCount += SyncAccountFriends(accountId); + ++accountCount; + } while (result->NextRow()); + + LOG_INFO("module.accountwidefriends", "AccountWideFriends: startup backfill checked {} account(s) and queued {} friend row sync operation(s).", accountCount, syncCount); +} + +void LoadModuleConfig() +{ + Config.Enabled = AccountBound::IsCategoryEnabled("Friends"); + Config.SyncOnCreate = sConfigMgr->GetOption("AccountBound.Friends.SyncOnCreate", true); + Config.SyncOnlineChanges = sConfigMgr->GetOption("AccountBound.Friends.SyncOnlineChanges", true); + Config.StartupBackfill = sConfigMgr->GetOption("AccountBound.Friends.StartupBackfill", true); + Config.SameFactionOnly = sConfigMgr->GetOption("AccountBound.Friends.SameFactionOnly", false); + Config.SyncIntervalMs = std::max( + 1000, + sConfigMgr->GetOption("AccountBound.Friends.SyncIntervalSeconds", 3) * 1000); +} +} + +class AccountWideFriendsWorldScript : public WorldScript +{ +public: + AccountWideFriendsWorldScript() : WorldScript("AccountWideFriendsWorldScript", { + WORLDHOOK_ON_AFTER_CONFIG_LOAD, + WORLDHOOK_ON_STARTUP + }) { } + + void OnAfterConfigLoad(bool reload) override + { + LoadModuleConfig(); + + LOG_INFO("module.accountwidefriends", "AccountWideFriends: {}. Enabled={}, CreateSync={}, OnlineSync={}, SameFactionOnly={}, IntervalMs={}, StartupBackfill={}.", + reload ? "configuration reloaded" : "configuration loaded", + Config.Enabled ? "on" : "off", + Config.Enabled && Config.SyncOnCreate ? "on" : "off", + Config.Enabled && Config.SyncOnlineChanges ? "on" : "off", + Config.Enabled && Config.SameFactionOnly ? "on" : "off", + Config.SyncIntervalMs, + Config.Enabled && Config.StartupBackfill ? "on" : "off"); + } + + void OnStartup() override + { + if (IsEnabled() && Config.StartupBackfill) + BackfillAllAccounts(); + } +}; + +class AccountWideFriendsPlayerScript : public PlayerScript +{ +public: + AccountWideFriendsPlayerScript() : PlayerScript("AccountWideFriendsPlayerScript", { + PLAYERHOOK_ON_LOGIN, + PLAYERHOOK_ON_CREATE, + PLAYERHOOK_ON_LOGOUT, + PLAYERHOOK_ON_UPDATE + }) { } + + void OnPlayerLogin(Player* player) override + { + if (!IsEnabled() || !player || !player->GetSession()) + return; + + CacheCharacterFriends(player->GetGUID().GetCounter()); + } + + void OnPlayerCreate(Player* player) override + { + if (IsEnabled() && Config.SyncOnCreate && player && player->GetSession()) + SyncAccountFriends(player->GetSession()->GetAccountId()); + } + + void OnPlayerLogout(Player* player) override + { + if (!player) + return; + + uint32 const characterGuid = player->GetGUID().GetCounter(); + UpdateTimersByCharacter.erase(characterGuid); + CachedFriendsByCharacter.erase(characterGuid); + } + + void OnPlayerUpdate(Player* player, uint32 diff) override + { + if (!IsEnabled() || !Config.SyncOnlineChanges || !player) + return; + + uint32 const characterGuid = player->GetGUID().GetCounter(); + uint32& timer = UpdateTimersByCharacter[characterGuid]; + timer = std::min(Config.SyncIntervalMs, timer + diff); + + if (timer < Config.SyncIntervalMs) + return; + + timer = 0; + DetectAndSyncOnlineChanges(player); + } +}; + +void AddAccountWideFriendsScripts() +{ + new AccountWideFriendsWorldScript(); + new AccountWideFriendsPlayerScript(); +} diff --git a/modules/AccountBound/src/Mounts.cpp b/modules/AccountBound/src/Mounts.cpp new file mode 100644 index 000000000..6bff5ef73 --- /dev/null +++ b/modules/AccountBound/src/Mounts.cpp @@ -0,0 +1,692 @@ +/* + * Account-bound mounts for AzerothCore. + */ + +#include "AccountBound.h" +#include "Config.h" +#include "DatabaseEnv.h" +#include "DBCEnums.h" +#include "ItemTemplate.h" +#include "Log.h" +#include "ObjectMgr.h" +#include "Player.h" +#include "PlayerScript.h" +#include "RaceMgr.h" +#include "SpellAuraDefines.h" +#include "SpellInfo.h" +#include "SpellMgr.h" +#include "StringFormat.h" +#include "WorldScript.h" + +#include +#include +#include +#include + +namespace +{ +struct ModuleConfig +{ + bool Enabled = true; + bool StartupBackfill = true; + bool SyncOnCreate = true; + bool ConvertFactionSpecific = false; + bool RespectFactionRestrictions = true; + bool RequireRiding = true; + bool SyncClassMounts = true; + bool RequireClass = true; + bool ClassMountsSameFactionOnly = true; + bool CleanupInvalid = true; + bool BackfillOnRidingSkillChange = true; + AccountBound::IdFilter Filter; +}; + +struct CharacterInfo +{ + uint32 Guid = 0; + uint32 AccountId = 0; + uint8 Race = 0; + uint8 Class = 0; + uint16 RidingSkill = 0; +}; + +struct MountSpellRequirements +{ + uint16 RequiredRidingRank = 1; + uint32 ClassMask = 0; +}; + +ModuleConfig Config; +std::unordered_map MountSpellFactionMap; +std::unordered_map MountSpellRequirementsMap; + +bool IsMountSpell(uint32 spellId) +{ + SpellInfo const* spellInfo = sSpellMgr->GetSpellInfo(spellId); + return spellInfo && spellInfo->HasAura(SPELL_AURA_MOUNTED); +} + +uint32 GetClassMaskForClass(uint8 classId) +{ + if (!classId || classId >= MAX_CLASSES) + return 0; + + return 1u << (classId - 1); +} + +uint32 NormalizeClassMask(uint32 classMask) +{ + classMask &= CLASSMASK_ALL_PLAYABLE; + return classMask && classMask != CLASSMASK_ALL_PLAYABLE ? classMask : 0; +} + +uint16 GetRidingRankFromRequiredSpell(uint32 spellId) +{ + switch (spellId) + { + case 33388: return 75; // Apprentice Riding + case 33391: return 150; // Journeyman Riding + case 34090: return 225; // Expert Riding + case 34091: return 300; // Artisan Riding + default: return 0; + } +} + +MountSpellRequirements& GetMountSpellRequirements(uint32 spellId) +{ + return MountSpellRequirementsMap.try_emplace(spellId).first->second; +} + +void SetMountRidingRequirement(uint32 spellId, uint16 requiredRidingRank) +{ + if (!IsMountSpell(spellId) || !requiredRidingRank) + return; + + MountSpellRequirements& requirements = GetMountSpellRequirements(spellId); + requirements.RequiredRidingRank = std::max(requirements.RequiredRidingRank, requiredRidingRank); +} + +void SetMountClassRestriction(uint32 spellId, uint32 classMask) +{ + classMask = NormalizeClassMask(classMask); + if (!IsMountSpell(spellId) || !classMask) + return; + + GetMountSpellRequirements(spellId).ClassMask |= classMask; +} + +void ApplyClassMountFallbacks() +{ + uint32 const paladinMask = GetClassMaskForClass(CLASS_PALADIN); + uint32 const warlockMask = GetClassMaskForClass(CLASS_WARLOCK); + uint32 const deathKnightMask = GetClassMaskForClass(CLASS_DEATH_KNIGHT); + uint32 const druidMask = GetClassMaskForClass(CLASS_DRUID); + + for (uint32 spellId : { 13819u, 23214u, 34767u, 34769u, 66906u }) + SetMountClassRestriction(spellId, paladinMask); + + for (uint32 spellId : { 5784u, 23161u }) + SetMountClassRestriction(spellId, warlockMask); + + for (uint32 spellId : { 48778u, 54729u }) + SetMountClassRestriction(spellId, deathKnightMask); + + for (uint32 spellId : { 33943u, 40120u, 40121u }) + SetMountClassRestriction(spellId, druidMask); +} + +TeamId GetAllowedMountTeamForRaceMask(uint32 allowableRace) +{ + uint32 const playableRaceMask = sRaceMgr->GetPlayableRaceMask(); + uint32 const allowedRaceMask = allowableRace ? (allowableRace & playableRaceMask) : playableRaceMask; + + if (!allowedRaceMask) + return TEAM_NEUTRAL; + + bool const hasAllianceRace = (allowedRaceMask & sRaceMgr->GetAllianceRaceMask()) != 0; + bool const hasHordeRace = (allowedRaceMask & sRaceMgr->GetHordeRaceMask()) != 0; + + if (hasAllianceRace && !hasHordeRace) + return TEAM_ALLIANCE; + + if (hasHordeRace && !hasAllianceRace) + return TEAM_HORDE; + + return TEAM_NEUTRAL; +} + +void SetMountSpellFaction(uint32 spellId, TeamId teamId, bool force = false) +{ + if (!IsMountSpell(spellId)) + return; + + auto itr = MountSpellFactionMap.find(spellId); + if (itr == MountSpellFactionMap.end()) + { + MountSpellFactionMap.emplace(spellId, teamId); + return; + } + + if (force) + { + itr->second = teamId; + return; + } + + if (teamId == TEAM_NEUTRAL) + { + itr->second = TEAM_NEUTRAL; + return; + } + + if (itr->second == TEAM_NEUTRAL) + { + itr->second = teamId; + return; + } + + if (itr->second != teamId) + itr->second = TEAM_NEUTRAL; +} + +void BuildMountCache() +{ + MountSpellFactionMap.clear(); + MountSpellRequirementsMap.clear(); + + for (uint32 spellId = 1; spellId < sSpellMgr->GetSpellInfoStoreSize(); ++spellId) + { + if (!IsMountSpell(spellId)) + continue; + + MountSpellFactionMap.emplace(spellId, TEAM_NEUTRAL); + MountSpellRequirementsMap.emplace(spellId, MountSpellRequirements{}); + + SkillLineAbilityMapBounds bounds = sSpellMgr->GetSkillLineAbilityMapBounds(spellId); + for (SkillLineAbilityMap::const_iterator itr = bounds.first; itr != bounds.second; ++itr) + { + SkillLineAbilityEntry const* skillLineAbility = itr->second; + if (!skillLineAbility) + continue; + + SetMountClassRestriction(spellId, skillLineAbility->ClassMask); + + if (skillLineAbility->SkillLine == SKILL_RIDING) + SetMountRidingRequirement(spellId, uint16(skillLineAbility->MinSkillLineRank)); + } + } + + if (ItemTemplateContainer const* itemTemplates = sObjectMgr->GetItemTemplateStore()) + { + for (auto const& itemTemplatePair : *itemTemplates) + { + ItemTemplate const& itemTemplate = itemTemplatePair.second; + if (itemTemplate.Class != ITEM_CLASS_MISC || itemTemplate.SubClass != ITEM_SUBCLASS_JUNK_MOUNT) + continue; + + TeamId const itemTeam = GetAllowedMountTeamForRaceMask(itemTemplate.AllowableRace); + + for (uint8 i = 0; i < MAX_ITEM_PROTO_SPELLS; ++i) + { + int32 const itemSpellId = itemTemplate.Spells[i].SpellId; + if (itemSpellId <= 0 || !IsMountSpell(uint32(itemSpellId))) + continue; + + SetMountSpellFaction(uint32(itemSpellId), itemTeam); + SetMountClassRestriction(uint32(itemSpellId), itemTemplate.AllowableClass); + + if (itemTemplate.RequiredSkill == SKILL_RIDING) + SetMountRidingRequirement(uint32(itemSpellId), uint16(itemTemplate.RequiredSkillRank)); + + SetMountRidingRequirement(uint32(itemSpellId), GetRidingRankFromRequiredSpell(itemTemplate.RequiredSpell)); + } + } + } + + for (auto const& [allianceSpell, hordeSpell] : sObjectMgr->FactionChangeSpells) + { + if (!IsMountSpell(allianceSpell) || !IsMountSpell(hordeSpell)) + continue; + + SetMountSpellFaction(allianceSpell, TEAM_ALLIANCE, true); + SetMountSpellFaction(hordeSpell, TEAM_HORDE, true); + } + + ApplyClassMountFallbacks(); + LOG_INFO("module.accountboundmounts", + "AccountBoundMounts: cached {} faction rule(s) and {} requirement rule(s).", + MountSpellFactionMap.size(), MountSpellRequirementsMap.size()); +} + +TeamId GetMountSpellFaction(uint32 spellId) +{ + auto itr = MountSpellFactionMap.find(spellId); + return itr != MountSpellFactionMap.end() ? itr->second : TEAM_NEUTRAL; +} + +uint32 GetFactionConvertedMountSpell(uint32 spellId, TeamId targetTeam) +{ + for (auto const& [allianceSpell, hordeSpell] : sObjectMgr->FactionChangeSpells) + { + if (!IsMountSpell(allianceSpell) || !IsMountSpell(hordeSpell)) + continue; + + if (spellId == allianceSpell && targetTeam == TEAM_HORDE) + return hordeSpell; + + if (spellId == hordeSpell && targetTeam == TEAM_ALLIANCE) + return allianceSpell; + } + + return 0; +} + +bool IsMountSpellAllowedForTeam(uint32 spellId, TeamId targetTeam) +{ + if (!Config.RespectFactionRestrictions) + return true; + + TeamId const mountTeam = GetMountSpellFaction(spellId); + return mountTeam == TEAM_NEUTRAL || mountTeam == targetTeam; +} + +uint32 GetMountSpellForRace(uint32 spellId, uint8 race) +{ + if (!Config.Filter.Allows(spellId) || !IsMountSpell(spellId)) + return 0; + + TeamId const targetTeam = Player::TeamIdForRace(race); + if (IsMountSpellAllowedForTeam(spellId, targetTeam)) + return spellId; + + if (!Config.ConvertFactionSpecific) + return 0; + + uint32 const convertedSpellId = GetFactionConvertedMountSpell(spellId, targetTeam); + return Config.Filter.Allows(convertedSpellId) && + IsMountSpellAllowedForTeam(convertedSpellId, targetTeam) ? convertedSpellId : 0; +} + +bool IsClassRestrictedMount(uint32 spellId) +{ + return GetMountSpellRequirements(spellId).ClassMask != 0; +} + +bool ShouldAccountSyncMount(uint32 spellId) +{ + return Config.Filter.Allows(spellId) && IsMountSpell(spellId) && + (Config.SyncClassMounts || !IsClassRestrictedMount(spellId)); +} + +bool CanShareClassMountFromRace(uint32 spellId, uint8 sourceRace, uint8 targetRace) +{ + if (!Config.ClassMountsSameFactionOnly || !sourceRace || !IsClassRestrictedMount(spellId)) + return true; + + return Player::TeamIdForRace(sourceRace) == Player::TeamIdForRace(targetRace); +} + +bool CanCharacterReceiveMount(uint32 spellId, uint8 classId, uint16 ridingSkill, uint8 sourceRace = 0, uint8 targetRace = 0) +{ + MountSpellRequirements const& requirements = GetMountSpellRequirements(spellId); + + if (Config.RequireRiding && ridingSkill < requirements.RequiredRidingRank) + return false; + + if (Config.RequireClass && requirements.ClassMask && !(requirements.ClassMask & GetClassMaskForClass(classId))) + return false; + + return !targetRace || CanShareClassMountFromRace(spellId, sourceRace, targetRace); +} + +bool CanCharacterReceiveMount(uint32 spellId, CharacterInfo const& character, uint8 sourceRace = 0) +{ + return CanCharacterReceiveMount(spellId, character.Class, character.RidingSkill, sourceRace, character.Race); +} + +bool CanPlayerReceiveMount(uint32 spellId, Player* player, uint8 sourceRace = 0) +{ + return player && CanCharacterReceiveMount( + spellId, player->getClass(), player->GetSkillValue(SKILL_RIDING), sourceRace, player->getRace(true)); +} + +void AppendOrCommit(CharacterDatabaseTransaction& trans, std::string_view sql) +{ + if (!trans) + trans = CharacterDatabase.BeginTransaction(); + + trans->Append(sql); + + if (trans->GetSize() >= 1000) + { + CharacterDatabase.DirectCommitTransaction(trans); + trans = CharacterDatabase.BeginTransaction(); + } +} + +void CommitIfNeeded(CharacterDatabaseTransaction& trans) +{ + if (trans && trans->GetSize()) + CharacterDatabase.DirectCommitTransaction(trans); +} + +void InsertMountForCharacter(CharacterDatabaseTransaction& trans, uint32 targetGuid, uint32 spellId) +{ + AppendOrCommit(trans, Acore::StringFormat( + "INSERT IGNORE INTO character_spell (guid, spell, specMask) VALUES ({}, {}, {})", + targetGuid, spellId, uint32(SPEC_MASK_ALL))); +} + +void DeleteMountForCharacter(CharacterDatabaseTransaction& trans, uint32 targetGuid, uint32 spellId) +{ + AppendOrCommit(trans, Acore::StringFormat( + "DELETE FROM character_spell WHERE guid = {} AND spell = {}", + targetGuid, spellId)); +} + +std::vector LoadAccountCharacters(uint32 accountId) +{ + std::vector characters; + QueryResult result = CharacterDatabase.Query( + "SELECT c.guid, c.account, c.race, c.`class`, COALESCE(cs.value, 0) " + "FROM characters c " + "LEFT JOIN character_skills cs ON cs.guid = c.guid AND cs.skill = {} " + "WHERE c.account = {}", + uint32(SKILL_RIDING), accountId); + + if (!result) + return characters; + + do + { + Field* fields = result->Fetch(); + characters.push_back({ + fields[0].Get(), + fields[1].Get(), + fields[2].Get(), + fields[3].Get(), + fields[4].Get() + }); + } while (result->NextRow()); + + return characters; +} + +void SyncMountToAccount(Player* player, uint32 spellId) +{ + if (!Config.Enabled || !player || !ShouldAccountSyncMount(spellId)) + return; + + uint8 const sourceRace = player->getRace(true); + std::vector characters = LoadAccountCharacters(player->GetSession()->GetAccountId()); + CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction(); + uint32 synced = 0; + + for (CharacterInfo const& target : characters) + { + if (target.Guid == player->GetGUID().GetCounter()) + continue; + + uint32 const targetSpellId = GetMountSpellForRace(spellId, target.Race); + if (!targetSpellId || !CanCharacterReceiveMount(targetSpellId, target, sourceRace)) + continue; + + InsertMountForCharacter(trans, target.Guid, targetSpellId); + ++synced; + } + + CommitIfNeeded(trans); + + if (synced) + LOG_DEBUG("module.accountboundmounts", + "AccountBoundMounts: synced mount spell {} from player {} to {} account character(s).", + spellId, player->GetGUID().GetCounter(), synced); +} + +void BackfillMountsForCharacter(Player* player) +{ + if (!Config.Enabled || !player) + return; + + uint32 const accountId = player->GetSession()->GetAccountId(); + uint32 const targetGuid = player->GetGUID().GetCounter(); + uint8 const targetRace = player->getRace(true); + QueryResult result = CharacterDatabase.Query( + "SELECT DISTINCT cs.spell, c.race " + "FROM character_spell cs " + "INNER JOIN characters c ON c.guid = cs.guid " + "WHERE c.account = {} AND c.guid <> {}", + accountId, targetGuid); + + if (!result) + return; + + CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction(); + uint32 inserted = 0; + + do + { + Field* fields = result->Fetch(); + uint32 const spellId = fields[0].Get(); + uint8 const sourceRace = fields[1].Get(); + + if (!ShouldAccountSyncMount(spellId)) + continue; + + uint32 const targetSpellId = GetMountSpellForRace(spellId, targetRace); + if (!targetSpellId || !CanPlayerReceiveMount(targetSpellId, player, sourceRace)) + continue; + + InsertMountForCharacter(trans, targetGuid, targetSpellId); + ++inserted; + } while (result->NextRow()); + + CommitIfNeeded(trans); + + if (inserted) + LOG_INFO("module.accountboundmounts", + "AccountBoundMounts: seeded {} mount spell row(s) for character {}.", + inserted, targetGuid); +} + +void BackfillAllMounts() +{ + if (!Config.Enabled || !Config.StartupBackfill) + return; + + QueryResult charactersResult = CharacterDatabase.Query( + "SELECT c.guid, c.account, c.race, c.`class`, COALESCE(cs.value, 0) " + "FROM characters c " + "LEFT JOIN character_skills cs ON cs.guid = c.guid AND cs.skill = {} " + "WHERE c.account <> 0", + uint32(SKILL_RIDING)); + + if (!charactersResult) + return; + + std::unordered_map> charactersByAccount; + do + { + Field* fields = charactersResult->Fetch(); + CharacterInfo character = { + fields[0].Get(), + fields[1].Get(), + fields[2].Get(), + fields[3].Get(), + fields[4].Get() + }; + charactersByAccount[character.AccountId].push_back(character); + } while (charactersResult->NextRow()); + + QueryResult spellsResult = CharacterDatabase.Query( + "SELECT DISTINCT c.account, c.race, cs.spell " + "FROM character_spell cs " + "INNER JOIN characters c ON c.guid = cs.guid " + "WHERE c.account <> 0"); + + if (!spellsResult) + return; + + CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction(); + uint64 inserted = 0; + + do + { + Field* fields = spellsResult->Fetch(); + uint32 const accountId = fields[0].Get(); + uint8 const sourceRace = fields[1].Get(); + uint32 const spellId = fields[2].Get(); + + if (!ShouldAccountSyncMount(spellId)) + continue; + + auto accountItr = charactersByAccount.find(accountId); + if (accountItr == charactersByAccount.end()) + continue; + + for (CharacterInfo const& target : accountItr->second) + { + uint32 const targetSpellId = GetMountSpellForRace(spellId, target.Race); + if (!targetSpellId || !CanCharacterReceiveMount(targetSpellId, target, sourceRace)) + continue; + + InsertMountForCharacter(trans, target.Guid, targetSpellId); + ++inserted; + } + } while (spellsResult->NextRow()); + + CommitIfNeeded(trans); + LOG_INFO("module.accountboundmounts", + "AccountBoundMounts: startup backfill queued {} mount spell row(s).", inserted); +} + +void CleanupInvalidMounts() +{ + if (!Config.Enabled || !Config.CleanupInvalid) + return; + + QueryResult result = CharacterDatabase.Query( + "SELECT c.guid, c.race, c.`class`, COALESCE(sk.value, 0), cs.spell " + "FROM character_spell cs " + "INNER JOIN characters c ON c.guid = cs.guid " + "LEFT JOIN character_skills sk ON sk.guid = c.guid AND sk.skill = {}", + uint32(SKILL_RIDING)); + + if (!result) + return; + + CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction(); + uint64 removed = 0; + + do + { + Field* fields = result->Fetch(); + CharacterInfo character = { + fields[0].Get(), + 0, + fields[1].Get(), + fields[2].Get(), + fields[3].Get() + }; + uint32 const spellId = fields[4].Get(); + + if (!ShouldAccountSyncMount(spellId)) + continue; + + uint32 const allowedSpellId = GetMountSpellForRace(spellId, character.Race); + if (allowedSpellId == spellId && CanCharacterReceiveMount(spellId, character)) + continue; + + DeleteMountForCharacter(trans, character.Guid, spellId); + ++removed; + } while (result->NextRow()); + + CommitIfNeeded(trans); + + if (removed) + LOG_INFO("module.accountboundmounts", + "AccountBoundMounts: removed {} invalid mount spell row(s).", removed); +} + +void LoadModuleConfig() +{ + Config.Enabled = AccountBound::IsCategoryEnabled("Mounts"); + Config.StartupBackfill = sConfigMgr->GetOption("AccountBound.Mounts.StartupBackfill", true); + Config.SyncOnCreate = sConfigMgr->GetOption("AccountBound.Mounts.SyncOnCreate", true); + Config.ConvertFactionSpecific = sConfigMgr->GetOption("AccountBound.Mounts.ConvertFactionSpecific", true); + Config.RespectFactionRestrictions = sConfigMgr->GetOption("AccountBound.Mounts.RespectFactionRestrictions", true); + Config.RequireRiding = sConfigMgr->GetOption("AccountBound.Mounts.RequireRiding", true); + Config.SyncClassMounts = sConfigMgr->GetOption("AccountBound.Mounts.SyncClassMounts", true); + Config.RequireClass = sConfigMgr->GetOption("AccountBound.Mounts.RequireClass", true); + Config.ClassMountsSameFactionOnly = sConfigMgr->GetOption("AccountBound.Mounts.ClassMountsSameFactionOnly", true); + Config.CleanupInvalid = sConfigMgr->GetOption("AccountBound.Mounts.CleanupInvalid", true); + Config.BackfillOnRidingSkillChange = sConfigMgr->GetOption("AccountBound.Mounts.BackfillOnRidingSkillChange", true); + Config.Filter = AccountBound::LoadIdFilter("Mounts"); +} +} + +class AccountBoundMountsWorldScript : public WorldScript +{ +public: + AccountBoundMountsWorldScript() : WorldScript("AccountBoundMountsWorldScript", { + WORLDHOOK_ON_AFTER_CONFIG_LOAD, + WORLDHOOK_ON_STARTUP + }) { } + + void OnAfterConfigLoad(bool reload) override + { + LoadModuleConfig(); + LOG_INFO("module.accountboundmounts", + "AccountBoundMounts: {}. CreateSync={}, StartupBackfill={}, RequireRiding={}, RequireClass={}.", + Config.Enabled ? (reload ? "configuration reloaded" : "configuration loaded") : "disabled", + Config.Enabled && Config.SyncOnCreate ? "on" : "off", + Config.Enabled && Config.StartupBackfill ? "on" : "off", + Config.Enabled && Config.RequireRiding ? "on" : "off", + Config.Enabled && Config.RequireClass ? "on" : "off"); + } + + void OnStartup() override + { + if (!Config.Enabled) + return; + + BuildMountCache(); + CleanupInvalidMounts(); + BackfillAllMounts(); + } +}; + +class AccountBoundMountsPlayerScript : public PlayerScript +{ +public: + AccountBoundMountsPlayerScript() : PlayerScript("AccountBoundMountsPlayerScript", { + PLAYERHOOK_ON_CREATE, + PLAYERHOOK_ON_LEARN_SPELL, + PLAYERHOOK_ON_SET_SKILL + }) { } + + void OnPlayerCreate(Player* player) override + { + if (Config.SyncOnCreate) + BackfillMountsForCharacter(player); + } + + void OnPlayerLearnSpell(Player* player, uint32 spellId) override + { + SyncMountToAccount(player, spellId); + } + + void OnPlayerSetSkill(Player* player, uint32 skillId, uint32 value, uint32 /*max*/, uint32 /*step*/, uint32 newValue) override + { + if (!Config.Enabled || !Config.BackfillOnRidingSkillChange || skillId != SKILL_RIDING || newValue <= value) + return; + + BackfillMountsForCharacter(player); + } +}; + +void AddAccountBoundMountsScripts() +{ + new AccountBoundMountsWorldScript(); + new AccountBoundMountsPlayerScript(); +} diff --git a/modules/AccountBound/src/Pets.cpp b/modules/AccountBound/src/Pets.cpp new file mode 100644 index 000000000..33f23f4bf --- /dev/null +++ b/modules/AccountBound/src/Pets.cpp @@ -0,0 +1,348 @@ +/* + * Account-bound companion pets for AzerothCore. + */ + +#include "AccountBound.h" +#include "Config.h" +#include "DatabaseEnv.h" +#include "DBCStores.h" +#include "Log.h" +#include "Player.h" +#include "PlayerScript.h" +#include "SpellInfo.h" +#include "SpellMgr.h" +#include "StringFormat.h" +#include "WorldScript.h" + +#include +#include +#include +#include + +namespace +{ +struct ModuleConfig +{ + bool Enabled = true; + bool StartupBackfill = true; + bool SyncOnCreate = true; + bool SameFactionOnly = false; + bool IncludeCompanionSkillLine = true; + bool IncludeMinipetSummons = true; + AccountBound::IdFilter Filter; +}; + +struct CharacterInfo +{ + uint32 Guid = 0; + uint8 Race = 0; +}; + +ModuleConfig Config; +std::unordered_set CompanionSpells; + +bool HasCompanionSkillLine(uint32 spellId) +{ + SkillLineAbilityMapBounds bounds = sSpellMgr->GetSkillLineAbilityMapBounds(spellId); + for (SkillLineAbilityMap::const_iterator itr = bounds.first; itr != bounds.second; ++itr) + if (itr->second && itr->second->SkillLine == SKILL_COMPANIONS) + return true; + + return false; +} + +bool IsMinipetSummon(SpellInfo const* spellInfo) +{ + if (!spellInfo) + return false; + + for (uint8 effectIndex = 0; effectIndex < MAX_SPELL_EFFECTS; ++effectIndex) + { + SpellEffectInfo const& effect = spellInfo->Effects[effectIndex]; + if (effect.Effect != SPELL_EFFECT_SUMMON || effect.MiscValue <= 0) + continue; + + SummonPropertiesEntry const* properties = sSummonPropertiesStore.LookupEntry(effect.MiscValueB); + if (properties && properties->Type == SUMMON_TYPE_MINIPET) + return true; + } + + return false; +} + +bool IsCompanionSpell(uint32 spellId) +{ + return Config.Filter.Allows(spellId) && CompanionSpells.find(spellId) != CompanionSpells.end(); +} + +void BuildCompanionCache() +{ + CompanionSpells.clear(); + + for (uint32 spellId = 1; spellId < sSpellMgr->GetSpellInfoStoreSize(); ++spellId) + { + SpellInfo const* spellInfo = sSpellMgr->GetSpellInfo(spellId); + if (!spellInfo) + continue; + + bool const companionSkill = Config.IncludeCompanionSkillLine && HasCompanionSkillLine(spellId); + bool const minipetSummon = Config.IncludeMinipetSummons && IsMinipetSummon(spellInfo); + if (companionSkill || minipetSummon) + CompanionSpells.insert(spellId); + } + + LOG_INFO("module.accountboundpets", + "AccountBoundPets: cached {} companion pet spell(s).", CompanionSpells.size()); +} + +bool CanShareBetweenRaces(uint8 sourceRace, uint8 targetRace) +{ + return !Config.SameFactionOnly || Player::TeamIdForRace(sourceRace) == Player::TeamIdForRace(targetRace); +} + +void AppendOrCommit(CharacterDatabaseTransaction& trans, std::string_view sql) +{ + if (!trans) + trans = CharacterDatabase.BeginTransaction(); + + trans->Append(sql); + + if (trans->GetSize() >= 1000) + { + CharacterDatabase.DirectCommitTransaction(trans); + trans = CharacterDatabase.BeginTransaction(); + } +} + +void CommitIfNeeded(CharacterDatabaseTransaction& trans) +{ + if (trans && trans->GetSize()) + CharacterDatabase.DirectCommitTransaction(trans); +} + +void InsertCompanionForCharacter(CharacterDatabaseTransaction& trans, uint32 targetGuid, uint32 spellId) +{ + AppendOrCommit(trans, Acore::StringFormat( + "INSERT IGNORE INTO character_spell (guid, spell, specMask) VALUES ({}, {}, {})", + targetGuid, spellId, uint32(SPEC_MASK_ALL))); +} + +std::vector LoadAccountCharacters(uint32 accountId) +{ + std::vector characters; + QueryResult result = CharacterDatabase.Query( + "SELECT guid, race FROM characters WHERE account = {}", accountId); + + if (!result) + return characters; + + do + { + Field* fields = result->Fetch(); + characters.push_back({ + fields[0].Get(), + fields[1].Get() + }); + } while (result->NextRow()); + + return characters; +} + +void SyncCompanionToAccount(Player* player, uint32 spellId) +{ + if (!Config.Enabled || !player || !IsCompanionSpell(spellId)) + return; + + uint32 const sourceGuid = player->GetGUID().GetCounter(); + uint8 const sourceRace = player->getRace(true); + std::vector characters = LoadAccountCharacters(player->GetSession()->GetAccountId()); + CharacterDatabaseTransaction trans; + uint32 synced = 0; + + for (CharacterInfo const& target : characters) + { + if (target.Guid == sourceGuid || !CanShareBetweenRaces(sourceRace, target.Race)) + continue; + + InsertCompanionForCharacter(trans, target.Guid, spellId); + ++synced; + } + + CommitIfNeeded(trans); + + if (synced) + LOG_DEBUG("module.accountboundpets", + "AccountBoundPets: synced companion spell {} from player {} to {} account character(s).", + spellId, sourceGuid, synced); +} + +void SeedCompanionsForCharacter(Player* player) +{ + if (!Config.Enabled || !player) + return; + + uint32 const accountId = player->GetSession()->GetAccountId(); + uint32 const targetGuid = player->GetGUID().GetCounter(); + uint8 const targetRace = player->getRace(true); + QueryResult result = CharacterDatabase.Query( + "SELECT DISTINCT cs.spell, c.race " + "FROM character_spell cs " + "INNER JOIN characters c ON c.guid = cs.guid " + "WHERE c.account = {} AND c.guid <> {}", + accountId, targetGuid); + + if (!result) + return; + + CharacterDatabaseTransaction trans; + uint32 inserted = 0; + + do + { + Field* fields = result->Fetch(); + uint32 const spellId = fields[0].Get(); + uint8 const sourceRace = fields[1].Get(); + + if (!IsCompanionSpell(spellId) || !CanShareBetweenRaces(sourceRace, targetRace)) + continue; + + InsertCompanionForCharacter(trans, targetGuid, spellId); + ++inserted; + } while (result->NextRow()); + + CommitIfNeeded(trans); + + if (inserted) + LOG_INFO("module.accountboundpets", + "AccountBoundPets: seeded {} companion spell row(s) for character {}.", + inserted, targetGuid); +} + +void BackfillAllCompanions() +{ + if (!Config.Enabled || !Config.StartupBackfill) + return; + + QueryResult charactersResult = CharacterDatabase.Query( + "SELECT guid, account, race FROM characters WHERE account <> 0"); + + if (!charactersResult) + return; + + std::unordered_map> charactersByAccount; + do + { + Field* fields = charactersResult->Fetch(); + charactersByAccount[fields[1].Get()].push_back({ + fields[0].Get(), + fields[2].Get() + }); + } while (charactersResult->NextRow()); + + QueryResult spellsResult = CharacterDatabase.Query( + "SELECT DISTINCT c.account, c.race, cs.spell " + "FROM character_spell cs " + "INNER JOIN characters c ON c.guid = cs.guid " + "WHERE c.account <> 0"); + + if (!spellsResult) + return; + + CharacterDatabaseTransaction trans; + uint64 queued = 0; + + do + { + Field* fields = spellsResult->Fetch(); + uint32 const accountId = fields[0].Get(); + uint8 const sourceRace = fields[1].Get(); + uint32 const spellId = fields[2].Get(); + + if (!IsCompanionSpell(spellId)) + continue; + + auto accountItr = charactersByAccount.find(accountId); + if (accountItr == charactersByAccount.end()) + continue; + + for (CharacterInfo const& target : accountItr->second) + { + if (!CanShareBetweenRaces(sourceRace, target.Race)) + continue; + + InsertCompanionForCharacter(trans, target.Guid, spellId); + ++queued; + } + } while (spellsResult->NextRow()); + + CommitIfNeeded(trans); + LOG_INFO("module.accountboundpets", + "AccountBoundPets: startup backfill queued {} companion spell row(s).", queued); +} + +void LoadModuleConfig() +{ + Config.Enabled = AccountBound::IsCategoryEnabled("Pets"); + Config.StartupBackfill = sConfigMgr->GetOption("AccountBound.Pets.StartupBackfill", true); + Config.SyncOnCreate = sConfigMgr->GetOption("AccountBound.Pets.SyncOnCreate", true); + Config.SameFactionOnly = sConfigMgr->GetOption("AccountBound.Pets.SameFactionOnly", false); + Config.IncludeCompanionSkillLine = sConfigMgr->GetOption("AccountBound.Pets.IncludeCompanionSkillLine", true); + Config.IncludeMinipetSummons = sConfigMgr->GetOption("AccountBound.Pets.IncludeMinipetSummons", true); + Config.Filter = AccountBound::LoadIdFilter("Pets"); +} +} + +class AccountBoundPetsWorldScript : public WorldScript +{ +public: + AccountBoundPetsWorldScript() : WorldScript("AccountBoundPetsWorldScript", { + WORLDHOOK_ON_AFTER_CONFIG_LOAD, + WORLDHOOK_ON_STARTUP + }) { } + + void OnAfterConfigLoad(bool reload) override + { + LoadModuleConfig(); + LOG_INFO("module.accountboundpets", + "AccountBoundPets: {}. StartupBackfill={}, CreateSync={}, SameFactionOnly={}.", + Config.Enabled ? (reload ? "configuration reloaded" : "configuration loaded") : "disabled", + Config.Enabled && Config.StartupBackfill ? "on" : "off", + Config.Enabled && Config.SyncOnCreate ? "on" : "off", + Config.Enabled && Config.SameFactionOnly ? "on" : "off"); + } + + void OnStartup() override + { + if (!Config.Enabled) + return; + + BuildCompanionCache(); + BackfillAllCompanions(); + } +}; + +class AccountBoundPetsPlayerScript : public PlayerScript +{ +public: + AccountBoundPetsPlayerScript() : PlayerScript("AccountBoundPetsPlayerScript", { + PLAYERHOOK_ON_CREATE, + PLAYERHOOK_ON_LEARN_SPELL + }) { } + + void OnPlayerCreate(Player* player) override + { + if (Config.SyncOnCreate) + SeedCompanionsForCharacter(player); + } + + void OnPlayerLearnSpell(Player* player, uint32 spellId) override + { + SyncCompanionToAccount(player, spellId); + } +}; + +void AddAccountBoundPetsScripts() +{ + new AccountBoundPetsWorldScript(); + new AccountBoundPetsPlayerScript(); +} diff --git a/modules/AccountBound/src/Professions.cpp b/modules/AccountBound/src/Professions.cpp new file mode 100644 index 000000000..9c841f0c4 --- /dev/null +++ b/modules/AccountBound/src/Professions.cpp @@ -0,0 +1,1148 @@ +/* + * Account-bound professions and configurable profession limits for AzerothCore. + */ + +#include "AccountBound.h" +#include "Config.h" +#include "DatabaseEnv.h" +#include "DBCEnums.h" +#include "DBCStores.h" +#include "Log.h" +#include "Player.h" +#include "PlayerScript.h" +#include "SharedDefines.h" +#include "SpellInfo.h" +#include "SpellMgr.h" +#include "StringConvert.h" +#include "StringFormat.h" +#include "Tokenize.h" +#include "World.h" +#include "WorldConfig.h" +#include "WorldScript.h" +#include "WorldSessionMgr.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr uint32 MaxSupportedPrimaryProfessions = 11; +constexpr uint16 DefaultMaxProfessionSkill = 450; + +struct ProfessionDefinition +{ + uint32 SkillId; + std::string_view Name; + std::array RankSpells; +}; + +constexpr std::array PrimaryProfessionDefinitions = +{{ + { SKILL_ALCHEMY, "alchemy", { 2259, 3101, 3464, 11611, 28596, 51304 } }, + { SKILL_BLACKSMITHING, "blacksmithing", { 2018, 3100, 3538, 9785, 29844, 51300 } }, + { SKILL_ENCHANTING, "enchanting", { 7411, 7412, 7413, 13920, 28029, 51313 } }, + { SKILL_ENGINEERING, "engineering", { 4036, 4037, 4038, 12656, 30350, 51306 } }, + { SKILL_HERBALISM, "herbalism", { 2366, 2368, 3570, 11993, 28695, 50300 } }, + { SKILL_INSCRIPTION, "inscription", { 45357, 45358, 45359, 45360, 45361, 45363 } }, + { SKILL_JEWELCRAFTING,"jewelcrafting", { 25229, 25230, 28894, 28895, 28897, 51311 } }, + { SKILL_LEATHERWORKING,"leatherworking",{ 2108, 3104, 3811, 10662, 32549, 51302 } }, + { SKILL_MINING, "mining", { 2575, 2576, 3564, 10248, 29354, 50310 } }, + { SKILL_SKINNING, "skinning", { 8613, 8617, 8618, 10768, 32678, 50305 } }, + { SKILL_TAILORING, "tailoring", { 3908, 3909, 3910, 12180, 26790, 51309 } }, +}}; + +constexpr std::array SecondaryProfessionDefinitions = +{{ + { SKILL_COOKING, "cooking", { 2550, 3102, 3413, 18260, 33359, 51296 } }, + { SKILL_FIRST_AID, "firstaid", { 3273, 3274, 7924, 10846, 27028, 45542 } }, + { SKILL_FISHING, "fishing", { 7620, 7731, 7732, 18248, 33095, 51294 } }, +}}; + +struct CharacterInfo +{ + uint32 Guid = 0; + uint32 AccountId = 0; + uint8 Race = 0; + uint8 Class = 0; +}; + +struct SkillSnapshot +{ + uint16 Value = 0; + uint16 Max = 0; +}; + +struct ModuleConfig +{ + bool Enabled = true; + uint32 MaxPrimaryProfessions = MaxSupportedPrimaryProfessions; + bool CountOnlyAllowedPrimaryProfessions = true; + bool EnforceAllowedPrimaryProfessions = false; + uint16 MaxSyncedSkillValue = DefaultMaxProfessionSkill; + + bool AccountBoundEnabled = true; + bool AccountBoundStartupBackfill = false; + bool AccountBoundSyncOnCreate = true; + bool AccountBoundSyncOnLearn = true; + bool AccountBoundIncludeSecondaryProfessions = false; + bool AccountBoundSyncSkillProgress = true; + bool AccountBoundSyncProfessionRanks = true; + bool AccountBoundSyncRecipes = true; + bool AccountBoundSyncSkillGrantedSpells = true; + bool AccountBoundRequireRecipeSkill = true; + + std::unordered_set AllowedPrimarySkills; + std::unordered_set SpellAllowList; + std::unordered_set SpellBlockList; +}; + +ModuleConfig Config; + +std::string NormalizeToken(std::string_view token) +{ + std::string normalized; + normalized.reserve(token.size()); + + for (char c : token) + { + if (c == ' ' || c == '_' || c == '-' || c == '.') + continue; + + normalized.push_back(char(std::tolower(static_cast(c)))); + } + + return normalized; +} + +uint32 ResolveProfessionSkillToken(std::string_view rawToken) +{ + std::string const token = NormalizeToken(rawToken); + + if (Optional id = Acore::StringTo(rawToken)) + return *id; + + static std::unordered_map const SkillNames = + { + { "alchemy", SKILL_ALCHEMY }, + { "alquimia", SKILL_ALCHEMY }, + { "blacksmithing", SKILL_BLACKSMITHING }, + { "smithing", SKILL_BLACKSMITHING }, + { "herreria", SKILL_BLACKSMITHING }, + { "enchanting", SKILL_ENCHANTING }, + { "enchant", SKILL_ENCHANTING }, + { "encantamiento", SKILL_ENCHANTING }, + { "engineering", SKILL_ENGINEERING }, + { "ingenieria", SKILL_ENGINEERING }, + { "herbalism", SKILL_HERBALISM }, + { "herbs", SKILL_HERBALISM }, + { "herboristeria", SKILL_HERBALISM }, + { "inscription", SKILL_INSCRIPTION }, + { "inscripcion", SKILL_INSCRIPTION }, + { "jewelcrafting", SKILL_JEWELCRAFTING }, + { "jewel", SKILL_JEWELCRAFTING }, + { "joyeria", SKILL_JEWELCRAFTING }, + { "leatherworking", SKILL_LEATHERWORKING }, + { "leather", SKILL_LEATHERWORKING }, + { "peleteria", SKILL_LEATHERWORKING }, + { "mining", SKILL_MINING }, + { "mineria", SKILL_MINING }, + { "skinning", SKILL_SKINNING }, + { "desuello", SKILL_SKINNING }, + { "tailoring", SKILL_TAILORING }, + { "sastreria", SKILL_TAILORING }, + { "cooking", SKILL_COOKING }, + { "cocina", SKILL_COOKING }, + { "firstaid", SKILL_FIRST_AID }, + { "primerosauxilios", SKILL_FIRST_AID }, + { "fishing", SKILL_FISHING }, + { "pesca", SKILL_FISHING }, + }; + + auto itr = SkillNames.find(token); + return itr != SkillNames.end() ? itr->second : 0; +} + +bool IsSupportedPrimarySkill(uint32 skillId) +{ + return std::any_of(PrimaryProfessionDefinitions.begin(), PrimaryProfessionDefinitions.end(), + [skillId](ProfessionDefinition const& profession) + { + return profession.SkillId == skillId; + }); +} + +bool IsSupportedSecondarySkill(uint32 skillId) +{ + return std::any_of(SecondaryProfessionDefinitions.begin(), SecondaryProfessionDefinitions.end(), + [skillId](ProfessionDefinition const& profession) + { + return profession.SkillId == skillId; + }); +} + +ProfessionDefinition const* GetProfessionDefinition(uint32 skillId) +{ + for (ProfessionDefinition const& profession : PrimaryProfessionDefinitions) + if (profession.SkillId == skillId) + return &profession; + + for (ProfessionDefinition const& profession : SecondaryProfessionDefinitions) + if (profession.SkillId == skillId) + return &profession; + + return nullptr; +} + +std::unordered_set ParsePrimaryProfessionList(std::string const& rawList, bool defaultToAll) +{ + std::unordered_set skills; + bool sawToken = false; + + for (std::string_view rawToken : Acore::Tokenize(rawList, ',', false)) + { + std::string const token = NormalizeToken(rawToken); + if (token.empty()) + continue; + + sawToken = true; + + if (token == "all") + { + for (ProfessionDefinition const& profession : PrimaryProfessionDefinitions) + skills.insert(profession.SkillId); + continue; + } + + if (token == "none") + { + skills.clear(); + continue; + } + + uint32 const skillId = ResolveProfessionSkillToken(rawToken); + if (IsSupportedPrimarySkill(skillId)) + skills.insert(skillId); + else + LOG_WARN("module.accountboundprofessions", "AccountBoundProfessions: ignoring unknown primary profession token '{}'.", std::string(rawToken)); + } + + if ((!sawToken || skills.empty()) && defaultToAll) + for (ProfessionDefinition const& profession : PrimaryProfessionDefinitions) + skills.insert(profession.SkillId); + + return skills; +} + +std::unordered_set ParseSpellList(std::string const& rawList) +{ + std::unordered_set spells; + + for (std::string_view rawToken : Acore::Tokenize(rawList, ',', false)) + { + std::string const token = NormalizeToken(rawToken); + if (token.empty()) + continue; + + if (Optional spellId = Acore::StringTo(rawToken)) + spells.insert(*spellId); + else + LOG_WARN("module.accountboundprofessions", "AccountBoundProfessions: ignoring unknown spell id token '{}'.", std::string(rawToken)); + } + + return spells; +} + +bool IsAllowedPrimarySkill(uint32 skillId) +{ + return Config.AllowedPrimarySkills.contains(skillId); +} + +bool IsManagedProfessionSkill(uint32 skillId) +{ + return IsAllowedPrimarySkill(skillId) || (Config.AccountBoundIncludeSecondaryProfessions && IsSupportedSecondarySkill(skillId)); +} + +uint32 RaceMaskForRace(uint8 race) +{ + return race ? 1u << (race - 1) : 0; +} + +uint32 ClassMaskForClass(uint8 playerClass) +{ + return playerClass ? 1u << (playerClass - 1) : 0; +} + +uint16 ClampSyncedSkillValue(uint32 value) +{ + return uint16(std::min(value, Config.MaxSyncedSkillValue)); +} + +uint32 GetProfessionSkillFromRankSpell(uint32 spellId, bool managedOnly = true) +{ + if (SpellLearnSkillNode const* learnSkill = sSpellMgr->GetSpellLearnSkill(spellId)) + if (!managedOnly || IsManagedProfessionSkill(learnSkill->skill)) + return learnSkill->skill; + + SpellInfo const* spellInfo = sSpellMgr->GetSpellInfo(spellId); + if (!spellInfo) + return 0; + + for (SpellEffectInfo const& effect : spellInfo->Effects) + if (effect.Effect == SPELL_EFFECT_SKILL && (!managedOnly || IsManagedProfessionSkill(effect.MiscValue))) + return effect.MiscValue; + + return 0; +} + +uint32 GetBestRankSpellForSkill(uint32 skillId, uint16 maxValue) +{ + ProfessionDefinition const* profession = GetProfessionDefinition(skillId); + if (!profession) + return 0; + + uint32 rankIndex = 0; + if (maxValue > 375) + rankIndex = 5; + else if (maxValue > 300) + rankIndex = 4; + else if (maxValue > 225) + rankIndex = 3; + else if (maxValue > 150) + rankIndex = 2; + else if (maxValue > 75) + rankIndex = 1; + + return profession->RankSpells[rankIndex]; +} + +bool IsSpellAllowedForTargetCharacter(uint32 spellId, CharacterInfo const& target) +{ + bool sawManagedAbility = false; + uint32 const targetRaceMask = RaceMaskForRace(target.Race); + uint32 const targetClassMask = ClassMaskForClass(target.Class); + + SkillLineAbilityMapBounds bounds = sSpellMgr->GetSkillLineAbilityMapBounds(spellId); + for (SkillLineAbilityMap::const_iterator itr = bounds.first; itr != bounds.second; ++itr) + { + SkillLineAbilityEntry const* ability = itr->second; + if (!ability || !IsManagedProfessionSkill(ability->SkillLine)) + continue; + + sawManagedAbility = true; + + if (ability->RaceMask && !(ability->RaceMask & targetRaceMask)) + continue; + + if (ability->ClassMask && !(ability->ClassMask & targetClassMask)) + continue; + + return true; + } + + return !sawManagedAbility; +} + +bool IsAccountBoundProfessionSpell(uint32 spellId) +{ + if (!sSpellMgr->GetSpellInfo(spellId) || Config.SpellBlockList.contains(spellId)) + return false; + + if (Config.SpellAllowList.contains(spellId)) + return true; + + if (GetProfessionSkillFromRankSpell(spellId)) + return Config.AccountBoundSyncProfessionRanks; + + SkillLineAbilityMapBounds bounds = sSpellMgr->GetSkillLineAbilityMapBounds(spellId); + for (SkillLineAbilityMap::const_iterator itr = bounds.first; itr != bounds.second; ++itr) + { + SkillLineAbilityEntry const* ability = itr->second; + if (!ability || !IsManagedProfessionSkill(ability->SkillLine)) + continue; + + if (ability->AcquireMethod == SKILL_LINE_ABILITY_LEARNED_ON_SKILL_LEARN) + return Config.AccountBoundSyncSkillGrantedSpells; + + return Config.AccountBoundSyncRecipes; + } + + return false; +} + +bool CharacterMeetsRecipeSkillRequirement(uint32 spellId, std::unordered_map const& skillSnapshots) +{ + if (!Config.AccountBoundRequireRecipeSkill || GetProfessionSkillFromRankSpell(spellId)) + return true; + + SkillLineAbilityMapBounds bounds = sSpellMgr->GetSkillLineAbilityMapBounds(spellId); + bool sawManagedAbility = false; + + for (SkillLineAbilityMap::const_iterator itr = bounds.first; itr != bounds.second; ++itr) + { + SkillLineAbilityEntry const* ability = itr->second; + if (!ability || !IsManagedProfessionSkill(ability->SkillLine)) + continue; + + sawManagedAbility = true; + auto skillItr = skillSnapshots.find(ability->SkillLine); + if (skillItr != skillSnapshots.end() && skillItr->second.Value >= ability->MinSkillLineRank) + return true; + } + + return !sawManagedAbility; +} + +bool CanStoreSyncedSpellForCharacter(uint32 spellId, CharacterInfo const& target, std::unordered_map const& skillSnapshots) +{ + if (!IsAccountBoundProfessionSpell(spellId)) + return false; + + if (!IsSpellAllowedForTargetCharacter(spellId, target)) + return false; + + return CharacterMeetsRecipeSkillRequirement(spellId, skillSnapshots); +} + +std::string BuildManagedSkillSqlList() +{ + std::vector skillIds; + skillIds.reserve(Config.AllowedPrimarySkills.size() + SecondaryProfessionDefinitions.size()); + + for (uint32 skillId : Config.AllowedPrimarySkills) + skillIds.push_back(skillId); + + if (Config.AccountBoundIncludeSecondaryProfessions) + for (ProfessionDefinition const& profession : SecondaryProfessionDefinitions) + skillIds.push_back(profession.SkillId); + + std::sort(skillIds.begin(), skillIds.end()); + skillIds.erase(std::unique(skillIds.begin(), skillIds.end()), skillIds.end()); + + if (skillIds.empty()) + return "0"; + + std::string result; + for (uint32 skillId : skillIds) + { + if (!result.empty()) + result += ","; + + result += std::to_string(skillId); + } + + return result; +} + +std::vector LoadAccountCharacters(uint32 accountId) +{ + std::vector characters; + + QueryResult result = CharacterDatabase.Query( + "SELECT guid, account, race, class FROM characters WHERE account = {}", accountId); + + if (!result) + return characters; + + do + { + Field* fields = result->Fetch(); + characters.push_back({ fields[0].Get(), fields[1].Get(), fields[2].Get(), fields[3].Get() }); + } while (result->NextRow()); + + return characters; +} + +uint32 GetConfiguredMaxPrimaryProfessions() +{ + uint32 maxPrimaryProfessions = Config.MaxPrimaryProfessions; + uint32 const allowedCount = uint32(Config.AllowedPrimarySkills.size()); + + if (!maxPrimaryProfessions) + maxPrimaryProfessions = allowedCount ? allowedCount : MaxSupportedPrimaryProfessions; + + maxPrimaryProfessions = std::clamp(maxPrimaryProfessions, 1, MaxSupportedPrimaryProfessions); + + if (Config.CountOnlyAllowedPrimaryProfessions && allowedCount) + maxPrimaryProfessions = std::min(maxPrimaryProfessions, allowedCount); + + return maxPrimaryProfessions; +} + +uint32 CountPrimaryProfessions(Player const* player) +{ + uint32 count = 0; + + for (ProfessionDefinition const& profession : PrimaryProfessionDefinitions) + { + if (!player->HasSkill(profession.SkillId)) + continue; + + if (Config.CountOnlyAllowedPrimaryProfessions && !IsAllowedPrimarySkill(profession.SkillId)) + continue; + + ++count; + } + + return count; +} + +void NormalizeFreeProfessionPoints(Player* player) +{ + if (!Config.Enabled || !player) + return; + + uint32 const maxPrimaryProfessions = GetConfiguredMaxPrimaryProfessions(); + uint32 const learnedPrimaryProfessions = CountPrimaryProfessions(player); + uint32 const freeProfessionPoints = learnedPrimaryProfessions >= maxPrimaryProfessions ? 0 : maxPrimaryProfessions - learnedPrimaryProfessions; + + if (player->GetFreePrimaryProfessionPoints() != freeProfessionPoints) + player->SetFreePrimaryProfessions(uint16(freeProfessionPoints)); +} + +void EnforceAllowedProfessions(Player* player) +{ + if (!Config.Enabled || !Config.EnforceAllowedPrimaryProfessions || !player) + return; + + for (ProfessionDefinition const& profession : PrimaryProfessionDefinitions) + { + if (IsAllowedPrimarySkill(profession.SkillId) || !player->HasSkill(profession.SkillId)) + continue; + + player->SetSkill(profession.SkillId, 0, 0, 0); + LOG_INFO("module.accountboundprofessions", "AccountBoundProfessions: removed disallowed profession skill {} from player {}.", + profession.SkillId, player->GetGUID().GetCounter()); + } +} + +void ApplyWorldProfessionLimit() +{ + if (!Config.Enabled) + { + sWorld->setIntConfig(CONFIG_MAX_PRIMARY_TRADE_SKILL, sConfigMgr->GetOption("MaxPrimaryTradeSkill", 2)); + return; + } + + sWorld->setIntConfig(CONFIG_MAX_PRIMARY_TRADE_SKILL, GetConfiguredMaxPrimaryProfessions()); +} + +void AppendOrCommit(CharacterDatabaseTransaction& trans, std::string_view sql) +{ + if (!trans) + trans = CharacterDatabase.BeginTransaction(); + + trans->Append(sql); + + if (trans->GetSize() >= 1000) + { + CharacterDatabase.DirectCommitTransaction(trans); + trans = CharacterDatabase.BeginTransaction(); + } +} + +void CommitIfNeeded(CharacterDatabaseTransaction& trans) +{ + if (trans && trans->GetSize()) + CharacterDatabase.DirectCommitTransaction(trans); +} + +void InsertOrUpdateSkillForCharacter(CharacterDatabaseTransaction& trans, uint32 targetGuid, uint32 skillId, uint16 value, uint16 max) +{ + if (!value || !max) + return; + + AppendOrCommit(trans, Acore::StringFormat( + "INSERT INTO character_skills (guid, skill, value, max) VALUES ({}, {}, {}, {}) " + "ON DUPLICATE KEY UPDATE value = GREATEST(value, VALUES(value)), max = GREATEST(max, VALUES(max))", + targetGuid, skillId, value, max)); +} + +void InsertSpellForCharacter(CharacterDatabaseTransaction& trans, uint32 targetGuid, uint32 spellId) +{ + AppendOrCommit(trans, Acore::StringFormat( + "INSERT IGNORE INTO character_spell (guid, spell, specMask) VALUES ({}, {}, {})", + targetGuid, spellId, uint32(SPEC_MASK_ALL))); +} + +void MergeRankSpellSkillSnapshot(uint32 spellId, std::unordered_map& snapshots) +{ + SpellLearnSkillNode const* learnSkill = sSpellMgr->GetSpellLearnSkill(spellId); + if (!learnSkill || !IsManagedProfessionSkill(learnSkill->skill)) + return; + + uint16 value = ClampSyncedSkillValue(learnSkill->value); + uint16 max = ClampSyncedSkillValue(learnSkill->maxvalue); + if (!value || !max) + return; + + if (max < value) + max = value; + + SkillSnapshot& snapshot = snapshots[learnSkill->skill]; + snapshot.Value = std::max(snapshot.Value, value); + snapshot.Max = std::max(snapshot.Max, max); +} + +bool StoreRankSpellSkillForCharacter(CharacterDatabaseTransaction& trans, CharacterInfo const& target, uint32 spellId, std::unordered_map const& skillSnapshots) +{ + SpellLearnSkillNode const* learnSkill = sSpellMgr->GetSpellLearnSkill(spellId); + if (!learnSkill || !IsManagedProfessionSkill(learnSkill->skill)) + return false; + + if (!GetSkillRaceClassInfo(learnSkill->skill, target.Race, target.Class)) + return false; + + uint16 value = ClampSyncedSkillValue(learnSkill->value); + uint16 max = ClampSyncedSkillValue(learnSkill->maxvalue); + + auto snapshotItr = skillSnapshots.find(learnSkill->skill); + if (snapshotItr != skillSnapshots.end()) + { + value = std::max(value, snapshotItr->second.Value); + max = std::max(max, snapshotItr->second.Max); + } + + if (!value || !max) + return false; + + if (max < value) + max = value; + + InsertOrUpdateSkillForCharacter(trans, target.Guid, learnSkill->skill, value, max); + return true; +} + +void MergePlayerCurrentProfessionSkills(Player* player, std::unordered_map& snapshots) +{ + if (!player || !Config.AccountBoundSyncSkillProgress) + return; + + auto mergeSkill = [player, &snapshots](uint32 skillId) + { + if (!IsManagedProfessionSkill(skillId) || !GetSkillRaceClassInfo(skillId, player->getRace(true), player->getClass())) + return; + + uint16 value = ClampSyncedSkillValue(player->GetPureSkillValue(skillId)); + uint16 max = ClampSyncedSkillValue(player->GetPureMaxSkillValue(skillId)); + if (!value || !max) + return; + + if (max < value) + max = value; + + SkillSnapshot& snapshot = snapshots[skillId]; + snapshot.Value = std::max(snapshot.Value, value); + snapshot.Max = std::max(snapshot.Max, max); + }; + + for (ProfessionDefinition const& profession : PrimaryProfessionDefinitions) + mergeSkill(profession.SkillId); + + if (Config.AccountBoundIncludeSecondaryProfessions) + for (ProfessionDefinition const& profession : SecondaryProfessionDefinitions) + mergeSkill(profession.SkillId); +} + +std::unordered_map LoadAccountProfessionSkills(Player* player) +{ + std::unordered_map snapshots; + + if (!player || !Config.AccountBoundSyncSkillProgress) + return snapshots; + + std::string const skillSqlList = BuildManagedSkillSqlList(); + QueryResult result = CharacterDatabase.Query( + "SELECT cs.skill, cs.value, cs.max " + "FROM character_skills cs " + "INNER JOIN characters c ON c.guid = cs.guid " + "WHERE c.account = {} AND cs.skill IN ({})", + player->GetSession()->GetAccountId(), skillSqlList); + + if (!result) + return snapshots; + + do + { + Field* fields = result->Fetch(); + uint32 const skillId = fields[0].Get(); + uint16 value = ClampSyncedSkillValue(fields[1].Get()); + uint16 max = ClampSyncedSkillValue(fields[2].Get()); + + if (!IsManagedProfessionSkill(skillId)) + continue; + + if (max < value) + max = value; + + SkillSnapshot& snapshot = snapshots[skillId]; + snapshot.Value = std::max(snapshot.Value, value); + snapshot.Max = std::max(snapshot.Max, max); + } while (result->NextRow()); + + return snapshots; +} + +void LoadAccountProfessionSpells(Player* player, std::vector& rankSpells, std::vector& recipeSpells) +{ + if (!player) + return; + + QueryResult result = CharacterDatabase.Query( + "SELECT DISTINCT cs.spell " + "FROM character_spell cs " + "INNER JOIN characters c ON c.guid = cs.guid " + "WHERE c.account = {}", + player->GetSession()->GetAccountId()); + + if (!result) + return; + + std::unordered_set seenRankSpells; + std::unordered_set seenRecipeSpells; + + do + { + Field* fields = result->Fetch(); + uint32 const spellId = fields[0].Get(); + + if (!IsAccountBoundProfessionSpell(spellId)) + continue; + + if (GetProfessionSkillFromRankSpell(spellId)) + { + if (seenRankSpells.insert(spellId).second) + rankSpells.push_back(spellId); + } + else if (seenRecipeSpells.insert(spellId).second) + recipeSpells.push_back(spellId); + } while (result->NextRow()); +} + +bool StoreSkillSnapshotForCharacter(CharacterDatabaseTransaction& trans, CharacterInfo const& target, uint32 skillId, SkillSnapshot const& snapshot) +{ + if (!snapshot.Value || !snapshot.Max || !IsManagedProfessionSkill(skillId)) + return false; + + if (!GetSkillRaceClassInfo(skillId, target.Race, target.Class)) + return false; + + uint16 value = snapshot.Value; + uint16 max = snapshot.Max; + value = ClampSyncedSkillValue(value); + max = ClampSyncedSkillValue(max); + + if (max < value) + max = value; + + InsertOrUpdateSkillForCharacter(trans, target.Guid, skillId, value, max); + + if (Config.AccountBoundSyncProfessionRanks) + if (uint32 rankSpell = GetBestRankSpellForSkill(skillId, max)) + if (!Config.SpellBlockList.contains(rankSpell) && IsSpellAllowedForTargetCharacter(rankSpell, target)) + InsertSpellForCharacter(trans, target.Guid, rankSpell); + + return true; +} + +void BackfillProfessionsForCharacter(Player* player) +{ + if (!Config.Enabled || !Config.AccountBoundEnabled || !player) + return; + + CharacterInfo const target{ player->GetGUID().GetCounter(), player->GetSession()->GetAccountId(), player->getRace(true), player->getClass() }; + std::unordered_map skillSnapshots = LoadAccountProfessionSkills(player); + MergePlayerCurrentProfessionSkills(player, skillSnapshots); + + CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction(); + uint32 storedRanks = 0; + uint32 storedSkills = 0; + uint32 storedRecipes = 0; + + for (auto const& [skillId, snapshot] : skillSnapshots) + if (StoreSkillSnapshotForCharacter(trans, target, skillId, snapshot)) + ++storedSkills; + + std::vector rankSpells; + std::vector recipeSpells; + LoadAccountProfessionSpells(player, rankSpells, recipeSpells); + + for (uint32 spellId : rankSpells) + MergeRankSpellSkillSnapshot(spellId, skillSnapshots); + + for (uint32 spellId : rankSpells) + { + if (!CanStoreSyncedSpellForCharacter(spellId, target, skillSnapshots)) + continue; + + StoreRankSpellSkillForCharacter(trans, target, spellId, skillSnapshots); + InsertSpellForCharacter(trans, target.Guid, spellId); + ++storedRanks; + } + + for (uint32 spellId : recipeSpells) + { + if (!CanStoreSyncedSpellForCharacter(spellId, target, skillSnapshots)) + continue; + + InsertSpellForCharacter(trans, target.Guid, spellId); + ++storedRecipes; + } + + CommitIfNeeded(trans); + + if (storedRanks || storedSkills || storedRecipes) + LOG_INFO("module.accountboundprofessions", "AccountBoundProfessions: seeded character {} through SQL (ranks={}, skills={}, recipes={}).", + target.Guid, storedRanks, storedSkills, storedRecipes); +} + +void SyncSkillToAccount(Player* player, uint32 skillId) +{ + if (!Config.Enabled || !Config.AccountBoundEnabled || !Config.AccountBoundSyncOnLearn || !Config.AccountBoundSyncSkillProgress || !player) + return; + + if (!IsManagedProfessionSkill(skillId)) + return; + + uint16 value = ClampSyncedSkillValue(player->GetPureSkillValue(skillId)); + uint16 max = ClampSyncedSkillValue(player->GetPureMaxSkillValue(skillId)); + if (!value || !max) + return; + + if (max < value) + max = value; + + std::vector characters = LoadAccountCharacters(player->GetSession()->GetAccountId()); + CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction(); + uint32 synced = 0; + uint32 const sourceGuid = player->GetGUID().GetCounter(); + + for (CharacterInfo const& target : characters) + { + if (target.Guid == sourceGuid) + continue; + + if (!GetSkillRaceClassInfo(skillId, target.Race, target.Class)) + continue; + + InsertOrUpdateSkillForCharacter(trans, target.Guid, skillId, value, max); + + if (Config.AccountBoundSyncProfessionRanks) + if (uint32 rankSpell = GetBestRankSpellForSkill(skillId, max)) + if (!Config.SpellBlockList.contains(rankSpell) && IsSpellAllowedForTargetCharacter(rankSpell, target)) + InsertSpellForCharacter(trans, target.Guid, rankSpell); + + ++synced; + } + + CommitIfNeeded(trans); + + if (synced) + LOG_DEBUG("module.accountboundprofessions", "AccountBoundProfessions: synced skill {} from player {} to {} account character(s).", + skillId, sourceGuid, synced); +} + +void SyncSpellToAccount(Player* player, uint32 spellId) +{ + if (!Config.Enabled || !Config.AccountBoundEnabled || !Config.AccountBoundSyncOnLearn || !player) + return; + + if (!IsAccountBoundProfessionSpell(spellId)) + return; + + std::vector characters = LoadAccountCharacters(player->GetSession()->GetAccountId()); + std::unordered_map skillSnapshots = LoadAccountProfessionSkills(player); + MergePlayerCurrentProfessionSkills(player, skillSnapshots); + MergeRankSpellSkillSnapshot(spellId, skillSnapshots); + CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction(); + uint32 synced = 0; + uint32 const sourceGuid = player->GetGUID().GetCounter(); + + for (CharacterInfo const& target : characters) + { + if (target.Guid == sourceGuid) + continue; + + if (!CanStoreSyncedSpellForCharacter(spellId, target, skillSnapshots)) + continue; + + StoreRankSpellSkillForCharacter(trans, target, spellId, skillSnapshots); + InsertSpellForCharacter(trans, target.Guid, spellId); + ++synced; + } + + CommitIfNeeded(trans); + + if (synced) + LOG_DEBUG("module.accountboundprofessions", "AccountBoundProfessions: synced profession spell {} from player {} to {} account character(s).", + spellId, sourceGuid, synced); +} + +std::unordered_map> LoadAllCharactersByAccount() +{ + std::unordered_map> charactersByAccount; + + QueryResult result = CharacterDatabase.Query( + "SELECT guid, account, race, class FROM characters WHERE account <> 0"); + + if (!result) + return charactersByAccount; + + do + { + Field* fields = result->Fetch(); + CharacterInfo character{ fields[0].Get(), fields[1].Get(), fields[2].Get(), fields[3].Get() }; + charactersByAccount[character.AccountId].push_back(character); + } while (result->NextRow()); + + return charactersByAccount; +} + +void BackfillAllProfessionSkills(std::unordered_map> const& charactersByAccount) +{ + if (!Config.AccountBoundSyncSkillProgress) + return; + + QueryResult result = CharacterDatabase.Query( + "SELECT c.account, cs.skill, cs.value, cs.max " + "FROM character_skills cs " + "INNER JOIN characters c ON c.guid = cs.guid " + "WHERE c.account <> 0 AND cs.skill IN ({})", + BuildManagedSkillSqlList()); + + if (!result) + return; + + CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction(); + uint64 updates = 0; + + do + { + Field* fields = result->Fetch(); + uint32 const accountId = fields[0].Get(); + uint32 const skillId = fields[1].Get(); + uint16 value = ClampSyncedSkillValue(fields[2].Get()); + uint16 max = ClampSyncedSkillValue(fields[3].Get()); + + if (!IsManagedProfessionSkill(skillId) || !value || !max) + continue; + + if (max < value) + max = value; + + auto accountItr = charactersByAccount.find(accountId); + if (accountItr == charactersByAccount.end()) + continue; + + for (CharacterInfo const& target : accountItr->second) + { + if (!GetSkillRaceClassInfo(skillId, target.Race, target.Class)) + continue; + + InsertOrUpdateSkillForCharacter(trans, target.Guid, skillId, value, max); + + if (Config.AccountBoundSyncProfessionRanks) + if (uint32 rankSpell = GetBestRankSpellForSkill(skillId, max)) + if (!Config.SpellBlockList.contains(rankSpell) && IsSpellAllowedForTargetCharacter(rankSpell, target)) + InsertSpellForCharacter(trans, target.Guid, rankSpell); + + ++updates; + } + } while (result->NextRow()); + + CommitIfNeeded(trans); + LOG_INFO("module.accountboundprofessions", "AccountBoundProfessions: startup skill backfill queued {} account profession row(s).", updates); +} + +void BackfillAllProfessionSpells(std::unordered_map> const& charactersByAccount) +{ + QueryResult result = CharacterDatabase.Query( + "SELECT DISTINCT c.account, cs.spell " + "FROM character_spell cs " + "INNER JOIN characters c ON c.guid = cs.guid " + "WHERE c.account <> 0"); + + if (!result) + return; + + CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction(); + uint64 inserted = 0; + + do + { + Field* fields = result->Fetch(); + uint32 const accountId = fields[0].Get(); + uint32 const spellId = fields[1].Get(); + + if (!IsAccountBoundProfessionSpell(spellId)) + continue; + + auto accountItr = charactersByAccount.find(accountId); + if (accountItr == charactersByAccount.end()) + continue; + + for (CharacterInfo const& target : accountItr->second) + { + if (!IsSpellAllowedForTargetCharacter(spellId, target)) + continue; + + InsertSpellForCharacter(trans, target.Guid, spellId); + ++inserted; + } + } while (result->NextRow()); + + CommitIfNeeded(trans); + LOG_INFO("module.accountboundprofessions", "AccountBoundProfessions: startup spell backfill queued {} account profession spell row(s).", inserted); +} + +void BackfillAccountBoundProfessions() +{ + if (!Config.Enabled || !Config.AccountBoundEnabled || !Config.AccountBoundStartupBackfill) + return; + + std::unordered_map> charactersByAccount = LoadAllCharactersByAccount(); + if (charactersByAccount.empty()) + return; + + BackfillAllProfessionSkills(charactersByAccount); + BackfillAllProfessionSpells(charactersByAccount); +} + +void LoadModuleConfig() +{ + Config.Enabled = AccountBound::IsCategoryEnabled("Professions"); + Config.MaxPrimaryProfessions = sConfigMgr->GetOption("AccountBound.Professions.MaxPrimaryProfessions", MaxSupportedPrimaryProfessions); + Config.CountOnlyAllowedPrimaryProfessions = sConfigMgr->GetOption("AccountBound.Professions.Primary.CountOnlyAllowed", true); + Config.EnforceAllowedPrimaryProfessions = sConfigMgr->GetOption("AccountBound.Professions.Primary.EnforceAllowedList", false); + Config.MaxSyncedSkillValue = uint16(std::clamp( + sConfigMgr->GetOption("AccountBound.Professions.MaxSkillValue", DefaultMaxProfessionSkill), + 1, DefaultMaxProfessionSkill)); + + Config.AllowedPrimarySkills = ParsePrimaryProfessionList( + sConfigMgr->GetOption("AccountBound.Professions.Primary.AllowList", "all"), true); + + for (uint32 blockedSkill : ParsePrimaryProfessionList( + sConfigMgr->GetOption("AccountBound.Professions.Primary.BlockList", ""), false)) + Config.AllowedPrimarySkills.erase(blockedSkill); + + Config.AccountBoundEnabled = Config.Enabled; + Config.AccountBoundStartupBackfill = sConfigMgr->GetOption("AccountBound.Professions.StartupBackfill", true); + Config.AccountBoundSyncOnCreate = sConfigMgr->GetOption("AccountBound.Professions.SyncOnCreate", true); + Config.AccountBoundSyncOnLearn = sConfigMgr->GetOption("AccountBound.Professions.SyncOnLearn", true); + Config.AccountBoundIncludeSecondaryProfessions = sConfigMgr->GetOption("AccountBound.Professions.IncludeSecondaryProfessions", true); + Config.AccountBoundSyncSkillProgress = sConfigMgr->GetOption("AccountBound.Professions.SyncSkillProgress", true); + Config.AccountBoundSyncProfessionRanks = sConfigMgr->GetOption("AccountBound.Professions.SyncProfessionRanks", true); + Config.AccountBoundSyncRecipes = sConfigMgr->GetOption("AccountBound.Professions.SyncRecipes", true); + Config.AccountBoundSyncSkillGrantedSpells = sConfigMgr->GetOption("AccountBound.Professions.SyncSkillGrantedSpells", true); + Config.AccountBoundRequireRecipeSkill = sConfigMgr->GetOption("AccountBound.Professions.RequireRecipeSkill", true); + Config.SpellAllowList = ParseSpellList(sConfigMgr->GetOption("AccountBound.Professions.SpellAllowList", "")); + Config.SpellBlockList = ParseSpellList(sConfigMgr->GetOption("AccountBound.Professions.SpellBlockList", "")); +} +} + +class AccountBoundProfessionsWorldScript : public WorldScript +{ +public: + AccountBoundProfessionsWorldScript() : WorldScript("AccountBoundProfessionsWorldScript", { + WORLDHOOK_ON_AFTER_CONFIG_LOAD, + WORLDHOOK_ON_STARTUP + }) { } + + void OnAfterConfigLoad(bool reload) override + { + LoadModuleConfig(); + ApplyWorldProfessionLimit(); + + if (reload) + { + sWorldSessionMgr->DoForAllOnlinePlayers([](Player* player) + { + EnforceAllowedProfessions(player); + NormalizeFreeProfessionPoints(player); + }); + + if (Config.AccountBoundStartupBackfill) + BackfillAccountBoundProfessions(); + } + + LOG_INFO("module.accountboundprofessions", + "AccountBoundProfessions: {}. MaxPrimary={}, AllowedPrimary={}, AccountBound={}, Recipes={}.", + Config.Enabled ? "enabled" : "disabled", + Config.Enabled ? GetConfiguredMaxPrimaryProfessions() : 0, + Config.AllowedPrimarySkills.size(), + Config.Enabled && Config.AccountBoundEnabled ? "on" : "off", + Config.AccountBoundSyncRecipes ? "on" : "off"); + } + + void OnStartup() override + { + ApplyWorldProfessionLimit(); + BackfillAccountBoundProfessions(); + } +}; + +class AccountBoundProfessionsPlayerScript : public PlayerScript +{ +public: + AccountBoundProfessionsPlayerScript() : PlayerScript("AccountBoundProfessionsPlayerScript", { + PLAYERHOOK_ON_LOGIN, + PLAYERHOOK_ON_CREATE, + PLAYERHOOK_ON_LEARN_SPELL, + PLAYERHOOK_ON_SET_SKILL + }) { } + + void OnPlayerLogin(Player* player) override + { + EnforceAllowedProfessions(player); + NormalizeFreeProfessionPoints(player); + } + + void OnPlayerCreate(Player* player) override + { + if (Config.AccountBoundSyncOnCreate) + BackfillProfessionsForCharacter(player); + } + + void OnPlayerLearnSpell(Player* player, uint32 spellId) override + { + if (!player) + return; + + if (Config.EnforceAllowedPrimaryProfessions) + if (uint32 skillId = GetProfessionSkillFromRankSpell(spellId, false)) + if (IsSupportedPrimarySkill(skillId) && !IsAllowedPrimarySkill(skillId)) + player->removeSpell(spellId, SPEC_MASK_ALL, false); + + SyncSpellToAccount(player, spellId); + NormalizeFreeProfessionPoints(player); + } + + void OnPlayerSetSkill(Player* player, uint32 skillId, uint32 /*value*/, uint32 /*max*/, uint32 /*step*/, uint32 /*newValue*/) override + { + if (!player) + return; + + if (!IsPrimaryProfessionSkill(skillId) && !IsSupportedSecondarySkill(skillId)) + return; + + if (Config.EnforceAllowedPrimaryProfessions && IsSupportedPrimarySkill(skillId) && !IsAllowedPrimarySkill(skillId)) + { + player->SetSkill(skillId, 0, 0, 0); + return; + } + + SyncSkillToAccount(player, skillId); + NormalizeFreeProfessionPoints(player); + } +}; + +void AddAccountBoundProfessionsScripts() +{ + new AccountBoundProfessionsWorldScript(); + new AccountBoundProfessionsPlayerScript(); +} diff --git a/modules/AccountBound/src/Reputations.cpp b/modules/AccountBound/src/Reputations.cpp new file mode 100644 index 000000000..e11a1a22b --- /dev/null +++ b/modules/AccountBound/src/Reputations.cpp @@ -0,0 +1,419 @@ +/* + * Account-bound reputations for AzerothCore. + */ + +#include "AccountBound.h" +#include "Config.h" +#include "DatabaseEnv.h" +#include "DBCStores.h" +#include "Log.h" +#include "ObjectMgr.h" +#include "Player.h" +#include "PlayerScript.h" +#include "ReputationMgr.h" +#include "StringFormat.h" +#include "WorldScript.h" + +#include +#include +#include +#include + +namespace +{ +struct ModuleConfig +{ + bool Enabled = true; + bool StartupBackfill = true; + bool SyncOnCreate = true; + bool SyncOnChange = true; + bool SameFactionOnly = true; + bool ConvertFactionSpecific = true; + bool SyncUnpairedCrossFaction = false; + AccountBound::IdFilter Filter; +}; + +struct ReputationInfo +{ + int32 Standing = 0; + uint16 Flags = 0; +}; + +struct CharacterInfo +{ + uint32 Guid = 0; + uint32 AccountId = 0; + uint8 Race = 0; + uint8 Class = 0; + std::unordered_map Reputations; +}; + +ModuleConfig Config; + +void AppendOrCommit(CharacterDatabaseTransaction& trans, std::string_view sql) +{ + if (!trans) + trans = CharacterDatabase.BeginTransaction(); + + trans->Append(sql); + + if (trans->GetSize() >= 1000) + { + CharacterDatabase.DirectCommitTransaction(trans); + trans = CharacterDatabase.BeginTransaction(); + } +} + +void CommitIfNeeded(CharacterDatabaseTransaction& trans) +{ + if (trans && trans->GetSize()) + CharacterDatabase.DirectCommitTransaction(trans); +} + +bool CanShareBetweenRaces(uint8 sourceRace, uint8 targetRace) +{ + return !Config.SameFactionOnly || Player::TeamIdForRace(sourceRace) == Player::TeamIdForRace(targetRace); +} + +uint32 GetFactionForTarget(uint32 factionId, uint8 sourceRace, uint8 targetRace) +{ + if (!Config.Filter.Allows(factionId)) + return 0; + + TeamId const sourceTeam = Player::TeamIdForRace(sourceRace); + TeamId const targetTeam = Player::TeamIdForRace(targetRace); + + if (sourceTeam == targetTeam) + return factionId; + + if (Config.SameFactionOnly) + return 0; + + if (Config.ConvertFactionSpecific) + { + for (auto const& [allianceFaction, hordeFaction] : sObjectMgr->FactionChangeReputation) + { + if (sourceTeam == TEAM_ALLIANCE && factionId == allianceFaction) + return Config.Filter.Allows(targetTeam == TEAM_HORDE ? hordeFaction : allianceFaction) ? + (targetTeam == TEAM_HORDE ? hordeFaction : allianceFaction) : 0; + + if (sourceTeam == TEAM_HORDE && factionId == hordeFaction) + return Config.Filter.Allows(targetTeam == TEAM_ALLIANCE ? allianceFaction : hordeFaction) ? + (targetTeam == TEAM_ALLIANCE ? allianceFaction : hordeFaction) : 0; + } + } + + return Config.SyncUnpairedCrossFaction ? factionId : 0; +} + +int32 GetAbsoluteStanding(CharacterInfo const& character, uint32 factionId, ReputationInfo const& reputation) +{ + FactionEntry const* faction = sFactionStore.LookupEntry(factionId); + if (!faction || !faction->CanHaveReputation()) + return ReputationMgr::Reputation_Bottom; + + int32 const base = sObjectMgr->GetBaseReputationOf(faction, character.Race, character.Class); + return std::clamp(base + reputation.Standing, ReputationMgr::Reputation_Bottom, ReputationMgr::Reputation_Cap); +} + +int32 GetStoredStanding(CharacterInfo const& character, uint32 factionId, int32 absoluteStanding) +{ + FactionEntry const* faction = sFactionStore.LookupEntry(factionId); + if (!faction) + return 0; + + int32 const base = sObjectMgr->GetBaseReputationOf(faction, character.Race, character.Class); + return std::clamp(absoluteStanding, ReputationMgr::Reputation_Bottom, ReputationMgr::Reputation_Cap) - base; +} + +std::vector LoadCharacters(uint32 accountId = 0) +{ + std::vector characters; + std::unordered_map characterIndexes; + QueryResult result; + + if (accountId) + { + result = CharacterDatabase.Query( + "SELECT c.guid, c.account, c.race, c.class, cr.faction, cr.standing, cr.flags " + "FROM characters c " + "LEFT JOIN character_reputation cr ON cr.guid = c.guid " + "WHERE c.account = {} " + "ORDER BY c.guid", + accountId); + } + else + { + result = CharacterDatabase.Query( + "SELECT c.guid, c.account, c.race, c.class, cr.faction, cr.standing, cr.flags " + "FROM characters c " + "LEFT JOIN character_reputation cr ON cr.guid = c.guid " + "WHERE c.account <> 0 " + "ORDER BY c.account, c.guid"); + } + + if (!result) + return characters; + + do + { + Field* fields = result->Fetch(); + uint32 const guid = fields[0].Get(); + auto [itr, inserted] = characterIndexes.try_emplace(guid, characters.size()); + + if (inserted) + { + characters.push_back({ + guid, + fields[1].Get(), + fields[2].Get(), + fields[3].Get(), + {} + }); + } + + if (!fields[4].IsNull()) + { + characters[itr->second].Reputations[fields[4].Get()] = { + fields[5].Get(), + fields[6].Get() + }; + } + } while (result->NextRow()); + + return characters; +} + +void PersistReputation( + CharacterDatabaseTransaction& trans, + CharacterInfo const& target, + uint32 factionId, + int32 absoluteStanding, + uint16 flags) +{ + int32 const storedStanding = GetStoredStanding(target, factionId, absoluteStanding); + AppendOrCommit(trans, Acore::StringFormat( + "INSERT INTO character_reputation (guid, faction, standing, flags) " + "VALUES ({}, {}, {}, {}) " + "ON DUPLICATE KEY UPDATE standing = VALUES(standing), flags = flags | VALUES(flags)", + target.Guid, factionId, storedStanding, flags)); +} + +bool ApplyReputation( + CharacterDatabaseTransaction& trans, + CharacterInfo& target, + uint32 factionId, + int32 absoluteStanding, + uint16 flags) +{ + FactionEntry const* faction = sFactionStore.LookupEntry(factionId); + if (!faction || !faction->CanHaveReputation()) + return false; + + int32 currentStanding = sObjectMgr->GetBaseReputationOf(faction, target.Race, target.Class); + auto currentItr = target.Reputations.find(factionId); + if (currentItr != target.Reputations.end()) + currentStanding = GetAbsoluteStanding(target, factionId, currentItr->second); + + absoluteStanding = std::clamp(absoluteStanding, ReputationMgr::Reputation_Bottom, ReputationMgr::Reputation_Cap); + if (absoluteStanding <= currentStanding) + return false; + + uint16 const mergedFlags = currentItr == target.Reputations.end() ? flags : uint16(currentItr->second.Flags | flags); + PersistReputation(trans, target, factionId, absoluteStanding, mergedFlags); + target.Reputations[factionId] = { GetStoredStanding(target, factionId, absoluteStanding), mergedFlags }; + + return true; +} + +uint32 MergeReputations(CharacterInfo const& source, CharacterInfo& target, CharacterDatabaseTransaction& trans) +{ + if (source.Guid == target.Guid || !CanShareBetweenRaces(source.Race, target.Race)) + return 0; + + uint32 updated = 0; + for (auto const& [sourceFactionId, sourceReputation] : source.Reputations) + { + uint32 const targetFactionId = GetFactionForTarget(sourceFactionId, source.Race, target.Race); + if (!targetFactionId) + continue; + + int32 const absoluteStanding = GetAbsoluteStanding(source, sourceFactionId, sourceReputation); + if (ApplyReputation(trans, target, targetFactionId, absoluteStanding, sourceReputation.Flags)) + ++updated; + } + + return updated; +} + +void BackfillReputationsForCharacter(Player* player) +{ + if (!Config.Enabled || !player) + return; + + std::vector characters = LoadCharacters(player->GetSession()->GetAccountId()); + auto targetItr = std::find_if(characters.begin(), characters.end(), [player](CharacterInfo const& character) + { + return character.Guid == player->GetGUID().GetCounter(); + }); + + if (targetItr == characters.end()) + return; + + CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction(); + uint32 updated = 0; + for (CharacterInfo const& source : characters) + updated += MergeReputations(source, *targetItr, trans); + + CommitIfNeeded(trans); + + if (updated) + LOG_INFO("module.accountboundreputations", + "AccountBoundReputations: seeded {} improved reputation(s) for character {}.", + updated, targetItr->Guid); +} + +void SyncReputationToAccount(Player* player, uint32 factionId, int32 absoluteStanding) +{ + if (!Config.Enabled || !Config.SyncOnChange || !player) + return; + + FactionEntry const* sourceFaction = sFactionStore.LookupEntry(factionId); + if (!sourceFaction || !sourceFaction->CanHaveReputation()) + return; + + std::vector characters = LoadCharacters(player->GetSession()->GetAccountId()); + auto sourceItr = std::find_if(characters.begin(), characters.end(), [player](CharacterInfo const& character) + { + return character.Guid == player->GetGUID().GetCounter(); + }); + + if (sourceItr == characters.end()) + return; + + uint16 flags = 0; + if (FactionState const* state = player->GetReputationMgr().GetState(sourceFaction)) + flags = state->Flags; + + CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction(); + uint32 updated = 0; + + for (CharacterInfo& target : characters) + { + if (target.Guid == sourceItr->Guid || !CanShareBetweenRaces(sourceItr->Race, target.Race)) + continue; + + uint32 const targetFactionId = GetFactionForTarget(factionId, sourceItr->Race, target.Race); + if (targetFactionId && ApplyReputation(trans, target, targetFactionId, absoluteStanding, flags)) + ++updated; + } + + CommitIfNeeded(trans); + + if (updated) + LOG_DEBUG("module.accountboundreputations", + "AccountBoundReputations: propagated faction {} from character {} to {} account character(s).", + factionId, sourceItr->Guid, updated); +} + +void BackfillAllReputations() +{ + if (!Config.Enabled || !Config.StartupBackfill) + return; + + std::vector characters = LoadCharacters(); + std::unordered_map> charactersByAccount; + + for (std::size_t index = 0; index < characters.size(); ++index) + charactersByAccount[characters[index].AccountId].push_back(index); + + CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction(); + uint64 updated = 0; + + for (auto const& [accountId, indexes] : charactersByAccount) + { + (void)accountId; + std::vector const snapshot = [&]() + { + std::vector result; + result.reserve(indexes.size()); + for (std::size_t index : indexes) + result.push_back(characters[index]); + return result; + }(); + + for (std::size_t targetIndex : indexes) + for (CharacterInfo const& source : snapshot) + updated += MergeReputations(source, characters[targetIndex], trans); + } + + CommitIfNeeded(trans); + LOG_INFO("module.accountboundreputations", + "AccountBoundReputations: startup backfill queued {} improved reputation row(s).", updated); +} + +void LoadModuleConfig() +{ + Config.Enabled = AccountBound::IsCategoryEnabled("Reputations"); + Config.StartupBackfill = sConfigMgr->GetOption("AccountBound.Reputations.StartupBackfill", true); + Config.SyncOnCreate = sConfigMgr->GetOption("AccountBound.Reputations.SyncOnCreate", true); + Config.SyncOnChange = sConfigMgr->GetOption("AccountBound.Reputations.SyncOnChange", true); + Config.SameFactionOnly = sConfigMgr->GetOption("AccountBound.Reputations.SameFactionOnly", true); + Config.ConvertFactionSpecific = sConfigMgr->GetOption("AccountBound.Reputations.ConvertFactionSpecific", true); + Config.SyncUnpairedCrossFaction = sConfigMgr->GetOption("AccountBound.Reputations.SyncUnpairedCrossFaction", false); + Config.Filter = AccountBound::LoadIdFilter("Reputations"); +} +} + +class AccountBoundReputationsWorldScript : public WorldScript +{ +public: + AccountBoundReputationsWorldScript() : WorldScript("AccountBoundReputationsWorldScript", { + WORLDHOOK_ON_AFTER_CONFIG_LOAD, + WORLDHOOK_ON_STARTUP + }) { } + + void OnAfterConfigLoad(bool reload) override + { + LoadModuleConfig(); + LOG_INFO("module.accountboundreputations", + "AccountBoundReputations: {}. CreateSync={}, ChangeSync={}, StartupBackfill={}, SameFactionOnly={}.", + Config.Enabled ? (reload ? "configuration reloaded" : "configuration loaded") : "disabled", + Config.Enabled && Config.SyncOnCreate ? "on" : "off", + Config.Enabled && Config.SyncOnChange ? "on" : "off", + Config.Enabled && Config.StartupBackfill ? "on" : "off", + Config.Enabled && Config.SameFactionOnly ? "on" : "off"); + } + + void OnStartup() override + { + BackfillAllReputations(); + } +}; + +class AccountBoundReputationsPlayerScript : public PlayerScript +{ +public: + AccountBoundReputationsPlayerScript() : PlayerScript("AccountBoundReputationsPlayerScript", { + PLAYERHOOK_ON_CREATE, + PLAYERHOOK_ON_REPUTATION_CHANGE + }) { } + + void OnPlayerCreate(Player* player) override + { + if (Config.SyncOnCreate) + BackfillReputationsForCharacter(player); + } + + bool OnPlayerReputationChange(Player* player, uint32 factionId, int32& standing, bool /*incremental*/) override + { + SyncReputationToAccount(player, factionId, standing); + return true; + } +}; + +void AddAccountBoundReputationsScripts() +{ + new AccountBoundReputationsWorldScript(); + new AccountBoundReputationsPlayerScript(); +} diff --git a/modules/AccountBound/src/Titles.cpp b/modules/AccountBound/src/Titles.cpp new file mode 100644 index 000000000..ab612dca4 --- /dev/null +++ b/modules/AccountBound/src/Titles.cpp @@ -0,0 +1,466 @@ +/* + * Account-bound character titles for AzerothCore. + */ + +#include "AchievementMgr.h" +#include "AccountBound.h" +#include "Config.h" +#include "DatabaseEnv.h" +#include "DBCStores.h" +#include "Log.h" +#include "ObjectMgr.h" +#include "Player.h" +#include "PlayerScript.h" +#include "StringFormat.h" +#include "WorldScript.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +using KnownTitlesMask = std::array; + +struct ModuleConfig +{ + bool Enabled = true; + bool StartupBackfill = true; + bool SyncOnCreate = true; + bool SyncOnSave = true; + bool SyncRealmFirst = false; + bool ConvertFactionSpecific = true; + AccountBound::IdFilter Filter; +}; + +ModuleConfig Config; +std::unordered_map TitleIdByBitIndex; +std::unordered_set RealmFirstTitleIds; +std::unordered_map LastKnownTitlesByCharacter; + +bool IsRealmFirstAchievement(AchievementEntry const* achievement) +{ + return achievement && (achievement->flags & (ACHIEVEMENT_FLAG_REALM_FIRST_REACH | ACHIEVEMENT_FLAG_REALM_FIRST_KILL)); +} + +bool IsTitleAllowed(uint32 titleId) +{ + return Config.Filter.Allows(titleId) && + (Config.SyncRealmFirst || !RealmFirstTitleIds.contains(titleId)); +} + +void BuildTitleCache() +{ + TitleIdByBitIndex.clear(); + RealmFirstTitleIds = + { + // Restored beta/datamined Realm First titles used by RealmFirstTitles. + 85, 86, 87, 89, 90, 91, 92, 93, 94, 95, + 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, + 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, + 116, 117, 118, 119, 123, 156 + }; + + for (uint32 titleId = 0; titleId < sCharTitlesStore.GetNumRows(); ++titleId) + if (CharTitlesEntry const* title = sCharTitlesStore.LookupEntry(titleId)) + TitleIdByBitIndex.try_emplace(title->bit_index, titleId); + + for (uint32 achievementId = 0; achievementId < sAchievementStore.GetNumRows(); ++achievementId) + { + AchievementEntry const* achievement = sAchievementStore.LookupEntry(achievementId); + if (!IsRealmFirstAchievement(achievement)) + continue; + + if (AchievementReward const* reward = sAchievementMgr->GetAchievementReward(achievement)) + for (uint32 titleId : reward->titleId) + if (titleId) + RealmFirstTitleIds.insert(titleId); + } + + LOG_INFO("module.accountboundtitles", + "AccountBoundTitles: cached {} title bit mapping(s) and {} Realm First exclusion(s).", + TitleIdByBitIndex.size(), RealmFirstTitleIds.size()); +} + +KnownTitlesMask ParseKnownTitles(std::string const& text) +{ + KnownTitlesMask titles = {}; + std::stringstream stream(text); + + for (uint32& value : titles) + stream >> value; + + return titles; +} + +KnownTitlesMask GetPlayerKnownTitles(Player const* player) +{ + KnownTitlesMask titles = {}; + if (!player) + return titles; + + for (uint32 index = 0; index < titles.size(); ++index) + titles[index] = player->GetUInt32Value(PLAYER__FIELD_KNOWN_TITLES + index); + + return titles; +} + +std::string KnownTitlesToString(KnownTitlesMask const& titles) +{ + std::ostringstream stream; + + for (uint32 index = 0; index < titles.size(); ++index) + { + if (index) + stream << ' '; + + stream << titles[index]; + } + + return stream.str(); +} + +bool HasKnownTitle(KnownTitlesMask const& titles, uint32 bitIndex) +{ + if (bitIndex >= titles.size() * 32) + return false; + + return (titles[bitIndex / 32] & (1u << (bitIndex % 32))) != 0; +} + +bool AddKnownTitle(KnownTitlesMask& titles, uint32 bitIndex) +{ + if (bitIndex >= titles.size() * 32) + return false; + + uint32& mask = titles[bitIndex / 32]; + uint32 const flag = 1u << (bitIndex % 32); + + if (mask & flag) + return false; + + mask |= flag; + return true; +} + +uint32 GetTitleForRace(uint32 titleId, uint8 sourceRace, uint8 targetRace) +{ + if (!IsTitleAllowed(titleId)) + return 0; + + TeamId const sourceTeam = Player::TeamIdForRace(sourceRace); + TeamId const targetTeam = Player::TeamIdForRace(targetRace); + + if (sourceTeam == targetTeam) + return titleId; + + for (auto const& [allianceTitle, hordeTitle] : sObjectMgr->FactionChangeTitles) + { + if (titleId == allianceTitle) + return Config.ConvertFactionSpecific && targetTeam == TEAM_HORDE && + IsTitleAllowed(hordeTitle) ? hordeTitle : 0; + + if (titleId == hordeTitle) + return Config.ConvertFactionSpecific && targetTeam == TEAM_ALLIANCE && + IsTitleAllowed(allianceTitle) ? allianceTitle : 0; + } + + return titleId; +} + +uint32 MergeTitlesForRace(KnownTitlesMask const& sourceTitles, uint8 sourceRace, uint8 targetRace, KnownTitlesMask& targetTitles) +{ + uint32 added = 0; + + for (uint32 sourceBitIndex = 0; sourceBitIndex < sourceTitles.size() * 32; ++sourceBitIndex) + { + if (!HasKnownTitle(sourceTitles, sourceBitIndex)) + continue; + + auto titleItr = TitleIdByBitIndex.find(sourceBitIndex); + if (titleItr == TitleIdByBitIndex.end()) + continue; + + uint32 const targetTitleId = GetTitleForRace(titleItr->second, sourceRace, targetRace); + CharTitlesEntry const* targetTitle = sCharTitlesStore.LookupEntry(targetTitleId); + + if (targetTitle && AddKnownTitle(targetTitles, targetTitle->bit_index)) + ++added; + } + + return added; +} + +void AppendOrCommit(CharacterDatabaseTransaction& trans, std::string_view sql) +{ + if (!trans) + trans = CharacterDatabase.BeginTransaction(); + + trans->Append(sql); + + if (trans->GetSize() >= 1000) + { + CharacterDatabase.DirectCommitTransaction(trans); + trans = CharacterDatabase.BeginTransaction(); + } +} + +void CommitIfNeeded(CharacterDatabaseTransaction& trans) +{ + if (trans && trans->GetSize()) + CharacterDatabase.DirectCommitTransaction(trans); +} + +void UpdateTitlesForCharacter(CharacterDatabaseTransaction& trans, uint32 targetGuid, KnownTitlesMask const& titles) +{ + AppendOrCommit(trans, Acore::StringFormat( + "UPDATE characters SET knownTitles = '{}' WHERE guid = {}", + KnownTitlesToString(titles), targetGuid)); +} + +void BackfillTitlesForCharacter(Player* player) +{ + if (!Config.Enabled || !player) + return; + + uint32 const accountId = player->GetSession()->GetAccountId(); + uint32 const targetGuid = player->GetGUID().GetCounter(); + uint8 const targetRace = player->getRace(true); + KnownTitlesMask targetTitles = {}; + uint32 added = 0; + QueryResult targetResult = CharacterDatabase.Query( + "SELECT COALESCE(knownTitles, '') FROM characters WHERE guid = {}", targetGuid); + + if (targetResult) + targetTitles = ParseKnownTitles(targetResult->Fetch()[0].Get()); + + QueryResult result = CharacterDatabase.Query( + "SELECT guid, race, COALESCE(knownTitles, '') " + "FROM characters WHERE account = {} AND guid <> {}", + accountId, targetGuid); + + if (result) + { + do + { + Field* fields = result->Fetch(); + uint8 const sourceRace = fields[1].Get(); + KnownTitlesMask const sourceTitles = ParseKnownTitles(fields[2].Get()); + added += MergeTitlesForRace(sourceTitles, sourceRace, targetRace, targetTitles); + } while (result->NextRow()); + } + + if (added) + { + CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction(); + UpdateTitlesForCharacter(trans, targetGuid, targetTitles); + CommitIfNeeded(trans); + + LOG_INFO("module.accountboundtitles", + "AccountBoundTitles: seeded {} account title(s) for character {}.", + added, targetGuid); + } +} + +void SyncTitlesFromPlayerToAccount(Player* player) +{ + if (!Config.Enabled || !Config.SyncOnSave || !player) + return; + + uint32 const sourceGuid = player->GetGUID().GetCounter(); + uint8 const sourceRace = player->getRace(true); + KnownTitlesMask const sourceTitles = GetPlayerKnownTitles(player); + auto snapshotItr = LastKnownTitlesByCharacter.find(sourceGuid); + + if (snapshotItr != LastKnownTitlesByCharacter.end() && snapshotItr->second == sourceTitles) + return; + + QueryResult result = CharacterDatabase.Query( + "SELECT guid, race, COALESCE(knownTitles, '') " + "FROM characters WHERE account = {} AND guid <> {}", + player->GetSession()->GetAccountId(), sourceGuid); + + if (!result) + { + LastKnownTitlesByCharacter[sourceGuid] = sourceTitles; + return; + } + + CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction(); + uint32 updatedCharacters = 0; + uint32 addedTitles = 0; + + do + { + Field* fields = result->Fetch(); + uint32 const targetGuid = fields[0].Get(); + uint8 const targetRace = fields[1].Get(); + KnownTitlesMask targetTitles = ParseKnownTitles(fields[2].Get()); + uint32 const added = MergeTitlesForRace(sourceTitles, sourceRace, targetRace, targetTitles); + + if (!added) + continue; + + UpdateTitlesForCharacter(trans, targetGuid, targetTitles); + + ++updatedCharacters; + addedTitles += added; + } while (result->NextRow()); + + CommitIfNeeded(trans); + LastKnownTitlesByCharacter[sourceGuid] = sourceTitles; + + if (updatedCharacters) + LOG_INFO("module.accountboundtitles", + "AccountBoundTitles: propagated {} title(s) from character {} to {} account character(s).", + addedTitles, sourceGuid, updatedCharacters); +} + +void BackfillAllTitles() +{ + if (!Config.Enabled || !Config.StartupBackfill) + return; + + struct TitleCharacterInfo + { + uint32 Guid; + uint8 Race; + KnownTitlesMask Titles; + }; + + QueryResult result = CharacterDatabase.Query( + "SELECT guid, account, race, COALESCE(knownTitles, '') " + "FROM characters WHERE account <> 0"); + + if (!result) + return; + + std::unordered_map> charactersByAccount; + do + { + Field* fields = result->Fetch(); + charactersByAccount[fields[1].Get()].push_back({ + fields[0].Get(), + fields[2].Get(), + ParseKnownTitles(fields[3].Get()) + }); + } while (result->NextRow()); + + CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction(); + uint32 updatedCharacters = 0; + uint32 addedTitles = 0; + + for (auto const& [accountId, characters] : charactersByAccount) + { + (void)accountId; + + for (TitleCharacterInfo const& target : characters) + { + KnownTitlesMask mergedTitles = target.Titles; + uint32 added = 0; + + for (TitleCharacterInfo const& source : characters) + { + if (source.Guid != target.Guid) + added += MergeTitlesForRace(source.Titles, source.Race, target.Race, mergedTitles); + } + + if (!added) + continue; + + UpdateTitlesForCharacter(trans, target.Guid, mergedTitles); + ++updatedCharacters; + addedTitles += added; + } + } + + CommitIfNeeded(trans); + LOG_INFO("module.accountboundtitles", + "AccountBoundTitles: startup backfill added {} title(s) to {} character(s).", + addedTitles, updatedCharacters); +} + +void LoadModuleConfig() +{ + Config.Enabled = AccountBound::IsCategoryEnabled("Titles"); + Config.StartupBackfill = sConfigMgr->GetOption("AccountBound.Titles.StartupBackfill", true); + Config.SyncOnCreate = sConfigMgr->GetOption("AccountBound.Titles.SyncOnCreate", true); + Config.SyncOnSave = sConfigMgr->GetOption("AccountBound.Titles.SyncOnSave", true); + Config.SyncRealmFirst = sConfigMgr->GetOption("AccountBound.Titles.SyncRealmFirst", false); + Config.ConvertFactionSpecific = sConfigMgr->GetOption("AccountBound.Titles.ConvertFactionSpecific", true); + Config.Filter = AccountBound::LoadIdFilter("Titles"); +} +} + +class AccountBoundTitlesWorldScript : public WorldScript +{ +public: + AccountBoundTitlesWorldScript() : WorldScript("AccountBoundTitlesWorldScript", { + WORLDHOOK_ON_AFTER_CONFIG_LOAD, + WORLDHOOK_ON_STARTUP + }) { } + + void OnAfterConfigLoad(bool reload) override + { + LoadModuleConfig(); + LOG_INFO("module.accountboundtitles", + "AccountBoundTitles: {}. CreateSync={}, SaveSync={}, StartupBackfill={}, RealmFirst={}.", + Config.Enabled ? (reload ? "configuration reloaded" : "configuration loaded") : "disabled", + Config.Enabled && Config.SyncOnCreate ? "on" : "off", + Config.Enabled && Config.SyncOnSave ? "on" : "off", + Config.Enabled && Config.StartupBackfill ? "on" : "off", + Config.Enabled && Config.SyncRealmFirst ? "on" : "off"); + } + + void OnStartup() override + { + if (!Config.Enabled) + return; + + BuildTitleCache(); + BackfillAllTitles(); + } +}; + +class AccountBoundTitlesPlayerScript : public PlayerScript +{ +public: + AccountBoundTitlesPlayerScript() : PlayerScript("AccountBoundTitlesPlayerScript", { + PLAYERHOOK_ON_LOAD_FROM_DB, + PLAYERHOOK_ON_LOGOUT, + PLAYERHOOK_ON_CREATE, + PLAYERHOOK_ON_SAVE + }) { } + + void OnPlayerLoadFromDB(Player* player) override + { + if (player) + LastKnownTitlesByCharacter[player->GetGUID().GetCounter()] = GetPlayerKnownTitles(player); + } + + void OnPlayerCreate(Player* player) override + { + if (Config.SyncOnCreate) + BackfillTitlesForCharacter(player); + } + + void OnPlayerSave(Player* player) override + { + SyncTitlesFromPlayerToAccount(player); + } + + void OnPlayerLogout(Player* player) override + { + if (player) + LastKnownTitlesByCharacter.erase(player->GetGUID().GetCounter()); + } +}; + +void AddAccountBoundTitlesScripts() +{ + new AccountBoundTitlesWorldScript(); + new AccountBoundTitlesPlayerScript(); +} diff --git a/modules/AccountBound/src/loader.cpp b/modules/AccountBound/src/loader.cpp new file mode 100644 index 000000000..16961aed7 --- /dev/null +++ b/modules/AccountBound/src/loader.cpp @@ -0,0 +1,18 @@ +void AddAccountBoundAchievementsScripts(); +void AddAccountBoundMountsScripts(); +void AddAccountBoundPetsScripts(); +void AddAccountBoundProfessionsScripts(); +void AddAccountBoundReputationsScripts(); +void AddAccountBoundTitlesScripts(); +void AddAccountWideFriendsScripts(); + +void AddAccountBoundScripts() +{ + AddAccountBoundAchievementsScripts(); + AddAccountBoundMountsScripts(); + AddAccountBoundPetsScripts(); + AddAccountBoundProfessionsScripts(); + AddAccountBoundReputationsScripts(); + AddAccountBoundTitlesScripts(); + AddAccountWideFriendsScripts(); +}