Plugin cookbook
How to use this page
Each recipe is a small piece of ordinary AzerothCore script code, taken from a plugin that ships with LonelyIce or from the core itself. The source is named under each recipe; read it when you need the full picture.
Every class shown is a ScriptObject that you create with new in your Add*Scripts() function, the one your
entry macro names (see Your first plugin):
void AddExampleScripts()
{
new ExamplePlayerScript();
new ExampleWorldScript();
new ExampleCommandScript();
}
Three rules hold for every recipe:
- The code must also run in a plain worldserver of the fork. Use only core interfaces, never the launcher.
- Reach the databases only through
WorldDatabase,CharacterDatabaseandLoginDatabase, in the AzerothCore SQL dialect. You never know which backend is behind them (Shipping SQL). - Never hard-code the id of a DBC row your plugin adds. Name it in a patch recipe and read the id at run time (Client patches).
PlayerScript, WorldScript and the other hook classes take the list of hooks they use in their constructor. With
a list, a hook you override but do not list is never called; list exactly what you use, as the shipped plugins do.
The hook classes and their signatures are in
src/server/game/Scripting/ScriptDefines/ of the core.
Player login and logout
Use this to load per-character state, greet players or clean up.
#include "Chat.h"
#include "Player.h"
#include "ScriptMgr.h"
#include "WorldSession.h"
class ExamplePlayerScript : public PlayerScript
{
public:
ExamplePlayerScript() : PlayerScript("ExamplePlayerScript",
{ PLAYERHOOK_ON_FIRST_LOGIN, PLAYERHOOK_ON_LOGIN, PLAYERHOOK_ON_BEFORE_LOGOUT, PLAYERHOOK_ON_LOGOUT }) { }
void OnPlayerFirstLogin(Player* player) override
{
ChatHandler(player->GetSession()).SendSysMessage("Welcome to your first adventure.");
}
void OnPlayerLogin(Player* player) override
{
if (player->GetSession()->IsBot())
return; // playerbots log in through the same hook
ChatHandler(player->GetSession()).PSendSysMessage("Welcome back, {}.", player->GetName());
}
// Still in the world here.
void OnPlayerBeforeLogout(Player* /*player*/) override { }
// The player is leaving; drop what you keep for it.
void OnPlayerLogout(Player* /*player*/) override { }
};
OnPlayerLoginruns for playerbots as well.WorldSession::IsBot()tells them apart;lonelyice.qolteaches its Sprint spell to every character, bots included, whilelonelyice.waystonesskips bots.OnPlayerDelete(ObjectGuid guid, uint32 accountId)(PLAYERHOOK_ON_DELETE) runs when a character is deleted; see Per-player data.
Source: mod-lonelyice-waystones/src/waystones.cpp (CustomWaystonesPlayerScript),
mod-lonelyice-tactics/src/TacticsDataScripts.cpp.
Level-up
OnPlayerLevelChanged runs right after the new level is applied and gets the old level.
class ExampleLevelScript : public PlayerScript
{
public:
ExampleLevelScript() : PlayerScript("ExampleLevelScript", { PLAYERHOOK_ON_LEVEL_CHANGED }) { }
void OnPlayerLevelChanged(Player* player, uint8 oldLevel) override
{
if (player->GetSession()->IsBot() || player->GetLevel() <= oldLevel)
return;
ChatHandler(player->GetSession()).PSendSysMessage("You reached level {}.", player->GetLevel());
}
};
Related hooks in PlayerScript.h: OnPlayerGiveXP(Player*, uint32& amount, Unit* victim, uint8 xpSource) runs
before experience is given and may change amount.
Source: src/server/game/Scripting/ScriptDefines/PlayerScript.h of the core.
Kills
#include "Creature.h"
class ExampleKillScript : public PlayerScript
{
public:
ExampleKillScript() : PlayerScript("ExampleKillScript",
{ PLAYERHOOK_ON_CREATURE_KILL, PLAYERHOOK_ON_CREATURE_KILLED_BY_PET, PLAYERHOOK_ON_PVP_KILL }) { }
void OnPlayerCreatureKill(Player* killer, Creature* killed) override { OnKill(killer, killed); }
void OnPlayerCreatureKilledByPet(Player* owner, Creature* killed) override
{
if (owner) // a pet or totem without a player owner gives nullptr
OnKill(owner, killed);
}
void OnPlayerPVPKill(Player* killer, Player* killed) override
{
ChatHandler(killer->GetSession()).PSendSysMessage("You defeated {}.", killed->GetName());
}
private:
static void OnKill(Player* player, Creature* killed)
{
ChatHandler(player->GetSession()).PSendSysMessage("Killed {} ({}).", killed->GetName(), killed->GetEntry());
}
};
OnPlayerCreatureKill runs only when a player dealt the killing blow; a kill by a pet or totem calls
OnPlayerCreatureKilledByPet with the owner instead. List both hooks if both count. The killer can be a bot.
Source: mod-lonelyice-qol/src/autoloot.cpp (CustomAutoLootPlayerScript); Unit::Kill in the core's Unit.cpp.
World startup and shutdown
OnStartup runs once the world is loaded and the server is ready, with the databases open. Load your tables here,
not in constructors or in Add*Scripts(): the databases are not open yet when scripts are created.
class ExampleWorldScript : public WorldScript
{
public:
ExampleWorldScript() : WorldScript("ExampleWorldScript", { WORLDHOOK_ON_STARTUP, WORLDHOOK_ON_SHUTDOWN }) { }
void OnStartup() override
{
LoadExampleData(); // e.g. SELECT ... FROM your world table
}
void OnShutdown() override { }
};
Source: mod-lonelyice-waystones/src/waystones.cpp (CustomWaystonesWorldScript loads custom_waystone and
looks up its spell id), mod-lonelyice-qol/src/sprint.cpp.
Timers on the world tick
WorldScript::OnUpdate(uint32 diff) runs on every world tick on the world thread, with the milliseconds since the
last one. Accumulate diff and do the work at your interval.
class ExampleTickScript : public WorldScript
{
public:
ExampleTickScript() : WorldScript("ExampleTickScript", { WORLDHOOK_ON_UPDATE }) { }
void OnUpdate(uint32 diff) override
{
_timer += diff;
if (_timer < 10 * IN_MILLISECONDS)
return;
_timer = 0;
// every 10 seconds
}
private:
uint32 _timer = 0;
};
Keep the work per tick small. The same pattern works per character in PlayerScript::OnPlayerUpdate(Player*, uint32 diff) (PLAYERHOOK_ON_UPDATE); lonelyice.qol keeps the timer in the character's CustomData and checks
bots every 500 ms.
Source: mod-lonelyice-citizens/src/CityLife.cpp (CityLifeWorldScript), mod-lonelyice-qol/src/sprint.cpp.
A delayed action
For "do this to that character in 1.5 seconds", queue a lambda on the object's event list. Capture the GUID, not the pointer, and look the object up again when the event runs.
#include "ObjectAccessor.h"
ObjectGuid const guid = player->GetGUID();
player->m_Events.AddEventAtOffset([guid]()
{
if (Player* p = ObjectAccessor::FindPlayer(guid))
if (p->IsAlive())
ChatHandler(p->GetSession()).SendSysMessage("A moment later...");
}, 1500ms);
Source: mod-lonelyice-waystones/src/waystones.cpp (OfferGroup: bots follow a teleport after 1.5 s).
Chat commands
A CommandScript returns a table of commands. Each entry names a handler, the lowest account level that may use it
and whether the server console may run it.
#include "Chat.h"
#include "CommandScript.h"
#include "Player.h"
#include "ScriptMgr.h"
using namespace Acore::ChatCommands;
class ExampleCommandScript : public CommandScript
{
public:
ExampleCommandScript() : CommandScript("ExampleCommandScript") { }
ChatCommandTable GetCommands() const override
{
static ChatCommandTable exampleCommandTable =
{
{ "hello", HandleHello, SEC_PLAYER, Console::No },
{ "status", HandleStatus, SEC_ADMINISTRATOR, Console::Yes },
};
static ChatCommandTable commandTable =
{
{ "example", exampleCommandTable },
};
return commandTable;
}
// .example hello [name]
static bool HandleHello(ChatHandler* handler, Optional<std::string> name)
{
handler->PSendSysMessage("Hello, {}!", name ? *name : handler->GetPlayer()->GetName());
return true;
}
// .example status <anything>
static bool HandleStatus(ChatHandler* handler, Tail args)
{
handler->PSendSysMessage("example: status, arguments '{}'", std::string(args));
return true;
}
};
| Level | Who |
|---|---|
SEC_PLAYER (0) |
every account |
SEC_MODERATOR (1) |
moderators and above |
SEC_GAMEMASTER (2) |
game masters and above |
SEC_ADMINISTRATOR (3) |
administrators |
SEC_CONSOLE (4) |
the console only |
- Handler arguments are parsed from their types: numbers,
bool,std::string,Optional<T>for optional trailing arguments,Tailfor the rest of the line. The core'ssrc/server/scripts/Commands/cs_*.cppshow more. - A handler with
Console::Yescan run without a player:handler->GetPlayer()is thennullptr, so only use it inConsole::Nocommands. - Help text comes from the world table
command(name,security,help). For a command without a row the server logs a warning at start that the table is missing its help text. A row whosesecuritydiffers from your table overrides your level. Add rows with your world SQL if you want.help example helloto show something.
Source: mod-lonelyice-citizens/src/CityLife.cpp (.citizen), mod-lonelyice-tactics/src/TacticsEngineScripts.cpp
(.tactics), src/server/game/Chat/ChatCommands/ChatCommand.cpp of the core (security check, command table).
Config values
The plugin's .conf.dist and then modules/<name>.conf beside the server's config (configs/modules in LonelyIce)
are loaded before scripts are created, so values are readable in Add*Scripts() already. .reload config reads
both files again. Read the values into a struct and read them again after a reload:
#include "Config.h"
namespace
{
struct ExampleConfig
{
bool enable = true;
uint32 intervalMs = 10000;
};
ExampleConfig sConfig;
void LoadConfig()
{
ExampleConfig cfg;
cfg.enable = sConfigMgr->GetOption<bool>("Example.Enable", true, false);
cfg.intervalMs = std::max<uint32>(1000, sConfigMgr->GetOption<uint32>("Example.IntervalMs", 10000, false));
sConfig = cfg;
}
}
class ExampleConfigScript : public WorldScript
{
public:
ExampleConfigScript() : WorldScript("ExampleConfigScript", { WORLDHOOK_ON_AFTER_CONFIG_LOAD }) { }
// At startup (reload == false) and after .reload config (reload == true)
void OnAfterConfigLoad(bool /*reload*/) override { LoadConfig(); }
};
- The third argument of
GetOptionisshowLogs. Withfalsea missing key silently gives the default, and a value taken from anAC_*environment variable is not logged. - Validate and clamp values in code; the file can be edited by hand.
- Making the options appear in the launcher is covered in Plugin settings.
Source: mod-lonelyice-citizens/src/CityLife.cpp (LoadConfig, called from AddScripts and
OnAfterConfigLoad), mod-lonelyice-tactics/src/TacticsEngine.cpp; src/common/Configuration/Config.h of the core.
Database reads and writes
Create your tables with the plugin's SQL (Shipping SQL); query them with the pools from
DatabaseEnv.h. Queries are written in the AzerothCore (MySQL) dialect and translated for the active backend.
Ad-hoc queries. Query is synchronous and returns nullptr when no row matched; Execute queues the statement
and returns at once. Both take {} placeholders.
#include "DatabaseEnv.h"
#include "Field.h"
#include "QueryResult.h"
ObjectGuid::LowType const guid = player->GetGUID().GetCounter();
// synchronous read
if (QueryResult result = CharacterDatabase.Query("SELECT `waystone` FROM `character_waystone` WHERE `guid` = {}", guid))
{
do
{
uint32 waystone = result->Fetch()[0].Get<uint32>();
// ...
} while (result->NextRow());
}
// asynchronous write
CharacterDatabase.Execute("INSERT IGNORE INTO `character_waystone` (`guid`, `waystone`) VALUES ({}, {})", guid, 7);
Strings from players go through EscapeString and into quotes:
std::string text = input; // anything a player typed
CharacterDatabase.EscapeString(text);
CharacterDatabase.Execute("REPLACE INTO `example_note` (`guid`, `note`) VALUES ({}, '{}')", guid, text);
Several writes at once go into a transaction:
CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction();
trans->Append("DELETE FROM `example_note` WHERE `guid` = {}", guid);
trans->Append("INSERT INTO `example_note` (`guid`, `note`) VALUES ({}, '{}')", guid, text);
CharacterDatabase.CommitTransaction(trans);
Asynchronous reads for a player go through the session's query processor. The callback runs later, during the session's update; look the player up again from the session, as the core does:
WorldSession* session = player->GetSession();
session->GetQueryProcessor().AddCallback(CharacterDatabase.AsyncQuery(
Acore::StringFormat("SELECT `waystone` FROM `character_waystone` WHERE `guid` = {}", guid))
.WithCallback([session](QueryResult result)
{
if (!session->GetPlayer()) // logged out meanwhile
return;
ChatHandler(session).PSendSysMessage("Attuned waycrystals: {}", result ? result->GetRowCount() : 0);
}));
Prepared statements exist only for the core's own statements; a plugin cannot add statements to the core pools. You may use an existing one:
CharacterDatabasePreparedStatement* stmt = CharacterDatabase.GetPreparedStatement(CHAR_SEL_CHARACTER_NAME_DATA);
stmt->SetData(0, guid);
if (PreparedQueryResult result = CharacterDatabase.Query(stmt))
{
uint8 level = (*result)[3].Get<uint8>(); // SELECT race, class, gender, level FROM characters WHERE guid = ?
}
For your own tables use ad-hoc queries. A plugin that needs its own prepared statements owns a whole database, as
playerbots does (A database of your own).
Source: mod-lonelyice-waystones/src/waystones.cpp, mod-lonelyice-tactics/src/TacticsStore.cpp (escaping, cache,
REPLACE), mod-playerbots/src/Mgr/Guild/GuildTaskMgr.cpp (transactions); in the core Player.cpp
(GetQueryProcessor().AddCallback(... AsyncQuery ...)), cs_character.cpp (CHAR_SEL_CHARACTER_NAME_DATA) and
src/server/database/Database/DatabaseWorkerPool.h.
Messages to players
All go through a ChatHandler for the player's session (Chat.h).
ChatHandler handler(player->GetSession());
handler.SendSysMessage("A system message in the chat frame.");
handler.PSendSysMessage("With arguments: {} has {} items.", player->GetName(), 3);
handler.SendSysMessage("|cffb48cffColoured text|r"); // the client's colour codes work
handler.SendNotification("A notification on the screen.");
// To every player in the world: use the format + arguments form.
ChatHandler(nullptr).SendWorldText("{} reached level {}!", player->GetName(), player->GetLevel());
SendWorldText sends to every player only in its format form (char const* format plus arguments) or with an
acore_string id; called with a single std::string it sends to the handler's own session.
Source: mod-lonelyice-waystones/src/waystones.cpp (Notify), src/server/game/Chat/Chat.h of the core.
Gossip menu on a creature
A CreatureScript answers when a player talks to a creature whose creature_template.ScriptName names the script
(and whose npcflag includes 1, gossip). The text above the options is an npc_text row.
#include "Creature.h"
#include "Player.h"
#include "ScriptMgr.h"
#include "ScriptedGossip.h"
enum
{
NPC_TEXT_EXAMPLE = 9200001, // npc_text row your world SQL adds
};
class npc_example_healer : public CreatureScript
{
public:
npc_example_healer() : CreatureScript("npc_example_healer") { }
bool OnGossipHello(Player* player, Creature* creature) override
{
ClearGossipMenuFor(player);
AddGossipItemFor(player, GOSSIP_ICON_CHAT, "Heal me.", GOSSIP_SENDER_MAIN, GOSSIP_ACTION_INFO_DEF + 1);
AddGossipItemFor(player, GOSSIP_ICON_CHAT, "Goodbye.", GOSSIP_SENDER_MAIN, GOSSIP_ACTION_INFO_DEF + 2);
SendGossipMenuFor(player, NPC_TEXT_EXAMPLE, creature);
return true;
}
bool OnGossipSelect(Player* player, Creature* /*creature*/, uint32 /*sender*/, uint32 action) override
{
CloseGossipMenuFor(player);
if (action == GOSSIP_ACTION_INFO_DEF + 1)
player->SetFullHealth();
return true;
}
};
The world SQL that goes with it (ids of your own, replaced wholesale on every run):
DELETE FROM `creature_template` WHERE `entry` = 9200001;
INSERT INTO `creature_template` (`entry`, `name`, `subname`, `minlevel`, `maxlevel`, `faction`, `npcflag`, `ScriptName`) VALUES
(9200001, 'Field Medic', 'Example', 80, 80, 35, 1, 'npc_example_healer');
DELETE FROM `npc_text` WHERE `ID` = 9200001;
INSERT INTO `npc_text` (`ID`, `text0_0`, `Probability0`) VALUES
(9200001, 'Need patching up?', 1);
A real creature also needs a model row (creature_template_model); copy the full set of rows from
2026_09_25_01_waystones.sql. For menus deeper than one level, encode the page in sender and action, as
lonelyice.waystones does (continent, zone, crystal).
Source: mod-lonelyice-waystones/src/waystones.cpp (npc_custom_waystone) and
data/sql/db-world/2026_09_25_01_waystones.sql; ScriptedGossip.h of the core.
Gossip menu on an item
An ItemScript is bound by item_template.ScriptName. OnUse runs when the item is used; show a menu with the
item's GUID as the source, and the player's choice comes back in OnGossipSelect.
#include "Item.h"
class item_example_book : public ItemScript
{
public:
item_example_book() : ItemScript("item_example_book") { }
bool OnUse(Player* player, Item* item, SpellCastTargets const& /*targets*/) override
{
ClearGossipMenuFor(player);
AddGossipItemFor(player, GOSSIP_ICON_CHAT, "Read the first page.", GOSSIP_SENDER_MAIN, GOSSIP_ACTION_INFO_DEF + 1);
SendGossipMenuFor(player, NPC_TEXT_EXAMPLE, item->GetGUID());
return true; // handled: the item's own spell is not cast
}
void OnGossipSelect(Player* player, Item* /*item*/, uint32 /*sender*/, uint32 action) override
{
CloseGossipMenuFor(player);
if (action == GOSSIP_ACTION_INFO_DEF + 1)
ChatHandler(player->GetSession()).SendSysMessage("It is written in an old tongue.");
}
};
- Returning
truefromOnUsestops the core from casting the item's spell. The core notes that a script which stops the cast must itself answer the client, or the item can stay greyed out (SpellHandler.cpp). - The client sends a use only for items that have a use spell.
Source: ItemScript.h, Handlers/SpellHandler.cpp and Handlers/MiscHandler.cpp (gossip choices on items) of the
core. No shipped plugin uses item gossip yet.
Creature scripts and spawns
Permanent spawns are rows in the world table creature, added by your world SQL; the creature's
ScriptName binds a CreatureScript. lonelyice.waystones spawns one crystal per inn this way, with its own GUID
range, and deletes that range before inserting it again.
AI. Return a ScriptedAI from GetAI to react to the world around the creature:
#include "ScriptedCreature.h"
class npc_example_watcher : public CreatureScript
{
public:
npc_example_watcher() : CreatureScript("npc_example_watcher") { }
struct npc_example_watcherAI : public ScriptedAI
{
npc_example_watcherAI(Creature* creature) : ScriptedAI(creature) { }
void MoveInLineOfSight(Unit* who) override
{
if (Player* player = who->ToPlayer())
if (me->IsWithinDistInMap(player, 15.0f))
{ /* the player came close */ }
}
void AttackStart(Unit* /*who*/) override { } // never fights
void UpdateAI(uint32 /*diff*/) override { }
};
CreatureAI* GetAI(Creature* creature) const override
{
return new npc_example_watcherAI(creature);
}
};
A creature script can have both GetAI and the gossip hooks, as npc_custom_waystone does.
Temporary spawns at run time:
#include "ObjectMgr.h"
#include "TemporarySummon.h"
if (sObjectMgr->GetCreatureTemplate(entry))
if (TempSummon* summon = player->SummonCreature(entry, player->GetPositionX(), player->GetPositionY(),
player->GetPositionZ(), player->GetOrientation(), TEMPSUMMON_TIMED_DESPAWN, 60 * IN_MILLISECONDS))
summon->SetReactState(REACT_PASSIVE);
TEMPSUMMON_MANUAL_DESPAWN keeps the creature until your code removes it.
Source: mod-lonelyice-waystones/src/waystones.cpp (npc_custom_waystoneAI), mod-lonelyice-tactics/src/TacticsSim.cpp
(SummonCreature in the simulator); Object.h of the core.
Spell scripts
A SpellScript changes what a spell does. It is bound to a spell id through the world table spell_script_names.
For a spell your plugin adds, that row belongs in the patch recipe's install list with {{id:name}}, so it gets
the id given out on installation (Client patches).
#include "SpellScript.h"
#include "SpellScriptLoader.h"
class spell_example_blink : public SpellScript
{
PrepareSpellScript(spell_example_blink);
void HandleTeleport(SpellEffIndex effIndex)
{
PreventHitDefaultEffect(effIndex); // replace the effect with your own
if (Player* player = GetHitUnit() ? GetHitUnit()->ToPlayer() : nullptr)
{
// ...
}
}
void Register() override
{
OnEffectHitTarget += SpellEffectFn(spell_example_blink::HandleTeleport, EFFECT_0, SPELL_EFFECT_TELEPORT_UNITS);
}
};
void AddExampleScripts()
{
RegisterSpellScript(spell_example_blink);
}
Source: mod-lonelyice-waystones/src/waystones.cpp (spell_custom_translocation) and its data/patches.json.
Translated texts
Declare the languages your player-facing texts are translated into with locales in plugin.json
(the manifest reference). Then pick one of three ways, by where the text lives.
Content rows (creatures, gossip texts, quests) use the core's locale tables next to your rows, so the client shows them in its language:
DELETE FROM `creature_template_locale` WHERE `entry` = 9200001;
INSERT INTO `creature_template_locale` (`entry`, `locale`, `Name`, `Title`) VALUES
(9200001, 'ruRU', 'Полевой лекарь', 'Пример');
DELETE FROM `npc_text_locale` WHERE `ID` = 9200001;
INSERT INTO `npc_text_locale` (`ID`, `Locale`, `Text0_0`) VALUES
(9200001, 'ruRU', 'Подлатать вас?');
Messages go into module_string (English) and module_string_locale (koKR, frFR, deDE, zhCN, zhTW,
esES, esMX, ruRU), keyed by a module name and a number. The core loads them at world start and picks the
session's language, falling back to English:
DELETE FROM `module_string` WHERE `module` = 'example.greeter';
INSERT INTO `module_string` (`module`, `id`, `string`) VALUES
('example.greeter', 1, 'Welcome back, {}.');
DELETE FROM `module_string_locale` WHERE `module` = 'example.greeter';
INSERT INTO `module_string_locale` (`module`, `id`, `locale`, `string`) VALUES
('example.greeter', 1, 'ruRU', 'С возвращением, {}.');
ChatHandler(player->GetSession()).PSendModuleSysMessage("example.greeter", 1, player->GetName());
A missing string is logged (Module string module ... id ... not found in DB.) and shows as error.
Texts in code can choose by the session's client locale, which is what lonelyice.waystones does for its menus:
char const* Text(Player const* player, char const* en, char const* ru)
{
return player->GetSession()->GetSessionDbcLocale() == LOCALE_ruRU ? ru : en;
}
Texts in patch recipes and in settings.json are localized objects instead; see
Client patches and Plugin settings.
Source: mod-lonelyice-waystones (Text() in waystones.cpp, *_locale rows in its world SQL); mod-aoe-loot
and mod-transmog, packaged by mod-aoe-loot-plugin and mod-transmog-plugin (module_string,
PSendModuleSysMessage); ObjectMgr::LoadModuleStrings in the core.
Per-player data
In memory, for the session. Every WorldObject has CustomData, a key-value map of your own types. The data
lives as long as the object, so it is gone after logout.
#include "DataMap.h"
struct ExampleState : public DataMap::Base
{
uint32 timer = 0;
bool active = false;
};
// in OnPlayerUpdate(Player* player, uint32 diff)
ExampleState* state = player->CustomData.GetDefault<ExampleState>("example_state"); // created on first use
state->timer += diff;
Use a key unique to your plugin.
In memory, keyed by character. A map by GUID, filled on login and cleared on logout. The core calls hooks from several threads (map updates, sessions, the world), so guard shared containers with a mutex:
#include <mutex>
#include <set>
#include <unordered_map>
namespace
{
std::mutex sLock;
std::unordered_map<ObjectGuid::LowType, std::set<uint32>> sKnown;
}
// OnPlayerLogin: query outside the lock, then store
std::set<uint32> loaded; // filled from CharacterDatabase.Query(...)
{
std::lock_guard<std::mutex> guard(sLock);
sKnown[player->GetGUID().GetCounter()] = std::move(loaded);
}
// OnPlayerLogout
{
std::lock_guard<std::mutex> guard(sLock);
sKnown.erase(player->GetGUID().GetCounter());
}
Persistent. A table in the characters database keyed by the character's GUID, created by your SQL. Write on change, read on login, and delete the rows when the character is deleted:
void OnPlayerDelete(ObjectGuid guid, uint32 /*accountId*/) override // PLAYERHOOK_ON_DELETE
{
CharacterDatabase.Execute("DELETE FROM `example_note` WHERE `guid` = {}", guid.GetCounter());
}
Source: mod-lonelyice-qol/src/sprint.cpp (BotSprintState in CustomData), mod-lonelyice-waystones/src/waystones.cpp
(sAttuned, sPlayerLock), mod-lonelyice-tactics/src/TacticsStore.cpp and TacticsDataScripts.cpp (cached
key-value store, cleanup on delete); src/common/Utilities/DataMap.h of the core.
Talking to a client addon
A plugin and its addon (shipped with client.addons) exchange addon messages. The server sends a whisper in
LANG_ADDON to the player from the player; the addon sends its requests the same way, and the plugin takes them
out of the chat before the core handles them.
#include "WorldPacket.h"
void SendAddon(Player* player, std::string const& body)
{
std::string const wire = std::string("EXMP\t") + body; // "<prefix>\t<body>"
WorldPacket data;
ChatHandler::BuildChatPacket(data, CHAT_MSG_WHISPER, LANG_ADDON, player, player, wire);
player->SendDirectMessage(&data);
}
class ExampleAddonScript : public PlayerScript
{
public:
ExampleAddonScript() : PlayerScript("ExampleAddonScript", { PLAYERHOOK_CAN_PLAYER_USE_PRIVATE_CHAT }) { }
bool OnPlayerCanUseChat(Player* player, uint32 /*type*/, uint32 lang, std::string& msg, Player* receiver) override
{
if (lang != LANG_ADDON || msg.compare(0, 5, "EXMP\t") != 0)
return true; // not ours: let the core handle it
if (receiver == player)
SendAddon(player, "PONG\t" + msg.substr(5));
return false; // swallowed
}
};
Source: mod-lonelyice-waystones/src/waystones.cpp (prefix KTWS), mod-lonelyice-tactics/src/TacticsDataScripts.cpp
(prefix BTAC).
Another plugin and your plugin's folder
sPluginMgr (PluginMgr.h) knows every plugin the server found:
#if __has_include("PluginMgr.h")
#include "PluginMgr.h"
#endif
// Files shipped in the plugin's lua/ folder; the old module path when built as a classic static module.
std::string ScriptDir()
{
#if __has_include("PluginMgr.h")
if (PluginInfo const* plugin = sPluginMgr->Find("example.greeter"))
return (plugin->dir / "lua").generic_string();
#endif
return "lua_scripts/example";
}
Find(id)returns a plugin whether or not it loaded (loaded,error);IsLoaded(id)tells whether it did. UseIsLoadedfor an optional integration with a plugin you do notdependson.PluginInfo::diris the plugin's folder. Onlydata,sql,conf,luaandclientare laid out there byAddPlugin, so ship run-time files in one of them.- Treat the folder as read-only. Installing or updating a package removes the old folder first. Write your own files
elsewhere, for example under the server's
LogsDir, as the tactics simulator does. - The
__has_includeguard keeps the code building as a classic module against a core without the plugin loader.
Source: mod-lonelyice-tactics/src/TacticsEngine.cpp (DefaultScriptDir), mod-npc-beastmaster-plugin/patches/
(the module's profanity.txt found through Find), mod-lonelyice-tactics/src/TacticsSim.cpp (OutputDir);
lonelyice/src/packages/PackageManager.cpp (install and update).
Playerbots
playerbots is a plugin. A plugin that uses its code declares and links it:
"depends": { "playerbots": ">=1.0.0" }
AddPlugin(example-botpal SOURCES ${SOURCES} plugin/plugin.cpp LINK mod-playerbots)
The core must be built with -DWITH_PLAYERBOTS_HOOKS=ON (the hooks playerbots needs in the core). LonelyIce's
build sets it; for a plain worldserver of the fork, pass it yourself.
Bot or player, and whose bot.
#include "Playerbots.h"
Player* RealMaster(Player* bot)
{
PlayerbotAI* ai = GET_PLAYERBOT_AI(bot); // nullptr for a real player
Player* master = ai ? ai->GetMaster() : nullptr;
if (!master || master == bot || !master->GetSession() || master->GetSession()->IsBot())
return nullptr;
return master;
}
New strategies, actions and triggers. PlayerbotExternalContexts (ExternalContexts.h) adds named objects to
every bot's contexts. Register in Add*Scripts(), before playerbots builds its shared contexts:
#include "ExternalContexts.h"
class ExampleStrategyContext : public NamedObjectContext<Strategy>
{
public:
ExampleStrategyContext() : NamedObjectContext<Strategy>(false, false)
{
creators["example"] = [](PlayerbotAI* botAI) -> Strategy* { return new ExampleStrategy(botAI); };
}
};
void AddExampleScripts()
{
PlayerbotExternalContexts::Register<Strategy>([]() -> NamedObjectContext<Strategy>* { return new ExampleStrategyContext(); });
// Register<Action> and Register<Trigger> work the same way
}
ExampleStrategy is your Strategy subclass. The strategy is then available by name like the built-in ones.
Pinning and decorating bots. PlayerbotExternalHooks::Register(isPinned, decorate) (ExternalHooks.h) lets a
plugin keep random bots in place (isPinned) and change their strategies whenever playerbots resets them
(decorate(bot, engine, botState)). Every registered pair is kept: a bot is pinned when any isPinned says so, and
every decorate runs, in registration order (lonelyice.citizens registers one too). Either function may be empty.
The context registries live once in the playerbots library (ExternalContexts.cpp, exported), so contexts your
plugin registers reach playerbots even though your plugin is a separate library. Link against playerbots as the
plugins above do. This holds from playerbots 1.0.4 on; before it each library had its own copy of the registries and
the contexts never reached the bots. A plugin that calls PlayerbotExternalContexts::Register therefore declares
"depends": { "playerbots": ">=1.0.4" }, as lonelyice.citizens does. That range also guarantees that every
plugin's PlayerbotExternalHooks::Register is kept; early builds kept only the last one.
Source: mod-lonelyice-qol/src/sprint.cpp and CMakeLists.txt, mod-lonelyice-tactics/src/TacticsEngineScripts.cpp,
mod-lonelyice-citizens/src/CityLife.cpp and CitizenStrategy.cpp; mod-playerbots/src/Bot/Engine/ExternalContexts.h,
ExternalHooks.h/.cpp; CMakeLists.txt of the core and of LonelyIce (WITH_PLAYERBOTS_HOOKS).