From 051ac6ebcdbc77f3bf69b0ba5f64da21cf679643 Mon Sep 17 00:00:00 2001 From: expanderbult Date: Fri, 2 Oct 2026 22:03:46 +0200 Subject: [PATCH 1/5] All my server changes: titles, /flag, staff tools, invis/ghost, broadcasts, events and fixes --- include/action/__action.cpp | 6 + include/action/buy.cpp | 3 +- .../action/dialog_return/__dialog_return.cpp | 29 + include/action/dialog_return/create_blast.cpp | 34 +- include/action/dialog_return/find_item.cpp | 23 +- include/action/dialog_return/lock_edit.cpp | 52 +- include/action/dialog_return/megaphone.cpp | 17 +- include/action/dialog_return/nuke_confirm.cpp | 60 ++ include/action/dialog_return/nuke_confirm.hpp | 2 + include/action/dialog_return/popup.cpp | 2 + include/action/dialog_return/socialportal.cpp | 25 +- include/action/dialog_return/trash_item.cpp | 59 +- include/action/drop.cpp | 2 +- include/action/enter_game.cpp | 19 +- include/action/info.cpp | 10 +- include/action/input.cpp | 52 +- include/action/join_request.cpp | 97 ++- include/action/join_request.hpp | 4 + include/action/protocol.cpp | 4 +- include/action/quit.cpp | 15 +- include/action/quit_to_exit.cpp | 96 ++- include/action/quit_to_exit.hpp | 9 + include/action/refresh_item_data.cpp | 3 +- include/action/respawn.cpp | 8 + include/action/store.cpp | 76 +- include/action/tankIDName.cpp | 40 +- include/action/wrench.cpp | 76 +- include/commands/__command.cpp | 149 +++- include/commands/__command.hpp | 2 +- include/commands/access.cpp | 92 ++ include/commands/access.hpp | 2 + include/commands/accounts.cpp | 315 +++++++ include/commands/accounts.hpp | 4 + include/commands/announce.cpp | 36 + include/commands/announce.hpp | 2 + include/commands/blasts.cpp | 793 ++++++++++++++++++ include/commands/blasts.hpp | 5 + include/commands/bot.cpp | 281 +++++++ include/commands/bot.hpp | 20 + include/commands/box.cpp | 86 ++ include/commands/box.hpp | 2 + include/commands/breakall.cpp | 21 + include/commands/breakall.hpp | 2 + include/commands/buffs.cpp | 144 ++++ include/commands/buffs.hpp | 12 + include/commands/buffs_items.hpp | 6 + include/commands/clear.cpp | 39 + include/commands/clear.hpp | 2 + include/commands/consumables.cpp | 211 +++++ include/commands/consumables.hpp | 3 + include/commands/cooking.cpp | 361 ++++++++ include/commands/cooking.hpp | 20 + include/commands/curse.cpp | 213 +++++ include/commands/curse.hpp | 6 + include/commands/deleteworld.cpp | 56 ++ include/commands/deleteworld.hpp | 2 + include/commands/dev.cpp | 34 + include/commands/dev.hpp | 2 + include/commands/devmenu.cpp | 232 +++++ include/commands/devmenu.hpp | 11 + include/commands/dropqueue.cpp | 105 +++ include/commands/dropqueue.hpp | 6 + include/commands/ducttape.cpp | 86 ++ include/commands/ducttape.hpp | 2 + include/commands/euphoria.cpp | 112 +++ include/commands/euphoria.hpp | 4 + include/commands/events.cpp | 347 ++++++++ include/commands/events.hpp | 7 + include/commands/extra_items.cpp | 256 ++++++ include/commands/extra_items.hpp | 3 + include/commands/fill.cpp | 56 ++ include/commands/fill.hpp | 2 + include/commands/find.cpp | 64 +- include/commands/find.hpp | 1 + include/commands/firework.cpp | 52 ++ include/commands/firework.hpp | 2 + include/commands/flag.cpp | 96 +++ include/commands/flag.hpp | 4 + include/commands/food.cpp | 75 ++ include/commands/food.hpp | 3 + include/commands/freeze.cpp | 74 ++ include/commands/freeze.hpp | 2 + include/commands/friendsys.cpp | 191 +++++ include/commands/friendsys.hpp | 8 + include/commands/gems.cpp | 38 + include/commands/gems.hpp | 2 + include/commands/ghost.cpp | 11 + include/commands/give.cpp | 71 ++ include/commands/give.hpp | 2 + include/commands/god.cpp | 25 + include/commands/god.hpp | 2 + include/commands/godmode.cpp | 99 +++ include/commands/godmode.hpp | 6 + include/commands/growscan.cpp | 74 ++ include/commands/growscan.hpp | 4 + include/commands/heal.cpp | 13 + include/commands/heal.hpp | 2 + include/commands/invis.cpp | 79 ++ include/commands/invis.hpp | 2 + include/commands/jammers.cpp | 139 +++ include/commands/jammers.hpp | 10 + include/commands/kick.cpp | 66 ++ include/commands/kick.hpp | 2 + include/commands/legendary.cpp | 793 ++++++++++++++++++ include/commands/legendary.hpp | 24 + include/commands/list.cpp | 41 + include/commands/list.hpp | 2 + include/commands/lockaccess.cpp | 129 +++ include/commands/lockaccess.hpp | 13 + include/commands/maintenance.cpp | 42 + include/commands/maintenance.hpp | 3 + include/commands/mod.cpp | 106 +++ include/commands/mod.hpp | 2 + include/commands/motd.cpp | 53 ++ include/commands/motd.hpp | 3 + include/commands/msg.cpp | 87 ++ include/commands/msg.hpp | 3 + include/commands/nick.cpp | 70 ++ include/commands/nick.hpp | 2 + include/commands/nuke.cpp | 41 + include/commands/nuke.hpp | 2 + include/commands/particle.cpp | 59 ++ include/commands/particle.hpp | 2 + include/commands/playeractions.cpp | 29 + include/commands/playeractions.hpp | 4 + include/commands/private.cpp | 63 ++ include/commands/private.hpp | 2 + include/commands/pull.cpp | 88 ++ include/commands/pull.hpp | 8 + include/commands/punch.cpp | 95 ++- include/commands/removeblock.cpp | 56 ++ include/commands/removeblock.hpp | 2 + include/commands/renameworld.cpp | 78 ++ include/commands/renameworld.hpp | 2 + include/commands/reset.cpp | 46 + include/commands/reset.hpp | 2 + include/commands/restart.cpp | 43 + include/commands/restart.hpp | 2 + include/commands/saveworld.cpp | 29 + include/commands/saveworld.hpp | 2 + include/commands/sb.cpp | 29 +- include/commands/scrolls.cpp | 100 +++ include/commands/scrolls.hpp | 3 + include/commands/search.cpp | 57 ++ include/commands/search.hpp | 2 + include/commands/setowner.cpp | 72 ++ include/commands/setowner.hpp | 2 + include/commands/setspawn.cpp | 32 + include/commands/setspawn.hpp | 2 + include/commands/showplayer.cpp | 75 ++ include/commands/showplayer.hpp | 2 + include/commands/shutdown.cpp | 89 ++ include/commands/shutdown.hpp | 4 + include/commands/skin.cpp | 6 + include/commands/spawnall.cpp | 41 + include/commands/spawnall.hpp | 2 + include/commands/sphere.cpp | 84 ++ include/commands/sphere.hpp | 2 + include/commands/staff.cpp | 139 +++ include/commands/staff.hpp | 5 + include/commands/stats.cpp | 44 + include/commands/stats.hpp | 2 + include/commands/summon.cpp | 97 +++ include/commands/summon.hpp | 2 + include/commands/swap.cpp | 66 ++ include/commands/swap.hpp | 2 + include/commands/tp.cpp | 67 ++ include/commands/tp.hpp | 2 + include/commands/trade.cpp | 501 +++++++++++ include/commands/trade.hpp | 17 + include/commands/unban.cpp | 54 ++ include/commands/unban.hpp | 3 + include/commands/undo.cpp | 70 ++ include/commands/undo.hpp | 2 + include/commands/undress.cpp | 19 + include/commands/undress.hpp | 2 + include/commands/unnuke.cpp | 38 + include/commands/unnuke.hpp | 2 + include/commands/uptime.cpp | 27 + include/commands/uptime.hpp | 2 + include/commands/useitems.cpp | 409 +++++++++ include/commands/useitems.hpp | 12 + include/commands/wands.cpp | 188 +++++ include/commands/wands.hpp | 4 + include/commands/warp.cpp | 6 + include/commands/warpto.cpp | 60 ++ include/commands/warpto.hpp | 2 + include/commands/wear.cpp | 48 ++ include/commands/wear.hpp | 2 + include/commands/whois.cpp | 89 ++ include/commands/whois.hpp | 2 + include/commands/worldreload.cpp | 14 + include/commands/worldreload.hpp | 4 + include/commands/worlds.cpp | 55 ++ include/commands/worlds.hpp | 2 + include/commands/worldtools.cpp | 574 +++++++++++++ include/commands/worldtools.hpp | 11 + include/commands/worldtools_use.hpp | 4 + include/commands/xp.cpp | 77 ++ include/commands/xp.hpp | 3 + include/database/database.cpp | 14 + include/database/items.cpp | 22 +- include/database/peer.cpp | 110 ++- include/database/peer.hpp | 25 +- include/database/world.cpp | 44 +- include/database/world.hpp | 8 +- include/eventType/receive.cpp | 3 +- include/gamePacket/_gamePacket.cpp | 9 +- include/gamePacket/item_activate_object.cpp | 37 +- include/gamePacket/movement.cpp | 3 +- include/gamePacket/tile_activate.cpp | 6 + include/gamePacket/tile_change.cpp | 87 +- include/https/https.cpp | 110 ++- include/https/server_data.cpp | 30 + include/https/server_data.hpp | 4 + include/onVariant/Action.cpp | 29 +- include/onVariant/CountryState.cpp | 25 +- include/onVariant/CountryState.hpp | 3 +- include/onVariant/RequestWorldSelectMenu.cpp | 3 +- include/onVariant/SetClothing.cpp | 85 +- include/onVariant/SetClothing.hpp | 2 + include/proton/Variant.cpp | 9 +- include/tools/bubble.hpp | 14 + include/tools/create_dialog.cpp | 7 +- include/tools/create_dialog.hpp | 1 + main.cpp | 25 +- 226 files changed, 11902 insertions(+), 368 deletions(-) create mode 100644 include/action/dialog_return/nuke_confirm.cpp create mode 100644 include/action/dialog_return/nuke_confirm.hpp create mode 100644 include/commands/access.cpp create mode 100644 include/commands/access.hpp create mode 100644 include/commands/accounts.cpp create mode 100644 include/commands/accounts.hpp create mode 100644 include/commands/announce.cpp create mode 100644 include/commands/announce.hpp create mode 100644 include/commands/blasts.cpp create mode 100644 include/commands/blasts.hpp create mode 100644 include/commands/bot.cpp create mode 100644 include/commands/bot.hpp create mode 100644 include/commands/box.cpp create mode 100644 include/commands/box.hpp create mode 100644 include/commands/breakall.cpp create mode 100644 include/commands/breakall.hpp create mode 100644 include/commands/buffs.cpp create mode 100644 include/commands/buffs.hpp create mode 100644 include/commands/buffs_items.hpp create mode 100644 include/commands/clear.cpp create mode 100644 include/commands/clear.hpp create mode 100644 include/commands/consumables.cpp create mode 100644 include/commands/consumables.hpp create mode 100644 include/commands/cooking.cpp create mode 100644 include/commands/cooking.hpp create mode 100644 include/commands/curse.cpp create mode 100644 include/commands/curse.hpp create mode 100644 include/commands/deleteworld.cpp create mode 100644 include/commands/deleteworld.hpp create mode 100644 include/commands/dev.cpp create mode 100644 include/commands/dev.hpp create mode 100644 include/commands/devmenu.cpp create mode 100644 include/commands/devmenu.hpp create mode 100644 include/commands/dropqueue.cpp create mode 100644 include/commands/dropqueue.hpp create mode 100644 include/commands/ducttape.cpp create mode 100644 include/commands/ducttape.hpp create mode 100644 include/commands/euphoria.cpp create mode 100644 include/commands/euphoria.hpp create mode 100644 include/commands/events.cpp create mode 100644 include/commands/events.hpp create mode 100644 include/commands/extra_items.cpp create mode 100644 include/commands/extra_items.hpp create mode 100644 include/commands/fill.cpp create mode 100644 include/commands/fill.hpp create mode 100644 include/commands/firework.cpp create mode 100644 include/commands/firework.hpp create mode 100644 include/commands/flag.cpp create mode 100644 include/commands/flag.hpp create mode 100644 include/commands/food.cpp create mode 100644 include/commands/food.hpp create mode 100644 include/commands/freeze.cpp create mode 100644 include/commands/freeze.hpp create mode 100644 include/commands/friendsys.cpp create mode 100644 include/commands/friendsys.hpp create mode 100644 include/commands/gems.cpp create mode 100644 include/commands/gems.hpp create mode 100644 include/commands/give.cpp create mode 100644 include/commands/give.hpp create mode 100644 include/commands/god.cpp create mode 100644 include/commands/god.hpp create mode 100644 include/commands/godmode.cpp create mode 100644 include/commands/godmode.hpp create mode 100644 include/commands/growscan.cpp create mode 100644 include/commands/growscan.hpp create mode 100644 include/commands/heal.cpp create mode 100644 include/commands/heal.hpp create mode 100644 include/commands/invis.cpp create mode 100644 include/commands/invis.hpp create mode 100644 include/commands/jammers.cpp create mode 100644 include/commands/jammers.hpp create mode 100644 include/commands/kick.cpp create mode 100644 include/commands/kick.hpp create mode 100644 include/commands/legendary.cpp create mode 100644 include/commands/legendary.hpp create mode 100644 include/commands/list.cpp create mode 100644 include/commands/list.hpp create mode 100644 include/commands/lockaccess.cpp create mode 100644 include/commands/lockaccess.hpp create mode 100644 include/commands/maintenance.cpp create mode 100644 include/commands/maintenance.hpp create mode 100644 include/commands/mod.cpp create mode 100644 include/commands/mod.hpp create mode 100644 include/commands/motd.cpp create mode 100644 include/commands/motd.hpp create mode 100644 include/commands/msg.cpp create mode 100644 include/commands/msg.hpp create mode 100644 include/commands/nick.cpp create mode 100644 include/commands/nick.hpp create mode 100644 include/commands/nuke.cpp create mode 100644 include/commands/nuke.hpp create mode 100644 include/commands/particle.cpp create mode 100644 include/commands/particle.hpp create mode 100644 include/commands/playeractions.cpp create mode 100644 include/commands/playeractions.hpp create mode 100644 include/commands/private.cpp create mode 100644 include/commands/private.hpp create mode 100644 include/commands/pull.cpp create mode 100644 include/commands/pull.hpp create mode 100644 include/commands/removeblock.cpp create mode 100644 include/commands/removeblock.hpp create mode 100644 include/commands/renameworld.cpp create mode 100644 include/commands/renameworld.hpp create mode 100644 include/commands/reset.cpp create mode 100644 include/commands/reset.hpp create mode 100644 include/commands/restart.cpp create mode 100644 include/commands/restart.hpp create mode 100644 include/commands/saveworld.cpp create mode 100644 include/commands/saveworld.hpp create mode 100644 include/commands/scrolls.cpp create mode 100644 include/commands/scrolls.hpp create mode 100644 include/commands/search.cpp create mode 100644 include/commands/search.hpp create mode 100644 include/commands/setowner.cpp create mode 100644 include/commands/setowner.hpp create mode 100644 include/commands/setspawn.cpp create mode 100644 include/commands/setspawn.hpp create mode 100644 include/commands/showplayer.cpp create mode 100644 include/commands/showplayer.hpp create mode 100644 include/commands/shutdown.cpp create mode 100644 include/commands/shutdown.hpp create mode 100644 include/commands/spawnall.cpp create mode 100644 include/commands/spawnall.hpp create mode 100644 include/commands/sphere.cpp create mode 100644 include/commands/sphere.hpp create mode 100644 include/commands/staff.cpp create mode 100644 include/commands/staff.hpp create mode 100644 include/commands/stats.cpp create mode 100644 include/commands/stats.hpp create mode 100644 include/commands/summon.cpp create mode 100644 include/commands/summon.hpp create mode 100644 include/commands/swap.cpp create mode 100644 include/commands/swap.hpp create mode 100644 include/commands/tp.cpp create mode 100644 include/commands/tp.hpp create mode 100644 include/commands/trade.cpp create mode 100644 include/commands/trade.hpp create mode 100644 include/commands/unban.cpp create mode 100644 include/commands/unban.hpp create mode 100644 include/commands/undo.cpp create mode 100644 include/commands/undo.hpp create mode 100644 include/commands/undress.cpp create mode 100644 include/commands/undress.hpp create mode 100644 include/commands/unnuke.cpp create mode 100644 include/commands/unnuke.hpp create mode 100644 include/commands/uptime.cpp create mode 100644 include/commands/uptime.hpp create mode 100644 include/commands/useitems.cpp create mode 100644 include/commands/useitems.hpp create mode 100644 include/commands/wands.cpp create mode 100644 include/commands/wands.hpp create mode 100644 include/commands/warpto.cpp create mode 100644 include/commands/warpto.hpp create mode 100644 include/commands/wear.cpp create mode 100644 include/commands/wear.hpp create mode 100644 include/commands/whois.cpp create mode 100644 include/commands/whois.hpp create mode 100644 include/commands/worldreload.cpp create mode 100644 include/commands/worldreload.hpp create mode 100644 include/commands/worlds.cpp create mode 100644 include/commands/worlds.hpp create mode 100644 include/commands/worldtools.cpp create mode 100644 include/commands/worldtools.hpp create mode 100644 include/commands/worldtools_use.hpp create mode 100644 include/commands/xp.cpp create mode 100644 include/commands/xp.hpp create mode 100644 include/tools/bubble.hpp diff --git a/include/action/__action.cpp b/include/action/__action.cpp index 45d76028..87b5f7f4 100644 --- a/include/action/__action.cpp +++ b/include/action/__action.cpp @@ -24,6 +24,7 @@ #include "buy.hpp" #include "quit.hpp" +#include "commands/trade.hpp" #include "__action.hpp" @@ -47,6 +48,11 @@ std::unordered_map ids{}; if (shouhin.btn == "basic_splice") // @note source: https://growtopia.fandom.com/wiki/Basic_Splicing_Kit { @@ -132,7 +133,7 @@ void action::buy(ENetEvent& event, const std::string& header, const std::string_ else modify_item_inventory(event, {item.first, item.second}); received.append(std::format("{}, ", raw_name)); // @todo add green text to rare items, or something cool. } - shouhin.items.clear(); // @todo + shouhin.items = base_items; // @note restore, so packs and backpack upgrades work every time send_varlist(event.peer, { "OnStorePurchaseResult", (_tab < 5) ? std::format( diff --git a/include/action/dialog_return/__dialog_return.cpp b/include/action/dialog_return/__dialog_return.cpp index 759aa374..e036377c 100644 --- a/include/action/dialog_return/__dialog_return.cpp +++ b/include/action/dialog_return/__dialog_return.cpp @@ -10,6 +10,17 @@ #include "create_blast.hpp" #include "socialportal.hpp" #include "megaphone.hpp" +#include "commands/worldtools.hpp" +#include "commands/devmenu.hpp" +#include "commands/growscan.hpp" +#include "commands/jammers.hpp" +#include "commands/accounts.hpp" +#include "commands/staff.hpp" +#include "nuke_confirm.hpp" +#include "commands/cooking.hpp" +#include "commands/trade.hpp" +#include "commands/friendsys.hpp" +#include "commands/legendary.hpp" #include "__dialog_return.hpp" @@ -31,4 +42,22 @@ std::unordered_map START - - switch (id) - { - case 1402: // @note Thermonuclear Blast - { - auto it = std::ranges::find(worlds, world_name, &::world::name); - if (it == worlds.end()) - { - ::world world{world_name}; // @note we don't need emplace into worlds since no one is in the world yet! - - action::quit_to_exit(event, "", true); - blast::thermonuclear(world); - } // @note save the world and continue in action::join_request. seems tedious so i might change later. - else - { - /* @todo prompt saying world already exists. */ - return; - } - break; - } - default: return; - } - action::join_request(event, "", world_name); -} \ No newline at end of file + blast_create(event, atoi(hPipe["id"].c_str()), hPipe["name"]); +} diff --git a/include/action/dialog_return/find_item.cpp b/include/action/dialog_return/find_item.cpp index 290c6df1..4a1bff15 100644 --- a/include/action/dialog_return/find_item.cpp +++ b/include/action/dialog_return/find_item.cpp @@ -1,13 +1,22 @@ #include "pch.hpp" +#include "commands/devmenu.hpp" -#include "onVariant/SetClothing.hpp" - +#include "commands/find.hpp" +#include "commands/legendary.hpp" #include "find_item.hpp" void find_item(ENetEvent& event, const ::hPipe &hPipe) { - std::string id = readch(hPipe["buttonClicked"], '_')[1]; // e.g. searchableItemListButton_2_0_-1 - if (id.empty()) return; - - modify_item_inventory(event, ::slot(atoi(id.c_str()), 200)); -} \ No newline at end of file + const std::string btn = hPipe["buttonClicked"]; + const bool staff = static_cast<::peer*>(event.peer->data)->role >= MODERATOR; + if (!btn.starts_with("searchableItemListButton_")) + { + if (!staff) find_players_list(event, hPipe["n"]); // @note the Search button (or Enter) + return; + } + const std::vector parts = readch(btn, '_'); // e.g. searchableItemListButton_2_0_-1 + if (parts.size() < 2 || parts[1].empty()) return; + const int id = atoi(parts[1].c_str()); + if (!staff && staff_only_item(id)) return; + find_item_popup(event, id); +} diff --git a/include/action/dialog_return/lock_edit.cpp b/include/action/dialog_return/lock_edit.cpp index 7e3491e7..5d59e6ca 100644 --- a/include/action/dialog_return/lock_edit.cpp +++ b/include/action/dialog_return/lock_edit.cpp @@ -1,6 +1,7 @@ #include "pch.hpp" #include "onVariant/ConsoleMessage.hpp" - +#include "database/world.hpp" +#include "commands/lockaccess.hpp" #include "lock_edit.hpp" void lock_edit(ENetEvent& event, const ::hPipe &hPipe) @@ -13,22 +14,53 @@ void lock_edit(ENetEvent& event, const ::hPipe &hPipe) ::pos pos{}; pos.x = atoi(hPipe["tilex"].c_str()); pos.y = atoi(hPipe["tiley"].c_str()); - - world->is_public = atoi(hPipe["checkbox_public"].c_str()); + + ::block &block = world->blocks[cord(pos.x, pos.y)]; + + /* "Get World Key" button: hand over the tradeable key item instead of + * toggling anything. only the owner (or staff) may take it. */ + if (hPipe["buttonClicked"] == "getKey") + { + if (world->owner != pPeer->user_id && pPeer->role < MODERATOR) + { + on::ConsoleMessage(event.peer, "`4Only the world owner can take the World Key.``"); + return; + } + + modify_item_inventory(event, ::slot(242 /* World Key */, 1)); + on::ConsoleMessage(event.peer, + std::format("`2You received a `$World Key`` for `w{}``.``", world->name)); + return; + } + + lock_access_apply(event, *world, hPipe); // @note access list: remove unchecked, add the picked player + if (atoi(hPipe["checkbox_disable_music"].c_str()) != 0) world->lock_state |= DISABLE_MUSIC; else world->lock_state &= ~DISABLE_MUSIC; - world->minimum_entry_level = atoi(hPipe["minimum_entry_level"].c_str()); - ::block &block = world->blocks[cord(pos.x, pos.y)]; + world->minimum_entry_level = atoi(hPipe["minimum_entry_level"].c_str()); - if (world->is_public) - block.state[2] |= S_PUBLIC; - else block.state[2] &= ~S_PUBLIC; - on::ConsoleMessage(event.peer, std::format("`2{}`` has set the `$World Lock`` to {}``", pPeer->growid, (block.state[2] & S_PUBLIC) ? "`$PUBLIC" : "`4PRIVATE")); + /* public/private is staff-only now - it lives behind /private instead */ + const bool wants_public = atoi(hPipe["checkbox_public"].c_str()) != 0; + if (wants_public != static_cast(world->is_public)) + { + if (pPeer->role < MODERATOR) + { + on::ConsoleMessage(event.peer, + "`4Use `$/private`` to change who can enter this world.``"); + } + else + { + world->is_public = wants_public; + if (world->is_public) + block.state[2] |= S_PUBLIC; + else block.state[2] &= ~S_PUBLIC; + } + } send_tile_update(event, { .id = block.fg, .punch = pos }, block, *world); -} \ No newline at end of file +} diff --git a/include/action/dialog_return/megaphone.cpp b/include/action/dialog_return/megaphone.cpp index 316461e5..5f9412de 100644 --- a/include/action/dialog_return/megaphone.cpp +++ b/include/action/dialog_return/megaphone.cpp @@ -1,10 +1,19 @@ #include "pch.hpp" - +#include "tools/bubble.hpp" +#include "onVariant/ConsoleMessage.hpp" #include "commands/sb.hpp" - #include "megaphone.hpp" void megaphone(ENetEvent& event, const ::hPipe &hPipe) { - sb(event, hPipe["message"]); // @todo handle this when /sb requires gems @todo handle trim -} \ No newline at end of file + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (pPeer->curse_until > std::time(nullptr)) + { + tell(event.peer, "`4You can't do that while cursed.``"); + return; + } + + std::string message = hPipe["message"]; + if (message.empty()) return; + sb(event, "sb " + message); // @note /sb skips the first 3 chars ("sb ") +} diff --git a/include/action/dialog_return/nuke_confirm.cpp b/include/action/dialog_return/nuke_confirm.cpp new file mode 100644 index 00000000..6b245033 --- /dev/null +++ b/include/action/dialog_return/nuke_confirm.cpp @@ -0,0 +1,60 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "action/join_request.hpp" +#include "action/quit_to_exit.hpp" +#include "database/world.hpp" +#include "nuke_confirm.hpp" + +void nuke_confirm(ENetEvent& event, const ::hPipe &hPipe) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) return; + if (pPeer->netid == 0) return; + + auto world = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + if (world == worlds.end()) return; + + const std::string world_name = world->name; + + blast::thermonuclear(*world); + world->signs.clear(); + world->trees.clear(); + world->nuked = true; + + // gather everyone here before we start moving people around + std::vector staying; + std::vector leaving; + + peers(world_name, PEER_SAME_WORLD, [&](ENetPeer &p) + { + ::peer *pOthers = static_cast<::peer*>(p.data); + if (!pOthers) return; + + on::ConsoleMessage(&p, std::format("`5[```4{}`` `5has been nuked from existence!```5]``", world_name)); + + if (pOthers->user_id == pPeer->user_id) return; // handled last + if (pOthers->role >= MODERATOR) staying.push_back(&p); + else leaving.push_back(&p); + }); + + // non-staff are sent to the world menu + for (ENetPeer *p : leaving) + { + ENetEvent ev{}; + ev.peer = p; + action::quit_to_exit(ev, "", false); + } + + // staff reload the world so they see the cleared map + for (ENetPeer *p : staying) + { + ENetEvent ev{}; + ev.peer = p; + action::quit_to_exit(ev, "", true); + action::join_request(ev, "", world_name); + } + + action::quit_to_exit(event, "", true); + action::join_request(event, "", world_name); +} diff --git a/include/action/dialog_return/nuke_confirm.hpp b/include/action/dialog_return/nuke_confirm.hpp new file mode 100644 index 00000000..1510426c --- /dev/null +++ b/include/action/dialog_return/nuke_confirm.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void nuke_confirm(ENetEvent& event, const ::hPipe &hPipe); diff --git a/include/action/dialog_return/popup.cpp b/include/action/dialog_return/popup.cpp index 4658af8a..1bd54751 100644 --- a/include/action/dialog_return/popup.cpp +++ b/include/action/dialog_return/popup.cpp @@ -1,10 +1,12 @@ #include "pch.hpp" +#include "commands/playeractions.hpp" #include "popup.hpp" void popup(ENetEvent& event, const ::hPipe &hPipe) { ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (wrench_action(event, hPipe)) return; // @note trade, friend, pull and the staff buttons of the wrench menu if (hPipe["buttonClicked"] == "my_worlds") { diff --git a/include/action/dialog_return/socialportal.cpp b/include/action/dialog_return/socialportal.cpp index dfce6bfa..34e9ff82 100644 --- a/include/action/dialog_return/socialportal.cpp +++ b/include/action/dialog_return/socialportal.cpp @@ -1,27 +1,8 @@ #include "pch.hpp" +#include "commands/friendsys.hpp" #include "socialportal.hpp" void socialportal(ENetEvent& event, const ::hPipe &hPipe) { - ::peer *pPeer = static_cast<::peer*>(event.peer->data); - - if (hPipe["buttonClicked"] == "showfriend") - { - send_varlist(event.peer, { - "OnDialogRequest", - "set_default_color|`o\n" - "add_label_with_icon|big|0 of 0 `wFriends Online``|left|1366|\n" - "add_spacer|small|\n" - "add_textbox|`oNone of your friends are currently online.``|left|\n" - "add_spacer|small|\n" - "add_spacer|small|\n" - "add_button|friend_all|Show offline and ignored too|noflags|0|0|\n" - "add_button|all_friends|Edit Friends|noflags|0|0|\n" - "add_button|friends_options|Friend Options|noflags|0|0|\n" - "add_button|back|Back|noflags|0|0|\n" - "add_button||Close|noflags|0|0|\n" - "end_dialog|friends|||\n" - "add_quick_exit|\n" - }); - } -} \ No newline at end of file + if (hPipe["buttonClicked"] == "showfriend") friends_show(event, false); +} diff --git a/include/action/dialog_return/trash_item.cpp b/include/action/dialog_return/trash_item.cpp index 8663ed1c..e18c9a56 100644 --- a/include/action/dialog_return/trash_item.cpp +++ b/include/action/dialog_return/trash_item.cpp @@ -1,22 +1,63 @@ #include "pch.hpp" +#include #include "onVariant/ConsoleMessage.hpp" +#include "onVariant/SetBux.hpp" +#include "database/shouhin.hpp" +#include "tools/random.hpp" #include "trash_item.hpp" +/* gems for recycling: items sold in the store give 10% of the store price (World Lock 2000 -> 200), + * everything else gives random gems by rarity, like breaking a block */ +static int recycle_gems(short id, int count) +{ + static std::unordered_map store_value{}; + if (store_value.empty()) + { + for (const auto &[tab, s] : shouhin_tachi) + if (s.items.size() == 1 && s.cost > 0 && s.items.front().second > 0) + store_value.try_emplace(s.items.front().first, s.cost * 0.10 / s.items.front().second); + store_value.try_emplace(0, 0.0); // @note so an empty store isn't rebuilt every time + } + if (const auto v = store_value.find(id); v != store_value.end()) return static_cast(std::lround(v->second * count)); + + const ::item &item = id_to_item(static_cast(id)); + const int per_item = + (item.rarity >= 87) ? 22 : + (item.rarity >= 68) ? 18 : + (item.rarity >= 53) ? 14 : + (item.rarity >= 41) ? 11 : + (item.rarity >= 36) ? 10 : + (item.rarity >= 32) ? 9 : + (item.rarity >= 24) ? 5 : 1; + int gems = 0; + for (int i = 0; i < count; ++i) gems += RandomRange(0, per_item); + return gems; +} + void trash_item(ENetEvent& event, const ::hPipe &hPipe) { ::peer *pPeer = static_cast<::peer*>(event.peer->data); - const short itemID = atoi(hPipe["itemID"].c_str()); - short count = atoi(hPipe["count"].c_str()); + const short itemID = static_cast(atoi(hPipe["itemID"].c_str())); + int count = atoi(hPipe["count"].c_str()); + if (itemID <= 0 || itemID >= static_cast(items.size()) || count <= 0) return; const ::item &item = id_to_item(itemID); + if (item.type == type::FIST || item.type == type::WRENCH) return; + + int have = 0; + for (const ::slot &slot : pPeer->slots) if (slot.id == itemID) have = slot.count; + if (have <= 0) return; + count = std::min(count, have); - for (const ::slot &slot : pPeer->slots) - if (slot.id == itemID) - if (count > slot.count) count = slot.count; - else if (count < 0) count = 0; + const int gems = recycle_gems(itemID, count); + modify_item_inventory(event, ::slot(itemID, static_cast(-count))); - modify_item_inventory(event, ::slot(itemID, -count)); - on::ConsoleMessage(event.peer, std::format("{} `w{}`` recycled, `w0`` gems earned.", count, item.raw_name)); -} \ No newline at end of file + if (gems > 0) + { + pPeer->gems += gems; + on::SetBux(event); + } + on::ConsoleMessage(event.peer, std::format("{} `w{}`` recycled, `w{}`` gems earned.", count, item.raw_name, gems)); +} diff --git a/include/action/drop.cpp b/include/action/drop.cpp index 8562ec0f..8ffa504f 100644 --- a/include/action/drop.cpp +++ b/include/action/drop.cpp @@ -8,7 +8,7 @@ void action::drop(ENetEvent& event, const std::string& header) const ::item &item = id_to_item(atoi(itemID.c_str())); - if (item.cat == CAT_UNTRADEABLE) + if ((item.cat & CAT_UNTRADEABLE) && static_cast<::peer*>(event.peer->data)->role < DEVELOPER) { send_varlist(event.peer, { "OnTextOverlay", "You can't drop that." }); return; diff --git a/include/action/enter_game.cpp b/include/action/enter_game.cpp index 15c4c32f..53fc0d8e 100644 --- a/include/action/enter_game.cpp +++ b/include/action/enter_game.cpp @@ -6,18 +6,22 @@ #include "tools/create_dialog.hpp" #include "automate/holiday.hpp" +#include "action/join_request.hpp" +#include "action/quit_to_exit.hpp" +#include "commands/friendsys.hpp" +#include "commands/legendary.hpp" #include "enter_game.hpp" void action::enter_game(ENetEvent& event, const std::string& header) { ::peer *pPeer = static_cast<::peer*>(event.peer->data); - pPeer->display_growid = std::format("`w{}``", pPeer->growid); + if (pPeer->nickname.empty()) pPeer->display_growid = (pPeer->role >= DEVELOPER) ? std::format("`6@{}``", pPeer->growid) : (pPeer->role >= MODERATOR) ? std::format("`5@{}``", pPeer->growid) : std::format("`w{}``", pPeer->growid); on::ConsoleMessage(event.peer, - std::format("Welcome back, {}. No friends are online.", - pPeer->display_growid - ) + std::format("Welcome back, {}. {}", pPeer->display_growid, friends_welcome(*pPeer)) ); + friends_alert(*pPeer, true); // @note tells your online friends + legend_load(*pPeer); on::ConsoleMessage(event.peer, holiday_greeting().second); on::ConsoleMessage(event.peer, "`5Personal Settings active:`` `#Can customize profile``"); @@ -45,4 +49,11 @@ void action::enter_game(ENetEvent& event, const std::string& header) "OnSetFeatureEnableFlags", "EA8DEAcGAgEOBQgKCQ0MEQQ=" // @todo Dw0JEQQMEAMPAgYBDgUICg== }); + + /* back into the same world after /god reloaded items.dat - done 2 seconds later by the timer */ + if (!pPeer->god_rejoin.empty()) + { + if (pPeer->netid != 0) action::quit_to_exit(event, "", true); // @note the game left the world when it reloaded + pPeer->god_rejoin_at = std::time(nullptr) + 2; + } } diff --git a/include/action/info.cpp b/include/action/info.cpp index 588b7d00..735bfaeb 100644 --- a/include/action/info.cpp +++ b/include/action/info.cpp @@ -45,9 +45,15 @@ void action::info(ENetEvent& event, const std::string& header) .add_textbox(std::format("Rarity: `w{}``", item.rarity)) .add_spacer("small"); - for (const std::string &prop : properties(item.property)) - create_dialog.add_textbox(prop); + for (const std::string &prop : properties(item.property)) + create_dialog.add_textbox(prop); + if (::peer *pDev = static_cast<::peer*>(event.peer->data); pDev && pDev->role >= DEVELOPER) + { + create_dialog.add_spacer("small"); + create_dialog.add_textbox(std::format("`6[dev]`` ID: `w{}`` type: `w{}`` prop: `w{}``", (int)item.id, (int)item.type, (int)item.property)); + create_dialog.add_textbox(std::format("`6[dev]`` hits: `w{}`` coll: `w{}`` cloth: `w{}``", (int)item.hits, (int)item.collision, (int)item.cloth_type)); + } send_varlist(event.peer, { "OnDialogRequest", create_dialog diff --git a/include/action/input.cpp b/include/action/input.cpp index 34b5c0e9..d07c5ae8 100644 --- a/include/action/input.cpp +++ b/include/action/input.cpp @@ -1,7 +1,9 @@ #include "pch.hpp" +#include "tools/bubble.hpp" #include "commands/__command.hpp" #include "onVariant/ConsoleMessage.hpp" #include "tools/time.hpp" +#include "commands/devmenu.hpp" #include "input.hpp" void action::input(ENetEvent& event, const std::string& header) @@ -19,7 +21,7 @@ void action::input(ENetEvent& event, const std::string& header) u_int now = ticks(); pPeer->messages.push_back(now); if (pPeer->messages.size() > 5) pPeer->messages.pop_front(); - if (pPeer->messages.size() == 5 && now - pPeer->messages.front() < 6) + if (pPeer->role < MODERATOR && pPeer->messages.size() == 5 && now - pPeer->messages.front() < 6) { on::ConsoleMessage(event.peer, "`6>>`4Spam detected! ``Please wait a bit before typing anything else. " @@ -29,6 +31,22 @@ void action::input(ENetEvent& event, const std::string& header) { send_action(*event.peer, "log", std::format("msg| `6{}``", text)); std::string command = text.substr(1, text.find(' ') - 1); + if (pPeer->role >= DEVELOPER && text.find(' ') == std::string::npos && devmenu_form(event, command, true)) return; // @note form popup + + static const std::unordered_map min_role{ + {"ghost", MODERATOR}, {"weather", DEVELOPER}, {"punch", DEVELOPER}, {"heal", DEVELOPER}, {"undress", DEVELOPER} + }; + if (auto r = min_role.find(command); r != min_role.end() && pPeer->role < r->second) + { + send_action(*event.peer, "log", "msg|`4Unknown command.`` Enter `$/?`` for a list of valid commands."); + return; + } + + if (pPeer->curse_until > std::time(nullptr) && (command == "sb" || command == "msg" || command == "reply")) + { + tell(event.peer, "`4You can't do that while cursed.``"); + return; + } if (auto it = cmd_pool.find(command); it != cmd_pool.end()) { @@ -36,7 +54,14 @@ void action::input(ENetEvent& event, const std::string& header) { send_action(*event.peer, "log", "msg|`4Unknown command.`` Enter `$/?`` for a list of valid commands."); } - else it->second(std::ref(event), std::move(text.substr(1)/* remove the '/' */)); + else { + try { it->second(std::ref(event), std::move(text.substr(1))); } + catch (const std::exception &e) + { + send_action(*event.peer, "log", std::format("msg|`4Command failed:`` {}", e.what())); + printf("[cmd] /%s failed: %s\n", command.c_str(), e.what()); + } + } } else { @@ -45,7 +70,7 @@ void action::input(ENetEvent& event, const std::string& header) } else { - if (pPeer->state & S_DUCT_TAPE) + if ((pPeer->state & S_DUCT_TAPE) || pPeer->curse_until > std::time(nullptr)) { static constexpr std::array muffled{ "mfmm", "mmfmfm", "mffm", "mfmfmm" }; @@ -61,19 +86,28 @@ void action::input(ENetEvent& event, const std::string& header) continue; } - while (i < text.size() && !std::isspace(text[i])) - ++i; + const std::size_t start = i; + while (i < text.size() && !std::isspace(text[i])) ++i; + + const char last = text[i - 1]; + const bool punct = (last == '?' || last == '!' || last == '.' || last == ','); + const std::size_t n = std::clamp(i - start - (punct ? 1 : 0), 2, 8); - muffled_text += muffled[word_index % muffled.size()]; + std::string word{ "m" }; + for (std::size_t k = 1; k < n; ++k) word += (std::rand() % 3 == 0) ? 'f' : 'm'; + muffled_text += word; + if (punct) muffled_text += last; ++word_index; } text = std::move(muffled_text); } - const std::string &player_chat = std::format("CP:0_PL:0_OID:_player_chat={}", text); - const std::string &message = std::format("CP:0_PL:0_OID:_CT:[W]_ `6<{}>`` `$`${}````", pPeer->display_growid, text); + + const char *chat_color = (pPeer->role >= DEVELOPER) ? "`5" : (pPeer->role >= MODERATOR) ? "`^" : "`w"; + const std::string &player_chat = std::format("CP:0_PL:0_OID:_player_chat={}{}``", chat_color, text); + const std::string &message = std::format("CP:0_PL:0_OID:_CT:[W]_ `6<{}>`` {}{}``", pPeer->display_growid, chat_color, text); peers(pPeer->recent_worlds.back(), PEER_SAME_WORLD, [&event, &pPeer, player_chat, message](ENetPeer& p) { - send_varlist(&p, { "OnTalkBubble", pPeer->netid, player_chat }); + send_varlist(&p, { "OnTalkBubble", pPeer->netid, player_chat, 0u }); on::ConsoleMessage(&p, message); }); } diff --git a/include/action/join_request.cpp b/include/action/join_request.cpp index 0ffd5492..22ef4671 100644 --- a/include/action/join_request.cpp +++ b/include/action/join_request.cpp @@ -5,10 +5,19 @@ #include "onVariant/SetClothing.hpp" #include "onVariant/CountryState.hpp" #include "onVariant/ConsoleMessage.hpp" +#include "commands/jammers.hpp" #include "commands/weather.hpp" #include "tools/time.hpp" +#include "commands/bot.hpp" +#include "action/quit_to_exit.hpp" +#include "commands/curse.hpp" +#include "commands/lockaccess.hpp" #include "join_request.hpp" +#include "commands/legendary.hpp" + +static std::unordered_map arrivals{}; // @note where an account should appear instead of the door (summon, pull) +void arrive_at(int user_id, ::pos at) { arrivals[user_id] = at; } void action::join_request(ENetEvent& event, const std::string& header, const std::string_view world_name = "") { @@ -24,12 +33,25 @@ void action::join_request(ENetEvent& event, const std::string& header, const std if (!alnum(name)) throw std::runtime_error("Sorry, spaces and special characters are not allowed in world or door names. Try again."); for (char &c : name) c = std::toupper(c); // @note start -> START + + if (pPeer->curse_until > std::time(nullptr) && name != "HELL") + { + on::ConsoleMessage(event.peer, std::format("`4You are cursed!`` You can't leave `wHELL`` for another `w{}``.", curse_time_left(*pPeer))); + name = "HELL"; + } + if (name == "HELL" && pPeer->curse_until <= std::time(nullptr) && pPeer->role < MODERATOR) + throw std::runtime_error("`4Only the cursed may enter HELL.``"); + + if (pPeer->netid != 0) action::quit_to_exit(event, "", true); // @note still in a world: leave it properly first auto it = std::ranges::find(worlds, name, &::world::name); if (it == worlds.end()) it = worlds.emplace(worlds.end(), name); ::world &world = *it; + + if (world.nuked && pPeer->role < MODERATOR && world.name != "HELL") + throw std::runtime_error("`4That world has been nuked and is closed.``"); { ::blob blob = compress_state(::gamePacket{ .type = 0x04, /*PACKET_SEND_MAP_DATA*/ .state = state::S_EXTENDED }); blob.push_back(world.serialize()); @@ -47,28 +69,34 @@ void action::join_request(ENetEvent& event, const std::string& header, const std } // @note delete name, first on::EmoticonDataChanged(event); - if (pPeer->user_id == world.owner) - pPeer->display_growid = std::format("`2{}``", pPeer->growid); - else if (std::ranges::find(world.access, pPeer->user_id) != world.access.end()) - pPeer->display_growid = std::format("`c{}``", pPeer->growid); + pPeer->display_growid = display_name_for(*pPeer, world); // @note /nick included // @note owner green, access light green, staff pink/gold pPeer->rest_pos = world.spawn; + if (auto at = arrivals.find(pPeer->user_id); at != arrivals.end()) { pPeer->rest_pos = at->second; arrivals.erase(at); } + pPeer->pos = pPeer->rest_pos; + leave_flush(world.name, pPeer->user_id); // @note if this account left this world a moment ago, finish that first + if (world.visitors == 0) world.netid_counter = 0; // @note nobody here, safe to start fresh pPeer->netid = ++world.netid_counter; peers(pPeer->recent_worlds.back(), PEER_SAME_WORLD, [&event, &pPeer, &world](ENetPeer& peer/*send to everyone in world*/) { ::peer *pOthers = static_cast<::peer*>(peer.data); // @note everyone in world's peer.data - if (pOthers->user_id != pPeer->user_id) + const bool other_hidden = (pOthers->state & S_INVISIBLE) == S_INVISIBLE, me_hidden = (pPeer->state & S_INVISIBLE) == S_INVISIBLE; // @note /invis players stay hidden + if (pOthers->user_id != pPeer->user_id) // @note hidden players still see everyone; nobody sees them { - on::Spawn(*event.peer, pOthers->netid, pOthers->user_id, pOthers->pos, pOthers->display_growid, pOthers->country, pOthers->role, pOthers->role >= DEVELOPER, false); - on::Spawn(peer, pPeer->netid, pPeer->user_id, pPeer->rest_pos, pPeer->display_growid, pPeer->country, pPeer->role, pPeer->role >= DEVELOPER, false); - on::SetClothing(peer); - on::ConsoleMessage(&peer, std::format("`5<{} entered, `w{}`` others here>``", pPeer->display_growid, world.visitors)); + if (!other_hidden) on::Spawn(*event.peer, pOthers->netid, pOthers->user_id, pOthers->pos, pOthers->display_growid, country_state_for(*pOthers), pOthers->role, pOthers->role >= DEVELOPER, false); + if (me_hidden) return; // @note /invis: nobody gets your spawn, name or entered message + on::Spawn(peer, pPeer->netid, pPeer->user_id, pPeer->rest_pos, pPeer->display_growid, country_state_for(*pPeer), pPeer->role, pPeer->role >= DEVELOPER, false); + // clothing is sent after all spawns are processed + on::SetClothing(peer, *pPeer); + send_varlist(&peer, { "OnNameChanged", pPeer->display_growid }, pPeer->netid); // @note redraw your title look for them + on::CountryStateOf(peer, *pPeer); // @note title flags for the others + if (!g_silent_move) on::ConsoleMessage(&peer, std::format("`5<{} entered, `w{}`` others here>``", pPeer->display_growid, world.visitors)); } - if (pOthers->user_id != pPeer->user_id) // @note the reason this is here is cause we need the peer's OnSpawn to happen before OnTalkBubble + if (pOthers->user_id != pPeer->user_id && !other_hidden && !me_hidden && !g_silent_move) // @note the reason this is here is cause we need the peer's OnSpawn to happen before OnTalkBubble { send_varlist(&peer, { "OnTalkBubble", @@ -78,7 +106,19 @@ void action::join_request(ENetEvent& event, const std::string& header, const std }); } }); - on::Spawn(*event.peer, pPeer->netid, pPeer->user_id, pPeer->rest_pos, pPeer->display_growid, pPeer->country, pPeer->role, pPeer->role >= DEVELOPER, true); + // second pass: now that every spawn is out, send each player's look to the newcomer + peers(pPeer->recent_worlds.back(), PEER_SAME_WORLD, [&event, &pPeer](ENetPeer& peer) + { + ::peer *pOthers = static_cast<::peer*>(peer.data); + if (pOthers->user_id == pPeer->user_id || (pOthers->state & S_INVISIBLE) == S_INVISIBLE) return; + on::SetClothing(*event.peer, *pOthers); + send_varlist(event.peer, { "OnNameChanged", pOthers->display_growid }, pOthers->netid); // @note redraw their title look + on::CountryStateOf(*event.peer, *pOthers); // @note their Dr./max level flags + send_varlist(event.peer, { "OnSetPos", CL_Vec2f{ pOthers->pos.x, pOthers->pos.y } }, pOthers->netid); + }); + spawn_bots_for(event, world.name); + on::Spawn(*event.peer, pPeer->netid, pPeer->user_id, pPeer->rest_pos, pPeer->display_growid, country_state_for(*pPeer), pPeer->role, pPeer->role >= DEVELOPER, true); + on::CountryState(event); // @note your own title flags (the Legendary look needs this) if (pPeer->billboard.id != 0) on::BillboardChange(event); // @note don't waste memory if billboard is empty. @@ -87,19 +127,50 @@ void action::join_request(ENetEvent& event, const std::string& header, const std CL_Vec2f{pPeer->rest_pos.x, pPeer->rest_pos.y} }, pPeer->netid); - on::ConsoleMessage(event.peer, + send_varlist(event.peer, { "OnNameChanged", pPeer->display_growid }, pPeer->netid); // @note redraw your own title look + on::CountryState(event); + if (!g_silent_move) on::ConsoleMessage(event.peer, std::format( "World `w{}`` entered. There are `w{}`` other people here, `w{}`` online.", world.name, world.visitors, peers().size() ) ); + if (world.owner != 0) + { + std::string owner_name{}; + signed owner_role = 0; + + // prefer the online peer list - no database round trip at all + peers("", peer_condition::PEER_ALL, [&](ENetPeer &p) + { + ::peer *o = static_cast<::peer*>(p.data); + if (o && o->user_id == world.owner) + { + owner_name = o->growid; + owner_role = o->role; + } + }); + + if (owner_name.empty()) owner_name = std::format("user #{}", world.owner); + + const std::string owner_display = + (owner_role >= 2) ? std::format("`6@{}``", owner_name) : + (owner_role == 1) ? std::format("`5@{}``", owner_name) : + std::format("`w{}``", owner_name); + + on::ConsoleMessage(event.peer, (std::format("`5[`w{}`` is owned by {}`5]``", world.name, owner_display) + jam_suffix(world.name))); + } + ++world.visitors; on::SetClothing(*event.peer); on::CountryState(event); + send_varlist(event.peer, { "OnSetCurrentWeather", world.base_weather }); } catch (const std::exception& exc) { + if (event.peer->data) std::printf("[join] %s could not enter a world: %s\n", static_cast<::peer*>(event.peer->data)->growid.c_str(), exc.what()[0] ? exc.what() : "(no message)"); send_varlist(event.peer, { "OnFailedToEnterWorld" }); + if (const std::string_view msg{ exc.what() }; !msg.empty()) on::ConsoleMessage(event.peer, std::string{ msg }); return; } -} \ No newline at end of file +} diff --git a/include/action/join_request.hpp b/include/action/join_request.hpp index 1081dd5d..a5e40a9f 100644 --- a/include/action/join_request.hpp +++ b/include/action/join_request.hpp @@ -4,3 +4,7 @@ namespace action { extern void join_request(ENetEvent& event, const std::string& header, const std::string_view world_name); } + +/* the next time this account enters a world it appears here instead of at the door (summon, pull) */ +extern void arrive_at(int user_id, ::pos at); + diff --git a/include/action/protocol.cpp b/include/action/protocol.cpp index 7d565cd9..a626c723 100644 --- a/include/action/protocol.cpp +++ b/include/action/protocol.cpp @@ -38,10 +38,10 @@ void action::protocol(ENetEvent& event, const std::string& header) send_varlist(event.peer, { "OnSendToServer", - (signed)gServer_data.port, + (int)gServer_data.port, 0, pPeer->user_id, - std::format("{}|0|0", gServer_data.server), // @todo UUIDToken + std::format("{}|0|0", server_for_peer(*event.peer)), 1, pPeer->growid.c_str() }); // @note PACKET_DISCONNECT from client. diff --git a/include/action/quit.cpp b/include/action/quit.cpp index 2c11a1f8..da3fd320 100644 --- a/include/action/quit.cpp +++ b/include/action/quit.cpp @@ -1,16 +1,23 @@ #include "pch.hpp" #include "action/quit_to_exit.hpp" +#include "commands/friendsys.hpp" +#include "commands/trade.hpp" +#include "commands/legendary.hpp" #include "quit.hpp" void action::quit(ENetEvent& event, const std::string& header) { - action::quit_to_exit(event, "", true); - if (event.peer == nullptr) return; if (event.peer->data != nullptr) { - delete static_cast<::peer*>(event.peer->data); + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (!pPeer->growid.empty()) friends_alert(*pPeer, false); // @note online friends see "logged off" + trade_end_for(*pPeer, "left the game"); // @note also drops trade requests + action::quit_to_exit(event, "", true); + legend_save(*pPeer); // @note Legendary Quest progress + + delete pPeer; event.peer->data = nullptr; } enet_peer_reset(event.peer); -} \ No newline at end of file +} diff --git a/include/action/quit_to_exit.cpp b/include/action/quit_to_exit.cpp index 1b899d16..9381ad51 100644 --- a/include/action/quit_to_exit.cpp +++ b/include/action/quit_to_exit.cpp @@ -1,30 +1,98 @@ #include "pch.hpp" +#include +#include #include "onVariant/RequestWorldSelectMenu.hpp" #include "onVariant/ConsoleMessage.hpp" +#include "commands/trade.hpp" #include "quit_to_exit.hpp" +namespace +{ +/* someone left: the others see the "left" bubble first, and the avatar is removed a moment later */ +struct pending_remove +{ + std::chrono::steady_clock::time_point at; + ENetPeer *to; + std::string world, netid, pid; + int leaver; +}; +std::deque pending{}; + +void send_remove(const pending_remove &r) +{ + if (r.to->state != ENET_PEER_STATE_CONNECTED || r.to->data == nullptr) return; + const ::peer *t = static_cast<::peer*>(r.to->data); + if (t->netid == 0 || t->recent_worlds.back() != r.world) return; // @note they left too, nothing to remove + send_varlist(r.to, { "OnRemove", r.netid, r.pid }); +} +} + +bool g_silent_move = false; + +/* the same account is entering this world again: finish its pending removals first so the new avatar isn't removed */ +void leave_flush(const std::string &world, int user_id) +{ + for (auto it = pending.begin(); it != pending.end(); ) + { + if (it->leaver == user_id && it->world == world) { send_remove(*it); it = pending.erase(it); } + else ++it; + } +} + +/* every loop of the server */ +void leave_pump() +{ + const auto now = std::chrono::steady_clock::now(); + while (!pending.empty() && pending.front().at <= now) + { + send_remove(pending.front()); + pending.pop_front(); + } +} + void action::quit_to_exit(ENetEvent& event, const std::string& header, bool skip_selection = false) { ::peer *pPeer = static_cast<::peer*>(event.peer->data); - auto world = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); - if (world == worlds.end()) return; // @note peer was not in a world, therefore nothing to exit from. + if (pPeer->netid == 0) // @note not in a world (already left): nothing to leave, and the visitor count must never drop twice + { + if (!skip_selection) on::RequestWorldSelectMenu(event); + return; + } - std::string message = std::format("`5<{} left, `w{}`` others here>``", pPeer->display_growid, world->visitors-1); - std::string netid = std::format("netID|{}\n", pPeer->netid); - std::string pId = std::format("pId|{}\n", pPeer->user_id); // @note this is found during OnSpawn 'eid', the value is the same for user_id. - peers(pPeer->recent_worlds.back(), PEER_SAME_WORLD, [&event, pPeer, message, netid, pId](ENetPeer& peer) + trade_end_for(*pPeer, "left the world"); // @note a trade can't outlive the world it started in + + auto world = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + if (world == worlds.end()) { - ::peer *pOthers = static_cast<::peer*>(peer.data); - if (pOthers->user_id == pPeer->user_id) return; + pPeer->netid = 0; + if (!skip_selection) on::RequestWorldSelectMenu(event); + return; + } + + const bool hidden = (pPeer->state & S_INVISIBLE) == S_INVISIBLE || g_silent_move; // @note /invis players leave silently + const std::string world_name = world->name; + const std::string message = std::format("`5<{} left, `w{}`` others here>``", pPeer->display_growid, world->visitors > 0 ? world->visitors - 1 : 0); + const std::string netid = std::format("netID|{}\n", pPeer->netid); + const std::string pId = std::format("pId|{}\n", pPeer->user_id); // @note this is found during OnSpawn 'eid', the value is the same for user_id. + const auto when = std::chrono::steady_clock::now() + std::chrono::milliseconds(1200); + const int leaver_netid = pPeer->netid; + + if (!hidden) + peers(world_name, PEER_SAME_WORLD, [&](ENetPeer& peer) + { + ::peer *pOthers = static_cast<::peer*>(peer.data); + if (pOthers->user_id == pPeer->user_id) return; - on::ConsoleMessage(event.peer, message); - send_varlist(&peer, { "OnRemove", netid, pId }); // @todo - }); + on::ConsoleMessage(&peer, message); + send_varlist(&peer, { "OnTalkBubble", leaver_netid, message, 1u }); // @note same bubble as when someone enters + pending.push_back({ when, &peer, world_name, netid, pId, pPeer->user_id }); + }); - if (--world->visitors <= 0) worlds.erase(world); // @note take 1, and if result is 0, delete memory copy of world. + if (world->visitors > 0) --world->visitors; + if (world->visitors == 0) { std::iter_swap(world, worlds.end() - 1); worlds.pop_back(); } // @note move to the end first so the world saves itself pPeer->netid = 0; // this will fix any packets being sent outside of world; this can also be used to check if peer is not in a world. - pPeer->display_growid = std::format("`w{}``", pPeer->growid); + if (pPeer->nickname.empty()) pPeer->display_growid = (pPeer->role >= DEVELOPER) ? std::format("`6@{}``", pPeer->growid) : (pPeer->role >= MODERATOR) ? std::format("`5@{}``", pPeer->growid) : std::format("`w{}``", pPeer->growid); if (!skip_selection) on::RequestWorldSelectMenu(event); -} \ No newline at end of file +} diff --git a/include/action/quit_to_exit.hpp b/include/action/quit_to_exit.hpp index e7812916..e08b2396 100644 --- a/include/action/quit_to_exit.hpp +++ b/include/action/quit_to_exit.hpp @@ -4,3 +4,12 @@ namespace action { extern void quit_to_exit(ENetEvent& event, const std::string& header, bool skip_selection); } + +/* leaving players stay visible for a moment (their "left" bubble); these finish or send the removal */ +extern void leave_flush(const std::string &world, int user_id); +extern void leave_pump(); + +/* true while a player is quietly re-entered (title change): no "left"/"entered" messages */ +extern bool g_silent_move; + + diff --git a/include/action/refresh_item_data.cpp b/include/action/refresh_item_data.cpp index 9a7122e6..441cf15f 100644 --- a/include/action/refresh_item_data.cpp +++ b/include/action/refresh_item_data.cpp @@ -1,9 +1,10 @@ #include "pch.hpp" #include "onVariant/ConsoleMessage.hpp" +#include "commands/godmode.hpp" #include "refresh_item_data.hpp" void action::refresh_item_data(ENetEvent& event, const std::string& header) { on::ConsoleMessage(event.peer, "One moment, updating item data..."); - enet_peer_send(event.peer, 0, enet_packet_create(im_data.data(), im_data.size(), ENET_PACKET_FLAG_RELIABLE)); + enet_peer_send(event.peer, 0, enet_packet_create(items_for(event).data(), items_for(event).size(), ENET_PACKET_FLAG_RELIABLE)); } \ No newline at end of file diff --git a/include/action/respawn.cpp b/include/action/respawn.cpp index d521248a..293e188a 100644 --- a/include/action/respawn.cpp +++ b/include/action/respawn.cpp @@ -1,10 +1,18 @@ #include "pch.hpp" +#include "commands/buffs.hpp" #include "respawn.hpp" void action::respawn(ENetEvent& event, const std::string& header) { ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (pPeer->god_mode || (header.find("respawn_spike") != std::string::npos && buff_active(*pPeer, buff::SPIKE))) + { + // client froze itself on "death" - release it without moving the player + send_varlist(event.peer, { "OnSetFreezeState" }, pPeer->netid); + return; + } + send_varlist(event.peer, { "OnSetFreezeState", 2 }, pPeer->netid); send_varlist(event.peer, { "OnKilled"}, pPeer->netid); diff --git a/include/action/store.cpp b/include/action/store.cpp index 2ad28422..2ae58da4 100644 --- a/include/action/store.cpp +++ b/include/action/store.cpp @@ -1,48 +1,46 @@ #include "pch.hpp" +#include "database/shouhin.hpp" #include "store.hpp" -std::string payment_site{ "https://discord.gg/zzWHgzaF7J" }; // @note replace with your payment site (paypal, ect) +/* the Home tab: popular items that can be bought right there */ +static constexpr const char *featured[]{ + "world_lock", "upgrade_backpack", "small_seed_pack", "signal_jammer", "punch_jammer", "zombie_jammer", + "door_mover", "change_of_address", "deluxe_grow_spray", "antigravity_generator", "treasure_blast", "cave_blast" +}; void action::store(ENetEvent& event, const std::string& header) { std::vector pipes = readch(header, '|'); + if (!header.empty() && (pipes.size() < 4 || pipes[3] != "gem")) return; // @note location|gem - if (header.empty() || pipes[3] == "gem") // @note location|gem - { - send_varlist(event.peer, { - "OnStoreRequest", - std::format( - "set_description_text|Welcome to the `2Growtopia Store``! Select the item you'd like more info on.`o `wThanks for being a supporter of Growtopia!\n" - - /* tabs */ - "enable_tabs|1\n" - "add_tab_button|main_menu|Home|interface/large/btn_shop.rttex||1|0|0|0||||-1|-1|||0|0|CustomParams:|\n" - "add_tab_button|locks_menu|Locks And Stuff|interface/large/btn_shop.rttex||0|1|0|0||||-1|-1|||0|0|CustomParams:|\n" - "add_tab_button|itempack_menu|Item Packs|interface/large/btn_shop.rttex||0|3|0|0||||-1|-1|||0|0|CustomParams:|\n" - "add_tab_button|bigitems_menu|Awesome Items|interface/large/btn_shop.rttex||0|4|0|0||||-1|-1|||0|0|CustomParams:|\n" - "add_tab_button|weather_menu|Weather Machines|interface/large/btn_shop.rttex|Tired of the same sunny sky? We offer alternatives within...|0|5|0|0||||-1|-1|||0|0|CustomParams:|\n" - "add_tab_button|token_menu|Growtoken Items|interface/large/btn_shop.rttex||0|2|0|0||||-1|-1|||0|0|CustomParams:|\n" + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + const int No = (pPeer->slot_size - 16) / 10 + 1; - "add_banner|interface/large/gui_shop_featured_header.rttex|0|1|\n" - "add_button|pet_battle_pack|`oPet Battle Pack``|interface/large/store_buttons/store_buttons42.rttex|`2You Get:`` 1 Pet Battle Supply Crate, 1 Battle Leash and 1 Battle Pet Cage.`5Description:`` When a Growtopian plucks up the courage, they can get their own battle pet. This pack contains all the ingredients to create your very own starter Battle Pet and get you ready for your very first Pet Battle. Splicing new battle abilities will cost 10 World Locks per ability, World Locks not included!`4This pack can be only be purchased once.``|1|6|40000|0|||-1|-1||-1|-1||1||||||0|0|CustomParams:|\n" - "add_button|rt_grope_supply_bundle05|`oScrolls Value Pack``|interface/large/store_buttons/store_buttons42.rttex|{0}|2|1|0||||-1|-1||-1|-1|OPENDIALOG&showscrollspurchaseui|1||||||0|0|CustomParams:|\n" - "add_button|rt_grope_supply_bundle04|`oDungeon Pass``|interface/large/store_buttons/store_buttons42.rttex|{0}|1|5|0||||-1|-1||-1|-1|OPENDIALOG&showdungeonpasspurchaseui|1||||||0|0|CustomParams:|\n" - "add_button|rt_grope_battlepass_bundle01|Royal Grow Pass|interface/large/store_buttons/store_buttons37.rttex|{0}|4|2|0||||-1|-1||-1|-1|`2You Get:`` 1 Royal Grow Pass Token.`5Description:`` Play to earn points and level up your Grow Pass to earn rewards. Consume to earn exclusive `5Royal`` rewards as you level up your Grow Pass as well as unlocking all daily bonuses and exclusive `5Royal Perks`` for the entire month. Upon consuming you will instantly receive `2300 Free`` points towards your pass progress. Note: The token is `#UNTRADEABLE``.|1||||||0|0|CustomParams:|\n" - "add_banner|interface/large/gui_shop_featured_header.rttex|0|2|\n" - "add_button|gems_glory|Road To Glory|interface/large/store_buttons/store_buttons30.rttex|{0}|0|0|0||||-1|-1|/interface/large/gui_shop_buybanner.rttex|1|0|`2You Get:`` Road To Glory and 120k Gems Instantly.`5Description:`` Earn Gem rewards when you level up. Every 10 levels you will get additional Gem rewards up to Level 50! Claim all rewards instantly if you are over level 50!! 1.92M Gems in total!!|1||||||0|0|CustomParams:|\n" - "add_button|gems_bundle06|Gem Abundance|interface/large/store_buttons/store_buttons37.rttex|{0}|2|1|0||||-1|-1||-1|-1|`2You Get:`` 19,680,000 Gems, 3,680 World Locks, 10 Growtokens and 5 Megaphones.`5Description:`` Get an abundance of gems to add to your wealth. Please note World Locks will be credited as 36 Diamond Locks and 80 World Locks. Please make sure you have room in your inventory to receive these items.|1||||||0|0|CustomParams:|\n" - "add_button|gems_bundle05|Gem Bounty|interface/large/store_buttons/store_buttons34.rttex|{0}|0|6|0||||-1|-1||-1|-1|`2You Get:`` 10,080,000 Gems, 2,100 World Locks, 6 Growtokens and 3 Megaphones.`5Description:`` Get a plethora of gems to add to your wealth. Please note World Locks will be credited as 21 Diamond Locks. Please make sure you have room in your inventory to receive these items.|1||||||0|0|CustomParams:|\n" - "add_button|gems_rain|It's Rainin' Gems|interface/large/store_buttons/store_buttons.rttex|{0}|1|5|0||||-1|-1||-1|-1|`2You Get:`` 2,730,000 Gems, 630 World Locks, 2 Growtokens and 1 Megaphone.`5Description:`` All the gems you could ever want and more plus 630 World Locks, 2 Growtokens and a Megaphone to tell the whole world about it. Please note World Locks will be credited as 6 Diamond Locks and 30 World Locks. Please make sure you have room in your inventory to receive these items.|1||||||0|0|CustomParams:|\n" - "add_button|gems_fountain|Gem Fountain|interface/large/store_buttons/store_buttons2.rttex|{0}|0|2|0||||-1|-1||-1|-1|`2You Get:`` 922,500 Gems, 260 World Locks and 1 Growtoken.`5Description:`` Get a pile of Gems to shop to your hearts desire, 260 World Locks and 1 Growtoken. Please note World Locks will be credited as 2 Diamond Locks and 60 World Locks. Please make sure you have room in your inventory to receive these items.|1||||||0|0|CustomParams:|\n" - "add_button|gems_chest|Chest o' Gems|interface/large/store_buttons/store_buttons.rttex|{0}|0|5|0||||-1|-1||-1|-1|`2You Get:`` 280,000 Gems and 84 World Locks.`5Description:`` Get a chest containing gems and 84 World Locks. Please make sure you have room in your inventory to receive these items.|1||||||0|0|CustomParams:|\n" - "add_button|grow_shop|`oGrowtopia Shop``|interface/large/store_buttons/store_buttons36.rttex|https://growtopiagame.com/shop|1|1|0|0|Open Shop||-1|-1|interface/large/gui_shop_buybanner.rttex|0|1|If you can purchase Gem Packs through your telecom provider, then open the Growtopia Shop website here.Please note, this service is only available in the following countries: `9Brazil``, `9Cambodia``, `9Estonia``, `9Indonesia``, `9Latvia``, `9Lithuania``, `9Malaysia``, `9Philippines``, `9Qatar``, `9Romania``, `9Saudi Arabia``, `9Singapore``, `9Thailand``, `9Turkey``, and `9United Arab Emirates``|1||||||0|0|CustomParams:|\n" - "add_banner|interface/large/gui_shop_featured_header.rttex|0|3|\n" - "add_image_button|image_button_battlepass_banner|interface/large/gui_shop_grow_pass.rttex|bannerlayout|OPENDIALOG|battlepasspopup||\n" - "add_button|365d|`o1-Year Subscription Token``|interface/large/store_buttons/store_buttons28.rttex|{0}|0|2|0||||-1|-1||-1|-1|`2You Get:`` 1x 1-Year Subscription Token , and 33 Growtokens. `5Description:`` One full year of special treatment AND 33 Growtokens upfront! You'll get 70 season tokens (as long as there's a seasonal clash running), 9600 gems, and 1 World Lock every day and a chance of doubling any XP earned, growtime reduction on all seeds planted and Exclusive Skins!|1||||||0|0|CustomParams:|\n" - "add_button|rt_grope_battlepass_bundle01|Royal Grow Pass|interface/large/store_buttons/store_buttons37.rttex|{0}|4|2|0||||-1|-1||-1|-1|`2You Get:`` 1 Royal Grow Pass Token.`5Description:`` Play to earn points and level up your Grow Pass to earn rewards. Consume to earn exclusive `5Royal`` rewards as you level up your Grow Pass as well as unlocking all daily bonuses and exclusive `5Royal Perks`` for the entire month. Upon consuming you will instantly receive `2300 Free`` points towards your pass progress. Note: The token is `#UNTRADEABLE``.|1||||||0|0|CustomParams:|\n" - "add_button|redeem_code|Redeem Code|interface/large/store_buttons/store_buttons40.rttex|OPENDIALOG&showredeemcodewindow|1|5|0|0|||-1|-1||-1|-1||1||||||0|0|CustomParams:|\n" - ,payment_site - ) - }); - } -} \ No newline at end of file + std::string s = + "set_description_text|Welcome to the `2Growtopia Store``! Here are some popular items - browse the tabs for everything else.\n" + "enable_tabs|1\n" + "add_tab_button|main_menu|Home|interface/large/btn_shop.rttex||1|0|0|0||||-1|-1|||0|0|CustomParams:|\n" + "add_tab_button|locks_menu|Locks And Stuff|interface/large/btn_shop.rttex||0|1|0|0||||-1|-1|||0|0|CustomParams:|\n" + "add_tab_button|itempack_menu|Item Packs|interface/large/btn_shop.rttex||0|3|0|0||||-1|-1|||0|0|CustomParams:|\n" + "add_tab_button|bigitems_menu|Awesome Items|interface/large/btn_shop.rttex||0|4|0|0||||-1|-1|||0|0|CustomParams:|\n" + "add_tab_button|weather_menu|Weather Machines|interface/large/btn_shop.rttex|Tired of the same sunny sky? We offer alternatives within...|0|5|0|0||||-1|-1|||0|0|CustomParams:|\n" + "add_tab_button|token_menu|Growtoken Items|interface/large/btn_shop.rttex||0|2|0|0||||-1|-1|||0|0|CustomParams:|\n" + "add_banner|interface/large/gui_shop_featured_header.rttex|0|1|\n"; + + for (const char *btn : featured) + for (const auto &[tab, sh] : shouhin_tachi) + { + if (sh.btn != btn) continue; + int cost = sh.cost; + if (sh.btn == "upgrade_backpack") + { + if (No > 38) break; // @note maxed out + cost = 100 * No * No - 200 * No + 200; + } + s += std::format("add_button|{}|{}|{}|{}|{}|{}|{}|0|||-1|-1||-1|-1||1||||||0|0|CustomParams:|\n", + sh.btn, sh.name, sh.rttx, sh.description, sh.tex1, sh.tex2, cost); + break; + } + + send_varlist(event.peer, { "OnStoreRequest", s }); +} diff --git a/include/action/tankIDName.cpp b/include/action/tankIDName.cpp index 8c9fa7d5..07361f27 100644 --- a/include/action/tankIDName.cpp +++ b/include/action/tankIDName.cpp @@ -1,6 +1,10 @@ #include "pch.hpp" #include "automate/holiday.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "commands/motd.hpp" +#include "commands/maintenance.hpp" +#include "commands/godmode.hpp" #include "tankIDName.hpp" /* GameLogicComponent::OnInitialLogonAccepted(VariantList*) */ @@ -13,30 +17,40 @@ void action::tankIDName(ENetEvent& event, const std::string& header) if (pipes.empty() || pipes.size() < 41ull) enet_peer_disconnect_later(event.peer, 0); /* @todo instead of setting these values we should check if there valid from SQL.*/ - std::string protocol{}; for (std::size_t i = 0; i < pipes.size(); ++i) { - if (pipes[i] == "tankIDName") pPeer->growid = pipes[i+1]; - else if (pipes[i] == "protocol") protocol = pipes[i+1]; - else if (pipes[i] == "country") pPeer->country = pipes[i+1]; - else if (pipes[i] == "user") pPeer->user_id = std::stoi(pipes[i+1]); // @todo validate user_id + if (pipes[i] == "tankIDName") pPeer->growid = pipes[i+1]; + else if (pipes[i] == "country") pPeer->country = pipes[i+1]; + else if (pipes[i] == "user") pPeer->user_id = std::stoi(pipes[i+1]); // @todo validate user_id } - if (atoi(protocol.c_str()) == 0) return; // @todo <226 - pPeer->load(pPeer->growid, pPeer->password); // @note the ACTUAL loading. the one in protocol.cpp must be removed for performance... + if (pPeer->ban_until > std::time(nullptr)) + { + on::ConsoleMessage(event.peer, std::format("`4This account is banned`` for another `w{}`` minutes.", (pPeer->ban_until - std::time(nullptr) + 59) / 60)); + enet_peer_disconnect_later(event.peer, 0); + return; + } + + if (gMaintenance && pPeer->role < MODERATOR) + { + on::ConsoleMessage(event.peer, "`4The server is under maintenance. Please try again later.``"); + enet_peer_disconnect_later(event.peer, 0); + return; + } + if (!gMotd.empty()) on::ConsoleMessage(event.peer, std::format("`5[MOTD]`` `o{}", gMotd)); send_varlist(event.peer, { "OnOverrideGDPRFromServer", 18, 1, 0, 1 }); - /* v5.58 */ + /* v5.51 */ send_varlist(event.peer, { "OnSuperMainStartAcceptLogonHrdxs47254722215a", - 1086086596u, // @note items.dat + items_hash_for(*pPeer), // @note items.dat (normal or /god version) "ubistatic-a.akamaihd.net", - "0098/024920264/cache/", + "0098/150726456789/cache/", "cc.cz.madkite.freedom org.aqua.gg idv.aqua.bulldog com.cih.gamecih2 com.cih.gamecih com.cih.game_cih cn.maocai.gamekiller com.gmd.speedtime org.dax.attack com.x0.strai.frep com.x0.strai.free org.cheatengine.cegui org.sbtools.gamehack com.skgames.traffikrider org.sbtoods.gamehaca com.skype.ralder org.cheatengine.cegui.xx.multi1458919170111 com.prohiro.macro me.autotouch.autotouch com.cygery.repetitouch.free com.cygery.repetitouch.pro com.proziro.zacro com.slash.gamebuster", std::format( - "proto={}|choosemusic=audio/mp3/about_theme.mp3|active_holiday={}|wing_week_day=0|ubi_week_day=0|server_tick=0|game_theme={}|clash_active=0|drop_lavacheck_faster=1|isPayingUser=1|usingStoreNavigation=1|enableInventoryTab=1|bigBackpack=1|seed_diary_hash=4266294761", - protocol, holiday, game_theme_string() - ) + "|m_clientBits=|eventButtons={\"EventButtonData\":[{\"active\":false,\"buttonAction\":\"eventmenu\",\"buttonTemplate\":\"BaseEventButton\",\"counter\":0,\"counterMax\":0,\"itemIdIcon\":6244,\"name\":\"ClashEventButton\",\"order\":9,\"rcssClass\":\"clash-event\",\"text\":\"\"},{\"active\":true,\"buttonAction\":\"dailychallengemenu\",\"buttonTemplate\":\"BaseEventButton\",\"counter\":0,\"counterMax\":0,\"itemIdIcon\":23,\"name\":\"DailyChallenge\",\"order\":10,\"rcssClass\":\"daily_challenge\",\"text\":\"\"},{\"active\":true,\"buttonAction\":\"openPiggyBank\",\"buttonTemplate\":\"BaseEventButton\",\"counter\":0,\"counterMax\":0,\"name\":\"PiggyBankButton\",\"order\":20,\"rcssClass\":\"piggybank\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"showdungeonsui\",\"buttonTemplate\":\"DungeonEventButton\",\"counter\":0,\"counterMax\":20,\"name\":\"ScrollsPurchaseButton\",\"order\":30,\"rcssClass\":\"scrollbank\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"show_mailbox_ui\",\"buttonTemplate\":\"BaseEventButton\",\"counter\":0,\"counterMax\":0,\"name\":\"MailboxButton\",\"order\":30,\"rcssClass\":\"mailbox\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"show_auction_ui\",\"buttonTemplate\":\"BaseEventButton\",\"counter\":0,\"counterMax\":0,\"name\":\"AuctionButton\",\"order\":30,\"rcssClass\":\"auction\",\"text\":\"\"},{\"active\":false,\"buttonTemplate\":\"ActiveAuctionEventButton\",\"counter\":0,\"counterMax\":20,\"name\":\"ActiveAuctionButton\",\"order\":30,\"rcssClass\":\"activeauction\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"eventmenu\",\"buttonTemplate\":\"BaseEventButton\",\"counter\":0,\"counterMax\":0,\"itemIdIcon\":6244,\"name\":\"ClashEventButton\",\"order\":21,\"rcssClass\":\"clash-event\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"show_bingo_ui\",\"buttonTemplate\":\"BaseEventButton\",\"counter\":0,\"counterMax\":0,\"name\":\"WinterBingoButton\",\"order\":49,\"rcssClass\":\"wf-bingo\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"show_bingo_ui\",\"buttonTemplate\":\"BaseEventButton\",\"name\":\"UbiBingoButton\",\"order\":50,\"rcssClass\":\"ubi-bingo\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"winterrallymenu\",\"buttonTemplate\":\"BaseEventButton\",\"counter\":0,\"counterMax\":0,\"name\":\"WinterRallyButton\",\"order\":50,\"rcssClass\":\"winter-rally\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"leaderboardBtnClicked\",\"buttonTemplate\":\"BaseEventButton\",\"counter\":0,\"counterMax\":0,\"name\":\"AnniversaryLeaderboardButton\",\"order\":50,\"rcssClass\":\"anniversary-leaderboard\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"euphoriaBtnClicked\",\"buttonTemplate\":\"BaseEventButton\",\"counter\":0,\"counterMax\":0,\"name\":\"AnniversaryEuphoriaButton\",\"order\":50,\"rcssClass\":\"anniversary-euphoria\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"openLnySparksPopup\",\"buttonTemplate\":\"BaseEventButton\",\"counter\":0,\"counterMax\":5,\"name\":\"LnyButton\",\"order\":50,\"rcssClass\":\"cny\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"ShowValentinesQuestDialog\",\"buttonTemplate\":\"EventButtonWithCounter\",\"counter\":0,\"counterMax\":100,\"name\":\"ValentinesButton\",\"order\":50,\"rcssClass\":\"valentines_day\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"showegseeventui\",\"buttonTemplate\":\"EventButtonWithCounter\",\"counter\":0,\"counterMax\":20,\"name\":\"EasterButton\",\"order\":50,\"rcssClass\":\"easter_event\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"openStPatrickPiggyBank\",\"buttonTemplate\":\"BaseEventButton\",\"name\":\"StPatrickPBButton\",\"order\":50,\"rcssClass\":\"st_patrick_event\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"dailyrewardmenu\",\"buttonTemplate\":\"BaseEventButton\",\"counter\":0,\"counterMax\":1,\"name\":\"CincoPinataButton\",\"order\":50,\"rcssClass\":\"cinco_pinata_event\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"show_fruit_mixer_dialog\",\"buttonTemplate\":\"BaseEventButton\",\"counter\":0,\"counterMax\":0,\"name\":\"SPP_TropicalFruitsButton\",\"order\":50,\"rcssClass\":\"spp_tropical_fruits\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"claimprogressbar\",\"buttonTemplate\":\"EventButtonWithCounter\",\"counter\":0,\"counterMax\":0,\"name\":\"SummerfestButton\",\"order\":50,\"rcssClass\":\"summerfest\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"claimprogressbar\",\"buttonTemplate\":\"EventButtonWithCounter\",\"counter\":0,\"counterMax\":15,\"name\":\"HalloweenButton\",\"order\":50,\"rcssClass\":\"halloween\",\"text\":\"\"}]}" + "proto=225|choosemusic=audio/mp3/about_theme.mp3|active_holiday={}|wing_week_day=0|ubi_week_day=0|server_tick=0|game_theme={}|clash_active=0|drop_lavacheck_faster=1|isPayingUser=1|usingStoreNavigation=1|enableInventoryTab=1|bigBackpack=1|seed_diary_hash=4266294761", + holiday, game_theme_string() + ) + "|m_clientBits=|eventButtons={\"EventButtonData\":[{\"active\":false,\"buttonAction\":\"eventmenu\",\"buttonTemplate\":\"BaseEventButton\",\"counter\":0,\"counterMax\":0,\"itemIdIcon\":6244,\"name\":\"ClashEventButton\",\"order\":9,\"rcssClass\":\"clash-event\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"dailychallengemenu\",\"buttonTemplate\":\"BaseEventButton\",\"counter\":0,\"counterMax\":0,\"itemIdIcon\":23,\"name\":\"DailyChallenge\",\"order\":10,\"rcssClass\":\"daily_challenge\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"openPiggyBank\",\"buttonTemplate\":\"BaseEventButton\",\"counter\":0,\"counterMax\":0,\"name\":\"PiggyBankButton\",\"order\":20,\"rcssClass\":\"piggybank\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"showdungeonsui\",\"buttonTemplate\":\"DungeonEventButton\",\"counter\":0,\"counterMax\":20,\"name\":\"ScrollsPurchaseButton\",\"order\":30,\"rcssClass\":\"scrollbank\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"show_mailbox_ui\",\"buttonTemplate\":\"BaseEventButton\",\"counter\":0,\"counterMax\":0,\"name\":\"MailboxButton\",\"order\":30,\"rcssClass\":\"mailbox\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"show_auction_ui\",\"buttonTemplate\":\"BaseEventButton\",\"counter\":0,\"counterMax\":0,\"name\":\"AuctionButton\",\"order\":30,\"rcssClass\":\"auction\",\"text\":\"\"},{\"active\":false,\"buttonTemplate\":\"ActiveAuctionEventButton\",\"counter\":0,\"counterMax\":20,\"name\":\"ActiveAuctionButton\",\"order\":30,\"rcssClass\":\"activeauction\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"eventmenu\",\"buttonTemplate\":\"BaseEventButton\",\"counter\":0,\"counterMax\":0,\"itemIdIcon\":6244,\"name\":\"ClashEventButton\",\"order\":21,\"rcssClass\":\"clash-event\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"show_bingo_ui\",\"buttonTemplate\":\"BaseEventButton\",\"counter\":0,\"counterMax\":0,\"name\":\"WinterBingoButton\",\"order\":49,\"rcssClass\":\"wf-bingo\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"show_bingo_ui\",\"buttonTemplate\":\"BaseEventButton\",\"name\":\"UbiBingoButton\",\"order\":50,\"rcssClass\":\"ubi-bingo\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"winterrallymenu\",\"buttonTemplate\":\"BaseEventButton\",\"counter\":0,\"counterMax\":0,\"name\":\"WinterRallyButton\",\"order\":50,\"rcssClass\":\"winter-rally\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"leaderboardBtnClicked\",\"buttonTemplate\":\"BaseEventButton\",\"counter\":0,\"counterMax\":0,\"name\":\"AnniversaryLeaderboardButton\",\"order\":50,\"rcssClass\":\"anniversary-leaderboard\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"euphoriaBtnClicked\",\"buttonTemplate\":\"BaseEventButton\",\"counter\":0,\"counterMax\":0,\"name\":\"AnniversaryEuphoriaButton\",\"order\":50,\"rcssClass\":\"anniversary-euphoria\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"openLnySparksPopup\",\"buttonTemplate\":\"BaseEventButton\",\"counter\":0,\"counterMax\":5,\"name\":\"LnyButton\",\"order\":50,\"rcssClass\":\"cny\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"ShowValentinesQuestDialog\",\"buttonTemplate\":\"EventButtonWithCounter\",\"counter\":0,\"counterMax\":100,\"name\":\"ValentinesButton\",\"order\":50,\"rcssClass\":\"valentines_day\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"showegseeventui\",\"buttonTemplate\":\"EventButtonWithCounter\",\"counter\":0,\"counterMax\":20,\"name\":\"EasterButton\",\"order\":50,\"rcssClass\":\"easter_event\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"openStPatrickPiggyBank\",\"buttonTemplate\":\"BaseEventButton\",\"name\":\"StPatrickPBButton\",\"order\":50,\"rcssClass\":\"st_patrick_event\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"dailyrewardmenu\",\"buttonTemplate\":\"BaseEventButton\",\"counter\":0,\"counterMax\":1,\"name\":\"CincoPinataButton\",\"order\":50,\"rcssClass\":\"cinco_pinata_event\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"show_fruit_mixer_dialog\",\"buttonTemplate\":\"BaseEventButton\",\"counter\":0,\"counterMax\":0,\"name\":\"SPP_TropicalFruitsButton\",\"order\":50,\"rcssClass\":\"spp_tropical_fruits\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"claimprogressbar\",\"buttonTemplate\":\"EventButtonWithCounter\",\"counter\":0,\"counterMax\":0,\"name\":\"SummerfestButton\",\"order\":50,\"rcssClass\":\"summerfest\",\"text\":\"\"},{\"active\":false,\"buttonAction\":\"claimprogressbar\",\"buttonTemplate\":\"EventButtonWithCounter\",\"counter\":0,\"counterMax\":15,\"name\":\"HalloweenButton\",\"order\":50,\"rcssClass\":\"halloween\",\"text\":\"\"}]}" }); } \ No newline at end of file diff --git a/include/action/wrench.cpp b/include/action/wrench.cpp index 66d8ee9c..3ab987b0 100644 --- a/include/action/wrench.cpp +++ b/include/action/wrench.cpp @@ -1,5 +1,10 @@ #include "pch.hpp" #include "tools/create_dialog.hpp" +#include "commands/curse.hpp" +#include "commands/useitems.hpp" +#include "commands/buffs.hpp" +#include "commands/staff.hpp" +#include "commands/legendary.hpp" #include "wrench.hpp" void action::wrench(ENetEvent& event, const std::string& header) @@ -25,9 +30,10 @@ void action::wrench(ENetEvent& event, const std::string& header) .embed_data("netID", netid) .add_popup_name("WrenchMenu") .set_default_color("`o") - .add_player_info(pOthers->display_growid, std::to_string(lvl), pOthers->level.back(), 50 * (lvl * lvl + 2)) + .add_player_info(pOthers->display_growid, (pOthers->role >= MODERATOR ? std::string{ "?" } : std::to_string(lvl)), pOthers->level.back(), 50 * (lvl * lvl + 2)) .add_spacer("small") .add_spacer("small") + .add_raw(title_quick_button(*pOthers)) // @note one-tap title on/off .add_button("renew_pvp_license", "Get Card Battle License") .add_spacer("small") .set_custom_spacing(5, 10) @@ -57,6 +63,10 @@ void action::wrench(ENetEvent& event, const std::string& header) .add_textbox("Surgeon Level: 0") .add_spacer("small") .add_textbox("`wActive effects:``") + .add_raw(curse_wrench_line(*pOthers)) + .add_raw(item_effects_wrench(*pOthers)) + .add_raw(buffs_wrench(*pOthers)) + .add_raw(mods_wrench(*pOthers)) /* @todo handle peer's effects */ .add_spacer("small") .add_smalltext(std::format("Fires Put Out: {}", pOthers->fires_removed)) @@ -66,7 +76,7 @@ void action::wrench(ENetEvent& event, const std::string& header) pOthers->recent_worlds.back(), pOthers->pos.by_32(true).x_int(), pOthers->pos.by_32(true).y_int())) .add_textbox("`oYou are standing on the note \"A\".``") .add_spacer("small") - .add_textbox("`oTotal time played is `w0.0`` hours. This account was created `w0`` days ago.``") + .add_textbox(std::format("`oThis account was created `w{}`` days ago.``", pOthers->created_at > 0 ? (std::time(nullptr) - pOthers->created_at) / 86400 : 0)) .add_spacer("small") .add_quick_exit() .end_dialog("popup", "", "Continue") @@ -75,35 +85,39 @@ void action::wrench(ENetEvent& event, const std::string& header) /* wrench someone else */ else { - send_varlist(event.peer, { - "OnDialogRequest", - ::create_dialog() - .embed_data("netID", netid) - .add_popup_name("WrenchMenu") - .set_default_color("`o") - .add_label_with_icon("big", std::format("{} (`2{}``)``", pOthers->display_growid, lvl), 18) - .embed_data("netID", netid) - .add_spacer("small") - .add_achieve("0"/*@todo add achivements*/) - .add_custom_margin(75, -70.85) - .add_custom_margin(-75, 70.85) - .add_spacer("small") - .add_label("small", "`1Achievements:`` 0/173"/*add total achivements*/) - .add_spacer("small") - .add_label("small", "`1Account Age:`` 0 days") - .add_spacer("small") - .add_button("trade", "`wTrade``") - .add_button("sendpm", "`wSend Message``") - .add_textbox("(No Battle Leash equipped)") - .add_textbox("You need a valid license to battle!") - .add_button("friend_add", "`wAdd as friend``") - .add_button("show_clothes", "`wView worn clothes``") - .add_button("ignore_player", "`wIgnore Player``") - .add_button("report_player", "`wReport Player``") - .add_spacer("small") - .add_quick_exit() - .end_dialog("popup", "", "Continue") - }); + const bool staff_target = pOthers->role >= MODERATOR; + const bool viewer_staff = pPeer->role >= MODERATOR; + const std::string level_text = staff_target ? std::string{ "?" } : std::to_string(lvl); + const std::string age_text = (staff_target && !viewer_staff) ? std::string{ "?" } : + std::format("{} days", pOthers->created_at > 0 ? (std::time(nullptr) - pOthers->created_at) / 86400 : 0); + + ::create_dialog d{}; + d.embed_data("netID", netid) + .add_popup_name("WrenchMenu") + .set_default_color("`o") + .add_label_with_icon("big", std::format("{} (`2{}``)``", pOthers->display_growid, level_text), 18) + .embed_data("netID", netid) + .add_spacer("small") + .add_label("small", std::format("`1Account Age:`` {}", age_text)) + .add_spacer("small") + .add_button("trade", "`wTrade``") + .add_button("sendpm", "`wSend Message``") + .add_button("show_clothes", "`wView worn clothes``"); + if (!staff_target || viewer_staff) // @note players can't friend, ignore or report staff + { + d.add_button("friend_add", "`wAdd as friend``") + .add_button("ignore_player", "`wIgnore Player``") + .add_button("report_player", "`wReport Player``"); + } + { + auto here = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + const bool is_owner = here != worlds.end() && here->owner != 0 && here->owner == pPeer->user_id; + if ((viewer_staff || is_owner) && !(staff_target && pOthers->role >= pPeer->role)) d.add_button("pull_player", "`wPull``"); // @note world owners and staff + } + if (viewer_staff) d.add_button("staff_panel", "`4Staff / Punish``"); // @note opens the staff window + d.add_spacer("small") + .add_quick_exit(); + send_varlist(event.peer, { "OnDialogRequest", d.end_dialog("popup", "", "Continue") }); } return; // @note early exit else iteration will continue for EVERYONE in the world. } diff --git a/include/commands/__command.cpp b/include/commands/__command.cpp index 938139b2..ff9185e1 100644 --- a/include/commands/__command.cpp +++ b/include/commands/__command.cpp @@ -11,6 +11,71 @@ #include "news.hpp" #include "weather.hpp" #include "ghost.hpp" +#include "reset.hpp" +#include "gems.hpp" +#include "give.hpp" +#include "invis.hpp" +#include "mod.hpp" +#include "removeblock.hpp" +#include "nick.hpp" +#include "flag.hpp" +#include "warpto.hpp" +#include "kick.hpp" +#include "private.hpp" +#include "dev.hpp" +#include "nuke.hpp" +#include "unnuke.hpp" +#include "announce.hpp" +#include "showplayer.hpp" +#include "god.hpp" +#include "firework.hpp" +#include "freeze.hpp" +#include "ducttape.hpp" +#include "fill.hpp" +#include "sphere.hpp" +#include "undo.hpp" +#include "worlds.hpp" +#include "deleteworld.hpp" +#include "saveworld.hpp" +#include "setowner.hpp" +#include "stats.hpp" +#include "summon.hpp" +#include "swap.hpp" +#include "list.hpp" +#include "uptime.hpp" +#include "shutdown.hpp" +#include "pull.hpp" +#include "trade.hpp" +#include "unban.hpp" +#include "legendary.hpp" +#include "breakall.hpp" +#include "access.hpp" +#include "renameworld.hpp" +#include "spawnall.hpp" +#include "wear.hpp" +#include "undress.hpp" +#include "heal.hpp" +#include "msg.hpp" +#include "whois.hpp" +#include "motd.hpp" +#include "maintenance.hpp" +#include "clear.hpp" +#include "xp.hpp" +#include "bot.hpp" +#include "search.hpp" +#include "msg.hpp" +#include "whois.hpp" +#include "motd.hpp" +#include "maintenance.hpp" +#include "clear.hpp" +#include "xp.hpp" +#include "bot.hpp" +#include "search.hpp" +#include "curse.hpp" +#include "events.hpp" +#include "worldtools.hpp" +#include "devmenu.hpp" +#include "accounts.hpp" #include "__command.hpp" /* emote commands all dispatch to on::Action. listed once here so the @@ -23,18 +88,22 @@ static constexpr std::string_view emotes[24]{ }; /* named commands with their usage hint, shown in /help */ -static constexpr std::string_view named_help = - "/time /sb {msg} /find /warp {world} /punch {id} /skin {id} /who /me {msg} " - "/news /weather {id} /ghost"; +static constexpr std::string_view player_help = "/time /find /warp {world} /sb {msg} /who /me {msg} /news /uptime /msg {player} {text} /reply {text} /pull {player} /trade {player} /title [title/on/off] /flag [code]"; +static constexpr std::string_view mod_help = "/warpto {player} /kick {player} /private /announce {msg} /freeze {player} /ducttape [player] /worlds /stats /summon {player} /swap {player} /list /spawnall /whois {player} /clear /curse {player} {time} {reason} /uncurse {player} /unban {player} /ghost"; +static constexpr std::string_view dev_help = "/skin {id} /reset /gems {amount} /give {id} {amount} /invis /mod {name} {0-2} /removeblock /nick {name} /nuke /unnuke /showplayer /god /firework [count] /fill {id} /sphere {id} {r} /undo [n] /deleteworld {name} /save /setowner [player] /shutdown [seconds] /breakall /access [player] /renameworld {name} /wear {id} /motd [text] /maintenance /xp {amount} /level [n] /bot [add/remove/clear/say] /search /punch {id} /weather {id} /dev /undress /heal /event [number] /tile [x y] /deleteallworlds [keep...] /fillworld /cleardrops /accounts [search] /title [player] [title] [off]"; -std::array cmd_requires_arg{ - "sb", "warp", "punch", "skin", "me", "weather" +std::array cmd_requires_arg{ + "sb", "warp", "punch", "skin", "me", "weather", "gems", "give", "mod", "warpto", "kick", "dev", "announce", "freeze", "fill", "sphere", "deleteworld", "summon", "swap", "renameworld", "wear", "msg", "reply", "whois", "xp", "pull", "unban" }; /* if you plan to use this outside of this file, please include in __command.hpp (^-^) - and just make it a void. */ auto help_return = [](ENetEvent& event, const std::string_view text) { - std::string list{ named_help }; + if (static_cast<::peer*>(event.peer->data)->role >= DEVELOPER) { devmenu_open(event); return; } // @note popup for devs + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + std::string list{ player_help }; + if (pPeer->role >= MODERATOR && !mod_help.empty()) list += std::format(" {}", mod_help); + if (pPeer->role >= DEVELOPER && !dev_help.empty()) list += std::format(" {}", dev_help); for (std::string_view emote : emotes) list += std::format(" /{}", emote); @@ -57,7 +126,70 @@ std::unordered_map -extern std::array cmd_requires_arg; +extern std::array cmd_requires_arg; extern std::unordered_map> cmd_pool; diff --git a/include/commands/access.cpp b/include/commands/access.cpp new file mode 100644 index 00000000..a49dc664 --- /dev/null +++ b/include/commands/access.cpp @@ -0,0 +1,92 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "tools/bubble.hpp" +#include "database/database.hpp" +#include "database/world.hpp" +#include "lockaccess.hpp" +#include "access.hpp" + +static std::string lower_of(std::string s) +{ + for (char &c : s) c = static_cast(std::tolower(static_cast(c))); + return s; +} + +/* an account number from a name: players in this world first (exact, or the first letters), then any account */ +static int resolve_uid(const std::string &arg, const std::string &world) +{ + const std::string want = lower_of(arg); + int exact = 0, prefix = 0, prefixes = 0; + peers(world, PEER_SAME_WORLD, [&](ENetPeer &p) + { + const ::peer *t = static_cast<::peer*>(p.data); + if (!t || t->growid.empty()) return; + const std::string name = lower_of(t->growid); + if (name == want) exact = t->user_id; + else if (want.size() >= 3 && name.starts_with(want)) { prefix = t->user_id; ++prefixes; } + }); + if (exact) return exact; + if (prefixes == 1) return prefix; + + signed uid = 0; + ::hStmt hStmt{ "SELECT uid FROM peer WHERE growid = ? LIMIT 1" }; + MYSQL_BIND param = make_bind_in(want); + hStmt.bind_param(¶m); + MYSQL_BIND result = make_bind_out(uid); + mysql_stmt_bind_result(hStmt.pStmt, &result); + hStmt.execute(); + hStmt.fetch(); + return uid; +} + +/* /access -> lists who has access to this world + * /access {player} -> gives that player access, or takes it away if they already have it + */ +void access_cmd(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + + auto world = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + if (world == worlds.end()) return; + + if (world->owner != pPeer->user_id && pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only the world owner can change access.``"); + return; + } + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + while (!arg.empty() && std::isspace(static_cast(arg.back()))) arg.pop_back(); + + if (arg.empty()) + { + std::string names{}; + for (int uid : world->access) + { + if (uid == 0) continue; + if (!names.empty()) names += "`o, "; + names += std::format("`w{}``", access_name_of(uid)); + } + if (names.empty()) on::ConsoleMessage(event.peer, "`oNobody else has access to this world."); + else on::ConsoleMessage(event.peer, std::format("`oAccess: {}", names)); + return; + } + + const int uid = resolve_uid(arg, world->name); + if (uid == 0) + { + on::ConsoleMessage(event.peer, std::format("`4No account named `w{}`` exists.``", arg)); + return; + } + + // same as the World Lock menu: already listed -> remove, otherwise add + if (!lock_access_remove(event, *world, uid)) lock_access_add(event, *world, uid); +} diff --git a/include/commands/access.hpp b/include/commands/access.hpp new file mode 100644 index 00000000..49d38a9f --- /dev/null +++ b/include/commands/access.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void access_cmd(ENetEvent& event, const std::string_view text); diff --git a/include/commands/accounts.cpp b/include/commands/accounts.cpp new file mode 100644 index 00000000..ab3017d6 --- /dev/null +++ b/include/commands/accounts.cpp @@ -0,0 +1,315 @@ +#include "pch.hpp" +#include +#include "onVariant/ConsoleMessage.hpp" +#include "tools/bubble.hpp" +#include "tools/create_dialog.hpp" +#include "database/database.hpp" +#include "database/world.hpp" +#include "accounts.hpp" + +namespace +{ +using row_t = std::vector>; // @note {column, value}, binary columns left out + +/* ------------------------------------------------------------------ database */ +std::vector query(const std::string &sql) +{ + std::vector out{}; + if (mysql_query(db, sql.c_str())) { std::fprintf(stderr, "[accounts] %s\n", mysql_error(db)); return out; } + MYSQL_RES *res = mysql_store_result(db); + if (!res) return out; + const unsigned n = mysql_num_fields(res); + MYSQL_FIELD *f = mysql_fetch_fields(res); + while (MYSQL_ROW r = mysql_fetch_row(res)) + { + unsigned long *len = mysql_fetch_lengths(res); + row_t row{}; + for (unsigned i = 0; i < n; ++i) + { + const bool blob = (f[i].type == MYSQL_TYPE_BLOB || f[i].type == MYSQL_TYPE_TINY_BLOB || + f[i].type == MYSQL_TYPE_MEDIUM_BLOB || f[i].type == MYSQL_TYPE_LONG_BLOB) && f[i].charsetnr == 63; + if (blob) continue; + row.emplace_back(f[i].name, r[i] ? std::string(r[i], len[i]) : std::string{}); + } + out.push_back(std::move(row)); + } + mysql_free_result(res); + return out; +} + +bool run(const std::string &sql) +{ + if (mysql_query(db, sql.c_str())) { std::fprintf(stderr, "[accounts] %s\n", mysql_error(db)); return false; } + return true; +} + +bool has(const row_t &r, std::string_view name) { for (const auto &[k, v] : r) if (k == name) return true; return false; } +std::string col(const row_t &r, std::string_view name) { for (const auto &[k, v] : r) if (k == name) return v; return {}; } + +std::string esc(const std::string &s) +{ + std::string out(s.size() * 2 + 1, '\0'); + out.resize(mysql_real_escape_string(db, out.data(), s.c_str(), static_cast(s.size()))); + return out; +} + +std::string clean(std::string s) +{ + for (char &c : s) if (c == '|' || c == '\n' || c == '\r') c = ' '; + return s; +} + +/* ------------------------------------------------------------------ formatting */ +std::string number(long long v) +{ + std::string s = std::to_string(v < 0 ? -v : v); + for (int i = static_cast(s.size()) - 3; i > 0; i -= 3) s.insert(static_cast(i), ","); + return (v < 0 ? "-" : "") + s; +} + +std::string date(std::time_t t) +{ + char buf[40]{}; + std::strftime(buf, sizeof(buf), "%d %b %Y, %H:%M", std::localtime(&t)); + return buf; +} + +std::string left(long long secs) +{ + const long long d = secs / 86400, h = (secs % 86400) / 3600, m = (secs % 3600) / 60; + if (d > 0) return std::format("{}d {}h left", d, h); + if (h > 0) return std::format("{}h {}m left", h, m); + return std::format("{}m left", std::max(1LL, m)); +} + +/* "2026-09-20 14:32:05" -> "20 Sep 2026, 14:32" */ +std::string sql_date(const std::string &s) +{ + static const char *months[]{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; + if (s.size() < 16) return s.empty() ? "unknown" : s; + const int mo = std::atoi(s.substr(5, 2).c_str()); + return std::format("{} {} {}, {}", s.substr(8, 2), (mo >= 1 && mo <= 12) ? months[mo - 1] : "?", s.substr(0, 4), s.substr(11, 5)); +} + +std::string until(const std::string &v) +{ + const long long t = std::atoll(v.c_str()), now = static_cast(std::time(nullptr)); + if (t <= now) return "No"; + return std::format("`4Yes``, until {} ({})", date(static_cast(t)), left(t - now)); +} + +std::string rank_name(int role) { return role >= DEVELOPER ? "Developer" : role >= MODERATOR ? "Moderator" : "Player"; } +std::string rank_color(int role) { return role >= DEVELOPER ? "`6" : role >= MODERATOR ? "`#" : "`w"; } + +std::string pretty(std::string k) +{ + for (char &c : k) if (c == '_') c = ' '; + if (!k.empty()) k[0] = static_cast(std::toupper(static_cast(k[0]))); + return k; +} + +/* ------------------------------------------------------------------ online */ +const ::peer *online(int uid) +{ + const ::peer *hit = nullptr; + peers("", peer_condition::PEER_ALL, [&](ENetPeer &p) + { + const ::peer *t = static_cast<::peer*>(p.data); + if (t && t->user_id == uid) hit = t; + }); + return hit; +} + +std::string online_text(int uid) +{ + const ::peer *t = online(uid); + if (!t) return "`sOffline``"; + if (t->netid != 0 && !t->recent_worlds.empty() && !t->recent_worlds.back().empty()) + return std::format("`2Online`` in `w{}``", t->recent_worlds.back()); + return "`2Online`` (choosing a world)"; +} + +/* ------------------------------------------------------------------ pages */ +void send(ENetEvent &event, ::create_dialog &d) +{ + send_varlist(event.peer, { "OnDialogRequest", d.end_dialog("accounts", "Close", "") }); +} + +void line(::create_dialog &d, const std::string &label, const std::string &value, int icon) +{ + d.add_label_with_icon("small", std::format("{}: `w{}``", label, value), icon); +} + +void show_list(ENetEvent &event, const std::string &search) +{ + const std::string where = search.empty() ? "" : " WHERE growid LIKE '%" + esc(search) + "%'"; + const std::vector rows = query("SELECT * FROM peer" + where + " ORDER BY uid DESC LIMIT 100"); + const std::vector total = query("SELECT COUNT(*) AS n FROM peer"); + int online_now = 0; + peers("", peer_condition::PEER_ALL, [&](ENetPeer &p) { if (p.data && !static_cast<::peer*>(p.data)->growid.empty()) ++online_now; }); + + ::create_dialog d{}; + d.set_default_color("`o") + .add_label_with_icon("big", "`wAccounts``", 1280) + .add_smalltext(std::format("`w{}`` accounts - `2{}`` online now", total.empty() ? "?" : number(std::atoll(col(total[0], "n").c_str())), online_now)) + .add_spacer("small") + .add_text_input("search", "Find a player:", clean(search), 18) + .add_button("acc_search", "Search"); + if (!search.empty()) d.add_button("acc_back", "Show all"); + d.add_spacer("small"); + + if (rows.empty()) d.add_textbox("No accounts found."); + else d.add_smalltext(search.empty() ? "Newest first:" : std::format("Matching `w{}``:", clean(search))); + + for (const row_t &r : rows) + { + const int uid = std::atoi(col(r, "uid").c_str()); + const int role = std::atoi(col(r, "role").c_str()); + std::string info = std::format("#{}", uid); + if (has(r, "level")) info += std::format(" - Level {}", col(r, "level")); + info += std::format(" - Created {}", sql_date(col(r, "created_at"))); + if (online(uid)) info += " - `2online``"; + + d.add_spacer("small") + .add_button(std::format("acc_{}", uid), std::format("{}{}``", rank_color(role), clean(col(r, "growid")))) + .add_smalltext(info); + } + send(event, d); +} + +void show_details(ENetEvent &event, int uid) +{ + const std::vector rows = query(std::format("SELECT * FROM peer WHERE uid = {}", uid)); + if (rows.empty()) { tell(event.peer, "`4That account doesn't exist anymore.``"); show_list(event, ""); return; } + const row_t &r = rows[0]; + const int role = std::atoi(col(r, "role").c_str()); + const std::vector owned = query(std::format("SELECT name FROM world WHERE owner = {} ORDER BY name", uid)); + + static const std::vector shown{ "uid", "growid", "password", "created_at", "role", "renamed_at", + "level", "xp", "gems", "curse_until", "ban_until", "god" }; + + ::create_dialog d{}; + d.set_default_color("`o") + .add_label_with_icon("big", std::format("{}{}``", rank_color(role), clean(col(r, "growid"))), 1280) + .add_smalltext(std::format("{} - {}", rank_name(role), online_text(uid))); + + d.add_spacer("small").add_label("big", "`wAccount``"); + line(d, "ID", std::format("#{}", uid), 1280); + line(d, "Created", sql_date(col(r, "created_at")), 1280); + line(d, "Rank", rank_name(role), role >= MODERATOR ? 278 : 18); + if (has(r, "renamed_at")) + { + const long long t = std::atoll(col(r, "renamed_at").c_str()); + line(d, "Last name change", t > 0 ? date(static_cast(t)) : "Never", 1280); + } + + if (has(r, "level") || has(r, "xp") || has(r, "gems")) + { + d.add_spacer("small").add_label("big", "`wProgress``"); + if (has(r, "level")) line(d, "Level", col(r, "level"), 1488); + if (has(r, "xp")) line(d, "XP", number(std::atoll(col(r, "xp").c_str())), 1488); + if (has(r, "gems")) line(d, "Gems", number(std::atoll(col(r, "gems").c_str())), 112); + } + + d.add_spacer("small").add_label("big", "`wStatus``"); + if (has(r, "curse_until")) line(d, "Cursed", until(col(r, "curse_until")), 278); + if (has(r, "ban_until")) line(d, "Banned", until(col(r, "ban_until")), 732); + if (has(r, "god")) line(d, "God mode", std::atoi(col(r, "god").c_str()) ? "`2On``" : "Off", 128); + + d.add_spacer("small").add_label("big", std::format("`wWorlds ({})``", owned.size())); + if (owned.empty()) d.add_smalltext("Doesn't own any worlds."); + for (const row_t &w : owned) d.add_label_with_icon("small", std::format("`w{}``", clean(col(w, "name"))), 242); + + bool other = false; + for (const auto &[k, v] : r) + { + if (std::ranges::find(shown, k) != shown.end()) continue; + if (!other) { d.add_spacer("small").add_label("big", "`wOther``"); other = true; } + d.add_smalltext(std::format("{}: `w{}``", pretty(k), v.empty() ? "-" : clean(v))); + } + + d.embed_data("uid", uid) + .add_spacer("small") + .add_button("acc_delete", "`4Delete Account``") + .add_button("acc_back", "Back to list"); + send(event, d); +} + +void confirm_delete(ENetEvent &event, int uid) +{ + const std::vector rows = query(std::format("SELECT growid FROM peer WHERE uid = {}", uid)); + if (rows.empty()) return; + const std::vector owned = query(std::format("SELECT name FROM world WHERE owner = {}", uid)); + + ::create_dialog d{}; + d.set_default_color("`o") + .add_label_with_icon("big", std::format("`4Delete {}?``", clean(col(rows[0], "growid"))), 1280) + .add_textbox("`4This can't be undone.`` The account, its backpack and its progress will be gone for good.") + .add_smalltext(owned.empty() ? "They don't own any worlds." : std::format("They own `w{}`` world(s). What should happen to them?", owned.size())) + .embed_data("uid", uid) + .add_spacer("small") + .add_button("acc_del_keep", owned.empty() ? "`4Delete Account``" : "Delete account, keep the worlds (unlocked)"); + if (!owned.empty()) d.add_button("acc_del_worlds", "`4Delete account AND their worlds``"); + d.add_button(std::format("acc_{}", uid), "Cancel"); + send(event, d); +} + +void do_delete(ENetEvent &event, int uid, bool with_worlds) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (uid == pPeer->user_id) { tell(event.peer, "`4You can't delete your own account.``"); return; } + if (online(uid)) { tell(event.peer, "`4That player is online. Kick them first.``"); return; } + + const std::vector rows = query(std::format("SELECT growid FROM peer WHERE uid = {}", uid)); + if (rows.empty()) return; + const std::string growid = col(rows[0], "growid"); + + int deleted = 0, unlocked = 0; + for (const row_t &w : query(std::format("SELECT name FROM world WHERE owner = {}", uid))) + { + const std::string name = col(w, "name"); + auto loaded = std::ranges::find(worlds, name, &::world::name); + if (with_worlds && loaded == worlds.end()) + { + if (run("DELETE FROM world WHERE name = '" + esc(name) + "'")) ++deleted; + } + else + { + if (loaded != worlds.end()) loaded->owner = 0; + if (run("UPDATE world SET owner = 0 WHERE name = '" + esc(name) + "'")) ++unlocked; + } + } + run("DELETE FROM renames WHERE new_name = '" + esc(growid) + "'"); + if (!run(std::format("DELETE FROM peer WHERE uid = {}", uid))) { tell(event.peer, "`4Deleting failed - see the server window.``"); return; } + + printf("[accounts] %s deleted account %s (#%d): %d worlds deleted, %d unlocked\n", pPeer->growid.c_str(), growid.c_str(), uid, deleted, unlocked); + on::ConsoleMessage(event.peer, std::format("`2Deleted `w{}``. Worlds deleted: `w{}``, unlocked: `w{}``.``", clean(growid), deleted, unlocked)); + show_list(event, ""); +} +} + +/* /accounts [search] */ +void accounts_cmd(ENetEvent &event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (pPeer->role < DEVELOPER) return; + std::string search{}; + if (const std::size_t sp = text.find(' '); sp != std::string_view::npos) search = std::string(text.substr(sp + 1)); + show_list(event, search); +} + +void accounts_return(ENetEvent &event, const ::hPipe &hPipe) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (pPeer->role < DEVELOPER) return; + + const std::string btn = hPipe["buttonClicked"]; + const int uid = std::atoi(hPipe["uid"].c_str()); + + if (btn == "acc_search") show_list(event, hPipe["search"]); + else if (btn == "acc_back") show_list(event, ""); + else if (btn == "acc_delete") confirm_delete(event, uid); + else if (btn == "acc_del_keep") do_delete(event, uid, false); + else if (btn == "acc_del_worlds") do_delete(event, uid, true); + else if (btn.starts_with("acc_")) show_details(event, std::atoi(btn.substr(4).c_str())); +} diff --git a/include/commands/accounts.hpp b/include/commands/accounts.hpp new file mode 100644 index 00000000..f4ffaa5c --- /dev/null +++ b/include/commands/accounts.hpp @@ -0,0 +1,4 @@ +#pragma once + +extern void accounts_cmd(ENetEvent &event, const std::string_view text); +extern void accounts_return(ENetEvent &event, const ::hPipe &hPipe); diff --git a/include/commands/announce.cpp b/include/commands/announce.cpp new file mode 100644 index 00000000..5cd25d57 --- /dev/null +++ b/include/commands/announce.cpp @@ -0,0 +1,36 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "announce.hpp" + +/* /announce {message} -> server-wide broadcast from staff */ +void announce(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < MODERATOR) + { + on::ConsoleMessage(event.peer, "`4Only staff can use this.``"); + return; + } + + std::string message{ text }; + if (const std::size_t sp = message.find(' '); sp != std::string::npos) message = message.substr(sp + 1); + else message.clear(); + + while (!message.empty() && std::isspace(static_cast(message.back()))) message.pop_back(); + + if (message.empty()) + { + on::ConsoleMessage(event.peer, "`4Usage: /announce {message}``"); + return; + } + + const std::string line = + std::format("`5** `wServer Announcement`` `5from `w{}`` **`` `o{}``", + pPeer->growid, message); + + peers("", peer_condition::PEER_ALL, [&line](ENetPeer &p) + { + on::ConsoleMessage(&p, line); + }); +} diff --git a/include/commands/announce.hpp b/include/commands/announce.hpp new file mode 100644 index 00000000..b9950353 --- /dev/null +++ b/include/commands/announce.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void announce(ENetEvent& event, const std::string_view text); diff --git a/include/commands/blasts.cpp b/include/commands/blasts.cpp new file mode 100644 index 00000000..fe21f149 --- /dev/null +++ b/include/commands/blasts.cpp @@ -0,0 +1,793 @@ +#include "pch.hpp" +#include "tools/bubble.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "action/join_request.hpp" +#include "action/quit_to_exit.hpp" +#include "tools/string.hpp" +#include "tools/time.hpp" +#include "tools/random.hpp" +#include "database/database.hpp" +#include "database/items.hpp" +#include "database/world.hpp" +#include "blasts.hpp" + +namespace +{ +constexpr int W = 100, H = 60, BEDROCK_ROW = 54; +using heights = std::array; + +bool chance(int percent) { return RandomRange(0, 100) < percent; } +bool inside(int x, int y) { return x >= 0 && y >= 0 && x < W && y < H; } +::block &at(::world &w, int x, int y) { return w.blocks[cord(x, y)]; } +short or_(short a, short b) { return a ? a : b; } + +std::string lower(std::string s) +{ + for (char &c : s) c = static_cast(std::tolower(static_cast(c))); + return s; +} + +/* item id by exact name, 0 if missing */ +short find_id(const char *name) +{ + const std::string want = lower(name); + for (const ::item &it : items) + if (it.raw_name.size() == want.size() && lower(it.raw_name) == want) return static_cast(it.id); + printf("[blast] no item named '%s'\n", name); + return 0; +} + +/* the id if it really is that item, otherwise looked up by name */ +short named(short id, const char *name) +{ + if (id > 0 && id_to_item(static_cast(id)).raw_name == name) return id; + return find_id(name); +} + +/* like named(), but only if it can be placed as a plain block (no extra tile data) */ +short block_of(short id, const char *name) +{ + const short real = named(id, name); + if (!real) return 0; + if (get_type(id_to_item(static_cast(real))) != 0) + { + printf("[blast] '%s' needs extra tile data - left out\n", name); + return 0; + } + return real; +} + +/* like block_of(), but it must be a background, otherwise fallback */ +short background_of(short id, const char *name, short fallback) +{ + const short real = block_of(id, name); + return (real && id_to_item(static_cast(real)).type == type::BACKGROUND) ? real : fallback; +} + +/* the seed of a block (block id + 1) */ +short seed_of(short block_id, const char *block_name) +{ + const short b = named(block_id, block_name); + if (!b) return 0; + const short s = static_cast(b + 1); + return (id_to_item(static_cast(s)).type == type::SEED) ? s : 0; +} + +heights hills(int base, int lo, int hi, int roughness, int step = 1) +{ + heights h{}; + int y = base; + for (int x = 0; x < W; ++x) + { + if (RandomRange(0, 100) < roughness) y += RandomRange(-step, step + 1); + y = std::clamp(y, lo, hi); + h[x] = y; + } + return h; +} + +heights flat(int y) { heights h{}; h.fill(y); return h; } + +void flatten(heights &h, int dx) +{ + for (int x = dx - 2; x <= dx + 2; ++x) if (x >= 0 && x < W) h[x] = h[dx]; +} + +struct palette +{ + short fill = 2, top = 0, rock = 10, lava = 4, bedrock = 8, bottom = 8, door_bedrock = 8, bg = 14; + int rock_pct = 5, lava_pct = 37; +}; + +void reset(::world &w) +{ + w.blocks.assign(W * H, ::block{ 0, 0 }); + w.doors.clear(); w.trees.clear(); w.objects.clear(); + w.signs.clear(); w.displays.clear(); w.random_blocks.clear(); + w.last_object_uid = 0; +} + +void ground(::world &w, const heights &h, const palette &p) +{ + for (int x = 0; x < W; ++x) + for (int y = h[x]; y < H; ++y) + { + ::block &b = at(w, x, y); + b.bg = p.bg; + if (y >= BEDROCK_ROW) b.fg = (y == H - 1) ? p.bottom : p.bedrock; + else if (y == h[x] && p.top) b.fg = p.top; + else if (y > h[x] && y < 50 && p.rock && chance(p.rock_pct)) b.fg = p.rock; + else if (y > 50 && p.lava && chance(p.lava_pct)) b.fg = p.lava; + else b.fg = p.fill; + } +} + +/* main door standing on the tile `surface`, with bedrock under it */ +void place_door(::world &w, int x, int surface, short bedrock) +{ + const int y = surface - 1; + at(w, x, y).fg = 6; + at(w, x, surface).fg = bedrock; + w.doors.emplace_back(::door("EXIT", "", "", ::pos{ x, y })); + w.spawn = ::pos{ x, y }.by_32(false); +} + +template +void decorate(::world &w, const heights &h, int dx, short id, int pct, F ok) +{ + if (!id) return; + for (int x = 1; x < W - 1; ++x) + { + if (std::abs(x - dx) <= 2 || !ok(x)) continue; + const int y = h[x] - 1; + if (!inside(x, y) || at(w, x, y).fg != 0) continue; + if (chance(pct)) at(w, x, y).fg = id; + } +} +void decorate(::world &w, const heights &h, int dx, short id, int pct) { decorate(w, h, dx, id, pct, [](int) { return true; }); } + +void drop(::world &w, short id, short count, int x, int y) +{ + if (!id || !inside(x, y)) return; + w.objects.emplace_back(::object(static_cast(id), static_cast(count), ::pos{ x * 32.0f + 8.0f, y * 32.0f + 8.0f }, ++w.last_object_uid)); +} + +/* a fully grown tree */ +void plant(::world &w, int x, int y, short seed) +{ + if (!seed || !inside(x, y)) return; + const ::item &s = id_to_item(static_cast(seed)); + if (s.type != type::SEED) return; + at(w, x, y).fg = seed; + w.trees.emplace_back(ticks() - static_cast(s.tick), static_cast(RandomRange(1, 5)), ::pos{ x, y }); +} + +void wet(::world &w, int x, int y) { if (inside(x, y)) at(w, x, y).state[3] |= S_WATER; } + +/* ------------------------------------------------------------------ worlds */ + +void make_thermonuclear(::world &w) +{ + reset(w); + palette p; p.fill = 0; p.rock = 0; p.lava = 0; p.bg = 0; + ground(w, flat(BEDROCK_ROW), p); + place_door(w, chance(50) ? 1 : W - 2, 37, 8); +} + +void make_desert(::world &w) +{ + reset(w); + palette p; + p.fill = or_(block_of(442, "Sand"), 2); + p.rock = block_of(332, "Sandstone"); + heights h = hills(37, 34, 39, 25); + const int dx = RandomRange(4, W - 4); + flatten(h, dx); + ground(w, h, p); + if (const short boulder = block_of(944, "Boulder")) + for (int i = 0; i < 25; ++i) { const int x = RandomRange(0, W), y = RandomRange(40, 50); if (at(w, x, y).fg == p.fill) at(w, x, y).fg = boulder; } + place_door(w, dx, h[dx], 8); + + const short stone = or_(block_of(332, "Sandstone"), p.fill); + const short wall = background_of(434, "Sandstone Wall", 0); + const short egypt = background_of(436, "Wall Like An Egyptian", wall); + std::vector used{ dx }; + int top_x = -1, top_y = -1; + for (int size : { 15, 11, 7 }) + { + int cx = -1; + for (int tries = 0; tries < 60 && cx < 0; ++tries) + { + const int c = RandomRange(size / 2 + 1, W - size / 2 - 1); + if (std::ranges::all_of(used, [&](int u) { return std::abs(u - c) > size / 2 + 8; })) cx = c; + } + if (cx < 0) continue; + used.push_back(cx); + int base = H; + for (int x = cx - size / 2; x <= cx + size / 2; ++x) base = std::min(base, h[x]); + base -= 1; + for (int r = 0; r <= size / 2; ++r) + { + const int y = base - r; + for (int x = cx - (size / 2 - r); x <= cx + (size / 2 - r); ++x) + { + if (!inside(x, y)) continue; + ::block &b = at(w, x, y); + b.fg = stone; + b.bg = (r > 0 && std::abs(x - cx) < size / 2 - r - 1) ? egypt : wall; + } + } + for (int x = cx - size / 2; x <= cx + size / 2; ++x) + for (int y = base + 1; y < h[x]; ++y) at(w, x, y).fg = stone; + if (top_x < 0) { top_x = cx; top_y = base - size / 2 - 1; } + } + if (top_x >= 0) + { + const int roll = RandomRange(0, 100); + const short prize = roll < 45 ? named(976, "Sungate") : roll < 90 ? named(970, "Silver Idol") + : roll < 95 ? named(12292, "Anubis Pharaoh Mask") : named(12294, "Pharaoh Mask"); + drop(w, prize, 1, top_x, top_y); + } + decorate(w, h, dx, block_of(330, "Cactus"), 7); + decorate(w, h, dx, block_of(974, "Obelisk"), 1); + decorate(w, h, dx, block_of(972, "Ancient Stone Gate"), 1); +} + +void make_jungle(::world &w) +{ + reset(w); + palette p; + p.fill = or_(block_of(1536, "Deep Sand"), 2); + p.rock = or_(block_of(1538, "Deep Rock"), 10); + p.rock_pct = 8; + heights h = hills(37, 34, 40, 30); + const int dx = RandomRange(4, W - 4); + flatten(h, dx); + ground(w, h, p); + if (const short quicksand = block_of(11210, "Quicksand Block")) + for (int x = 0; x < W; ++x) if (std::abs(x - dx) > 2 && chance(4)) at(w, x, h[x]).fg = quicksand; + place_door(w, dx, h[dx], 8); + + const short ancient = or_(block_of(4702, "Ancient Block"), p.fill); + const short wall = background_of(434, "Sandstone Wall", 0); + int zx = -1; + for (int tries = 0; tries < 60 && zx < 0; ++tries) { const int c = RandomRange(12, W - 12); if (std::abs(c - dx) > 20) zx = c; } + if (zx >= 0) + { + int base = H; + for (int x = zx - 10; x <= zx + 10; ++x) base = std::min(base, h[x]); + base -= 1; + for (int step = 0; step < 5; ++step) + for (int r = 0; r < 2; ++r) + { + const int y = base - step * 2 - r, half = 10 - step * 2; + for (int x = zx - half; x <= zx + half; ++x) + if (inside(x, y)) { at(w, x, y).fg = ancient; at(w, x, y).bg = wall; } + } + for (int x = zx - 10; x <= zx + 10; ++x) + for (int y = base + 1; y < h[x]; ++y) at(w, x, y).fg = ancient; + for (int x = zx - 2; x <= zx + 2; ++x) + for (int y = base - 3; y <= base - 1; ++y) at(w, x, y).fg = 0; // @note hidden chamber + const int roll = RandomRange(0, 100); + const short prize = roll < 50 ? named(4778, "Adventurer's Whip") : roll < 80 ? named(4714, "Minecart") + : roll < 85 ? named(13766, "Koala Hood") : roll < 90 ? named(13782, "Raccoon Hood") + : roll < 95 ? named(10066, "Riding Gorilla") : named(11188, "Wearable Ostrich"); + drop(w, prize, 1, zx, base - 1); + } + + int sx = -1; + for (int tries = 0; tries < 60 && sx < 0; ++tries) + { + const int c = RandomRange(3, W - 3); + if (std::abs(c - dx) > 6 && (zx < 0 || std::abs(c - zx) > 14)) sx = c; + } + if (sx >= 0) + { + const short body = block_of(4784, "Statue Block"), eye = block_of(4786, "Statue Eye"), head = block_of(4788, "Statue Headdress"), + nose = block_of(4790, "Statue Nose"), mouth = block_of(4792, "Statue Mouth"); + const int g = std::min({ h[sx - 1], h[sx], h[sx + 1] }) - 1; + const short layout[5][3] = { { body, head, body }, { eye, body, eye }, { body, nose, body }, { body, mouth, body }, { body, body, body } }; + for (int r = 0; r < 5; ++r) + for (int c = 0; c < 3; ++c) + { + const int x = sx - 1 + c, y = g - 4 + r; + if (inside(x, y) && layout[r][c]) at(w, x, y).fg = layout[r][c]; + } + for (int x = sx - 1; x <= sx + 1; ++x) + for (int y = g + 1; y < h[x]; ++y) at(w, x, y).fg = body ? body : p.fill; + } + decorate(w, h, dx, block_of(4782, "Jungle Fern"), 8); + decorate(w, h, dx, block_of(854, "Palm Tree"), 4); + decorate(w, h, dx, block_of(1102, "Sequoia Tree"), 3); + decorate(w, h, dx, block_of(4798, "Overgrown Vines"), 3); +} + +void make_mars(::world &w) +{ + reset(w); + palette p; + p.fill = or_(block_of(1132, "Martian Soil"), 2); + p.rock = or_(block_of(1134, "Mars Rock"), 10); + p.rock_pct = 8; + heights h = hills(37, 32, 41, 35); + const int dx = RandomRange(4, W - 4); + flatten(h, dx); + ground(w, h, p); + if (const short ice = block_of(440, "Ice")) + for (int i = 0; i < 8; ++i) + { + const int cx = RandomRange(0, W), cy = RandomRange(40, 51), r = RandomRange(1, 3); + for (int x = cx - r; x <= cx + r; ++x) + for (int y = cy - r; y <= cy + r; ++y) + if (inside(x, y) && at(w, x, y).fg == p.fill) at(w, x, y).fg = ice; + } + place_door(w, dx, h[dx], 8); + decorate(w, h, dx, block_of(1138, "Martian Tree"), 5); +} + +void make_cave(::world &w) +{ + reset(w); + const short dirt = or_(block_of(3564, "Cave Dirt"), 2); + const short bg = background_of(3556, "Dark Cave Background", 14); + for (int x = 0; x < W; ++x) + for (int y = 0; y < H; ++y) + { + ::block &b = at(w, x, y); + b.bg = bg; + b.fg = (y >= BEDROCK_ROW) ? 8 : (y >= 50 && chance(20)) ? 4 : dirt; + } + auto carve = [&](int cx, int cy, int r) + { + for (int x = cx - r; x <= cx + r; ++x) + for (int y = cy - r; y <= cy + r; ++y) + if (inside(x, y) && y >= 1 && y < 50) at(w, x, y).fg = 0; + }; + for (int worm = 0; worm < 9; ++worm) + { + int x = RandomRange(5, 95), y = RandomRange(4, 48); + for (int step = 0; step < 140; ++step) + { + carve(x, y, 1); + x = std::clamp(x + RandomRange(-1, 2), 2, W - 3); + if (chance(30)) y = std::clamp(y + RandomRange(-1, 2), 3, 48); + } + } + const int dx = RandomRange(6, W - 6), dy = RandomRange(12, 28); + for (int x = dx - 4; x <= dx + 4; ++x) + for (int y = dy - 4; y <= dy; ++y) at(w, x, y).fg = 0; + for (int x = dx - 4; x <= dx + 4; ++x) if (at(w, x, dy + 1).fg == 0) at(w, x, dy + 1).fg = dirt; // @note floor under the door pocket + + const short stalagmite = block_of(3568, "Stalagmite"), stalactite = block_of(3570, "Stalactite"), + crystal = block_of(3584, "Aqua Cave Crystal"), column = block_of(3566, "Cave Column"), + iron = block_of(3608, "Deep Iron"), hoard = block_of(3604, "Golden Treasure Hoard"); + int hoards = 0; + for (int x = 1; x < W - 1; ++x) + for (int y = 2; y < 50; ++y) + { + ::block &b = at(w, x, y); + if (b.fg == dirt) { if (iron && y > 20 && chance(3)) b.fg = iron; continue; } + if (b.fg != 0 || (std::abs(x - dx) <= 4 && y >= dy - 4 && y <= dy)) continue; + const bool floor = at(w, x, y + 1).fg == dirt, ceiling = at(w, x, y - 1).fg == dirt; + if (floor && hoard && hoards < 2 && chance(1)) { b.fg = hoard; ++hoards; } + else if (floor && stalagmite && chance(6)) b.fg = stalagmite; + else if (floor && crystal && chance(2)) b.fg = crystal; + else if (floor && column && chance(1)) b.fg = column; + else if (ceiling && stalactite && chance(6)) b.fg = stalactite; + } + for (int tries = 0; hoard && hoards < 2 && tries < 3000; ++tries) // @note deep treasure: always 2 hoards + { + const int x = RandomRange(1, W - 1), y = RandomRange(30, 50); + if (at(w, x, y).fg == 0 && at(w, x, y + 1).fg == dirt && std::abs(x - dx) > 4) { at(w, x, y).fg = hoard; ++hoards; } + } + place_door(w, dx, dy + 1, 8); + drop(w, named(0, "Hand Torch"), 10, dx + 1, dy); +} + +void make_beach(::world &w) +{ + reset(w); + palette p; + p.fill = or_(block_of(442, "Sand"), 2); + p.rock_pct = 3; + p.bg = background_of(850, "Ocean Rock", 14); + heights h = hills(37, 35, 38, 20); + const bool left = chance(50); + auto ocean = [&](int x) { return left ? x < 40 : x >= 60; }; + for (int x = 0; x < W; ++x) + if (ocean(x)) { const int d = left ? 40 - x : x - 59; h[x] = std::min(46, 38 + d / 3); } + const int dx = left ? RandomRange(50, W - 4) : RandomRange(4, 50); + flatten(h, dx); + ground(w, h, p); + for (int x = 0; x < W; ++x) + if (ocean(x)) for (int y = 37; y < h[x]; ++y) wet(w, x, y); + if (const short chest = block_of(596, "Treasure Chest")) + for (int i = RandomRange(1, 4); i > 0; --i) + { + const int x = left ? RandomRange(42, W - 1) : RandomRange(1, 58); + const int y = h[x] + RandomRange(2, 6); + if (std::abs(x - dx) > 2 && at(w, x, y).fg == p.fill) { at(w, x, y).fg = chest; w.treasure_spots += std::format("{},{};", x, y); } // @note has treasure + } + place_door(w, dx, h[dx], 8); + auto land = [&](int x) { return !ocean(x); }; + decorate(w, h, dx, block_of(854, "Palm Tree"), 6, land); + decorate(w, h, dx, block_of(848, "Beach Umbrella"), 4, land); + decorate(w, h, dx, block_of(846, "Seaweed"), 12, ocean); + decorate(w, h, dx, block_of(832, "Coral"), 6, ocean); + decorate(w, h, dx, block_of(5038, "Anemone"), 4, ocean); + decorate(w, h, dx, block_of(8252, "Sea Urchin"), 3, ocean); +} + +void make_undersea(::world &w) +{ + reset(w); + palette p; + p.fill = or_(block_of(1536, "Deep Sand"), 2); + p.rock = or_(block_of(1538, "Deep Rock"), 10); + p.rock_pct = 8; + p.bg = background_of(850, "Ocean Rock", 14); + heights h = hills(40, 36, 44, 30); + const int dx = RandomRange(4, W - 4); + flatten(h, dx); + ground(w, h, p); + place_door(w, dx, h[dx], 8); + for (int x = 0; x < W; ++x) + for (int y = 0; y < h[x]; ++y) wet(w, x, y); + + const short seaweed = block_of(846, "Seaweed"), coral = block_of(832, "Coral"); + for (int x = 1; x < W - 1; ++x) + { + if (std::abs(x - dx) <= 2 || !seaweed || !chance(18)) continue; + for (int y = h[x] - 1, n = RandomRange(1, 4); n > 0 && y > 0 && at(w, x, y).fg == 0; --y, --n) at(w, x, y).fg = seaweed; + } + if (coral) + for (int castle = 0; castle < 3; ++castle) + { + const int cx = RandomRange(3, W - 3); + if (std::abs(cx - dx) < 6) continue; + for (int x = cx - 2; x <= cx + 2; ++x) + for (int y = h[x] - 1, n = RandomRange(2, 6); n > 0 && y > 0; --y, --n) at(w, x, y).fg = coral; + } + auto on_floor = [&](short id, int count) + { + if (!id) return; + for (int i = 0; i < count; ++i) + { + const int x = RandomRange(1, W - 1); + if (std::abs(x - dx) > 10 && at(w, x, h[x] - 1).fg == 0) at(w, x, h[x] - 1).fg = id; + } + }; + on_floor(block_of(1520, "Great White Shark"), RandomRange(0, 3)); + on_floor(block_of(0, "Giant Clam"), RandomRange(1, 3)); + if (chance(20)) on_floor(block_of(0, "Sunken Anchor"), 1); + if (const short jelly = block_of(0, "Jellyfish")) + for (int i = RandomRange(1, 4); i > 0; --i) + { + const int x = RandomRange(1, W - 1), y = RandomRange(10, std::max(11, h[x] - 5)); + if (std::abs(x - dx) > 4 && at(w, x, y).fg == 0) at(w, x, y).fg = jelly; + } +} + +void make_harvest_moon(::world &w) +{ + reset(w); + palette p; + const heights h = flat(37); + const int dx = RandomRange(4, W - 4); + ground(w, h, p); + place_door(w, dx, h[dx], 8); + std::vector seeds{}; + for (const char *n : { "Autumn Leaf Block", "Pinecone", "Maple Leaf", "Dirt", "Rock", "Cave Background", "Wood Block", + "Grass", "Sand", "Sandstone", "Glass Pane", "Bricks", "Wooden Platform", "Lava", "Daisy" }) + if (const short s = seed_of(0, n)) seeds.push_back(s); + if (seeds.empty()) return; + for (int x = 0; x < W; ++x) + if (std::abs(x - dx) > 1) plant(w, x, h[x] - 1, seeds[RandomRange(0, static_cast(seeds.size()))]); +} + +void make_bountiful(::world &w) +{ + reset(w); + palette p; + heights h = hills(37, 33, 40, 30); + const int dx = RandomRange(4, W - 4); + flatten(h, dx); + ground(w, h, p); + if (const short soil = block_of(8772, "Fertile Soil Block")) + for (int x = 0; x < W; ++x) if (chance(35)) at(w, x, h[x]).fg = soil; + place_door(w, dx, h[dx], 8); + std::vector seeds{}; + for (const auto &[id, name] : { std::pair{ 8622, "Bountiful Flowering Lattice" }, { 8670, "Bountiful Bamboo Background" }, + { 8694, "Bountiful White Doll's Eyes" }, { 8646, "Bountiful Jungle Temple" } }) + if (const short s = seed_of(id, name)) seeds.push_back(s); + if (seeds.empty()) return; + for (int i = 0; i < 14; ++i) + { + const int x = RandomRange(1, W - 1); + if (std::abs(x - dx) > 2 && at(w, x, h[x] - 1).fg == 0) plant(w, x, h[x] - 1, seeds[RandomRange(0, static_cast(seeds.size()))]); + } +} + +void make_monochrome(::world &w) +{ + reset(w); + palette p; + p.fill = or_(block_of(7374, "Monochromatic Dirt"), 2); + p.bg = background_of(7378, "Monochromatic Cave Background", 14); + p.lava = or_(block_of(7376, "Monochromatic Lava"), 4); + p.bedrock = p.bottom = p.door_bedrock = or_(block_of(7372, "Monochromatic Bedrock"), 8); + const heights h = flat(37); + const int dx = RandomRange(4, W - 4); + ground(w, h, p); + if (const short onyx = block_of(7382, "Onyx Block")) + for (int x = 0; x < W; ++x) + for (int y = 40; y < BEDROCK_ROW; ++y) if (at(w, x, y).fg == p.fill && RandomRange(0, 1000) < 15) at(w, x, y).fg = onyx; + place_door(w, dx, h[dx], p.door_bedrock); +} + +void make_candyland(::world &w) +{ + reset(w); + palette p; + p.fill = or_(block_of(14898, "Colorful Cake"), 2); + p.top = block_of(14902, "Colorful Cake Toppings"); + p.rock = 0; + if (const short slice = block_of(14900, "Colorful Cake Slice")) + { + if (id_to_item(static_cast(slice)).type == type::BACKGROUND) p.bg = slice; + else { p.rock = slice; p.rock_pct = 6; } + } + p.lava = or_(block_of(14906, "Hot Fudge"), 4); + p.bedrock = p.door_bedrock = or_(block_of(14908, "Bedrock Candy"), 8); + p.bottom = or_(block_of(14910, "Data Bedrock Candy"), p.bedrock); + heights h = hills(37, 34, 39, 25); + const int dx = RandomRange(4, W - 4); + flatten(h, dx); + ground(w, h, p); + place_door(w, dx, h[dx], p.door_bedrock); + decorate(w, h, dx, block_of(14904, "Cherry on Top"), 8); +} + +void make_fruit_kingdom(::world &w) +{ + reset(w); + palette p; + p.fill = or_(block_of(16194, "Fruit Sand"), 2); + p.rock = block_of(16200, "Rock Melon"); + p.rock_pct = 6; + p.lava = 0; + p.bedrock = p.door_bedrock = or_(block_of(16206, "Bedrock Melon"), 8); + p.bottom = or_(block_of(16208, "Data Bedrock Melon"), p.bedrock); + p.bg = background_of(16196, "Fruit Sand Background", 14); + heights h = hills(37, 34, 39, 25); + const int dx = RandomRange(4, W - 4); + flatten(h, dx); + ground(w, h, p); + if (const short fruity = block_of(16198, "Fruit in Sand")) + for (int x = 0; x < W; ++x) + for (int y = h[x] + 1; y < 50; ++y) if (at(w, x, y).fg == p.fill && chance(8)) at(w, x, y).fg = fruity; + place_door(w, dx, h[dx], p.door_bedrock); + decorate(w, h, dx, block_of(16204, "Spikey Leaves"), 5); + + std::vector fruits{}; + for (const auto &[id, name] : { std::pair{ 13512, "Watermelon Block" }, { 13510, "Fresh Watermelon Block" }, + { 13516, "Papaya Block" }, { 13514, "Fresh Papaya Block" }, { 13520, "Kiwi Block" }, { 13518, "Fresh Kiwi Block" }, + { 13524, "Dragon Fruit Block" }, { 13522, "Fresh Dragon Fruit Block" }, { 2732, "Pineapple Block" }, { 13526, "Fresh Pineapple Block" } }) + if (const short f = block_of(id, name)) fruits.push_back(f); + if (fruits.empty()) return; + for (int x = 1; x < W - 1; ++x) + { + if (std::abs(x - dx) <= 2 || !chance(10)) continue; + const short f = fruits[RandomRange(0, static_cast(fruits.size()))]; + for (int y = h[x] - 1, n = RandomRange(1, 4); n > 0 && y > 0 && at(w, x, y).fg == 0; --y, --n) at(w, x, y).fg = f; + } +} + +void make_treasure(::world &w) +{ + reset(w); + palette p; + p.fill = or_(block_of(7620, "Frozen Stone Cliffs"), 10); + p.rock = block_of(7624, "Rune Stone"); + p.rock_pct = 3; + p.lava = 0; + p.bg = background_of(7622, "Glacier Background", 14); + heights h = hills(32, 18, 42, 70, 2); + const int dx = RandomRange(4, W - 4); + flatten(h, dx); + ground(w, h, p); + if (const short ice = block_of(440, "Ice")) + for (int x = 0; x < W; ++x) if (std::abs(x - dx) > 2 && (h[x] < 28 || chance(20))) at(w, x, h[x]).fg = ice; + place_door(w, dx, h[dx], 8); + + if (const short pillar = block_of(7630, "Rune Carved Stone Pillar")) + for (int i = 0; i < 3; ++i) + { + const int x = RandomRange(2, W - 2); + if (std::abs(x - dx) <= 3) continue; + for (int y = h[x] - 1, n = RandomRange(2, 5); n > 0 && y > 0 && at(w, x, y).fg == 0; --y, --n) at(w, x, y).fg = pillar; + } + decorate(w, h, dx, block_of(2226, "Icicles"), 3); + decorate(w, h, dx, block_of(7626, "Altar"), 1); + if (const short hidden = block_of(7628, "Hidden Treasure")) + for (int i = 0; i < 3; ++i) + { + const int x = RandomRange(1, W - 1), y = h[x] + RandomRange(2, 8); + if (std::abs(x - dx) > 2 && inside(x, y) && at(w, x, y).fg == p.fill) at(w, x, y).fg = hidden; + } + for (int pool = 0; pool < 2; ++pool) + { + const int x0 = RandomRange(2, W - 8); + if (std::abs(x0 - dx) < 8) continue; + for (int x = x0; x < x0 + 5; ++x) + for (int y = h[x] - 2; y < h[x]; ++y) if (inside(x, y) && at(w, x, y).fg == 0) wet(w, x, y); + } +} + +struct blast_def { short item; int weather; void (*make)(::world&); }; +const blast_def blasts[] = { + { 1402, 0, make_thermonuclear }, + { 830, 1, make_beach }, + { 942, 3, make_desert }, + { 1060, 6, make_harvest_moon }, + { 1136, 7, make_mars }, + { 1532, 14, make_undersea }, + { 3562, 0, make_cave }, + { 4774, 32, make_jungle }, + { 7380, 43, make_monochrome }, + { 7588, 44, make_treasure }, + { 8738, 46, make_bountiful }, + { 14896, 78, make_candyland }, + { 16172, 0, make_fruit_kingdom }, +}; + +bool world_in_db(const std::string &name) +{ + ::hStmt h{ "SELECT 1 FROM world WHERE name = ? LIMIT 1" }; + MYSQL_BIND param = make_bind_in(name); + h.bind_param(¶m); + h.execute(); + mysql_stmt_store_result(h.pStmt); + return mysql_stmt_num_rows(h.pStmt) > 0; +} + +int count_of(const ::peer &p, short id) +{ + for (const ::slot &s : p.slots) if (s.id == id) return s.count; + return 0; +} +} // namespace + +/* called when the blast dialog is confirmed */ +void blast_create(ENetEvent &event, int id, std::string name) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + while (!name.empty() && std::isspace(static_cast(name.back()))) name.pop_back(); + while (!name.empty() && std::isspace(static_cast(name.front()))) name.erase(0, 1); + for (char &c : name) c = static_cast(std::toupper(static_cast(c))); + + if (name.empty() || name.size() > 24 || !alnum(name)) + { + tell(event.peer, "`4World names can only use letters and numbers (1-24).``"); + return; + } + const auto def = std::ranges::find(blasts, static_cast(id), &blast_def::item); + if (def == std::end(blasts)) + { + tell(event.peer, "`4That blast can't create a world on this server yet.``"); + return; + } + if (count_of(*pPeer, def->item) < 1) return; + if (def->item == 830 && pPeer->role < DEVELOPER && count_of(*pPeer, 834) < 100) + { + tell(event.peer, "`4The Beach Blast needs `w100 Fireworks`` to detonate!``"); + return; + } + if (std::ranges::find(worlds, name, &::world::name) != worlds.end() || world_in_db(name)) + { + tell(event.peer, "`4That world already exists - pick a new name.``"); + return; + } + + { + ::world w{ name }; // @note a brand new world + def->make(w); + w.base_weather = def->weather; + } // @note saved here + + modify_item_inventory(event, ::slot(def->item, -1)); + if (def->item == 830 && pPeer->role < DEVELOPER) modify_item_inventory(event, ::slot(834, -100)); + printf("[blast] %s created %s with %s\n", pPeer->growid.c_str(), name.c_str(), id_to_item(static_cast(def->item)).raw_name.c_str()); + + if (pPeer->netid != 0) action::quit_to_exit(event, "", true); + action::join_request(event, "", name); +} + + +/* called when a block breaks: loot for blast treasure blocks */ +void blast_treasure_break(ENetEvent &event, ::world &world, const ::item &item, const ::gamePacket &gamePacket) +{ + const ::pos spot = gamePacket.punch.by_32(); + if (item.type == type::TREASURE || item.id == 596 || item.id == 7628) (void)0; // @note debug line removed + + if (item.id == 596 && world.base_weather == 1) // @note Treasure Chest in a Beach Blast world + { + static const std::vector loot = [] + { + std::vector v{}; + for (const char *n : { "Atomic Fireball", "Barbecue Grill", "Beach Ball", "Beach Blast", "Body Tattoos", "Bubble Machine", + "Frangipani", "Great Ball of Fire", "Greg, The Octopus", "Growmoji Fireworks", "Hydro Cannon", + "Long Surfer Hair", "Oceanic Crown", "Pet Toucan", "Pet Turtle", "Poseidon's Trident", "Riding Flamingo", + "Sandcastle", "Sandtopian", "Sea Monster Floatie", "Seafoam Beard", "Seafoam Hair", "Shark Head", + "Shark Suit", "Sharkzooka", "Short Surfer Hair", "Squirt Gun", "Summer Breeze", "Summer Kite", + "Surfboard", "Swim Fins", "Water Wings", "Watermelon Slice", "White Fury" }) + if (const short id = find_id(n)) v.push_back(id); + return v; + }(); + if (loot.empty()) return; + const short prize = loot[RandomRange(0, static_cast(loot.size()))]; + add_drop(event, ::slot(prize, 1), spot, world); + tell(event.peer, std::format("`2You found a `w{}`` in the treasure chest!``", id_to_item(static_cast(prize)).raw_name)); + } + else if (item.id == 7628) // @note Hidden Treasure + { + static const short fragment = find_id("Map Fragment"); + static const short map = named(7602, "Treasure Map"); + static const short egg = find_id("Stone Egg"); + + const short fragments = static_cast(RandomRange(1, 11)); + if (fragment) add_drop(event, ::slot(fragment, fragments), spot, world); + std::string found = std::format("`w{}`` Map Fragments", fragments); + if (map && chance(10)) { add_drop(event, ::slot(map, 1), spot, world); found += ", a `wTreasure Map``"; } + if (egg && chance(5)) { add_drop(event, ::slot(egg, 1), spot, world); found += ", a `wStone Egg``"; } + tell(event.peer, std::format("`2The Hidden Treasure held {}!``", found)); + } +} + +static const std::vector &summer_loot() +{ + static const std::vector loot = [] + { + std::vector v{}; + for (const char *n : { "Atomic Fireball", "Barbecue Grill", "Beach Ball", "Beach Blast", "Body Tattoos", "Bubble Machine", + "Frangipani", "Great Ball of Fire", "Greg, The Octopus", "Growmoji Fireworks", "Hydro Cannon", + "Long Surfer Hair", "Oceanic Crown", "Pet Toucan", "Pet Turtle", "Poseidon's Trident", "Riding Flamingo", + "Sandcastle", "Sandtopian", "Sea Monster Floatie", "Seafoam Beard", "Seafoam Hair", "Shark Head", + "Shark Suit", "Sharkzooka", "Short Surfer Hair", "Squirt Gun", "Summer Breeze", "Summer Kite", + "Surfboard", "Swim Fins", "Water Wings", "Watermelon Slice", "White Fury" }) + if (const short id = find_id(n)) v.push_back(id); + return v; + }(); + return loot; +} + +/* punching a Treasure Chest opens it right away. @return true if it was a chest */ +bool blast_chest_punch(ENetEvent &event, ::world &world, ::block &block, const ::gamePacket &gamePacket) +{ + if (block.fg != 596) return false; + (void)0; // @note debug line removed + + const std::string key = std::format("{},{};", static_cast(gamePacket.punch.x), static_cast(gamePacket.punch.y)); + const std::size_t found = (";" + world.treasure_spots).find(";" + key); + if (found == std::string::npos) return false; // @note no treasure (placed by a player, or already opened): punch it like a normal block + world.treasure_spots.erase(found, key.size()); + + block.state[2] |= S_TOGGLE; // @note show it as opened + send_tile_update(event, { .id = block.fg, .punch = gamePacket.punch }, block, world); + + const ::pos spot = gamePacket.punch.by_32(); + const std::vector &loot = summer_loot(); + if (world.base_weather == 1 && !loot.empty()) // @note Beach Blast world + { + const short prize = loot[RandomRange(0, static_cast(loot.size()))]; + add_drop(event, ::slot(prize, 1), spot, world); + tell(event.peer, std::format("`2You found a `w{}`` in the treasure chest!``", id_to_item(static_cast(prize)).raw_name)); + } + else + { + const short gems = static_cast(RandomRange(5, 21)); + add_drop(event, ::slot(112, gems), spot, world); + tell(event.peer, std::format("`2You found `w{}`` gems in the treasure chest!``", gems)); + } + return true; +} \ No newline at end of file diff --git a/include/commands/blasts.hpp b/include/commands/blasts.hpp new file mode 100644 index 00000000..652a6d53 --- /dev/null +++ b/include/commands/blasts.hpp @@ -0,0 +1,5 @@ +#pragma once + +extern void blast_create(ENetEvent &event, int id, std::string name); +extern void blast_treasure_break(ENetEvent &event, ::world &world, const ::item &item, const ::gamePacket &gamePacket); +extern bool blast_chest_punch(ENetEvent &event, ::world &world, ::block &block, const ::gamePacket &gamePacket); diff --git a/include/commands/bot.cpp b/include/commands/bot.cpp new file mode 100644 index 00000000..597d40a7 --- /dev/null +++ b/include/commands/bot.cpp @@ -0,0 +1,281 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "onVariant/Spawn.hpp" +#include "database/world.hpp" +#include "onVariant/SetClothing.hpp" +#include "database/items.hpp" +#include "bot.hpp" + +std::vector<::bot> bots{}; + +/* negative user ids so they never clash with real accounts */ +static int next_bot_uid = -1; + +/* send every bot in this world to the peer that just walked in */ +void spawn_bots_for(ENetEvent& event, const std::string &world_name) +{ + for (::bot &b : bots) + { + if (b.world != world_name) continue; + on::Spawn(*event.peer, b.netid, b.user_id, b.pos, b.name, b.country, 0, false, false); + on::SetClothing(*event.peer, b.netid, b.skin, b.state, &b.clothing); + send_varlist(event.peer, { "OnSetPos", CL_Vec2f{ b.pos.x, b.pos.y } }, b.netid); + } +} + +/* /bot -> lists bots in this world + * /bot add {name} -> creates one at your position + * /bot remove {name} -> removes it + * /bot clear -> removes every bot in this world + * /bot say {name} {msg}-> makes one talk + */ +void bot_cmd(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + + auto world = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + if (world == worlds.end()) return; + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + while (!arg.empty() && std::isspace(static_cast(arg.back()))) arg.pop_back(); + + std::string sub{}, rest{}; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) + { + sub = arg.substr(0, sp); + rest = arg.substr(sp + 1); + } + else sub = arg; + + for (char &c : sub) c = std::tolower(static_cast(c)); + + /* no argument: list what's here */ + if (sub.empty()) + { + std::string names{}; + std::size_t count = 0; + for (const ::bot &b : bots) + { + if (b.world != world->name) continue; + if (!names.empty()) names += "`o, "; + names += std::format("`w{}``", b.name); + ++count; + } + + if (count == 0) + on::ConsoleMessage(event.peer, "`oNo bots here. `$/bot add {name}`o to make one."); + else + on::ConsoleMessage(event.peer, std::format("`o{} bot{} here: {}", count, count == 1 ? "" : "s", names)); + return; + } + + if (sub == "add") + { + if (rest.empty() || rest.length() > 18) + { + on::ConsoleMessage(event.peer, "`4Usage: /bot add {name} (max 18 characters)``"); + return; + } + + for (const ::bot &b : bots) + if (b.world == world->name && b.name == rest) + { + on::ConsoleMessage(event.peer, "`4A bot with that name is already here.``"); + return; + } + + ::bot b{}; + b.netid = ++world->netid_counter; + b.user_id = next_bot_uid--; + b.name = std::format("`w{}``", rest); + b.world = world->name; + b.pos = pPeer->pos; + + bots.emplace_back(b); + + // show it to everyone standing here + peers(world->name, PEER_SAME_WORLD, [&b](ENetPeer &p) + { + on::Spawn(p, b.netid, b.user_id, b.pos, b.name, b.country, 0, false, false); + on::SetClothing(p, b.netid, b.skin, b.state, &b.clothing); + send_varlist(&p, { "OnSetPos", CL_Vec2f{ b.pos.x, b.pos.y } }, b.netid); + }); + + on::ConsoleMessage(event.peer, std::format("`2Spawned bot `w{}``.``", rest)); + return; + } + + if (sub == "remove") + { + if (rest.empty()) + { + on::ConsoleMessage(event.peer, "`4Usage: /bot remove {name}``"); + return; + } + + for (auto it = bots.begin(); it != bots.end(); ++it) + { + if (it->world != world->name) continue; + if (it->name.find(rest) == std::string::npos) continue; + + const std::string netid = std::format("netID|{}\n", it->netid); + const std::string pId = std::format("pId|{}\n", it->user_id); + + peers(world->name, PEER_SAME_WORLD, [netid, pId](ENetPeer &p) + { + send_varlist(&p, { "OnRemove", netid, pId }); + }); + + bots.erase(it); + on::ConsoleMessage(event.peer, std::format("`2Removed bot `w{}``.``", rest)); + return; + } + + on::ConsoleMessage(event.peer, std::format("`4No bot named `w{}`` here.``", rest)); + return; + } + + if (sub == "clear") + { + std::size_t removed = 0; + for (auto it = bots.begin(); it != bots.end(); ) + { + if (it->world != world->name) { ++it; continue; } + + const std::string netid = std::format("netID|{}\n", it->netid); + const std::string pId = std::format("pId|{}\n", it->user_id); + + peers(world->name, PEER_SAME_WORLD, [netid, pId](ENetPeer &p) + { + send_varlist(&p, { "OnRemove", netid, pId }); + }); + + it = bots.erase(it); + ++removed; + } + + on::ConsoleMessage(event.peer, std::format("`2Removed `w{}`` bot{}.``", removed, removed == 1 ? "" : "s")); + return; + } + + if (sub == "say") + { + std::string bot_name{}, message{}; + if (const std::size_t sp = rest.find(' '); sp != std::string::npos) + { + bot_name = rest.substr(0, sp); + message = rest.substr(sp + 1); + } + + if (bot_name.empty() || message.empty()) + { + on::ConsoleMessage(event.peer, "`4Usage: /bot say {name} {message}``"); + return; + } + + for (const ::bot &b : bots) + { + if (b.world != world->name) continue; + if (b.name.find(bot_name) == std::string::npos) continue; + + const std::string bubble = std::format("CP:0_PL:0_OID:_player_chat=`${}``", message); + const std::string line = std::format("CP:0_PL:0_OID:_CT:[W]_ `6<{}>`` `${}``", b.name, message); + + peers(world->name, PEER_SAME_WORLD, [&b, bubble, line](ENetPeer &p) + { + send_varlist(&p, { "OnTalkBubble", b.netid, bubble }); + on::ConsoleMessage(&p, line); + }); + return; + } + + on::ConsoleMessage(event.peer, std::format("`4No bot named `w{}`` here.``", bot_name)); + return; + } + + /* visual toggles and clothing */ + if (sub == "ghost" || sub == "invis" || sub == "wear") + { + std::string bot_name{}, extra{}; + if (const std::size_t sp = rest.find(' '); sp != std::string::npos) + { + bot_name = rest.substr(0, sp); + extra = rest.substr(sp + 1); + } + else bot_name = rest; + + if (bot_name.empty()) + { + on::ConsoleMessage(event.peer, std::format("`4Usage: /bot {} {{name}}{}``", sub, sub == "wear" ? " {itemID}" : "")); + return; + } + + for (::bot &b : bots) + { + if (b.world != world->name) continue; + if (b.name.find(bot_name) == std::string::npos) continue; + + if (sub == "ghost") + { + b.state ^= S_GHOST; + on::ConsoleMessage(event.peer, std::format("`2`w{}`` ghost {}.``", bot_name, (b.state & S_GHOST) ? "on" : "off")); + } + else if (sub == "invis") + { + b.state ^= S_INVISIBLE; + on::ConsoleMessage(event.peer, std::format("`2`w{}`` invisible {}.``", bot_name, (b.state & S_INVISIBLE) ? "on" : "off")); + } + else + { + std::string id_str = extra; + std::erase_if(id_str, [](char c){ return !std::isdigit(static_cast(c)); }); + if (id_str.empty()) + { + on::ConsoleMessage(event.peer, "`4Usage: /bot wear {name} {itemID}``"); + return; + } + + signed id{}; + try { id = std::stoi(id_str); } + catch (...) { on::ConsoleMessage(event.peer, "`4Not a valid item id.``"); return; } + + const ::item &it = id_to_item(static_cast(id)); + if (it.cloth_type == clothing::NONE) + { + on::ConsoleMessage(event.peer, "`4That item can't be worn.``"); + return; + } + + b.clothing[it.cloth_type] = static_cast(id); + on::ConsoleMessage(event.peer, std::format("`2`w{}`` is now wearing item `w{}``.``", bot_name, id)); + } + + // push the change to everyone in the world + peers(world->name, PEER_SAME_WORLD, [&b](ENetPeer &p) + { + on::SetClothing(p, b.netid, b.skin, b.state, &b.clothing); + }); + return; + } + + on::ConsoleMessage(event.peer, std::format("`4No bot named `w{}`` here.``", bot_name)); + return; + } + + on::ConsoleMessage(event.peer, "`4Usage: /bot [add|remove|clear|say|ghost|invis|wear]``"); +} diff --git a/include/commands/bot.hpp b/include/commands/bot.hpp new file mode 100644 index 00000000..1589b612 --- /dev/null +++ b/include/commands/bot.hpp @@ -0,0 +1,20 @@ +#pragma once + +/* a fake player kept purely server-side - no ENet connection */ +struct bot +{ + int netid{}; + int user_id{}; + std::string name{}; + std::string world{}; + ::pos pos{}; + std::string country{"se"}; + int state{}; // @note S_GHOST, S_INVISIBLE etc + std::array clothing{}; // @note same slot order as a peer + u_int skin{2527912447u}; +}; + +extern std::vector<::bot> bots; + +extern void bot_cmd(ENetEvent& event, const std::string_view text); +extern void spawn_bots_for(ENetEvent& event, const std::string &world_name); diff --git a/include/commands/box.cpp b/include/commands/box.cpp new file mode 100644 index 00000000..8d0bae1c --- /dev/null +++ b/include/commands/box.cpp @@ -0,0 +1,86 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/world.hpp" +#include "box.hpp" + +/* /box {itemID} {width} {height} -> draws a rectangle with you at the top-left */ +void box(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + + auto world = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + if (world == worlds.end()) return; + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + std::vector parts; + { + std::size_t start = 0; + while (start < arg.size()) + { + const std::size_t sp = arg.find(' ', start); + if (sp == std::string::npos) { parts.push_back(arg.substr(start)); break; } + parts.push_back(arg.substr(start, sp - start)); + start = sp + 1; + } + } + + if (parts.size() < 3) + { + on::ConsoleMessage(event.peer, "`4Usage: /box {itemID} {width} {height}``"); + return; + } + + auto digits = [](std::string s){ std::erase_if(s, [](char c){ return !std::isdigit(static_cast(c)); }); return s; }; + + signed id{}, w{}, h{}; + try + { + id = std::stoi(digits(parts[0])); + w = std::stoi(digits(parts[1])); + h = std::stoi(digits(parts[2])); + } + catch (...) { on::ConsoleMessage(event.peer, "`4Those are not valid numbers.``"); return; } + + w = std::clamp(w, 1, 60); + h = std::clamp(h, 1, 40); + + const ::pos tile = pPeer->pos.by_32(true); + const signed ox = static_cast(tile.x); + const signed oy = static_cast(tile.y); + + std::size_t changed = 0; + for (signed dy = 0; dy < h; ++dy) + { + for (signed dx = 0; dx < w; ++dx) + { + const signed x = ox + dx; + const signed y = oy + dy; + if (x < 0 || x >= 100 || y < 0 || y >= 60) continue; + + ::block &b = world->blocks[cord(x, y)]; + if (b.fg == 6 || b.fg == 8) continue; + + b.fg = static_cast(id); + send_tile_update(event, { .id = static_cast(id), .punch = ::pos{ x, y } }, b, *world); + ++changed; + } + } + + on::ConsoleMessage(event.peer, + std::format("`2Placed `w{}`` blocks in a `w{}``x`w{}`` box.``", changed, w, h)); +} diff --git a/include/commands/box.hpp b/include/commands/box.hpp new file mode 100644 index 00000000..9d1e7ca7 --- /dev/null +++ b/include/commands/box.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void box(ENetEvent& event, const std::string_view text); diff --git a/include/commands/breakall.cpp b/include/commands/breakall.cpp new file mode 100644 index 00000000..10d768ca --- /dev/null +++ b/include/commands/breakall.cpp @@ -0,0 +1,21 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "breakall.hpp" + +/* /breakall -> toggles one-hit breaking on every block */ +void breakall(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + pPeer->instant_break = !pPeer->instant_break; + + on::ConsoleMessage(event.peer, pPeer->instant_break + ? "`2One-hit breaking `wON``.``" + : "`4One-hit breaking `wOFF``.``"); +} diff --git a/include/commands/breakall.hpp b/include/commands/breakall.hpp new file mode 100644 index 00000000..32e5ffb3 --- /dev/null +++ b/include/commands/breakall.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void breakall(ENetEvent& event, const std::string_view text); diff --git a/include/commands/buffs.cpp b/include/commands/buffs.cpp new file mode 100644 index 00000000..555d2b9f --- /dev/null +++ b/include/commands/buffs.cpp @@ -0,0 +1,144 @@ +#include "pch.hpp" +#include "tools/bubble.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "onVariant/SetClothing.hpp" +#include "tools/random.hpp" +#include "database/items.hpp" +#include "database/world.hpp" +#include "buffs_items.hpp" + +struct buff_def +{ + u_short item; + u_char id; + const char *name; + int seconds; + bool food; // @note says YUM!, and Glutton's Gazpacho makes it last longer +}; + +static const std::vector buff_items{ + { 1474, buff::XP_DOUBLE, "Double XP Chance", 1800, true }, // Eggs Benedict + { 5452, buff::XP_TRIPLE, "Triple XP Chance", 1800, true }, // Gingerbread Cookie + { 4324, buff::XP_BOOST, "Brain Enhanced", 1800, false }, // Biotronic Brain Enhancer + { 4768, buff::CONSUME_XP, "Sticky Fingers", 1800, true }, // Pineapple Turnover + { 4604, buff::GEM_CHANCE, "Gem Finder", 1800, true }, // Arroz Con Pollo + { 4348, buff::GEM_DOUBLE, "Gemmin'", 1800, false }, // Gemmin' Juice + { 750, buff::LUCKY, "Lucky", 1800, false }, // Rabbit's Foot + { 528, buff::LUCKY, "Lucky", 1800, false }, // Lucky Clover + { 4594, buff::TREE_GROW, "Green Thumb", 1800, true }, // Apple Strudel + { 5984, buff::HIGH_JUMP, "High Jump", 1800, true }, // Chocolate Bunny + { 12152, buff::SPEEDY, "Speedy", 600, false }, // Janeway's Coffee + { 5262, buff::SPEEDY, "Speedy", 600, false }, // Neon Gum + { 12156, buff::PUNCH, "Punch Damage", 600, true }, // Blood Truffle Biscuits + { 1662, buff::SPIKE, "Spike Proof", 5, false }, // Spike Juice + { 6908, buff::GAZPACHO, "Glutton", 1800, true }, // Glutton's Gazpacho + { 10660, buff::PURE_LOVE, "Pure Love", 1800, false }, // Pure Love Essence + { 1056, buff::LUCKY, "Lucky", 1800, true }, // Songpyeon +}; + +static const char *name_of(u_char id) +{ + for (const buff_def &d : buff_items) if (d.id == id) return d.name; + return "buff"; +} + +static std::string left_str(std::time_t ends) +{ + long long s = static_cast(ends) - static_cast(std::time(nullptr)); + if (s < 0) s = 0; + const long long h = s / 3600, m = s % 3600 / 60, sec = s % 60; + if (h) return std::format("{} hours, {} mins, {} secs", h, m, sec); + if (m) return std::format("{} mins, {} secs", m, sec); + return std::format("{} secs", sec); +} + +bool buff_active(const ::peer &p, u_char id) +{ + const std::time_t now = std::time(nullptr); + return std::ranges::any_of(p.buffs, [&](const ::active_buff &b) { return b.id == id && b.ends > now; }); +} + +u_short buff_xp(const ::peer &p, u_short value) +{ + long long v = value; + if (buff_active(p, buff::XP_BOOST)) { v += v / 2; if ((value % 2) && RandomRange(0, 2)) v += 1; } + if (buff_active(p, buff::XP_DOUBLE) && !RandomRange(0, 4)) v *= 2; + if (buff_active(p, buff::XP_TRIPLE) && !RandomRange(0, 10)) v *= 3; + return static_cast(std::min(v, 65535)); +} + +u_short consumable_xp(const ::peer &p) +{ + return buff_active(p, buff::CONSUME_XP) ? 3 : 1; +} + +bool buff_use(ENetEvent &event, const ::item &item) +{ + auto def = std::ranges::find(buff_items, item.id, &buff_def::item); + if (def == buff_items.end()) return false; + ::peer *p = static_cast<::peer*>(event.peer->data); + + int secs = def->seconds; + if (def->food && def->id != buff::GAZPACHO && buff_active(*p, buff::GAZPACHO)) secs = secs * 13 / 10; + const std::time_t ends = std::time(nullptr) + secs; + + bool found = false; + for (::active_buff &b : p->buffs) if (b.id == def->id) { b.ends = ends; b.item = item.id; found = true; } + if (!found) p->buffs.push_back({ def->id, ends, item.id }); + + modify_item_inventory(event, ::slot(static_cast(item.id), -1)); + p->add_xp(event, consumable_xp(*p)); + + if (def->food) + peers(p->recent_worlds.back(), PEER_SAME_WORLD, [&](ENetPeer &x) { send_varlist(&x, { "OnTalkBubble", speaker(*p).netid, "YUM!", 0u }); }); + on::ConsoleMessage(event.peer, std::format("`2{}`` `ois active for`` `w{}``.", def->name, left_str(ends))); + + if (def->id == buff::SPEEDY || def->id == buff::HIGH_JUMP) on::SetClothing(*event.peer); + return true; +} + +void buff_on_break(ENetEvent &event, ::world &world, const ::item &item, const ::gamePacket &gamePacket) +{ + if (item.type == type::SEED) return; + ::peer *p = static_cast<::peer*>(event.peer->data); + const ::pos at = gamePacket.punch.by_32(); + + if (buff_active(*p, buff::GEM_CHANCE) && !RandomRange(0, 10)) add_drop(event, ::slot(112, 1), at, world); + if (buff_active(*p, buff::GEM_DOUBLE) && !RandomRange(0, 10)) add_drop(event, ::slot(112, static_cast(RandomRange(1, 6))), at, world); + if (buff_active(*p, buff::LUCKY) && !RandomRange(0, 10)) add_drop(event, ::slot(static_cast(item.id + 1), 1), at, world); +} + +u_int buff_tree_head_start(const ::peer &p, const ::item &seed) +{ + return buff_active(p, buff::TREE_GROW) ? static_cast(seed.tick) / 20 : 0; // @note 5% +} + +std::string buffs_wrench(const ::peer &p) +{ + const std::time_t now = std::time(nullptr); + std::string out{}; + for (const ::active_buff &b : p.buffs) + if (b.ends > now) + out += std::format("add_label_with_icon|small|`w{}`` (`w{}`` left)|left|{}|\n", name_of(b.id), left_str(b.ends), b.item); + return out; +} + +/* runs once a second: ends buffs */ +void buffs_tick(std::time_t now) +{ + peers("", peer_condition::PEER_ALL, [now](ENetPeer &x) + { + ::peer *t = static_cast<::peer*>(x.data); + if (!t || t->growid.empty()) return; + + bool refresh = false; + std::erase_if(t->buffs, [&](const ::active_buff &b) + { + if (b.ends > now) return false; + if (b.id == buff::SPEEDY || b.id == buff::HIGH_JUMP) refresh = true; + on::ConsoleMessage(&x, std::format("`oYour `w{}`` `owore off.``", name_of(b.id))); + return true; + }); + if (refresh && t->netid != 0) on::SetClothing(x); + }); +} diff --git a/include/commands/buffs.hpp b/include/commands/buffs.hpp new file mode 100644 index 00000000..75bb4c42 --- /dev/null +++ b/include/commands/buffs.hpp @@ -0,0 +1,12 @@ +#pragma once + +namespace buff +{ + enum id : u_char { XP_DOUBLE = 1, XP_TRIPLE, XP_BOOST, CONSUME_XP, GEM_CHANCE, GEM_DOUBLE, TREE_GROW, LUCKY, SPEEDY, HIGH_JUMP, PUNCH, SPIKE, GAZPACHO, PURE_LOVE }; +} + +extern bool buff_active(const ::peer &p, u_char id); +extern u_short buff_xp(const ::peer &p, u_short value); +extern u_short consumable_xp(const ::peer &p); +extern void buffs_tick(std::time_t now); +extern std::string buffs_wrench(const ::peer &p); diff --git a/include/commands/buffs_items.hpp b/include/commands/buffs_items.hpp new file mode 100644 index 00000000..1d4cdc86 --- /dev/null +++ b/include/commands/buffs_items.hpp @@ -0,0 +1,6 @@ +#pragma once +#include "buffs.hpp" + +extern bool buff_use(ENetEvent &event, const ::item &item); +extern void buff_on_break(ENetEvent &event, ::world &world, const ::item &item, const ::gamePacket &gamePacket); +extern u_int buff_tree_head_start(const ::peer &p, const ::item &seed); diff --git a/include/commands/clear.cpp b/include/commands/clear.cpp new file mode 100644 index 00000000..957cbe6b --- /dev/null +++ b/include/commands/clear.cpp @@ -0,0 +1,39 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/world.hpp" +#include "dropqueue.hpp" +#include "clear.hpp" + +/* /clear -> removes every dropped item lying on the ground (everyone sees it, nobody has to rejoin) */ +void clear_cmd(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < MODERATOR) + { + on::ConsoleMessage(event.peer, "`4Only staff can use this.``"); + return; + } + + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + + auto world = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + if (world == worlds.end()) return; + + const std::size_t count = world->objects.size(); + if (count == 0) + { + on::ConsoleMessage(event.peer, "`4There is nothing on the ground here.``"); + return; + } + + dropqueue_cancel_adds(world->name); + for (const ::object &o : world->objects) + queue_remove_drop(world->name, static_cast(o.uid), pPeer->growid); + + on::ConsoleMessage(event.peer, std::format("`2Clearing `w{}`` dropped item{}...``", count, count == 1 ? "" : "s")); +} diff --git a/include/commands/clear.hpp b/include/commands/clear.hpp new file mode 100644 index 00000000..423b3a83 --- /dev/null +++ b/include/commands/clear.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void clear_cmd(ENetEvent& event, const std::string_view text); diff --git a/include/commands/consumables.cpp b/include/commands/consumables.cpp new file mode 100644 index 00000000..14417502 --- /dev/null +++ b/include/commands/consumables.cpp @@ -0,0 +1,211 @@ +#include "pch.hpp" +#include "tools/bubble.hpp" +#include +#include "onVariant/ConsoleMessage.hpp" +#include "tools/time.hpp" +#include "tools/random.hpp" +#include "database/items.hpp" +#include "database/world.hpp" +#include "wands.hpp" +#include "food.hpp" +#include "useitems.hpp" +#include "buffs_items.hpp" +#include "worldtools_use.hpp" +#include "scrolls.hpp" +#include "extra_items.hpp" +#include "euphoria.hpp" +#include "jammers.hpp" +#include "consumables.hpp" + +static constexpr short FIREWORKS = 834; +static constexpr short SUPER_FIREWORKS = 1680; +static constexpr short FIREWORKS_NEEDED = 200; // @note Fireworks used per Super Fireworks. 0 = no requirement + +/* ---------------------------------------------------------- name lookup */ + +static std::string lower(std::string s) +{ + for (char &c : s) c = std::tolower(static_cast(c)); + return s; +} + +static short id_from_name(const char *name) +{ + const std::string want = lower(name); + for (const ::item &it : items) + if (it.raw_name.size() == want.size() && lower(it.raw_name) == want) + return static_cast(it.id); + return 0; +} + +/* resolved once on first use */ +static std::vector grow_items{}; +static std::vector firework_items{}; +static std::vector super_drops{}; +static bool resolved = false; + +static void resolve() +{ + if (resolved) return; + resolved = true; + + for (const char *n : { "Grow Spray Fertilizer", "My First Grow Spray Fertilizer" }) + { + const short id = id_from_name(n); + if (id) grow_items.emplace_back(id); + else (void)0; // @note debug line removed + } + + /* every consumable with "firework" in the name, except Super Fireworks */ + for (const ::item &it : items) + { + if (it.id == SUPER_FIREWORKS || it.type != type::CONSUMEABLE) continue; + if (lower(it.raw_name).find("firework") != std::string::npos) + firework_items.emplace_back(static_cast(it.id)); + } + + for (const char *n : { + "Atomic Fireball", "Barbecue Grill", "Beach Ball", "Beach Blast", "Body Tattoos", + "Bubble Machine", "Frangipani", "Great Ball of Fire", "Greg, The Octopus", "Growmoji Fireworks", + "Hydro Cannon", "Long Surfer Hair", "Oceanic Crown", "Pet Toucan", "Pet Turtle", + "Poseidon's Trident", "Riding Flamingo", "Sandcastle", "Sandtopian", "Sea Monster Floatie", + "Seafoam Beard", "Seafoam Hair", "Shark Head", "Shark Suit", "Sharkzooka", + "Short Surfer Hair", "Squirt Gun", "Summer Breeze", "Summer Kite", "Surfboard", + "Swim Fins", "Water Wings", "Watermelon Slice", "White Fury" }) + { + const short id = id_from_name(n); + if (id) super_drops.emplace_back(id); + else (void)0; // @note debug line removed + } + + (void)0; // @note debug line removed +} + +static bool is_in(const std::vector &list, short id) +{ + return std::ranges::find(list, id) != list.end(); +} + +/* ---------------------------------------------------------------- usage */ + +static bool consumable_use_inner(ENetEvent& event, ::world &world, const ::item &item, ::gamePacket &gamePacket) +{ + resolve(); + if (wand_use(event, world, item, gamePacket)) return true; + if (food_use(event, item)) return true; + if (item_use(event, world, item, gamePacket)) return true; + if (buff_use(event, item)) return true; + if (tool_use(event, world, item, gamePacket)) return true; + if (scroll_use(event, item)) return true; + if (extra_item_use(event, world, item, gamePacket)) return true; + + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + const short id = static_cast(item.id); + + const int x = static_cast(gamePacket.punch.x); + const int y = static_cast(gamePacket.punch.y); + if (x < 0 || x >= 100 || y < 0 || y >= 60) return false; + + const ::pos at{ x * 32.0f + 16.0f, y * 32.0f + 16.0f }; + + /* ---- fertilizer / grow spray: finish a tree instantly ---- */ + if (is_in(grow_items, id)) + { + ::block &block = world.blocks[cord(x, y)]; + const ::item &target = id_to_item(block.fg); + + if (target.type != type::SEED) + { + tell(event.peer, "`4Use that on a tree.``"); + return true; + } + + auto tree = std::ranges::find(world.trees, gamePacket.punch, &::tree::pos); + if (tree == world.trees.end()) return true; + + if (ticks() - tree->tick >= target.tick) + { + on::ConsoleMessage(event.peer, "`oThat tree is already fully grown.``"); + return true; + } + + tree->tick = ticks() - target.tick; + modify_item_inventory(event, ::slot(id, -1)); + + send_tile_update(event, { .id = block.fg, .punch = gamePacket.punch }, block, world); + send_particle_effect(event, at, { 0x02, 0x61 }); + + on::ConsoleMessage(event.peer, std::format("`2The `w{}`` is ready to harvest!``", target.raw_name)); + return true; + } + + /* ---- Super Fireworks: uses 200 Fireworks, drops a random SummerFest item ---- */ + if (id == SUPER_FIREWORKS) + { + if (super_drops.empty()) + { + tell(event.peer, "`4No Super Fireworks prizes found in items.dat.``"); + return true; + } + + int have = 0; + for (const ::slot &s : pPeer->slots) + if (s.id == FIREWORKS) have += s.count; + + if (have < FIREWORKS_NEEDED) + { + on::ConsoleMessage(event.peer, std::format( + "`4You need {} Fireworks to use Super Fireworks (you have {}).``", FIREWORKS_NEEDED, have)); + return true; + } + + modify_item_inventory(event, ::slot(SUPER_FIREWORKS, -1)); + if (FIREWORKS_NEEDED > 0) modify_item_inventory(event, ::slot(FIREWORKS, -FIREWORKS_NEEDED)); + + for (int i = 0; i < 6; ++i) fireworks(event, at); + + const short prize = super_drops[RandomRange(0, static_cast(super_drops.size()))]; + add_drop(event, ::slot(prize, 1), gamePacket.punch.by_32(), world); + + on::ConsoleMessage(event.peer, std::format("`2The Super Fireworks dropped a `w{}``!``", id_to_item(prize).raw_name)); + return true; + } + + /* ---- regular fireworks: just the show ---- */ + if (is_in(firework_items, id)) + { + modify_item_inventory(event, ::slot(id, -1)); + for (int i = 0; i < 3; ++i) fireworks(event, at); + return true; + } + + return false; // @note not a consumable we handle - carry on as normal +} + + +/* wraps the item chain: party items that were really used add Euphoria points */ +bool consumable_use(ENetEvent& event, ::world &world, const ::item &item, ::gamePacket &gamePacket) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + const short id = static_cast(item.id); + if (pPeer->role < DEVELOPER && jammer_blocks(event, world, id)) return true; // @note Punch / Zombie Jammer + auto have = [&] { for (const ::slot &s : pPeer->slots) if (s.id == id) return static_cast(s.count); return 0; }; + const int before = have(); + { // @note used on another player? then their head says the item's text + const int tx = static_cast(gamePacket.punch.x), ty = static_cast(gamePacket.punch.y); + g_consume_target = nullptr; + peers(world.name, PEER_SAME_WORLD, [&](ENetPeer &p) + { + ::peer *t = static_cast<::peer*>(p.data); + if (!t || t == pPeer || g_consume_target) return; + const int x0 = static_cast(std::floor(t->pos.x / 32.0f)), x1 = static_cast(std::floor((t->pos.x + 19.0f) / 32.0f)); + const int y0 = static_cast(std::floor(t->pos.y / 32.0f)), y1 = static_cast(std::floor((t->pos.y + 29.0f) / 32.0f)); + if (tx >= x0 && tx <= x1 && ty >= y0 && ty <= y1) g_consume_target = t; + }); + } + struct clear_target { ~clear_target() { g_consume_target = nullptr; } } clear{}; + const bool handled = consumable_use_inner(event, world, item, gamePacket); + const int used = before - have(); + if (handled && used > 0) euphoria_used(event, world, id, used); + return handled; +} \ No newline at end of file diff --git a/include/commands/consumables.hpp b/include/commands/consumables.hpp new file mode 100644 index 00000000..50354632 --- /dev/null +++ b/include/commands/consumables.hpp @@ -0,0 +1,3 @@ +#pragma once +/* returns true if the item was a consumable and has been used up */ +extern bool consumable_use(ENetEvent& event, ::world &world, const ::item &item, ::gamePacket &gamePacket); diff --git a/include/commands/cooking.cpp b/include/commands/cooking.cpp new file mode 100644 index 00000000..9f47053d --- /dev/null +++ b/include/commands/cooking.cpp @@ -0,0 +1,361 @@ +#include "pch.hpp" +#include +#include "onVariant/ConsoleMessage.hpp" +#include "tools/time.hpp" +#include "database/items.hpp" +#include "database/world.hpp" +#include "cooking.hpp" + +std::vector<::cooking_pot> cooking_pots{}; + +/* ------------------------------------------------------------ recipe data + * written by item NAME - ids are resolved from items.dat on first use. + * names must match the in-game name exactly (singular, not plural). + */ +struct named_recipe +{ + std::vector> needs; // @note {item name, amount} + const char *makes; +}; + +static const std::vector<::named_recipe> named_recipes = { + /* ---- cooking ---- */ + { { {"Wheat", 4}, {"Egg", 1}, {"Sugar Cane", 1} }, "Dough" }, + { { {"Egg", 1}, {"Milk", 1}, {"Dough", 4} }, "Bread" }, + { { {"Dough", 3}, {"Swiss Cheese Block", 1}, {"Tomato", 1} }, "Pizza" }, + { { {"Potato", 1}, {"Fish Chunk", 2}, {"Wheat", 3} }, "Fish And Chips" }, + + /* ---- general ---- */ + { { {"Cyclopean Visor", 10}, {"Checkpoint", 1}, {"Portcullis", 1} }, "Laser Grid" }, + { { {"Stone Egg", 1}, {"Lava", 200}, {"Boss Goo", 1} }, "Ghost Egg" }, + { { {"Stone Egg", 1}, {"Lava", 200}, {"Wriggling Tentacle", 1} }, "Void Egg" }, + { { {"Stone Egg", 1}, {"Lava", 200}, {"Head Bangin' Steel Wolf", 1} }, "Water Egg" }, + { { {"Rune Stone", 20}, {"Dragon Glass", 10}, {"Table Lamp", 1} }, "Dragon Lamp" }, + { { {"Rune Stone", 20}, {"Dragon Glass", 10}, {"Bathroom Mirror", 1} }, "Dragon Mirror" }, + { { {"Rune Stone", 20}, {"Dragon Glass", 10}, {"Theater Seat", 10} }, "Dragon Throne" }, + { { {"U.S.S. Protostar Bulkhead", 200}, {"Protostar Captain's Seat", 10}, {"Steel Block", 200} }, "Vehicle Replicator" }, + + /* ---- anniversary ---- */ + { { {"80's Tape Deck", 1}, {"Mega Rock Speaker", 2}, {"D Battery", 4} }, "Party Blaster" }, + { { {"Reanimator Remote", 1}, {"Spare Body", 1}, {"Limb Connector", 200} },"Living Dead Remote" }, + { { {"Party Style Scarf", 1}, {"Party Bunting", 1}, {"Transmog Crystal", 1} }, "Ultra Party Glow Scarf" }, + { { {"Party Style Top Hat", 1}, {"Party Hat", 1}, {"Glowstick", 1} }, "Ultra Party Glow Top Hat" }, + { { {"Party Style Shades", 1}, {"Party Glasses", 1}, {"Transmog Crystal", 1} }, "Ultra Party Glow Shades" }, + { { {"Party Light Wave Vest", 1}, {"Party Vest", 1}, {"Transmog Crystal", 1} }, "Ultra Party Light Wave Vest" }, + { { {"Equalizer Tee", 1}, {"Party Tunes", 10}, {"Tempered Party Tunes", 5} }, "Ultra Equalizer Tee" }, + { { {"Mega Rock Speaker", 3}, {"Party Tunes", 10}, {"Tempered Party Tunes", 5} }, "Megawatt Bass Speaker" }, + { { {"Stone Egg", 1}, {"Raver Hoodie", 1}, {"Rave Stage", 20} }, "Euphoric Dragon Egg" }, + { { {"Das Red Balloon", 5}, {"Hero Portrait - Buddy", 1}, {"Sand", 1} }, "Buddy Balloons" }, + + /* ---- forged ---- */ + { { {"Iron Buckle", 1}, {"Silkworm Leash", 1}, {"Burnt Leather", 4} }, "Silkworm Saddle" }, + { { {"Gift of the Unicorn", 1}, {"Sword Blade", 2}, {"Sword Pommel", 2} }, "Twin Swords" }, + { { {"Warrior's Shield", 1}, {"Sword Blade", 1}, {"Sword Pommel", 1} }, "Sword And Shield" }, + { { {"Grip Tape", 2}, {"Steel Axe Handle", 2}, {"Pick Blade", 2} }, "Climbing Picks" }, + { { {"Cosmic Bridle", 1}, {"Pegasus", 1}, {"Synthetic Chemical", 20} }, "Cosmic Sky Horse" }, + { { {"Celestial Blade", 1}, {"Grip Tape", 1}, {"Steel Axe Handle", 2} }, "Celestial Lance" }, + + /* ---- miscellaneous ---- */ + { { {"Transmog Crystal", 1}, {"Lava", 1}, {"Super Crate Box", 20} }, "Lava Cube" }, + { { {"Transmog Crystal", 1}, {"Cow", 1}, {"Brown Block", 20} }, "Buffalo" }, + { { {"Transmog Crystal", 1}, {"Death Spikes", 1}, {"Steam Tubes", 20} }, "Steam Spikes" }, + { { {"Doodad", 1}, {"Doohickey", 1}, {"Whatchamacallit", 1} }, "Legen Seed" }, + { { {"Kerjigger", 1}, {"Thingamajig", 1}, {"Thingamabob", 1} }, "Dary Seed" }, + { { {"Transmog Crystal", 1}, {"Paintbrush", 1}, {"Art Wall", 20} }, "Painting Easel" }, + { { {"Transmog Crystal", 1}, {"Diamond Lock", 1}, {"Robot Wants Dubstep", 20} }, "Robotic Lock" }, + { { {"Unearthly Chemical", 10}, {"Synthetic Chemical", 10}, {"Mysterious Chemical", 10} }, "Untrade-a-Box" }, + { { {"Unearthly Chemical", 10}, {"Radioactive Chemical", 10}, {"Synthetic Chemical", 10} }, "Tomb Robber" }, + { { {"Transmog Crystal", 1}, {"Challenge Timer", 1}, {"Race Start Flag", 20} }, "Challenge Start Flag" }, + { { {"Transmog Crystal", 1}, {"Challenge Timer", 1}, {"Race End Flag", 20} }, "Challenge End Flag" }, + { { {"Radioactive Chemical", 200}, {"Haunted Chemical", 200}, {"Unearthly Chemical", 200} }, "Xenonite Crystal" }, + { { {"Mysterious Chemical", 1}, {"Radioactive Chemical", 5}, {"Haunted Chemical", 50} }, "Containment Field Power Node" }, + + /* ---- lunar new year ---- */ + { { {"Terracotta Pot", 100}, {"Red Coin Wallpaper", 5}, {"Statue Block", 50} }, "Terracotta Growtopian" }, + { { {"Chinese Temple Pillar", 200}, {"Grip Tape", 1}, {"Lucky Golden Crest", 2} }, "Monkey Warrior's Staff" }, + { { {"Bamboo", 1}, {"Checker Wallpaper", 10}, {"Milk", 1} }, "Bamboo Boba Block" }, + { { {"Bunny Ears", 5}, {"Jade Pillar", 6}, {"Paper Dividing Wall", 5} }, "Zodiac Rabbit Ears" }, + { { {"Terracotta Couch", 1}, {"Lunar Spice Spray", 1}, {"Paper Dividing Wall", 1} }, "Ta Couch" }, + + /* ---- valentine's ---- */ + { { {"Lovewillow's Lace", 5}, {"Table Lamp", 1}, {"Golden Block", 1} }, "Lovely Dinner Light" }, + { { {"Charmer Smile", 1}, {"Muscle Suit", 1}, {"Steel Block", 200} }, "Beefy Biceps" }, + { { {"Medical Scarf", 1}, {"Mutated Virus", 1}, {"Grey Cat As A Hat", 1} },"Snuggly Kitty Scarf" }, + { { {"Heart Antennae Headband", 1}, {"Transmog Crystal", 1}, {"Candy Heart", 10} }, "Heart Antennae Hat" }, + { { {"Candy Heart", 10}, {"Cotton Candy Clouds", 1}, {"Valentine's Dust", 10} }, "Tunnel of Love" }, + { { {"Cotton Candy Clouds", 50}, {"Polka Dot Block", 50}, {"Strawberry Block", 1} }, "Strawbeanie" }, + { { {"Colorful Cake Slice", 1}, {"Cotton Candy Clouds", 1}, {"Carnival Platform", 1} }, "Rainbow Platform" }, + { { {"Golden Heart Crystal", 1}, {"Heart Glasses", 1}, {"Candy Heart", 200} }, "Golden Heart Glasses" }, + { { {"Golden Heart Crystal", 1}, {"Ruby Necklace", 1}, {"Candy Heart", 200} }, "Golden Diamond Necklace" }, + { { {"Golden Heart Crystal", 1}, {"Heartbow", 1}, {"Candy Heart", 200} }, "Golden Heartbow" }, + { { {"Golden Heart Crystal", 1}, {"Angel Wings", 1}, {"Candy Heart", 200} }, "Golden Angel Wings" }, + { { {"Golden Heart Crystal", 1}, {"Diaper", 1}, {"Candy Heart", 200} }, "Golden Diaper" }, + { { {"Golden Heart Crystal", 1}, {"Love Bug", 1}, {"Candy Heart", 200} }, "Golden Love Bug" }, + { { {"Golden Heart Crystal", 1}, {"Pegasus", 1}, {"Candy Heart", 200} }, "Golden Pegasus" }, + { { {"Golden Heart Crystal", 1}, {"Air Robinsons", 1}, {"Candy Heart", 200} }, "Golden Air Robinsons" }, + { { {"Golden Heart Crystal", 1}, {"Heartbreaker Hammer", 1}, {"Candy Heart", 200} }, "Heavenly Scythe" }, + { { {"Golden Heart Crystal", 1}, {"Teeny Angel Wings", 1}, {"Candy Heart", 200} }, "Teeny Golden Wings" }, + { { {"Golden Heart Crystal", 1}, {"Heart Shirt", 1}, {"Candy Heart", 200} }, "Golden Heart Shirt" }, + { { {"Golden Heart Crystal", 1}, {"USMom's Talaria", 1}, {"Candy Heart", 200} }, "Golden Talaria" }, + { { {"Golden Heart Crystal", 1}, {"Heartstaff", 1}, {"Candy Heart", 200} }, "Golden Heartstaff" }, + { { {"Golden Heart Crystal", 1}, {"Heartsword", 1}, {"Candy Heart", 200} }, "Golden Heartsword" }, + { { {"Golden Heart Crystal", 1}, {"Heartthrob Helm", 1}, {"Candy Heart", 200} }, "Golden Heartthrob Helm" }, + { { {"Golden Heart Crystal", 1}, {"Cloud Nine Cape", 1}, {"Candy Heart", 200} }, "Golden Sunset Cape" }, + { { {"Golden Heart Crystal", 1}, {"Datemaster's Bling", 1}, {"Datemaster's Rose", 1} }, "Datemaster's Heart Locket" }, + { { {"Golden Heart Crystal", 1}, {"Heartstaff", 1}, {"Heartsword", 1} }, "Stained Glass Heartwings" }, + { { {"Golden Heart Crystal", 1}, {"Silk Scarf", 1}, {"Candy Heart", 200} }, "Golden Silk Scarf" }, + { { {"Golden Heart Crystal", 1}, {"Loving Halo", 1}, {"Candy Heart", 200} }, "Golden Heart Aura" }, + { { {"Golden Heart Crystal", 1}, {"Heartbreak Wings", 1}, {"Candy Heart", 200} },"Golden Heartbreak Wings" }, + { { {"Golden Heart Crystal", 1}, {"Stained Glass Scepter", 1}, {"Candy Heart", 200} }, "Golden Stained Glass Scepter" }, + { { {"Golden Heart Crystal", 1}, {"Ribbon Wings", 1}, {"Candy Heart", 200} }, "Golden Ribbon Wings" }, + + /* ---- st. patrick's ---- */ + { { {"Cobra Statuette", 1}, {"Yellow Diamond", 2}, {"Lucky Clover", 10} }, "Serpent Staff" }, + { { {"Frosty Wings", 1}, {"Emerald Shard", 10}, {"Lucky Clover", 15} }, "Shamrock Wings" }, + { { {"Emerald Shard", 2}, {"Lazy Cobra", 10}, {"Potato Couch", 2} }, "King Cobra Chair" }, + { { {"Cloverleaf", 1}, {"Bouncy Balloon", 10}, {"Moss-Covered Stone", 5} }, "Shamrock Bouncing Buddy" }, + { { {"Lucky Clover", 1}, {"Green Beer", 10}, {"Fountain", 5} }, "Green Fountain" }, + { { {"Leprechaun Hat", 1}, {"Green Pants", 1}, {"Lucky Clover", 5} }, "Golden Leprechaun Hat" }, + { { {"Leprechaun Suit", 1}, {"Leprechaun Shoes", 1}, {"Lucky Clover", 5} }, "Golden Leprechaun Suit" }, + { { {"Field Grass", 5}, {"Window Curtains", 50}, {"Celtic Block", 10} }, "Green Curtains" }, + { { {"Green Beer", 5}, {"Gingerbread Roof", 25}, {"Stone Wall", 50} }, "Ye Olde Tavern Roof" }, + { { {"Round Shield", 1}, {"Lucky Clover", 20}, {"Leprechaun Shoes", 100} }, "Shamrock Shields" }, + + /* ---- super pineapple party ---- */ + { { {"Super Pineapple", 1}, {"Mysterious Chemical", 1}, {"Uranium Block", 20} }, "Super Exploding Pineapple" }, + { { {"Pineapple Engine Part", 2}, {"Pineapple Wheel", 4}, {"Pineapple Plating", 10} }, "Pine-ch Buggy" }, + { { {"Super Pineapple", 3}, {"Dangerous Pineapple", 3}, {"Pineapple", 100} }, "Pineapple Guzzler" }, + { { {"High Tech Block", 5}, {"Pineapple", 5}, {"Dangerous Pineapple", 1} }, "Super Pineapple Time Warp Device" }, + { { {"Pineapple Pizza Slice", 50}, {"Pineapple Root Cutting", 200}, {"Pineapple Spear", 1} }, "Giant Pineapple Pizza Paddle" }, +}; + +/* ------------------------------------------------------ resolved recipes */ + +struct recipe +{ + std::vector> needs; // @note {item id, amount} + short makes; + std::string name; +}; + +static std::vector<::recipe> recipes{}; +static bool resolved = false; + +/* case-insensitive exact name match against items.dat */ +static short id_from_name(const char *name) +{ + std::string want = name; + for (char &c : want) c = std::tolower(static_cast(c)); + + for (const ::item &it : items) + { + if (it.raw_name.size() != want.size()) continue; + std::string have = it.raw_name; + for (char &c : have) c = std::tolower(static_cast(c)); + if (have == want) return static_cast(it.id); + } + return 0; +} + +static void resolve_recipes() +{ + if (resolved) return; + resolved = true; + + std::size_t ok = 0, skipped = 0; + + for (const ::named_recipe &nr : named_recipes) + { + ::recipe r{}; + r.name = nr.makes; + r.makes = id_from_name(nr.makes); + + bool complete = (r.makes != 0); + if (!complete) printf("[combiner] unknown result '%s'\n", nr.makes); + + for (const auto &[name, amount] : nr.needs) + { + const short id = id_from_name(name); + if (id == 0) + { + printf("[combiner] '%s' needs unknown ingredient '%s'\n", nr.makes, name); + complete = false; + } + r.needs.emplace_back(id, amount); + } + + if (complete) { recipes.emplace_back(std::move(r)); ++ok; } + else ++skipped; + } + + printf("[combiner] %zu recipes loaded, %zu skipped (names not found)\n", ok, skipped); +} + +/* ---------------------------------------------------------------- helpers */ + +static bool on_tile(const ::object &o, int x, int y) +{ + return o.count > 0 + && static_cast(o.pos.x / 32.0f) == x + && static_cast(o.pos.y / 32.0f) == y; +} + +static std::map tally_tile(const ::world &world, int x, int y) +{ + std::map have{}; + for (const ::object &o : world.objects) + if (on_tile(o, x, y)) have[static_cast(o.id)] += o.count; + return have; +} + +/* the recipe using the most distinct ingredients wins */ +static const ::recipe *find_recipe(const std::map &have) +{ + const ::recipe *best = nullptr; + std::size_t best_size = 0; + + for (const ::recipe &r : recipes) + { + bool ok = true; + for (const auto &[id, need] : r.needs) + { + auto it = have.find(id); + if (it == have.end() || it->second < need) { ok = false; break; } + } + if (ok && r.needs.size() >= best_size) { best = &r; best_size = r.needs.size(); } + } + return best; +} + +/* remove a dropped object without crediting anyone - netid -1 means it just vanishes */ +static void vanish_object(ENetEvent& event, ::world &world, u_int uid) +{ + item_change_object(event, ::gamePacket{ + .netid = 0, // @note no player has netid 0: removed, collected by nobody + .uid = (int)0xffffffff, + .id = static_cast(uid) + }); + std::erase_if(world.objects, [uid](const ::object &o) { return o.uid == uid; }); +} + +/* set the oven open or closed visually. + * if it ever looks backwards in-game, swap the two lines inside the if/else */ +static void set_oven(ENetEvent& event, ::world &world, int x, int y, bool closed) +{ + ::block &b = world.blocks[cord(x, y)]; + if (closed) b.state[2] &= ~S_TOGGLE; + else b.state[2] |= S_TOGGLE; + send_tile_update(event, { .id = b.fg, .punch = ::pos{ x, y } }, b, world); +} + +/* ------------------------------------------------------------------ usage */ + +bool cooking_use(ENetEvent& event, ::world &world, int x, int y) +{ + resolve_recipes(); + + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + /* a finished dish is waiting inside - this punch takes it out */ + for (auto it = cooking_pots.begin(); it != cooking_pots.end(); ++it) + { + if (it->world != world.name || it->x != x || it->y != y) continue; + + const short made = it->result; + cooking_pots.erase(it); + + set_oven(event, world, x, y, false); // @note open it up + add_drop(event, ::slot(made, 1), ::pos{ x * 32.0f + 8.0f, (y - 1) * 32.0f + 8.0f }, world); + + const ::item &dish = id_to_item(made); + on::ConsoleMessage(event.peer, std::format("`2Took out a `w{}``!``", dish.raw_name)); + return true; + } + + const std::map have = tally_tile(world, x, y); + + /* empty: let the punch through so it can be broken */ + if (have.empty()) return false; + + std::string listing{}; + for (const auto &[id, count] : have) + { + const ::item &ing = id_to_item(id); + if (!listing.empty()) listing += "`o, "; + listing += std::format("`w{}`` x`w{}``", ing.raw_name, count); + } + on::ConsoleMessage(event.peer, std::format("`oIn the combiner: {}", listing)); + + const ::recipe *r = find_recipe(have); + + if (r == nullptr) + { + on::ConsoleMessage(event.peer, "`4Nothing can be made from that.``"); + return true; + } + + /* decide what to consume before touching the object list */ + std::map still_need{}; + for (const auto &[id, need] : r->needs) still_need[id] = need; + + std::vector remove_uids{}; + std::vector> shrink{}; + + for (const ::object &o : world.objects) + { + if (!on_tile(o, x, y)) continue; + + auto it = still_need.find(static_cast(o.id)); + if (it == still_need.end() || it->second <= 0) continue; + + if (o.count <= it->second) + { + it->second -= o.count; + remove_uids.emplace_back(o.uid); + } + else + { + shrink.emplace_back(o.uid, it->second); + it->second = 0; + } + } + + std::vector> leftovers{}; + for (const auto &[uid, take] : shrink) + for (const ::object &o : world.objects) + if (o.uid == uid) + { + leftovers.emplace_back(::slot(static_cast(o.id), static_cast(o.count - take)), o.pos); + remove_uids.emplace_back(uid); + break; + } + + for (u_int uid : remove_uids) vanish_object(event, world, uid); + for (const auto &[s, p] : leftovers) add_drop(event, s, p, world); + + /* the result lands on the tile ABOVE the oven so you can walk into it, + and so it isn't counted as an ingredient next time */ + ::cooking_pot waiting{}; + waiting.world = world.name; + waiting.x = x; + waiting.y = y; + waiting.owner = pPeer->user_id; + waiting.result = r->makes; + cooking_pots.emplace_back(waiting); + + set_oven(event, world, x, y, true); // @note closed while it cooks + + on::ConsoleMessage(event.peer, std::format("`2Made a `w{}``! Punch the oven again to take it out.``", r->name)); + + peers(world.name, PEER_SAME_WORLD, [pPeer, r](ENetPeer &p) + { + send_varlist(&p, { "OnTalkBubble", pPeer->netid, + std::format("`5<{} combines a {}>``", pPeer->display_growid, r->name), 1u }); + }); + + return true; +} + +void cooking_dialog(ENetEvent& event, const ::hPipe &hPipe) +{ +} diff --git a/include/commands/cooking.hpp b/include/commands/cooking.hpp new file mode 100644 index 00000000..402a54eb --- /dev/null +++ b/include/commands/cooking.hpp @@ -0,0 +1,20 @@ +#pragma once + +/* one oven that currently has something in it */ +struct cooking_pot +{ + std::string world{}; + int x{}, y{}; + std::vector ingredients{}; + short result{}; // @note 0 while still being filled + std::time_t ready_at{}; // @note when the dish is done + int owner{}; // @note user_id who started it +}; + +extern std::vector<::cooking_pot> cooking_pots; + +/* called from tile_activate when a COOKING_OVEN is punched */ +extern bool cooking_use(ENetEvent& event, ::world &world, int x, int y); + +/* called from dialog_return when the ingredient picker comes back */ +extern void cooking_dialog(ENetEvent& event, const ::hPipe &hPipe); diff --git a/include/commands/curse.cpp b/include/commands/curse.cpp new file mode 100644 index 00000000..9d09520c --- /dev/null +++ b/include/commands/curse.cpp @@ -0,0 +1,213 @@ +#include "pch.hpp" +#include "tools/bubble.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "onVariant/SetClothing.hpp" +#include "database/items.hpp" +#include "action/join_request.hpp" +#include "action/quit_to_exit.hpp" +#include "database/world.hpp" +#include "curse.hpp" + +static std::string lower(std::string s) +{ + for (char &c : s) c = static_cast(std::tolower(static_cast(c))); + return s; +} + +static std::string take(std::string &rest) +{ + while (!rest.empty() && std::isspace(static_cast(rest.front()))) rest.erase(0, 1); + const std::size_t sp = rest.find(' '); + std::string token = rest.substr(0, sp); + rest = (sp == std::string::npos) ? std::string{} : rest.substr(sp + 1); + return token; +} + +/* "30" = 30 minutes, or with a unit: 45s, 30m, 2h, 7d, 1w. @return seconds, -1 if invalid */ +static long long parse_duration(std::string s) +{ + if (s.empty()) return -1; + long long mult = 60; + const char unit = static_cast(std::tolower(static_cast(s.back()))); + if (std::isalpha(static_cast(unit))) + { + switch (unit) + { + case 's': mult = 1; break; + case 'm': mult = 60; break; + case 'h': mult = 3600; break; + case 'd': mult = 86400; break; + case 'w': mult = 604800; break; + default: return -1; + } + s.pop_back(); + } + if (s.empty() || s.size() > 6) return -1; + for (char c : s) if (!std::isdigit(static_cast(c))) return -1; + return std::stoll(s) * mult; +} + +static ENetPeer *find_online(const std::string &name) +{ + const std::string want = lower(name); + ENetPeer *found = nullptr; + peers("", peer_condition::PEER_ALL, [&](ENetPeer &p) + { + ::peer *t = static_cast<::peer*>(p.data); + if (t && !t->growid.empty() && lower(t->growid) == want) found = &p; + }); + return found; +} + +std::string curse_time_left(const ::peer &p) +{ + long long left = static_cast(p.curse_until) - static_cast(std::time(nullptr)); + if (left < 0) left = 0; + const long long d = left / 86400, h = left % 86400 / 3600, m = left % 3600 / 60, s = left % 60; + if (d) return std::format("{} days, {} hours, {} mins, {} secs", d, h, m, s); + if (h) return std::format("{} hours, {} mins, {} secs", h, m, s); + if (m) return std::format("{} mins, {} secs", m, s); + return std::format("{} secs", s); +} + +/* /curse {player} {time} {reason} -> sends the player to HELL until the time runs out */ +void curse_cmd(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (pPeer->role < MODERATOR) + { + tell(event.peer, "`4Only staff can use this.``"); + return; + } + + std::string rest{ text }; + take(rest); // @note "curse" + const std::string target = take(rest); + const std::string length = take(rest); + while (!rest.empty() && std::isspace(static_cast(rest.back()))) rest.pop_back(); + const std::string reason = rest.empty() ? "No reason given" : rest; + + if (target.empty() || length.empty()) + { + tell(event.peer, "`4Usage: /curse {player} {time} {reason}`` - time like 30m, 2h, 1d, 1w (plain number = minutes)"); + return; + } + + long long seconds = parse_duration(length); + if (seconds <= 0) + { + tell(event.peer, "`4Invalid time.`` Use e.g. 30m, 2h, 1d or 1w."); + return; + } + seconds = std::min(seconds, 315360000LL); // @note cap at 10 years + + ENetPeer *found = find_online(target); + if (!found) + { + tell(event.peer, std::format("`4`w{}`` is not online.``", target)); + return; + } + ::peer *pTarget = static_cast<::peer*>(found->data); + + if (pTarget != pPeer && pTarget->role >= pPeer->role) + { + tell(event.peer, "`4You cannot curse someone of equal or higher rank.``"); + return; + } + + pTarget->curse_until = std::time(nullptr) + seconds; + pTarget->mysql_update("curse_until", static_cast(pTarget->curse_until)); if (pTarget->netid != 0) on::SetClothing(*found); + + peers("", peer_condition::PEER_ALL, [&](ENetPeer &p) + { + ::peer *o = static_cast<::peer*>(p.data); + if (!o || o->growid.empty()) return; + on::ConsoleMessage(&p, std::format("`#** `$The Ancient Ones`` have used `#Curse`` on `w{}``! `#**``", pTarget->growid)); + }); + + tell(found, std::format("`4You have been cursed`` for `w{}``. Reason: `w{}``", curse_time_left(*pTarget), reason)); + + if (pTarget->netid != 0 && pTarget->recent_worlds.back() != "HELL") + { + ENetEvent ev{}; + ev.peer = found; + action::quit_to_exit(ev, "", true); + action::join_request(ev, "", "HELL"); + if (pTarget->netid == 0) // @note the join failed: say why instead of leaving them stuck + { + printf("[curse] could not move %s to HELL - see the [join] line above\n", pTarget->growid.c_str()); + on::ConsoleMessage(event.peer, "`4They are cursed, but moving them to HELL failed - see the server window for the reason.``"); + } + } + + if (pTarget != pPeer) + on::ConsoleMessage(event.peer, std::format("`2Cursed `w{}`` for {}.``", pTarget->growid, curse_time_left(*pTarget))); +} + +/* /uncurse {player} -> lifts the curse early */ +void uncurse_cmd(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (pPeer->role < MODERATOR) + { + tell(event.peer, "`4Only staff can use this.``"); + return; + } + + std::string rest{ text }; + take(rest); // @note "uncurse" + const std::string target = take(rest); + if (target.empty()) + { + tell(event.peer, "`4Usage: /uncurse {player}``"); + return; + } + + ENetPeer *found = find_online(target); + if (!found) + { + tell(event.peer, std::format("`4`w{}`` is not online.``", target)); + return; + } + ::peer *pTarget = static_cast<::peer*>(found->data); + + if (pTarget->curse_until <= std::time(nullptr)) + { + on::ConsoleMessage(event.peer, std::format("`w{}`` is not cursed.", pTarget->growid)); + return; + } + + pTarget->curse_until = 0; + pTarget->mysql_update("curse_until", static_cast(0)); if (pTarget->netid != 0) on::SetClothing(*found); + + if (pTarget->netid != 0 && pTarget->recent_worlds.back() == "HELL") // @note out of HELL, back to the world menu + { + ENetEvent ev{}; + ev.peer = found; + action::quit_to_exit(ev, "", false); + } + on::ConsoleMessage(found, "`2Your curse has been lifted!``"); + if (pTarget != pPeer) + on::ConsoleMessage(event.peer, std::format("`2Lifted the curse on `w{}``.``", pTarget->growid)); +} + + +/* Curse Wand icon for the wrench menu, looked up by name */ +short curse_icon() +{ + static short id = -1; + if (id == -1) + { + id = 278; + for (const ::item &it : items) + if (it.raw_name == "Curse Wand") { id = static_cast(it.id); break; } + } + return id; +} + +/* "Curse (x left)" line for the wrench menu, empty when not cursed */ +std::string curse_wrench_line(const ::peer &p) +{ + if (p.curse_until <= std::time(nullptr)) return ""; + return std::format("add_label_with_icon|small|`wCurse`` (`w{}`` left)|left|{}|\n", curse_time_left(p), curse_icon()); +} \ No newline at end of file diff --git a/include/commands/curse.hpp b/include/commands/curse.hpp new file mode 100644 index 00000000..bae71974 --- /dev/null +++ b/include/commands/curse.hpp @@ -0,0 +1,6 @@ +#pragma once + +extern void curse_cmd(ENetEvent& event, const std::string_view text); +extern void uncurse_cmd(ENetEvent& event, const std::string_view text); +extern std::string curse_time_left(const ::peer &p); +extern std::string curse_wrench_line(const ::peer &p); diff --git a/include/commands/deleteworld.cpp b/include/commands/deleteworld.cpp new file mode 100644 index 00000000..82e48a1b --- /dev/null +++ b/include/commands/deleteworld.cpp @@ -0,0 +1,56 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/database.hpp" +#include "database/world.hpp" +#include "deleteworld.hpp" + +/* /deleteworld {name} -> removes a world from the database entirely */ +void deleteworld(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + while (!arg.empty() && std::isspace(static_cast(arg.back()))) arg.pop_back(); + + if (arg.empty()) + { + on::ConsoleMessage(event.peer, "`4Usage: /deleteworld {name}``"); + return; + } + + for (char &c : arg) c = std::toupper(static_cast(c)); + + if (arg == pPeer->recent_worlds.back()) + { + on::ConsoleMessage(event.peer, "`4Leave the world before deleting it.``"); + return; + } + + // drop the in-memory copy first so its destructor doesn't rewrite the row + if (auto it = std::ranges::find(worlds, arg, &::world::name); it != worlds.end()) + { + if (it->visitors > 0) + { + on::ConsoleMessage(event.peer, "`4Somebody is still in that world.``"); + return; + } + { std::iter_swap(it, worlds.end() - 1); worlds.pop_back(); } // @note move to the end first so the world saves itself + } + + ::hStmt hStmt{ "DELETE FROM world WHERE name = ?" }; + MYSQL_BIND param = make_bind_in(arg); + hStmt.bind_param(¶m); + hStmt.execute(); + + on::ConsoleMessage(event.peer, + std::format("`2Deleted `w{}``. It will regenerate fresh if entered again.``", arg)); +} diff --git a/include/commands/deleteworld.hpp b/include/commands/deleteworld.hpp new file mode 100644 index 00000000..7d29dc47 --- /dev/null +++ b/include/commands/deleteworld.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void deleteworld(ENetEvent& event, const std::string_view text); diff --git a/include/commands/dev.cpp b/include/commands/dev.cpp new file mode 100644 index 00000000..14f3df3d --- /dev/null +++ b/include/commands/dev.cpp @@ -0,0 +1,34 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "onVariant/NameChanged.hpp" +#include "dev.hpp" + +/* /dev {password} -> grants yourself developer, regardless of current role. + * change DEV_PASSWORD below to whatever you like. */ +static constexpr std::string_view DEV_PASSWORD = "change-me"; + +void dev(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + // trim stray whitespace / newlines the client may append + while (!arg.empty() && std::isspace(static_cast(arg.back()))) arg.pop_back(); + + if (arg != DEV_PASSWORD) + { + send_action(*event.peer, "log", "msg|`4Unknown command.`` Enter `$/?`` for a list of valid commands."); + return; + } + + pPeer->role = DEVELOPER; + + if (pPeer->nickname.empty()) + pPeer->display_growid = std::format("`6@{}``", pPeer->growid); + + on::NameChanged(event); + on::ConsoleMessage(event.peer, "`2You are now a `wDeveloper``.``"); +} diff --git a/include/commands/dev.hpp b/include/commands/dev.hpp new file mode 100644 index 00000000..aaaf0df2 --- /dev/null +++ b/include/commands/dev.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void dev(ENetEvent& event, const std::string_view text); diff --git a/include/commands/devmenu.cpp b/include/commands/devmenu.cpp new file mode 100644 index 00000000..db62470c --- /dev/null +++ b/include/commands/devmenu.cpp @@ -0,0 +1,232 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "tools/bubble.hpp" +#include "tools/create_dialog.hpp" +#include "database/items.hpp" +#include "__command.hpp" +#include "legendary.hpp" +#include "devmenu.hpp" + +/* ------------------------------------------------------------------ descriptions (edit freely) */ +static const std::unordered_map descriptions{ + { "time", "Shows the server time." }, + { "find", "Search any item and add it to your backpack." }, + { "warp", "Travel to a world." }, + { "punch", "Change your punch effect by number." }, + { "skin", "Change your skin color." }, + { "sb", "Super-broadcast a message to everyone online." }, + { "who", "Lists the players in your world." }, + { "me", "Say an action in the third person." }, + { "news", "Shows the news." }, + { "weather", "Changes the weather you see." }, + { "ghost", "Walk through blocks." }, + { "dev", "Become a developer with the password." }, + { "uptime", "How long the server has been running." }, + { "undress", "Takes off all your clothes." }, + { "heal", "Restores your health." }, + { "msg", "Send a private message to a player." }, + { "reply", "Reply to your last private message." }, + { "god", "Nothing can hurt you; walk on spikes and lava." }, + { "curse", "Sends a player to HELL for a while." }, + { "uncurse", "Lifts a player's curse early." }, + { "freeze", "Freezes or unfreezes a player." }, + { "summon", "Pulls a player to you." }, + { "kick", "Kicks a player." }, + { "nick", "Changes your displayed name." }, + { "flag", "Changes the flag beside your name." }, + { "event", "Starts a special event in your world." }, + { "tile", "Shows what the server stores on a tile." }, + { "dumpitems", "Writes every item name to items_list.txt." }, + { "deleteallworlds", "Deletes every world except the ones kept." }, + { "fillworld", "Fills the world with dropped items." }, + { "cleardrops", "Removes every dropped item in the world." }, + { "bot", "Adds, removes or controls bots." }, + { "nuke", "Nukes a world." }, + { "xp", "Gives you XP." }, + { "level", "Sets your level." }, + { "save", "Saves all worlds." }, + { "accounts", "Lists every account, with details and delete." }, + { "shutdown", "Countdown in chat, then saves and stops the server." }, + { "pull", "Pulls a player in your world to where you stand." }, + { "trade", "Starts a trade with a player in your world." }, + { "unban", "Lifts a ban so the player can log in again." }, + { "title", "Gives or takes the of Legend / Dr. title." }, +}; + +/* ------------------------------------------------------------------ help text parsing */ +struct cmd_info +{ + std::string name; + std::vector args; // @note labels without brackets + std::vector required; + std::string usage; // @note "/name {a} [b]" + int level; +}; + +static std::vector all_commands() +{ + std::vector out{}; + for (int level : { 2, 1, 0 }) + { + std::string help{ help_for(level) }; + std::string word{}; + cmd_info *cur = nullptr; + for (char ch : help + ' ') + { + if (ch != ' ') { word += ch; continue; } + if (word.empty()) continue; + if (word.front() == '/') + { + out.push_back({ word.substr(1), {}, {}, word, level }); + cur = &out.back(); + } + else if (cur) + { + const bool req = word.front() == '{'; + std::string label = word; + std::erase_if(label, [](char x) { return x == '{' || x == '}' || x == '[' || x == ']'; }); + cur->args.push_back(label); + cur->required.push_back(req); + cur->usage += " " + word; + } + word.clear(); + } + } + return out; +} + +static const char *describe(const std::string &name) +{ + const auto d = descriptions.find(name); + return d == descriptions.end() ? "" : d->second; +} + +/* ------------------------------------------------------------------ /? menu */ +void devmenu_open(ENetEvent &event) +{ + ::create_dialog d{}; + d.set_default_color("`o") + .add_label_with_icon("big", "`wCommands``", 32) + .add_smalltext("Tap a command to use it. Commands that need more info open a form first."); + + const std::vector cmds = all_commands(); + for (int level : { 2, 1, 0 }) + { + bool header = false; + for (const cmd_info &c : cmds) + { + if (c.level != level) continue; + if (!header) + { + d.add_spacer("small").add_label("big", level == 2 ? "`4Developer``" : level == 1 ? "`5Moderator``" : "`2Everyone``"); + header = true; + } + const char *desc = describe(c.name); + d.add_button("run_" + c.name, "/" + c.name); + d.add_smalltext(*desc ? std::format("`w{}`` - {}", c.usage, desc) : std::format("`w{}``", c.usage)); + } + } + send_varlist(event.peer, { "OnDialogRequest", d.end_dialog("dev_menu", "Close", "") }); +} + +/* form with one box per argument. @return true if a form was opened */ +bool devmenu_form(ENetEvent &event, const std::string &name, bool only_if_required) +{ + const std::vector cmds = all_commands(); + const auto c = std::ranges::find(cmds, name, &cmd_info::name); + if (c == cmds.end() || c->args.empty()) return false; + if (only_if_required && std::ranges::none_of(c->required, [](bool r) { return r; })) return false; + + ::create_dialog d{}; + d.set_default_color("`o") + .add_label_with_icon("big", std::format("`w/{}``", c->name), 32); + if (const char *desc = describe(c->name); *desc) d.add_smalltext(desc); + d.add_smalltext(std::format("Usage: `w{}``", c->usage)); + for (std::size_t i = 0; i < c->args.size(); ++i) + d.add_text_input(std::format("a{}", i), std::format("{}{}:", c->args[i], c->required[i] ? "" : " (optional)"), std::string{}, 60); + d.embed_data("cmd", c->name); + send_varlist(event.peer, { "OnDialogRequest", d.end_dialog("cmd_form", "Cancel", "Run") }); + return true; +} + +static void run_command(ENetEvent &event, const std::string &text) +{ + const std::string name = text.substr(0, text.find(' ')); + const auto it = cmd_pool.find(name); + if (it == cmd_pool.end()) { tell(event.peer, "`4That command doesn't exist.``"); return; } + send_action(*event.peer, "log", std::format("msg| `6/{}``", text).c_str()); + it->second(event, text); +} + +void dev_menu_return(ENetEvent &event, const ::hPipe &hPipe) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (pPeer->role < DEVELOPER) return; + const std::string btn = hPipe["buttonClicked"]; + if (!btn.starts_with("run_")) return; + const std::string name = btn.substr(4); + if (devmenu_form(event, name, false)) return; + run_command(event, name); +} + +void cmd_form_return(ENetEvent &event, const ::hPipe &hPipe) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (pPeer->role < DEVELOPER) return; + std::string text = hPipe["cmd"]; + if (text.empty()) return; + for (int i = 0; i < 10; ++i) + { + std::string v = hPipe[std::format("a{}", i)]; + while (!v.empty() && std::isspace(static_cast(v.back()))) v.pop_back(); + while (!v.empty() && std::isspace(static_cast(v.front()))) v.erase(0, 1); + if (!v.empty()) text += " " + v; + } + run_command(event, text); +} + +/* ------------------------------------------------------------------ /find item card */ +void find_item_popup(ENetEvent &event, int id) +{ + if (id <= 0 || id >= static_cast(items.size())) return; + const ::item &it = id_to_item(static_cast(id)); + std::string info = it.info.empty() ? std::string{ "No description." } : it.info; + for (char &ch : info) if (ch == '|' || ch == '\n' || ch == '\r') ch = ' '; + + ::create_dialog d{}; + d.set_default_color("`o") + .add_label_with_icon("big", std::format("`w{}``", it.raw_name), id) + .add_smalltext(std::format("ID `w{}`` Rarity `w{}``", id, it.rarity)) + .add_textbox(info) + .embed_data("item", id) + .add_button("get_1", "Get 1") + .add_button("get_10", "Get 10") + .add_button("get_100", "Get 100") + .add_button("get_200", "Get 200"); + if (id + 1 < static_cast(items.size()) && id_to_item(static_cast(id + 1)).type == type::SEED) + d.add_button("seed_200", "Get 200 Seeds"); + send_varlist(event.peer, { "OnDialogRequest", d.end_dialog("find_give", "Close", "") }); +} + +void find_give_return(ENetEvent &event, const ::hPipe &hPipe) +{ + const int id = std::atoi(hPipe["item"].c_str()); + const std::string btn = hPipe["buttonClicked"]; + if (id <= 0 || id >= static_cast(items.size())) return; + + int give = id, amount = 0; + if (btn == "get_1") amount = 1; + else if (btn == "get_10") amount = 10; + else if (btn == "get_100") amount = 100; + else if (btn == "get_200") amount = 200; + else if (btn == "seed_200") { give = id + 1; amount = 200; } + if (amount == 0) return; + if (static_cast<::peer*>(event.peer->data)->role < MODERATOR && staff_only_item(give)) + { + tell(event.peer, "`4That item is for staff only.``"); + return; + } + + modify_item_inventory(event, ::slot(static_cast(give), static_cast(amount))); + tell(event.peer, std::format("`2Added `w{}x {}``!``", amount, id_to_item(static_cast(give)).raw_name)); +} diff --git a/include/commands/devmenu.hpp b/include/commands/devmenu.hpp new file mode 100644 index 00000000..935f494d --- /dev/null +++ b/include/commands/devmenu.hpp @@ -0,0 +1,11 @@ +#pragma once + +extern std::string_view help_for(int level); // @note 0 everyone, 1 mod, 2 dev (from __command.cpp) + +extern void devmenu_open(ENetEvent &event); +extern bool devmenu_form(ENetEvent &event, const std::string &name, bool only_if_required); +extern void dev_menu_return(ENetEvent &event, const ::hPipe &hPipe); +extern void cmd_form_return(ENetEvent &event, const ::hPipe &hPipe); + +extern void find_item_popup(ENetEvent &event, int id); +extern void find_give_return(ENetEvent &event, const ::hPipe &hPipe); diff --git a/include/commands/dropqueue.cpp b/include/commands/dropqueue.cpp new file mode 100644 index 00000000..3455a6da --- /dev/null +++ b/include/commands/dropqueue.cpp @@ -0,0 +1,105 @@ +#include "pch.hpp" +#include "onVariant/SetBux.hpp" +#include +#include +#include "database/world.hpp" +#include "dropqueue.hpp" + +namespace +{ +constexpr int PER_BATCH = 25; // @note drops sent per batch +constexpr int REMOVE_PER_BATCH = 15; // @note removals per batch (/cleardrops) +constexpr auto EVERY = std::chrono::milliseconds(100); // @note time between batches + +struct queued +{ + std::string world; + bool add; + u_short id, count; + ::pos pos; + int uid; + std::string by; +}; +std::deque q{}; + +/* the player who asked for it if they're still in the world, else anyone in it */ +ENetPeer *peer_for(const std::string &world, const std::string &by) +{ + ENetPeer *any = nullptr, *pref = nullptr; + peers(world, PEER_SAME_WORLD, [&](ENetPeer &p) + { + ::peer *t = static_cast<::peer*>(p.data); + if (!any) any = &p; + if (!by.empty() && t && t->growid == by) pref = &p; + }); + return pref ? pref : any; +} +} + +void queue_add_drop(const std::string &world, u_short id, u_short count, ::pos pos) +{ + q.push_back({ world, true, id, count, pos, 0, {} }); +} + +void queue_remove_drop(const std::string &world, int uid, const std::string &by_growid) +{ + q.push_back({ world, false, 0, 0, ::pos{}, uid, by_growid }); +} + +void dropqueue_cancel_adds(const std::string &world) +{ + std::erase_if(q, [&](const queued &d) { return d.add && d.world == world; }); +} + +/* called every loop of the server: sends the next batch when it's time */ +void dropqueue_pump() +{ + static auto next = std::chrono::steady_clock::now(); + if (q.empty()) return; + const auto now = std::chrono::steady_clock::now(); + if (now < next) return; + next = now + EVERY; + + ENetPeer *resync = nullptr; + for (int adds = 0, removes = 0; !q.empty() && adds < PER_BATCH && removes < REMOVE_PER_BATCH; ) + { + const queued d = q.front(); + q.pop_front(); + d.add ? ++adds : ++removes; + + auto w = std::ranges::find(worlds, d.world, &::world::name); + if (w == worlds.end()) continue; // @note world closed meanwhile + ENetPeer *p = peer_for(d.world, d.by); + + if (d.add) + { + if (p) + { + ENetEvent ev{}; + ev.peer = p; + add_object(ev, ::slot(static_cast(d.id), static_cast(d.count)), d.pos, *w); + } + else w->objects.emplace_back(::object(d.id, d.count, d.pos, ++w->last_object_uid)); + } + else + { + auto o = std::ranges::find(w->objects, static_cast(d.uid), &::object::uid); + if (o == w->objects.end()) continue; // @note someone already picked it up + w->objects.erase(o); + if (p) + { + ENetEvent ev{}; + ev.peer = p; + item_change_object(ev, ::gamePacket{ .netid = static_cast<::peer*>(p->data)->netid, .uid = (int)0xffffffff, .id = d.uid }); + resync = p; + } + } + } + if (resync) // @note the removal looks like a pickup to that client, so give it the real backpack and gems back + { + ENetEvent ev{}; + ev.peer = resync; + send_inventory_state(ev); + on::SetBux(ev); + } +} diff --git a/include/commands/dropqueue.hpp b/include/commands/dropqueue.hpp new file mode 100644 index 00000000..083e1204 --- /dev/null +++ b/include/commands/dropqueue.hpp @@ -0,0 +1,6 @@ +#pragma once + +extern void queue_add_drop(const std::string &world, u_short id, u_short count, ::pos pos); +extern void queue_remove_drop(const std::string &world, int uid, const std::string &by_growid); +extern void dropqueue_cancel_adds(const std::string &world); +extern void dropqueue_pump(); diff --git a/include/commands/ducttape.cpp b/include/commands/ducttape.cpp new file mode 100644 index 00000000..b4638dbe --- /dev/null +++ b/include/commands/ducttape.cpp @@ -0,0 +1,86 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "onVariant/SetClothing.hpp" +#include "database/world.hpp" +#include "ducttape.hpp" + +/* /ducttape -> toggles duct tape on yourself + * /ducttape {player} -> toggles it on someone in your world + */ +void ducttape(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < MODERATOR) + { + on::ConsoleMessage(event.peer, "`4Only staff can use this.``"); + return; + } + + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + while (!arg.empty() && std::isspace(static_cast(arg.back()))) arg.pop_back(); + + // no argument: apply to yourself + if (arg.empty()) + { + pPeer->state ^= S_DUCT_TAPE; + on::SetClothing(*event.peer); + + peers(pPeer->recent_worlds.back(), PEER_SAME_WORLD, [pPeer](ENetPeer &p) + { + on::SetClothing(p, *pPeer); + }); + + on::ConsoleMessage(event.peer, (pPeer->state & S_DUCT_TAPE) + ? "`2Duct tape applied.``" + : "`2Duct tape removed.``"); + return; + } + + std::string lower_target = arg; + for (char &c : lower_target) c = std::tolower(static_cast(c)); + + bool found = false; + peers(pPeer->recent_worlds.back(), PEER_SAME_WORLD, [&](ENetPeer &p) + { + ::peer *pTarget = static_cast<::peer*>(p.data); + if (!pTarget) return; + + std::string their_name = pTarget->growid; + for (char &c : their_name) c = std::tolower(static_cast(c)); + if (their_name != lower_target) return; + + if (pTarget->role >= pPeer->role && pTarget->user_id != pPeer->user_id) + { + on::ConsoleMessage(event.peer, "`4You cannot duct tape someone of equal or higher rank.``"); + found = true; + return; + } + + found = true; + pTarget->state ^= S_DUCT_TAPE; + const bool taped = (pTarget->state & S_DUCT_TAPE) != 0; + + on::SetClothing(p, *pTarget); + peers(pPeer->recent_worlds.back(), PEER_SAME_WORLD, [pTarget](ENetPeer &q) { ::peer *o = static_cast<::peer*>(q.data); if (o && o->user_id != pTarget->user_id) on::SetClothing(q, *pTarget); }); + + on::ConsoleMessage(&p, taped + ? "`4Somebody duct taped your mouth shut!``" + : "`2The duct tape was removed.``"); + + on::ConsoleMessage(event.peer, std::format("`2`w{}`` is now {}.``", + pTarget->growid, taped ? "`4duct taped``" : "`2free to speak``")); + }); + + if (!found) + on::ConsoleMessage(event.peer, std::format("`4`w{}`` is not in this world.``", arg)); +} diff --git a/include/commands/ducttape.hpp b/include/commands/ducttape.hpp new file mode 100644 index 00000000..c90cdbb0 --- /dev/null +++ b/include/commands/ducttape.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void ducttape(ENetEvent& event, const std::string_view text); diff --git a/include/commands/euphoria.cpp b/include/commands/euphoria.cpp new file mode 100644 index 00000000..79b1114c --- /dev/null +++ b/include/commands/euphoria.cpp @@ -0,0 +1,112 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "tools/bubble.hpp" +#include "database/items.hpp" +#include "database/world.hpp" +#include "weather.hpp" +#include "euphoria.hpp" + +namespace +{ +struct party_world +{ + int points = 0; + bool started = false, euphoria = false; + std::unordered_map by{}; // @note points per player + std::vector weather_sent{}; // @note players who already got the party weather +}; +std::unordered_map party{}; + +/* points per item used */ +int points_for(short id) +{ + switch (id) + { + case 4378: case 4370: return 8; // @note Party Cake, Party Popper + case 2306: return 10; // @note Party-In-A-Box + case 9264: return 50; // @note Anniversary Skyrocket + case 4366: return 150; // @note Party Screamer + case 2288: return 4; // @note Party Confetti (100 = 400) + } + return 0; +} + +int party_weather() +{ + static int w = -1; + if (w == -1) + { + w = 0; + for (const ::item &it : items) + if (it.raw_name == "Weather Machine - Party") { w = get_weather_id(it.id); break; } + } + return w; +} + +void banner(const std::string &world, const std::string &text) +{ + peers(world, PEER_SAME_WORLD, [&](ENetPeer &p) + { + send_varlist(&p, { "OnAddNotification", "interface/large/special_event.rttex", text, "audio/cumbia_horns.wav", 0u }); + on::ConsoleMessage(&p, text); + }); +} +} + +void euphoria_used(ENetEvent &event, ::world &w, short id, int used) +{ + const int pts = points_for(id) * used; + if (pts <= 0) return; + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + party_world &pw = party[w.name]; + pw.points += pts; + pw.by[pPeer->growid] += pts; + on::ConsoleMessage(event.peer, std::format("`2+{} party points`` (world: `w{}``/5500, you: `w{}``)", pts, pw.points, pw.by[pPeer->growid])); + + if (!pw.started && pw.points >= 1500) + { + pw.started = true; + banner(w.name, "`2Party Started!:`` `oThis world is now a party world!``"); + } + if (!pw.euphoria && pw.points >= 5500) + { + pw.euphoria = true; + banner(w.name, "`2EUPHORIA!:`` `oEveryone who added `w500`` party points gets a `wGolden Party-In-A-Box``!``"); + peers(w.name, PEER_SAME_WORLD, [&](ENetPeer &p) + { + ::peer *t = static_cast<::peer*>(p.data); + if (!t || pw.by[t->growid] < 500) return; + ENetEvent ev{}; + ev.peer = &p; + modify_item_inventory(ev, ::slot(7672, 1)); + tell(&p, "`2You got a `wGolden Party-In-A-Box``!``"); + }); + } +} + +/* every second: party weather for newcomers, reset when the world is empty */ +void euphoria_tick(std::time_t) +{ + for (auto it = party.begin(); it != party.end(); ) + { + std::vector in{}; + peers(it->first, PEER_SAME_WORLD, [&](ENetPeer &p) { in.push_back(&p); }); + if (in.empty()) { it = party.erase(it); continue; } + + party_world &pw = it->second; + std::erase_if(pw.weather_sent, [&](const std::string &g) + { + return std::ranges::none_of(in, [&](ENetPeer *p) { const ::peer *t = static_cast<::peer*>(p->data); return t && t->growid == g; }); + }); + if (pw.started) + for (ENetPeer *p : in) + { + const ::peer *t = static_cast<::peer*>(p->data); + if (!t || std::ranges::find(pw.weather_sent, t->growid) != pw.weather_sent.end()) continue; + pw.weather_sent.push_back(t->growid); + send_varlist(p, { "OnSetCurrentWeather", party_weather() }); + } + ++it; + } +} diff --git a/include/commands/euphoria.hpp b/include/commands/euphoria.hpp new file mode 100644 index 00000000..243e31f9 --- /dev/null +++ b/include/commands/euphoria.hpp @@ -0,0 +1,4 @@ +#pragma once + +extern void euphoria_used(ENetEvent &event, ::world &w, short id, int used); +extern void euphoria_tick(std::time_t now); diff --git a/include/commands/events.cpp b/include/commands/events.cpp new file mode 100644 index 00000000..f2226024 --- /dev/null +++ b/include/commands/events.cpp @@ -0,0 +1,347 @@ +#include "pch.hpp" +#include "tools/bubble.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/items.hpp" +#include "database/world.hpp" +#include "tools/random.hpp" +#include "onVariant/SetBux.hpp" +#include "events.hpp" + +static constexpr int EVENT_CHANCE = 3; // @note % chance per player per minute that a world gets a random event (max 50%) + +struct event_def +{ + const char *title; + const char *text; + std::vector items; // @note item names, resolved from items.dat + int count; // @note how many of each item (or in total, when mixed) + int seconds; // @note 0 = no time limit, leftovers stay + bool gems; // @note spawn piles of gems + bool mixed; // @note count drops, each a random item from the list + bool anytime; // @note can start randomly; false = only with /event (holiday events) +}; + +static const std::vector defs{ + /* ---- can happen anytime ---- */ + { "Beautiful Crystal!", "You have `w30`` seconds to find and grab the `#Crystal Block Seed``.", { "Crystal Block Seed" }, 1, 30, false, false, true }, + { "Magical Seeds!", "You have `w30`` seconds to find and grab the `w4`` `#Mystery Block Seeds``.", { "Mystery Block Seed" }, 4, 30, false, false, true }, + { "Spoiler Alert!", "You have `w30`` seconds to find the `w10`` pieces of the `#Secret Of Growtopia``!", { "Secret Of Growtopia" }, 10, 30, false, false, true }, + { "Desert Blast!", "You have `w30`` seconds to find and grab the `#Desert Blast``.", { "Desert Blast" }, 1, 30, false, false, true }, + { "Well, Well, Well!", "You have `w30`` seconds to find the `w3`` `#Well`` blocks hidden around the world!", { "Well" }, 3, 30, false, false, true }, + { "Lost Gold!", "You have `w30`` seconds to find the `#Gold Choker`` hidden in the world!", { "Gold Choker" }, 1, 30, false, false, true }, + { "Howling At The Moon!", "You have `w30`` seconds to find `w3`` `#Wolf Whistles`` hidden in the world!", { "Wolf Whistle" }, 3, 30, false, false, true }, + { "Jungle Blast!", "You have `w30`` seconds to find `w1`` `#Jungle Blast``.", { "Jungle Blast" }, 1, 30, false, false, true }, + { "What's that?", "You have `w10`` seconds to find and grab the `#Doohickey``.", { "Doohickey" }, 1, 10, false, false, true }, + { "That's Puzzling...", "You have `w20`` seconds to find the `w20`` `#Jigsaw Wallpapers`` hidden in this world!", { "Jigsaw Wallpaper" }, 20, 20, false, false, true }, + { "Jackpot!", "`#Gems`` are spawned throughout the world!", { }, 0, 0, true, false, true }, + { "Beat The Heat!", "Cool off with some free `#Water Buckets``!", { "Water Bucket" }, 10, 0, false, false, true }, + { "Chemical Spill!", "`#Toxic Waste`` spawns everywhere!", { "Toxic Waste" }, 20, 0, false, false, true }, + + /* ---- holiday events: only with /event ---- */ + { "Egg Hunt!", "Go find them eggs! `w20`` `#Magic Eggs`` have spawned in the world!", { "Magic Egg" }, 20, 0, false, false, false }, + { "Valentine's Card!", "You have `w30`` seconds to collect the `w5`` `#Valentine's Dust`` spawned in your world!", { "Valentine's Dust" }, 5, 30, false, false, false }, + { "Luck of the Growish!", "`#Green Beer`` and `#Lucky Clovers`` spawn everywhere!", { "Green Beer", "Lucky Clover" }, 5, 0, false, false, false }, + { "Lucky Kitty!", "You have `w20`` seconds to find a `#Lucky Kitty`` or an `#Unlucky Kitty``!", { "Lucky Kitty", "Unlucky Kitty" }, 1, 20, false, true, false }, + { "Songpyeon!", "`w3`` `#Songpyeon`` spawned in your world, you have `w30`` seconds to collect them!", { "Songpyeon" }, 3, 30, false, false, false }, + { "Mooncake Madness!", "You have `w30`` seconds to find all the `w40`` `#Mooncakes`` scattered around the world!", { "Mooncake" }, 40, 30, false, false, false }, + { "Let it Snow!", "You have to collect `w30`` `#Snowballs`` before they melt!", { "Snowball" }, 30, 30, false, false, false }, + { "Spirit of Giving!", "`#Winter Gifts`` are spawned everywhere in the world!", { "Winter Gift" }, 10, 0, false, false, false }, + { "Festive Spirit!", "You have `w30`` seconds to find all `w5`` `#Winterfest Crackers`` hidden in the world!", { "Winterfest Cracker" }, 5, 30, false, false, false }, + { "Royal Winter!", "`#Royal Winter Seals`` for everyone! Be quick, you have `w30`` seconds to collect them!", { "Royal Winter Seal" }, 5, 30, false, false, false }, + { "Surgical Supply Drop!", "You have `w30`` seconds to find the `w20`` assorted surgical supplies hidden in the world!", + { "Surgical Sponge", "Surgical Scalpel", "Surgical Anesthetic", "Surgical Antiseptic", "Surgical Antibiotics", "Surgical Splint", + "Surgical Stitches", "Surgical Pins", "Surgical Transfusion", "Surgical Defibrillator", "Surgical Clamp", "Surgical Ultrasound", "Surgical Lab Kit" }, + 20, 30, false, true, false }, + { "Summertime Surprise!", "A `#Summer Surprise`` has spawned!", { "Summer Surprise" }, 1, 0, false, false, false }, + { "Explodiversary!", "`#Anniversary Skyrockets`` for everyone! Be quick, you have `w20`` seconds to collect them!", { "Anniversary Skyrocket" }, 5, 20, false, false, false }, + { "Anniversary Party!", "You have `w30`` seconds to collect the `w5`` party items spawned in your world!", + { "Ballroom Party Chair", "Birthday Band Hair", "Birthday Cake Hat", "Black Display Shelf", "Confetti Crown", "Confetti Popper", + "Disco Dude's Pants", "Disco Dude's Shoes", "Electric Guitar", "Equalizer Tee", "Fireflies In A Jar", "Flame Tee", "Fox Ears", + "Fox Onesie", "Glitter Pillow Trampoline", "Light Box - Green", "Light Box - Pink", "Light Box - Yellow", "Party Block", + "Party Bunting", "Party Cake", "Party Confetti", "Party Couch", "Party Glasses", "Party Glow Sneakers", "Party Hat", "Party Horn", + "Party Light Wave Vest", "Party Mocktail Table", "Party Pants", "Party Popper", "Party Screamer", "Party Socks", + "Party Style Scarf", "Party Style Shades", "Party Style Top Hat", "Party Table", "Party Tunes", "Party Vest", + "Party-In-A-Box Head", "Rave Cap", "Rave Hall Floor", "Rave Hall Wall", "Rave Haze Light", "Rave Stage", "Raver Hoodie", + "Raver Pants", "Resting Headphones", "Retro Pattern Background", "Retro Pattern Block", "Roobux Cube", + "Sheet Music: Electric Guitar", "Skyrocket", "T-Shirt Cannon" }, + 5, 30, false, true, false }, +}; + +struct active_event +{ + std::string world; + std::string title; + std::time_t ends; + std::vector uids; +}; +static std::vector active{}; + +static std::string lower(std::string s) +{ + for (char &c : s) c = static_cast(std::tolower(static_cast(c))); + return s; +} + +static short item_id(const char *name) +{ + const std::string want = lower(name); + for (const ::item &it : items) + if (it.raw_name.size() == want.size() && lower(it.raw_name) == want) return static_cast(it.id); + return 0; +} + +static ENetPeer *any_peer_in(const std::string &world) +{ + ENetPeer *found = nullptr; + peers(world, PEER_SAME_WORLD, [&](ENetPeer &p) { if (!found) found = &p; }); + return found; +} + +static void world_msg(const std::string &world, const std::string &text) +{ + peers(world, PEER_SAME_WORLD, [&](ENetPeer &p) { on::ConsoleMessage(&p, text); }); +} + +/* the "Special Event!" banner + chat line for everyone in the world */ +static void notify(const std::string &world, const std::string &text) +{ + peers(world, PEER_SAME_WORLD, [&](ENetPeer &peer) + { + send_varlist(&peer, { "OnAddNotification", "interface/large/special_event.rttex", text, "audio/cumbia_horns.wav", 0u }); + on::ConsoleMessage(&peer, text); + }); +} +static bool random_air(::world &w, int &x, int &y) +{ + const int width = static_cast(w.blocks.size() / 60), height = 60; + for (int tries = 0; tries < 300; ++tries) + { + x = RandomRange(0, width); + y = RandomRange(0, height); + if (w.blocks[cord(x, y)].fg == 0) return true; + } + return false; +} + +static bool event_running(const std::string &world) +{ + return std::ranges::any_of(active, [&](const active_event &a) { return a.world == world; }); +} + +static bool start_event(::world &w, const event_def &d) +{ + ENetPeer *p = any_peer_in(w.name); + if (!p) return false; + ENetEvent ev{}; + ev.peer = p; + + std::vector ids{}; + for (const char *name : d.items) + { + const short id = item_id(name); + if (id) ids.emplace_back(id); + else printf("[event] item not found: '%s'\n", name); + } + if (!d.gems && ids.empty()) return false; + + active_event a{ w.name, d.title, d.seconds ? std::time(nullptr) + d.seconds : 0, {} }; + int spawned = 0; + + auto drop = [&](short id, short amount, bool track) + { + int x, y; + if (!random_air(w, x, y)) return; + const int uid = add_object(ev, ::slot(id, amount), ::pos{ x * 32.0f + 8.0f, y * 32.0f + 8.0f }, w); + if (track) a.uids.emplace_back(static_cast(uid)); + ++spawned; + }; + + if (d.gems) + for (int i = 0; i < 30; ++i) drop(112, static_cast(RandomRange(1, 6)), false); + + if (d.mixed) + for (int i = 0; i < d.count; ++i) drop(ids[RandomRange(0, static_cast(ids.size()))], 1, true); + else + for (short id : ids) + for (int i = 0; i < d.count; ++i) drop(id, 1, true); + + if (spawned == 0) return false; + + const std::string text = std::format("`2{}:`` `o{}``", d.title, d.text); + peers(w.name, PEER_SAME_WORLD, [&](ENetPeer &peer) + { + send_varlist(&peer, { "OnAddNotification", "interface/large/special_event.rttex", text, "audio/cumbia_horns.wav", 0u }); + on::ConsoleMessage(&peer, text); + }); + + if (d.seconds > 0 && !a.uids.empty()) active.emplace_back(std::move(a)); + return true; +} + +/* called when someone picks up a dropped object */ +void event_object_taken(ENetEvent &event, ::world &w, u_int uid, short id) +{ + for (auto it = active.begin(); it != active.end(); ++it) + { + if (it->world != w.name) continue; + auto u = std::ranges::find(it->uids, uid); + if (u == it->uids.end()) continue; + it->uids.erase(u); + + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + world_msg(w.name, std::format("{} `2found a`` `w{}``!", pPeer->display_growid, id_to_item(id).raw_name)); + + if (it->uids.empty()) + { + notify(w.name, std::format("`2{}:`` `oEverything was found! The event is over.``", it->title)); + active.erase(it); + } + return; + } +} + +/* how often each random event is picked compared to the others (default 10) */ +static int event_weight(const event_def &d) +{ + const std::string_view t{ d.title }; + if (t == "Jungle Blast!") return 1; // @note rare: about 1 in 121 events + return 10; +} + +static std::size_t pick_anytime(const std::vector &anytime) +{ + int total = 0; + for (std::size_t i : anytime) total += event_weight(defs[i]); + int r = RandomRange(0, total); + for (std::size_t i : anytime) { r -= event_weight(defs[i]); if (r < 0) return i; } + return anytime.back(); +} +/* runs once a second from tick_timers() */ +void events_tick(std::time_t now) +{ + for (auto it = active.begin(); it != active.end();) + { + if (now < it->ends) { ++it; continue; } + + auto w = std::ranges::find(worlds, it->world, &::world::name); + if (w != worlds.end()) + { + ENetPeer *p = any_peer_in(it->world); + for (u_int uid : it->uids) + { + auto o = std::ranges::find(w->objects, uid, &::object::uid); + if (o == w->objects.end()) continue; + if (p) + { + ENetEvent ev{}; + ev.peer = p; + item_change_object(ev, ::gamePacket{ .netid = static_cast<::peer*>(p->data)->netid, .uid = (int)0xffffffff, .id = (int)uid }); // @note same message as a pickup + } + w->objects.erase(o); + } + if (p) // @note the removal message looks like a pickup to that client (it adds the item), so send the real backpack again + { + ENetEvent ev{}; + ev.peer = p; + send_inventory_state(ev); + on::SetBux(ev); + } + notify(it->world, std::format("`2{}:`` `4Time's up!`` `oThe event is over.``", it->title)); + } + it = active.erase(it); + } + + static std::time_t next_roll = 0; + if (now < next_roll) return; + next_roll = now + 60; + + std::vector anytime{}; + for (std::size_t i = 0; i < defs.size(); ++i) if (defs[i].anytime) anytime.emplace_back(i); + + for (::world &w : worlds) + { + if (event_running(w.name)) continue; + const int here = static_cast(peers(w.name, PEER_SAME_WORLD).size()); + if (here == 0) continue; + if (RandomRange(0, 100) >= std::min(EVENT_CHANCE * here, 50)) continue; + start_event(w, defs[pick_anytime(anytime)]); + } +} + +/* /event -> list events + * /event {number} -> start that event in your world (developers) */ +void event_cmd(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (pPeer->role < DEVELOPER) + { + send_action(*event.peer, "log", "msg|`4Unknown command.`` Enter `$/?`` for a list of valid commands."); + return; + } + if (pPeer->netid == 0) + { + tell(event.peer, "`4You must be in a world.``"); + return; + } + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + while (!arg.empty() && std::isspace(static_cast(arg.back()))) arg.pop_back(); + + if (arg.empty()) + { + std::string anytime{ "`oAnytime:``" }, holiday{ "`oHoliday:``" }; + for (std::size_t i = 0; i < defs.size(); ++i) + (defs[i].anytime ? anytime : holiday) += std::format(" `w{}`` {}", i + 1, defs[i].title); + on::ConsoleMessage(event.peer, anytime); + on::ConsoleMessage(event.peer, holiday); + on::ConsoleMessage(event.peer, "`oUse `w/event {number}`` to start one here.``"); + return; + } + + const int n = std::atoi(arg.c_str()) - 1; + if (n < 0 || n >= static_cast(defs.size())) + { + tell(event.peer, "`4No event with that number.`` Type `w/event`` for the list."); + return; + } + + auto w = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + if (w == worlds.end()) return; + if (!start_event(*w, defs[n])) + tell(event.peer, "`4Couldn't start that event - check the server window for missing items.``"); +} + + +/* Mystery Lens: one random valuable wearable hidden in the world for 90 seconds */ +bool event_hidden_object(::world &w) +{ + ENetPeer *p = any_peer_in(w.name); + if (!p) return false; + + short prize = 0; + for (int tries = 0; tries < 500 && !prize; ++tries) + { + const ::item &it = items[RandomRange(0, static_cast(items.size()))]; + if (it.raw_name.empty() || it.type == type::SEED || it.cloth_type == clothing::NONE) continue; + if (it.rarity < 30 || it.rarity > 200) continue; + prize = static_cast(it.id); + } + int x, y; + if (!prize || !random_air(w, x, y)) return false; + + ENetEvent ev{}; + ev.peer = p; + const int uid = add_object(ev, ::slot(prize, 1), ::pos{ x * 32.0f + 8.0f, y * 32.0f + 8.0f }, w); + active.push_back({ w.name, "Hidden in Plain Sight!", std::time(nullptr) + 90, { static_cast(uid) } }); + notify(w.name, "`2Hidden in Plain Sight!:`` `oSomething valuable is hidden somewhere in this world. You have `w90`` seconds to find it!``"); + return true; +} + +/* Party-In-A-Box: starts the Anniversary Party event in this world */ +bool event_party(::world &w) +{ + const auto d = std::ranges::find_if(defs, [](const event_def &e) { return std::string_view{ e.title } == "Anniversary Party!"; }); + return d != defs.end() && start_event(w, *d); +} \ No newline at end of file diff --git a/include/commands/events.hpp b/include/commands/events.hpp new file mode 100644 index 00000000..f267e455 --- /dev/null +++ b/include/commands/events.hpp @@ -0,0 +1,7 @@ +#pragma once + +extern void events_tick(std::time_t now); +extern void event_object_taken(ENetEvent &event, ::world &w, u_int uid, short id); +extern void event_cmd(ENetEvent& event, const std::string_view text); +extern bool event_hidden_object(::world &w); +extern bool event_party(::world &w); diff --git a/include/commands/extra_items.cpp b/include/commands/extra_items.cpp new file mode 100644 index 00000000..92138039 --- /dev/null +++ b/include/commands/extra_items.cpp @@ -0,0 +1,256 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "onVariant/SetClothing.hpp" +#include "action/respawn.hpp" +#include "tools/bubble.hpp" +#include "tools/random.hpp" +#include "database/items.hpp" +#include "database/world.hpp" +#include "buffs.hpp" +#include "events.hpp" +#include "extra_items.hpp" + +static int count_of(const ::peer &p, short id) +{ + for (const ::slot &s : p.slots) if (s.id == id) return s.count; + return 0; +} + +static void bubble(const ::peer &who, const std::string &text) +{ + const ::peer &s = speaker(who); // @note used on another player: they say it + peers(s.recent_worlds.back(), PEER_SAME_WORLD, [&](ENetPeer &p) { send_varlist(&p, { "OnTalkBubble", s.netid, text, 0u }); }); +} + +static ENetPeer *player_at(const std::string &world, int x, int y, const ::peer *skip) +{ + ENetPeer *hit = nullptr; + peers(world, PEER_SAME_WORLD, [&](ENetPeer &p) + { + ::peer *t = static_cast<::peer*>(p.data); + if (!t || t == skip || hit) return; + const int x0 = static_cast(std::floor(t->pos.x / 32.0f)), x1 = static_cast(std::floor((t->pos.x + 19.0f) / 32.0f)); + const int y0 = static_cast(std::floor(t->pos.y / 32.0f)), y1 = static_cast(std::floor((t->pos.y + 29.0f) / 32.0f)); + if (x >= x0 && x <= x1 && y >= y0 && y <= y1) hit = &p; + }); + return hit; +} + +static void add_state(::peer &p, int flag, int seconds) +{ + const std::time_t ends = std::time(nullptr) + seconds; + for (auto &e : p.item_states) if (e.first == flag) { e.second = ends; return; } + p.item_states.emplace_back(flag, ends); +} + +struct combine { short from; int need; short into; }; +static constexpr combine combines[]{ + { 1234, 4, 1206 }, // @note Devil Wing Fragment x4 -> Devil Wings + { 3122, 16, 3120 }, // @note Teeny Wing Fragment x16 -> Teeny Devil Wings + { 3318, 100, 3336 }, { 3320, 100, 3338 }, { 3322, 100, 3340 }, // @note Silk Thread x100 -> Silk Bolt (red, green, blue) + { 3324, 100, 3342 }, { 3326, 100, 3344 }, { 3328, 100, 3346 }, // @note black, white, grey + { 3330, 100, 3348 }, { 3332, 100, 3350 }, { 3334, 100, 3352 }, // @note yellow, purple, aqua + { 3890, 100, 3892 }, // @note Wool x100 -> Wool Bolt +}; + +bool extra_item_use(ENetEvent &event, ::world &world, const ::item &item, ::gamePacket &gamePacket) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + const short id = static_cast(item.id); + const int x = static_cast(gamePacket.punch.x), y = static_cast(gamePacket.punch.y); + const int width = static_cast(world.blocks.size() / 60); + auto use_one = [&] { modify_item_inventory(event, ::slot(id, -1)); pPeer->add_xp(event, consumable_xp(*pPeer)); }; + + for (const combine &c : combines) + if (id == c.from) + { + const int have = count_of(*pPeer, c.from); + const std::string into = id_to_item(static_cast(c.into)).raw_name; + if (have < c.need) + { + tell(event.peer, std::format("`4You need `w{}`` of these to make `w{}`` (you have {}).``", c.need, into, have)); + return true; + } + modify_item_inventory(event, ::slot(c.from, static_cast(-c.need))); + modify_item_inventory(event, ::slot(c.into, 1)); + tell(event.peer, std::format("`2You made `w{}``!``", into)); + return true; + } + + switch (id) + { + case 2966: // @note Enchanted Spatula: flip which way a block faces + { + if (x < 0 || y < 0 || x >= width || y >= 60) return true; + ::block &b = world.blocks[cord(x, y)]; + if (b.fg == 0) { tell(event.peer, "`4Slap a block with it.``"); return true; } + b.state[2] ^= S_LEFT; + send_tile_update(event, { .id = b.fg, .punch = gamePacket.punch }, b, world); + modify_item_inventory(event, ::slot(id, -1)); + bubble(*pPeer, "*SLAP!*"); + tell(event.peer, "`oThe spatula shatters.``"); + return true; + } + case 3102: // @note Eldritch Flame: fires all over the world + { + use_one(); + int lit = 0; + for (int tries = 0; tries < 600 && lit < 15; ++tries) + { + const int fx = RandomRange(0, width), fy = RandomRange(0, 54); + ::block &b = world.blocks[cord(fx, fy)]; + if ((b.fg == 0 && b.bg == 0) || (b.state[3] & (S_FIRE | S_WATER)) || b.fg == 6 || b.fg == 8) continue; + b.state[3] |= S_FIRE; + send_tile_update(event, { .id = b.fg, .punch = ::pos{ fx, fy } }, b, world); + ++lit; + } + peers(world.name, PEER_SAME_WORLD, [&](ENetPeer &p) + { + on::ConsoleMessage(&p, std::format("{} `4unleashed the Eldritch Flame!`` `w{}`` fires broke out!", pPeer->display_growid, lit)); + }); + return true; + } + case 8520: // @note Dangerous Pineapple: most don't survive it + { + use_one(); + if (RandomRange(0, 100) < 75) + { + bubble(*pPeer, "`4*chokes*``"); + action::respawn(event, ""); + } + else bubble(*pPeer, "YUM!"); + return true; + } + case 1964: // @note Devilfruit: devil horns for an hour + { + use_one(); + add_state(*pPeer, 0x40, 3600); + on::SetClothing(*event.peer); + bubble(*pPeer, "`4*feels evil*``"); + return true; + } + case 2306: // @note Party-In-A-Box + { + if (!event_party(world)) + { + tell(event.peer, "`4The party couldn't start here right now.``"); + return true; + } + modify_item_inventory(event, ::slot(id, -1)); + return true; + } + case 7672: // @note Golden Party-In-A-Box: one random prize + { + static constexpr std::pair prizes[]{ + { 7668, 2 }, { 7662, 3 }, { 7666, 5 }, { 11574, 1 }, { 10568, 1 }, { 11572, 3 }, { 7664, 3 }, { 13000, 1 }, + { 12996, 1 }, { 14166, 1 }, { 10534, 1 }, { 14172, 1 }, { 14168, 1 }, { 14170, 1 }, { 13030, 1 }, { 15132, 1 }, + { 15116, 1 }, { 11534, 1 }, { 9256, 1 }, { 15118, 1 }, { 14152, 1 }, { 11544, 1 }, { 10492, 1 }, { 9262, 1 }, + { 13002, 1 }, { 7660, 1 }, { 10494, 1 }, { 4366, 1 }, { 1406, 1 }, + }; + const auto &[prize, qty] = prizes[RandomRange(0, static_cast(std::size(prizes)))]; + modify_item_inventory(event, ::slot(id, -1)); + modify_item_inventory(event, ::slot(prize, qty)); + tell(event.peer, std::format("`2The Golden Party-In-A-Box contained `w{}x {}``!``", qty, id_to_item(static_cast(prize)).raw_name)); + return true; + } + case 2288: // @note Party Confetti: 100 at once gets the party going + { + const int have = count_of(*pPeer, 2288); + if (have < 100) + { + tell(event.peer, std::format("`4It takes `w100`` Party Confetti to get the party started (you have {}).``", have)); + return true; + } + modify_item_inventory(event, ::slot(2288, -100)); + bubble(*pPeer, "`2*CONFETTI EVERYWHERE*``"); + return true; + } + case 9264: // @note Anniversary Skyrocket + { + use_one(); + fireworks(event, ::pos{ x * 32.0f + 16.0f, y * 32.0f + 16.0f }); + return true; + } + case 15650: // @note Mystery Lens: Hidden in Plain Sight + { + if (!event_hidden_object(world)) + { + tell(event.peer, "`4Nothing could be hidden here right now.``"); + return true; + } + modify_item_inventory(event, ::slot(id, -1)); + return true; + } + case 5706: // @note Small Seed Pack: 4 common seeds (rarity 2-12) + 1 rare (13-21) + { + static std::vector common{}, rare{}; + if (common.empty()) + for (const ::item &it : items) + { + if (it.type != type::SEED || it.raw_name.empty()) continue; + if (it.rarity >= 2 && it.rarity <= 12) common.push_back(static_cast(it.id)); + else if (it.rarity >= 13 && it.rarity <= 21) rare.push_back(static_cast(it.id)); + } + if (common.empty() || rare.empty()) return true; + modify_item_inventory(event, ::slot(id, -1)); + std::string got{}; + for (int i = 0; i < 5; ++i) + { + const short seed = (i < 4) ? common[RandomRange(0, static_cast(common.size()))] : rare[RandomRange(0, static_cast(rare.size()))]; + modify_item_inventory(event, ::slot(seed, 1)); + got += std::format("{}`w{}``", got.empty() ? "" : ", ", id_to_item(static_cast(seed)).raw_name); + } + on::ConsoleMessage(event.peer, std::format("`2The Small Seed Pack contained:`` {}", got)); + return true; + } + case 4754: // @note Super Exploding Pineapple: kills everyone within 2 tiles + { + use_one(); + const ::pos at{ x * 32.0f + 16.0f, y * 32.0f + 16.0f }; + for (int i = 0; i < 5; ++i) fireworks(event, at); + std::vector hit{}; + peers(world.name, PEER_SAME_WORLD, [&](ENetPeer &p) + { + ::peer *t = static_cast<::peer*>(p.data); + if (!t) return; + const int tx = static_cast((t->pos.x + 10.0f) / 32.0f), ty = static_cast((t->pos.y + 15.0f) / 32.0f); + if (std::abs(tx - x) <= 2 && std::abs(ty - y) <= 2) hit.push_back(&p); + }); + for (ENetPeer *p : hit) + { + ENetEvent ev{}; + ev.peer = p; + action::respawn(ev, ""); + } + peers(world.name, PEER_SAME_WORLD, [&](ENetPeer &p) + { + on::ConsoleMessage(&p, std::format("`4KABOOM!`` {}`4's Super Exploding Pineapple went off!``", pPeer->display_growid)); + }); + return true; + } + case 540: // @note Green Beer: turns you green for a while + { + use_one(); + pPeer->tint = 0x40D040FFu; + pPeer->tint_item = 540; + pPeer->tint_until = std::time(nullptr) + 600; + on::SetClothing(*event.peer); + bubble(*pPeer, "YUM!"); + return true; + } + case 1988: // @note Doppelganger Potion: haunted shadows for 10 minutes + { + ENetPeer *found = player_at(world.name, x, y, pPeer); + if (!found) found = player_at(world.name, x, y, nullptr); + if (!found) { tell(event.peer, "`4Throw it on a player.``"); return true; } + ::peer *t = static_cast<::peer*>(found->data); + if (t->role > pPeer->role) { tell(event.peer, "`4That player is immune.``"); return true; } + use_one(); + add_state(*t, 0x40000, 600); + on::SetClothing(*found); + bubble(*t, "`8*the past comes back to haunt them*``"); + return true; + } + } + return false; +} diff --git a/include/commands/extra_items.hpp b/include/commands/extra_items.hpp new file mode 100644 index 00000000..5d3b5706 --- /dev/null +++ b/include/commands/extra_items.hpp @@ -0,0 +1,3 @@ +#pragma once + +extern bool extra_item_use(ENetEvent &event, ::world &world, const ::item &item, ::gamePacket &gamePacket); diff --git a/include/commands/fill.cpp b/include/commands/fill.cpp new file mode 100644 index 00000000..7c536a29 --- /dev/null +++ b/include/commands/fill.cpp @@ -0,0 +1,56 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "worldreload.hpp" +#include "database/world.hpp" +#include "fill.hpp" + +/* /fill {itemID} -> fills every empty tile in the world with that block */ +void fill(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + + auto world = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + if (world == worlds.end()) return; + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + std::erase_if(arg, [](char c){ return !std::isdigit(static_cast(c)); }); + if (arg.empty()) + { + on::ConsoleMessage(event.peer, "`4Usage: /fill {itemID}``"); + return; + } + + signed id{}; + try { id = std::stoi(arg); } + catch (...) { on::ConsoleMessage(event.peer, "`4That is not a valid item id.``"); return; } + + std::size_t changed = 0; + for (std::size_t i = 0ull; i < world->blocks.size(); ++i) + { + ::block &b = world->blocks[i]; + if (b.fg == 6 || b.fg == 8) continue; // keep main door + bedrock + if (b.fg != 0) continue; // only fill empty space + b.fg = static_cast(id); + ++changed; + } + + const std::string world_name = world->name; + on::ConsoleMessage(event.peer, std::format("`2Filled `w{}`` tiles with item `w{}``.``", changed, id)); + + reload_world_all(world_name); // @note everyone in the world, not just you +} diff --git a/include/commands/fill.hpp b/include/commands/fill.hpp new file mode 100644 index 00000000..9bc0aa90 --- /dev/null +++ b/include/commands/fill.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void fill(ENetEvent& event, const std::string_view text); diff --git a/include/commands/find.cpp b/include/commands/find.cpp index 769a1e36..b0ca1e27 100644 --- a/include/commands/find.cpp +++ b/include/commands/find.cpp @@ -1,14 +1,64 @@ #include "pch.hpp" +#include "legendary.hpp" #include "find.hpp" +static std::string lower_of(std::string s) +{ + for (char &c : s) c = static_cast(std::tolower(static_cast(c))); + return s; +} + +/* normal players: the server picks the results, so wands and legendary rewards never show up */ +void find_players_list(ENetEvent &event, std::string query) +{ + for (char &ch : query) if (ch == '|' || ch == '\n') ch = ' '; + while (!query.empty() && std::isspace(static_cast(query.back()))) query.pop_back(); + const std::string want = lower_of(query); + + std::string list{}; + int shown = 0; + if (want.size() >= 2) + for (const ::item &it : items) + { + if (it.id == 0 || it.raw_name.empty() || it.id % 2 == 1) continue; // @note seeds are the odd ids + if (staff_only_item(it.id)) continue; + if (lower_of(it.raw_name).find(want) == std::string::npos) continue; + std::string name = it.raw_name; + for (char &ch : name) if (ch == '|') ch = ' '; + list += std::format("add_button_with_icon|searchableItemListButton_{}_0_-1|{}|staticBlueFrame|{}||\n", it.id, name, it.id); + if (++shown >= 50) break; + } + + std::string d = std::format( + "set_default_color|`o\n" + "add_label_with_icon|big|`wFind an Item``|left|1252|\n" + "add_smalltext|Type part of a name and press Search, then tap an item to choose how many you want.|left|\n" + "add_text_input|n|Search: |{}|26|\n" + "add_button|search|Search|noflags|0|0|\n", query); + if (!list.empty()) d += "add_spacer|small|\n" + list + "add_button_with_icon||END_LIST|noflags|0||\n"; + else if (want.size() >= 2) d += "add_textbox|`oNothing found.``|left|\n"; + d += "add_quick_exit|\nend_dialog|find_item|||\n"; + send_varlist(event.peer, { "OnDialogRequest", d }); +} + +/* /find [text] -> item search; tapping an item opens its card (see devmenu.cpp) */ void find(ENetEvent& event, const std::string_view text) { + std::string query{}; + if (const std::size_t sp = text.find(' '); sp != std::string_view::npos) query = std::string(text.substr(sp + 1)); + for (char &ch : query) if (ch == '|') ch = ' '; + + if (static_cast<::peer*>(event.peer->data)->role < MODERATOR) { find_players_list(event, query); return; } + send_varlist(event.peer, { - "OnDialogRequest", - "set_default_color|`o\n" - "add_text_input|n|Search: ||26|\n" - "add_searchable_item_list||sourceType:allItems;listType:iconWithCustomLabel;resultLimit:30|n|\n" - "add_quick_exit|\n" - "end_dialog|find_item|||" + "OnDialogRequest", + std::format( + "set_default_color|`o\n" + "add_label_with_icon|big|`wFind an Item``|left|1252|\n" + "add_smalltext|Type part of a name, then tap an item to choose how many you want.|left|\n" + "add_text_input|n|Search: |{}|26|\n" + "add_searchable_item_list||sourceType:allItems;listType:iconWithCustomLabel;resultLimit:50|n|\n" + "add_quick_exit|\n" + "end_dialog|find_item|||", query) }); -} \ No newline at end of file +} diff --git a/include/commands/find.hpp b/include/commands/find.hpp index a314eccf..e01686c8 100644 --- a/include/commands/find.hpp +++ b/include/commands/find.hpp @@ -1,3 +1,4 @@ #pragma once extern void find(ENetEvent& event, const std::string_view text); +extern void find_players_list(ENetEvent &event, std::string query); diff --git a/include/commands/firework.cpp b/include/commands/firework.cpp new file mode 100644 index 00000000..0f980aed --- /dev/null +++ b/include/commands/firework.cpp @@ -0,0 +1,52 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/world.hpp" +#include "tools/random.hpp" +#include "firework.hpp" + +/* /firework -> one burst at your position + * /firework {count} -> several bursts scattered around you (max 20) + */ +void firework(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + signed count = 1; + if (!arg.empty()) + { + std::erase_if(arg, [](char c){ return !std::isdigit(static_cast(c)); }); + if (!arg.empty()) + { + try { count = std::stoi(arg); } catch (...) { count = 1; } + } + } + count = std::clamp(count, 1, 20); + + for (signed i = 0; i < count; ++i) + { + const ::pos at = (count == 1) + ? pPeer->pos + : ::pos{ pPeer->pos.x + static_cast(RandomRange(0, 320)) - 160.0f, + pPeer->pos.y + static_cast(RandomRange(0, 200)) - 160.0f }; + fireworks(event, at); + } + + on::ConsoleMessage(event.peer, + std::format("`2Launched `w{}`` firework{}.``", count, count == 1 ? "" : "s")); +} diff --git a/include/commands/firework.hpp b/include/commands/firework.hpp new file mode 100644 index 00000000..c998212c --- /dev/null +++ b/include/commands/firework.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void firework(ENetEvent& event, const std::string_view text); diff --git a/include/commands/flag.cpp b/include/commands/flag.cpp new file mode 100644 index 00000000..640d4bb7 --- /dev/null +++ b/include/commands/flag.cpp @@ -0,0 +1,96 @@ +#include "pch.hpp" +#include +#include "onVariant/ConsoleMessage.hpp" +#include "onVariant/CountryState.hpp" +#include "database/database.hpp" +#include "database/world.hpp" +#include "flag.hpp" + +namespace +{ +std::map flags{}; // @note uid -> chosen flag, "" = default + +void ensure_table() +{ + static bool done = false; + if (done) return; + done = true; + mysql_query(db, "CREATE TABLE IF NOT EXISTS flags (uid INT PRIMARY KEY, country VARCHAR(16) NOT NULL)"); +} + +const std::string &chosen(int uid) +{ + if (auto it = flags.find(uid); it != flags.end()) return it->second; + ensure_table(); + std::string c{}; + if (mysql_query(db, std::format("SELECT country FROM flags WHERE uid = {}", uid).c_str()) == 0) + if (MYSQL_RES *res = mysql_store_result(db)) + { + if (MYSQL_ROW r = mysql_fetch_row(res)) c = r[0] ? r[0] : ""; + mysql_free_result(res); + } + return flags[uid] = c; +} + +/* badges and title icons: devs only (titles give the icons themselves); the icon sheets would look broken */ +bool staff_flag(const std::string &code) +{ + return code == "ccbadge" || code == "ttbadge" || code == "g4g" || code == "master_icon" || code == "game_icons" || code == "faction_icons"; +} +} + +std::string flag_of(const ::peer &p) +{ + if (p.user_id != 0) + if (const std::string &c = chosen(static_cast(p.user_id)); !c.empty()) return c; + if (p.role >= DEVELOPER) return "ccBadge"; // @note devs get the blue checkmark + return p.country; +} + +/* /flag - show your flag + * /flag {code} - change it (no, us, jp ...) + * /flag reset - back to the default (devs: the checkmark badge) */ +void flag_cmd(ENetEvent &event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (pPeer->user_id == 0) { on::ConsoleMessage(event.peer, "`4You need a GrowID to change your flag.``"); return; } + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + while (!arg.empty() && std::isspace(static_cast(arg.back()))) arg.pop_back(); + for (char &c : arg) c = static_cast(std::tolower(static_cast(c))); + + if (arg.empty()) + { + on::ConsoleMessage(event.peer, std::format("`oYour flag is `w{}``. Use `w/flag {{code}}`` (like `wno``, `wus``, `wjp``), `w/flag none`` or `w/flag reset``.``", flag_of(*pPeer))); + return; + } + if (arg == "reset") arg.clear(); + else if (arg.size() < 2 || arg.size() > 16 || !std::ranges::all_of(arg, [](char c) { return std::isalnum(static_cast(c)) || c == '_'; })) + { + on::ConsoleMessage(event.peer, "`4That's not a flag code.`` Use a country code like `wse``, `wno``, `wus``."); + return; + } + else if (staff_flag(arg) && pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4That flag is for staff only.``"); + return; + } + + const int uid = static_cast(pPeer->user_id); + (void)chosen(uid); // @note makes sure the table exists + flags[uid] = arg; + if (arg.empty()) mysql_query(db, std::format("DELETE FROM flags WHERE uid = {}", uid).c_str()); + else mysql_query(db, std::format("REPLACE INTO flags (uid, country) VALUES ({}, '{}')", uid, arg).c_str()); // @note arg is letters/digits only + + on::CountryState(event); + if (pPeer->netid != 0) + peers(pPeer->recent_worlds.back(), PEER_SAME_WORLD, [pPeer](ENetPeer &p) + { + ::peer *o = static_cast<::peer*>(p.data); + if (!o || o->user_id == pPeer->user_id) return; + on::CountryStateOf(p, *pPeer); + }); + on::ConsoleMessage(event.peer, arg.empty() ? std::format("`2Flag reset to `w{}``.``", flag_of(*pPeer)) : std::format("`2Your flag is now `w{}``.``", arg)); +} \ No newline at end of file diff --git a/include/commands/flag.hpp b/include/commands/flag.hpp new file mode 100644 index 00000000..56988df6 --- /dev/null +++ b/include/commands/flag.hpp @@ -0,0 +1,4 @@ +#pragma once + +extern std::string flag_of(const ::peer &p); // @note the flag beside a name: chosen with /flag, else the game's own country +extern void flag_cmd(ENetEvent &event, const std::string_view text); \ No newline at end of file diff --git a/include/commands/food.cpp b/include/commands/food.cpp new file mode 100644 index 00000000..af2af9d4 --- /dev/null +++ b/include/commands/food.cpp @@ -0,0 +1,75 @@ +#include "pch.hpp" +#include "tools/bubble.hpp" +#include "database/items.hpp" +#include "database/world.hpp" +#include "tools/random.hpp" +#include "food.hpp" +#include "buffs.hpp" + +enum class taste : u_char { FOOD, DRINK, FIZZY, SPICY, GROSS, COLD, GUM, HEART }; + +/* plain food and drinks, by item id (from this server's items.dat) */ +static const std::unordered_map menu{ + /* food */ + {406, taste::FOOD}, {676, taste::FOOD}, {950, taste::FOOD}, {958, taste::FOOD}, {964, taste::FOOD}, {966, taste::FOOD}, + {968, taste::FOOD}, {1208, taste::FOOD}, {1374, taste::FOOD}, {1580, taste::FOOD}, {2002, taste::FOOD}, {2734, taste::FOOD}, + {3420, taste::FOOD}, {3428, taste::FOOD}, {3474, taste::FOOD}, {3546, taste::FOOD}, {3600, taste::FOOD}, {3816, taste::FOOD}, + {3836, taste::FOOD}, {4256, taste::FOOD}, {4410, taste::FOOD}, {4422, taste::FOOD}, {4602, taste::FOOD}, {4666, taste::FOOD}, + {4668, taste::FOOD}, {4672, taste::FOOD}, {4764, taste::FOOD}, {4982, taste::FOOD}, {5256, taste::FOOD}, {7346, taste::FOOD}, + {7638, taste::FOOD}, {10270, taste::FOOD}, {10628, taste::FOOD}, {10988, taste::FOOD}, {14246, taste::FOOD}, {4592, taste::FOOD}, {5114, taste::DRINK}, {10490, taste::DRINK}, {7464, taste::FOOD}, {7058, taste::FOOD}, {6912, taste::FOOD}, {4232, taste::FOOD}, {6046, taste::FOOD}, {6048, taste::FOOD}, {6050, taste::FOOD}, {6910, taste::FOOD}, {7466, taste::DRINK}, + /* drinks */ + {868, taste::DRINK}, {1602, taste::DRINK}, {1634, taste::DRINK}, {3622, taste::DRINK}, {4984, taste::DRINK}, + {7052, taste::DRINK}, {11068, taste::DRINK}, {3240, taste::DRINK}, + /* fizzy */ + {3740, taste::FIZZY}, {4230, taste::FIZZY}, + /* spicy */ + {712, taste::SPICY}, {960, taste::SPICY}, {4258, taste::SPICY}, {8378, taste::SPICY}, + /* gross */ + {8380, taste::GROSS}, {8382, taste::GROSS}, {8384, taste::GROSS}, {8386, taste::GROSS}, {8400, taste::GROSS}, + {8402, taste::GROSS}, {8404, taste::GROSS}, {8406, taste::GROSS}, {8408, taste::GROSS}, {8410, taste::GROSS}, + {8412, taste::GROSS}, {8414, taste::GROSS}, + /* cold */ + {3228, taste::COLD}, {6314, taste::COLD}, {6316, taste::COLD}, + /* other */ + {1772, taste::GUM}, {386, taste::HEART}, +}; + +static const char *pick(std::initializer_list lines) +{ + const int i = RandomRange(0, static_cast(lines.size())); + return *(lines.begin() + i); +} + +static std::string bubble_for(taste t) +{ + switch (t) + { + case taste::FOOD: return "YUM!"; + case taste::DRINK: return "YUM!"; + case taste::FIZZY: return pick({ "*BURP*", "*burp* ...excuse me." }); + case taste::SPICY: return pick({ "`4HOT HOT HOT!``", "`4*breathes fire*``", "`4WATER! I NEED WATER!``" }); + case taste::GROSS: return pick({ "*gags*", "Blegh!", "Why did I eat that..." }); + case taste::COLD: return pick({ "`1BRAIN FREEZE!``", "`1*shivers*``" }); + case taste::GUM: return pick({ "*blows a big bubble*", "*pop*" }); + case taste::HEART: return std::format("`4{}``", pick({ "BE MINE", "LUV U", "XOXO", "HUG ME", "CUTIE PIE", "TRUE LOVE", "SO FINE", "SWEET TALK" })); + } + return "*nom*"; +} + +/* eat or drink a plain food item. @return true if handled */ +bool food_use(ENetEvent &event, const ::item &item) +{ + const auto it = menu.find(item.id); + if (it == menu.end()) return false; + + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + modify_item_inventory(event, ::slot(static_cast(item.id), -1)); + pPeer->add_xp(event, consumable_xp(*pPeer)); + + const std::string text = bubble_for(it->second); + peers(pPeer->recent_worlds.back(), PEER_SAME_WORLD, [&](ENetPeer &p) + { + send_varlist(&p, { "OnTalkBubble", speaker(*pPeer).netid, text, 0u }); // @note used on another player: they say it + }); + return true; +} diff --git a/include/commands/food.hpp b/include/commands/food.hpp new file mode 100644 index 00000000..f397f96d --- /dev/null +++ b/include/commands/food.hpp @@ -0,0 +1,3 @@ +#pragma once + +extern bool food_use(ENetEvent &event, const ::item &item); diff --git a/include/commands/freeze.cpp b/include/commands/freeze.cpp new file mode 100644 index 00000000..77d5cb92 --- /dev/null +++ b/include/commands/freeze.cpp @@ -0,0 +1,74 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/world.hpp" +#include "onVariant/SetClothing.hpp" +#include "freeze.hpp" + +/* /freeze {player} -> toggles a player's ability to move (same world only) */ +void freeze(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < MODERATOR) + { + on::ConsoleMessage(event.peer, "`4Only staff can use this.``"); + return; + } + + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + while (!arg.empty() && std::isspace(static_cast(arg.back()))) arg.pop_back(); + + if (arg.empty()) + { + on::ConsoleMessage(event.peer, "`4Usage: /freeze {player}``"); + return; + } + + std::string lower_target = arg; + for (char &c : lower_target) c = std::tolower(static_cast(c)); + + bool found = false; + peers(pPeer->recent_worlds.back(), PEER_SAME_WORLD, [&](ENetPeer &p) + { + ::peer *pTarget = static_cast<::peer*>(p.data); + if (!pTarget) return; + + std::string their_name = pTarget->growid; + for (char &c : their_name) c = std::tolower(static_cast(c)); + if (their_name != lower_target) return; + + if (pTarget->role >= pPeer->role && pTarget->user_id != pPeer->user_id) + { + on::ConsoleMessage(event.peer, "`4You cannot freeze someone of equal or higher rank.``"); + found = true; + return; + } + + found = true; + pTarget->frozen = !pTarget->frozen; + + if (pTarget->frozen) pTarget->state |= S_FROZEN; + else pTarget->state &= ~S_FROZEN; + on::SetClothing(p); + send_varlist(&p, { "OnSetFreezeState", pTarget->frozen ? 1u : 0u }, pTarget->netid); + + on::ConsoleMessage(&p, pTarget->frozen + ? "`4You have been frozen.``" + : "`2You can move again.``"); + + on::ConsoleMessage(event.peer, std::format("`2`w{}`` is now {}.``", + pTarget->growid, pTarget->frozen ? "`4frozen``" : "`2unfrozen``")); + }); + + if (!found) + on::ConsoleMessage(event.peer, std::format("`4`w{}`` is not in this world.``", arg)); +} diff --git a/include/commands/freeze.hpp b/include/commands/freeze.hpp new file mode 100644 index 00000000..b77983a6 --- /dev/null +++ b/include/commands/freeze.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void freeze(ENetEvent& event, const std::string_view text); diff --git a/include/commands/friendsys.cpp b/include/commands/friendsys.cpp new file mode 100644 index 00000000..609fa312 --- /dev/null +++ b/include/commands/friendsys.cpp @@ -0,0 +1,191 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "tools/bubble.hpp" +#include "database/database.hpp" +#include "database/world.hpp" +#include "friendsys.hpp" + +namespace +{ +std::vector> pending{}; // @note friend requests {from, to} + +std::string clean(std::string s) +{ + for (char &c : s) if (c == '|' || c == '\n' || c == '\r') c = ' '; + return s; +} + +std::vector friends_of(int uid) +{ + std::vector out{}; + if (mysql_query(db, std::format("SELECT friend_uid FROM friends WHERE uid = {}", uid).c_str())) return out; + if (MYSQL_RES *res = mysql_store_result(db)) + { + while (MYSQL_ROW r = mysql_fetch_row(res)) if (r[0]) out.push_back(std::atoi(r[0])); + mysql_free_result(res); + } + return out; +} + +bool are_friends(int a, int b) { const std::vector list = friends_of(a); return std::ranges::find(list, b) != list.end(); } + +void link(int a, int b) { mysql_query(db, std::format("INSERT IGNORE INTO friends (uid, friend_uid) VALUES ({0},{1}),({1},{0})", a, b).c_str()); } +void unlink(int a, int b) { mysql_query(db, std::format("DELETE FROM friends WHERE (uid = {0} AND friend_uid = {1}) OR (uid = {1} AND friend_uid = {0})", a, b).c_str()); } + +std::string name_of(int uid) +{ + std::string name{}; + if (mysql_query(db, std::format("SELECT growid FROM peer WHERE uid = {}", uid).c_str())) return "?"; + if (MYSQL_RES *res = mysql_store_result(db)) + { + if (MYSQL_ROW r = mysql_fetch_row(res)) if (r[0]) name = r[0]; + mysql_free_result(res); + } + return name.empty() ? std::format("user #{}", uid) : name; +} + +int role_of(int uid) +{ + int role = 0; + if (mysql_query(db, std::format("SELECT role FROM peer WHERE uid = {}", uid).c_str())) return 0; + if (MYSQL_RES *res = mysql_store_result(db)) + { + if (MYSQL_ROW r = mysql_fetch_row(res)) if (r[0]) role = std::atoi(r[0]); + mysql_free_result(res); + } + return role; +} + +std::string colored(const std::string &name, int role) +{ + if (role >= DEVELOPER) return std::format("`6@{}``", name); + if (role >= MODERATOR) return std::format("`5@{}``", name); + return std::format("`w{}``", name); +} + +ENetPeer *online(int uid) +{ + ENetPeer *hit = nullptr; + peers("", peer_condition::PEER_ALL, [&](ENetPeer &p) + { + const ::peer *t = static_cast<::peer*>(p.data); + if (t && t->user_id == uid && !t->growid.empty()) hit = &p; + }); + return hit; +} +} + +void friend_request(ENetEvent &event, ENetPeer &target) +{ + ::peer *me = static_cast<::peer*>(event.peer->data); + ::peer *t = static_cast<::peer*>(target.data); + + if (me == t) { tell(event.peer, "`4You can't add yourself.``"); return; } + if (t->role >= MODERATOR && me->role < MODERATOR) { tell(event.peer, "`4You can't add staff as a friend.``"); return; } + if (are_friends(me->user_id, t->user_id)) { tell(event.peer, std::format("`w{}`` is already your friend.", t->growid)); return; } + + // they already asked me: that's a yes + if (auto r = std::ranges::find(pending, std::make_pair(t->user_id, me->user_id)); r != pending.end()) + { + pending.erase(r); + link(me->user_id, t->user_id); + on::ConsoleMessage(event.peer, std::format("`2You and `w{}`` are friends now!``", t->growid)); + on::ConsoleMessage(&target, std::format("`2You and `w{}`` are friends now!``", me->growid)); + return; + } + if (std::ranges::find(pending, std::make_pair(me->user_id, t->user_id)) != pending.end()) { tell(event.peer, "`4You already sent them a request.``"); return; } + + pending.emplace_back(me->user_id, t->user_id); + on::ConsoleMessage(event.peer, std::format("`2Friend request sent to `w{}``.``", t->growid)); + on::ConsoleMessage(&target, std::format("`5{} sent you a friend request!``", me->growid)); + send_varlist(&target, { "OnDialogRequest", std::format( + "set_default_color|`o\n" + "add_label_with_icon|big|`wFriend request``|left|1366|\n" + "add_textbox|`w{}`` wants to be your friend.|left|\n" + "embed_data|from|{}\n" + "add_button|fr_yes|`2Accept``|noflags|0|0|\n" + "add_button|fr_no|`4Decline``|noflags|0|0|\n" + "end_dialog|friend_request|||\n", clean(me->growid), me->user_id) }); +} + +void friends_show(ENetEvent &event, bool edit) +{ + ::peer *me = static_cast<::peer*>(event.peer->data); + const std::vector list = friends_of(me->user_id); + + std::string on_lines{}, off_lines{}; + int on_count = 0; + for (int uid : list) + { + ENetPeer *p = online(uid); + const ::peer *t = p ? static_cast<::peer*>(p->data) : nullptr; + const std::string name = clean(t ? t->growid : name_of(uid)); + const int role = t ? t->role : role_of(uid); + const std::string shown = colored(name, role); // @note dev gold, mod pink, player white - same as in game + std::string &lines = p ? on_lines : off_lines; + if (t) + { + ++on_count; + lines += std::format("add_label_with_icon|small|{} `o- in `w{}``|left|1366|\n", shown, t->netid != 0 ? clean(t->recent_worlds.back()) : "the world menu"); + } + else lines += std::format("add_label|small|{} `o- `4offline``|left|\n", shown); + if (edit) lines += std::format("add_button|fr_rm_{}|Remove {}|noflags|0|0|\n", uid, name); + } + + std::string d = "set_default_color|`o\n"; + d += std::format("add_label_with_icon|big|{} of {} `wFriends Online``|left|1366|\nadd_spacer|small|\n", on_count, list.size()); + if (list.empty()) d += "add_textbox|`oYou don't have any friends yet. Wrench a player and choose `wAdd as friend``.``|left|\n"; + d += on_lines + off_lines + "add_spacer|small|\n"; + d += edit ? "add_button|fr_done|Done editing|noflags|0|0|\n" : "add_button|fr_edit|Edit Friends|noflags|0|0|\n"; + d += "add_quick_exit|\nend_dialog|friends_list|Close||\n"; + send_varlist(event.peer, { "OnDialogRequest", d }); +} + +void friends_return(ENetEvent &event, const ::hPipe &hPipe) +{ + ::peer *me = static_cast<::peer*>(event.peer->data); + const std::string dialog = hPipe["dialog_name"], btn = hPipe["buttonClicked"]; + + if (dialog == "friend_request") + { + const int from = std::atoi(hPipe["from"].c_str()); + auto r = std::ranges::find(pending, std::make_pair(from, me->user_id)); + if (r == pending.end()) { tell(event.peer, "`4That friend request isn't valid anymore.``"); return; } + pending.erase(r); + ENetPeer *asker = online(from); + if (btn != "fr_yes") + { + if (asker) on::ConsoleMessage(asker, std::format("`4{} declined your friend request.``", me->growid)); + return; + } + link(from, me->user_id); + on::ConsoleMessage(event.peer, std::format("`2You and `w{}`` are friends now!``", name_of(from))); + if (asker) on::ConsoleMessage(asker, std::format("`2You and `w{}`` are friends now!``", me->growid)); + return; + } + + if (btn == "fr_edit") friends_show(event, true); + else if (btn == "fr_done") friends_show(event, false); + else if (btn.starts_with("fr_rm_")) + { + const int uid = std::atoi(btn.c_str() + 6); + const std::string name = name_of(uid); + unlink(me->user_id, uid); + on::ConsoleMessage(event.peer, std::format("`oRemoved `w{}`` from your friends.``", name)); + friends_show(event, true); + } +} + +std::string friends_welcome(const ::peer &p) +{ + int n = 0; + for (int uid : friends_of(p.user_id)) if (online(uid)) ++n; + return n == 0 ? "No friends are online." : std::format("`w{}`` friend{} online.", n, n == 1 ? " is" : "s are"); +} + +void friends_alert(const ::peer &p, bool logged_on) +{ + for (int uid : friends_of(p.user_id)) + if (ENetPeer *f = online(uid); f && f->data != nullptr && static_cast<::peer*>(f->data) != &p) + on::ConsoleMessage(f, std::format("`3FRIEND ALERT:`` `w{}`` has {}!", p.growid, logged_on ? "`2logged on``" : "`4logged off``")); +} diff --git a/include/commands/friendsys.hpp b/include/commands/friendsys.hpp new file mode 100644 index 00000000..aa964df5 --- /dev/null +++ b/include/commands/friendsys.hpp @@ -0,0 +1,8 @@ +#pragma once + +/* friends: wrench > Add as friend, Social Portal > Show Friends */ +extern void friend_request(ENetEvent &event, ENetPeer &target); +extern void friends_show(ENetEvent &event, bool edit); +extern void friends_return(ENetEvent &event, const ::hPipe &hPipe); // @note dialogs "friend_request" and "friends_list" +extern std::string friends_welcome(const ::peer &p); // @note "2 friends are online." +extern void friends_alert(const ::peer &p, bool logged_on); // @note tells online friends diff --git a/include/commands/gems.cpp b/include/commands/gems.cpp new file mode 100644 index 00000000..0bc4e2c4 --- /dev/null +++ b/include/commands/gems.cpp @@ -0,0 +1,38 @@ +#include "pch.hpp" +#include "onVariant/SetBux.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "gems.hpp" + +void gems(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + if (text.empty()) + { + on::ConsoleMessage(event.peer, "`4Usage: /gems {amount}``"); + return; + } + + signed amount{}; + try + { + std::string arg{ text }; + std::erase_if(arg, [](char c){ return !std::isdigit(static_cast(c)) && c != '-'; }); + amount = std::stoi(arg); + } + catch (...) + { + on::ConsoleMessage(event.peer, "`4That is not a valid number.``"); + return; + } + + pPeer->gems = amount; + on::SetBux(event); + on::ConsoleMessage(event.peer, std::format("`2Gems set to `w{}``.``", pPeer->gems)); +} diff --git a/include/commands/gems.hpp b/include/commands/gems.hpp new file mode 100644 index 00000000..04ac84d9 --- /dev/null +++ b/include/commands/gems.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void gems(ENetEvent& event, const std::string_view text); diff --git a/include/commands/ghost.cpp b/include/commands/ghost.cpp index fb42c6d4..82f7d485 100644 --- a/include/commands/ghost.cpp +++ b/include/commands/ghost.cpp @@ -1,5 +1,6 @@ #include "pch.hpp" #include "onVariant/SetClothing.hpp" +#include "database/world.hpp" #include "ghost.hpp" void ghost(ENetEvent& event, const std::string_view text) @@ -9,4 +10,14 @@ void ghost(ENetEvent& event, const std::string_view text) pPeer->state ^= S_GHOST; on::SetClothing(*event.peer); + + if ((pPeer->state & S_INVISIBLE) == S_INVISIBLE) return; // @note nobody can see you anyway + + // let everyone else in the world see the change too + peers(pPeer->recent_worlds.back(), PEER_SAME_WORLD, [pPeer](ENetPeer &p) + { + ::peer *pOthers = static_cast<::peer*>(p.data); + if (!pOthers || pOthers->user_id == pPeer->user_id) return; + on::SetClothing(p, *pPeer); + }); } \ No newline at end of file diff --git a/include/commands/give.cpp b/include/commands/give.cpp new file mode 100644 index 00000000..57357c8f --- /dev/null +++ b/include/commands/give.cpp @@ -0,0 +1,71 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/world.hpp" +#include "give.hpp" + +void give(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + if (text.empty()) + { + on::ConsoleMessage(event.peer, "`4Usage: /give {itemID} {amount}``"); + return; + } + + // split on the first space: "{id} {amount}" + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + if (arg.empty()) { on::ConsoleMessage(event.peer, "`4Usage: /give {itemID} {amount}``"); return; } + std::string id_str{}, amount_str{}; + + if (const std::size_t space = arg.find(' '); space != std::string::npos) + { + id_str = arg.substr(0, space); + amount_str = arg.substr(space + 1); + } + else + { + id_str = arg; + amount_str = "200"; // default to a full stack + } + + auto digits_only = [](std::string s) + { + std::erase_if(s, [](char c){ return !std::isdigit(static_cast(c)); }); + return s; + }; + + id_str = digits_only(id_str); + amount_str = digits_only(amount_str); + + if (id_str.empty() || amount_str.empty()) + { + on::ConsoleMessage(event.peer, "`4Usage: /give {itemID} {amount}``"); + return; + } + + signed id{}, amount{}; + try + { + id = std::stoi(id_str); + amount = std::stoi(amount_str); + } + catch (...) + { + on::ConsoleMessage(event.peer, "`4That is not a valid number.``"); + return; + } + + amount = std::clamp(amount, 1, 200); + + modify_item_inventory(event, ::slot(id, amount)); + on::ConsoleMessage(event.peer, std::format("`2Gave `w{}`` of item ID `w{}``.``", amount, id)); +} diff --git a/include/commands/give.hpp b/include/commands/give.hpp new file mode 100644 index 00000000..76952c21 --- /dev/null +++ b/include/commands/give.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void give(ENetEvent& event, const std::string_view text); diff --git a/include/commands/god.cpp b/include/commands/god.cpp new file mode 100644 index 00000000..de232fea --- /dev/null +++ b/include/commands/god.cpp @@ -0,0 +1,25 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "godmode.hpp" +#include "god.hpp" + +/* /god -> toggles immunity to lava and other damage */ +void god(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + pPeer->god_mode = !pPeer->god_mode; + god_items_changed(event); + pPeer->pain_hp = 10; // top up in case they were mid-burn + + on::ConsoleMessage(event.peer, + pPeer->god_mode + ? "`2God mode `wON``. Nothing can hurt you.``" + : "`4God mode `wOFF``.``"); +} diff --git a/include/commands/god.hpp b/include/commands/god.hpp new file mode 100644 index 00000000..bf6b8ae5 --- /dev/null +++ b/include/commands/god.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void god(ENetEvent& event, const std::string_view text); diff --git a/include/commands/godmode.cpp b/include/commands/godmode.cpp new file mode 100644 index 00000000..073fc2b0 --- /dev/null +++ b/include/commands/godmode.cpp @@ -0,0 +1,99 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/items.hpp" +#include "action/join_request.hpp" +#include "action/quit_to_exit.hpp" +#include "godmode.hpp" + +extern std::vector harmful_type_pos; // @note filled by decode_items() +extern u_int im_file_offset; + +static constexpr u_int HASH_NORMAL = 2966867045u; // @note same as the hardcoded value in tankIDName.cpp + +static std::vector im_data_god{}; +static u_int hash_god = 0; + +/* items.dat checksum */ +static u_int proton_hash(const u_char *data, std::size_t len) +{ + u_int acc = 0x55555555u; + for (std::size_t i = 0; i < len; ++i) acc = (acc >> 27) + (acc << 5) + data[i]; + return acc; +} + +static u_int proton_hash_signed(const u_char *data, std::size_t len) +{ + u_int acc = 0x55555555u; + for (std::size_t i = 0; i < len; ++i) acc = (acc >> 27) + (acc << 5) + static_cast(static_cast(static_cast(data[i]))); + return acc; +} + +/* a copy of items.dat where spikes, lava and other painful blocks are plain blocks */ +static void build() +{ + if (!im_data_god.empty()) return; + im_data_god = im_data; + for (u_int p : harmful_type_pos) + if (p < im_data_god.size()) im_data_god[p] = type::FOREGROUND; + + const u_int computed_normal = proton_hash(im_data.data() + im_file_offset, im_data.size() - im_file_offset); + const u_int computed_signed = proton_hash_signed(im_data.data() + im_file_offset, im_data.size() - im_file_offset); + (void)0; // @note debug line removed + hash_god = (computed_signed == HASH_NORMAL) + ? proton_hash_signed(im_data_god.data() + im_file_offset, im_data_god.size() - im_file_offset) + : proton_hash(im_data_god.data() + im_file_offset, im_data_god.size() - im_file_offset); + (void)0; // @note debug line removed +} + +const std::vector &items_for(ENetEvent &event) +{ + ::peer *p = static_cast<::peer*>(event.peer->data); + if (!p || !p->god_mode) return im_data; + build(); + return im_data_god; +} + +u_int items_hash_for(const ::peer &p) +{ + if (!p.god_mode) return HASH_NORMAL; + build(); + return hash_god; +} + +/* called by /god after it toggles god_mode */ +void god_items_changed(ENetEvent &event) +{ + ::peer *p = static_cast<::peer*>(event.peer->data); + p->mysql_update("god", static_cast(p->god_mode ? 1 : 0)); + + if (p->netid != 0) { p->god_rejoin = p->recent_worlds.back(); p->god_rejoin_pos = p->pos; } + const std::vector &data = items_for(event); + enet_peer_send(event.peer, 0, enet_packet_create(data.data(), data.size(), ENET_PACKET_FLAG_RELIABLE)); + on::ConsoleMessage(event.peer, p->god_mode + ? "`2Spikes and lava can't hurt you now.`` `oIf they still do, re-enter the world or log in again.``" + : "`oSpikes and lava are dangerous again. Re-enter the world or log in again if they're not.``"); +} + + +/* runs once a second: puts players back in their world after /god reloaded items.dat */ +void god_rejoin_tick(std::time_t now) +{ + peers("", peer_condition::PEER_ALL, [now](ENetPeer &p) + { + ::peer *t = static_cast<::peer*>(p.data); + if (!t || t->god_rejoin_at == 0 || now < t->god_rejoin_at) return; + + const std::string world = t->god_rejoin; + const ::pos at = t->god_rejoin_pos; + t->god_rejoin.clear(); + t->god_rejoin_at = 0; + if (world.empty()) return; + + if (t->netid != 0) return; // @note the game already went back into a world by itself + ENetEvent ev{}; + ev.peer = &p; + action::join_request(ev, "", world); + t->pos = at; + send_varlist(&p, { "OnSetPos", CL_Vec2f{ at.x, at.y } }, t->netid); + }); +} \ No newline at end of file diff --git a/include/commands/godmode.hpp b/include/commands/godmode.hpp new file mode 100644 index 00000000..9e381e93 --- /dev/null +++ b/include/commands/godmode.hpp @@ -0,0 +1,6 @@ +#pragma once + +extern const std::vector &items_for(ENetEvent &event); +extern u_int items_hash_for(const ::peer &p); +extern void god_items_changed(ENetEvent &event); +extern void god_rejoin_tick(std::time_t now); diff --git a/include/commands/growscan.cpp b/include/commands/growscan.cpp new file mode 100644 index 00000000..9b8befad --- /dev/null +++ b/include/commands/growscan.cpp @@ -0,0 +1,74 @@ +#include "pch.hpp" +#include +#include "tools/create_dialog.hpp" +#include "database/items.hpp" +#include "database/world.hpp" +#include "growscan.hpp" + +static constexpr u_short GROWSCAN_ID = 6016; + +static void send_dialog(ENetEvent &event, ::create_dialog &d) +{ + send_varlist(event.peer, { "OnDialogRequest", d.end_dialog("growscan", "Close", "") }); +} + +/* adds one icon line per item, most first */ +static void add_counts(::create_dialog &d, const std::map &counts) +{ + std::vector> list(counts.begin(), counts.end()); + std::ranges::sort(list, [](const auto &a, const auto &b) { return a.second > b.second; }); + for (const auto &[id, n] : list) + d.add_label_with_icon("small", std::format("`w{}``: {}", id_to_item(id).raw_name, n), id); +} + +void growscan_open(ENetEvent &event, ::world &w) +{ + ::create_dialog d{}; + d.set_default_color("`o") + .add_label_with_icon("big", "`wGrowscan 9000``", GROWSCAN_ID) + .add_textbox(std::format("Scanning `w{}``. What would you like to see?", w.name)) + .add_button("gs_blocks", "World Blocks") + .add_button("gs_floating", "Floating Items"); + send_dialog(event, d); +} + +void growscan_return(ENetEvent &event, const ::hPipe &hPipe) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + auto w = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + if (w == worlds.end()) return; + + const std::string btn = hPipe["buttonClicked"]; + if (btn == "gs_back") { growscan_open(event, *w); return; } + + ::create_dialog d{}; + d.set_default_color("`o"); + + if (btn == "gs_blocks") + { + std::map fg{}, bg{}; + for (const ::block &b : w->blocks) + { + if (b.fg != 0) ++fg[b.fg]; + if (b.bg != 0) ++bg[b.bg]; + } + d.add_label_with_icon("big", "`wWorld Blocks``", GROWSCAN_ID) + .add_spacer("small").add_label("big", "`wBlocks``"); + add_counts(d, fg); + d.add_spacer("small").add_label("big", "`wBackgrounds``"); + add_counts(d, bg); + } + else if (btn == "gs_floating") + { + std::map floating{}; + for (const ::object &o : w->objects) floating[o.id] += o.count; + d.add_label_with_icon("big", "`wFloating Items``", GROWSCAN_ID) + .add_smalltext(std::format("`w{}`` drops lying around.", w->objects.size())); + if (floating.empty()) d.add_textbox("Nothing is floating in this world."); + else add_counts(d, floating); + } + else return; + + d.add_spacer("small").add_button("gs_back", "Back"); + send_dialog(event, d); +} diff --git a/include/commands/growscan.hpp b/include/commands/growscan.hpp new file mode 100644 index 00000000..3f8a6913 --- /dev/null +++ b/include/commands/growscan.hpp @@ -0,0 +1,4 @@ +#pragma once + +extern void growscan_open(ENetEvent &event, ::world &w); +extern void growscan_return(ENetEvent &event, const ::hPipe &hPipe); diff --git a/include/commands/heal.cpp b/include/commands/heal.cpp new file mode 100644 index 00000000..fcbbcfb8 --- /dev/null +++ b/include/commands/heal.cpp @@ -0,0 +1,13 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "heal.hpp" + +/* /heal -> restores your health */ +void heal(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + pPeer->pain_hp = 10; + + on::ConsoleMessage(event.peer, "`2Health restored.``"); +} diff --git a/include/commands/heal.hpp b/include/commands/heal.hpp new file mode 100644 index 00000000..1cf32f2c --- /dev/null +++ b/include/commands/heal.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void heal(ENetEvent& event, const std::string_view text); diff --git a/include/commands/invis.cpp b/include/commands/invis.cpp new file mode 100644 index 00000000..7e3488df --- /dev/null +++ b/include/commands/invis.cpp @@ -0,0 +1,79 @@ +#include "pch.hpp" +#include +#include "onVariant/Spawn.hpp" +#include "onVariant/SetClothing.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "onVariant/CountryState.hpp" +#include "database/world.hpp" +#include "invis.hpp" + +extern std::string country_state_for(const ::peer &p); // @note commands/legendary.cpp: flag + title flags + +static std::set ghost_before{}; // @note players who had /ghost on before /invis + + +void invis(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + + pPeer->state ^= S_INVISIBLE; + const bool hidden = (pPeer->state & S_INVISIBLE) != 0; + const int uid = static_cast(pPeer->user_id); + if (hidden) + { + if (pPeer->state & S_GHOST) ghost_before.insert(uid); else ghost_before.erase(uid); + pPeer->state |= S_GHOST; // @note /invis turns /ghost on + } + else + { + if (!ghost_before.contains(uid)) pPeer->state &= ~S_GHOST; // @note /ghost stays on if you had it before /invis + ghost_before.erase(uid); + } + + const std::string netid = std::format("netID|{}\n", pPeer->netid); + const std::string pId = std::format("pId|{}\n", pPeer->user_id); + + peers(pPeer->recent_worlds.back(), PEER_SAME_WORLD, + [pPeer, hidden, netid, pId](ENetPeer& peer) + { + ::peer *pOthers = static_cast<::peer*>(peer.data); + if (!pOthers) return; + + if (pOthers->user_id == pPeer->user_id) // @note your own game: eyes, mouth, name and flag only while hidden + { + on::SetClothing(peer); + return; + } + + if (hidden) + { + send_varlist(&peer, { "OnRemove", netid, pId }); + } + else + { + on::Spawn(peer, pPeer->netid, pPeer->user_id, pPeer->pos, + pPeer->display_growid, country_state_for(*pPeer), + pPeer->role, pPeer->role >= DEVELOPER, false); + on::SetClothing(peer, *pPeer); + send_varlist(&peer, { "OnNameChanged", pPeer->display_growid }, pPeer->netid); // @note title look + on::CountryStateOf(peer, *pPeer); + send_varlist(&peer, { "OnSetPos", CL_Vec2f{ pPeer->pos.x, pPeer->pos.y } }, pPeer->netid); + } + }); + + on::ConsoleMessage(event.peer, + hidden ? "`2You are now invisible to other players.``" + : "`2You are now visible again.``"); +} \ No newline at end of file diff --git a/include/commands/invis.hpp b/include/commands/invis.hpp new file mode 100644 index 00000000..d3cdee9b --- /dev/null +++ b/include/commands/invis.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void invis(ENetEvent& event, const std::string_view text); diff --git a/include/commands/jammers.cpp b/include/commands/jammers.cpp new file mode 100644 index 00000000..3c82a84c --- /dev/null +++ b/include/commands/jammers.cpp @@ -0,0 +1,139 @@ +#include "pch.hpp" +#include +#include "onVariant/ConsoleMessage.hpp" +#include "onVariant/SetClothing.hpp" +#include "tools/bubble.hpp" +#include "database/items.hpp" +#include "database/world.hpp" +#include "jammers.hpp" + +static constexpr u_short SIGNAL = 226, PUNCH = 1276, ZOMBIE = 1278, ANTIGRAVITY = 4992; + +bool is_jammer(u_short id) { return id == SIGNAL || id == PUNCH || id == ZOMBIE || id == ANTIGRAVITY; } + +/* is there a switched-on jammer of this kind in the world? */ +bool world_jammer(const ::world &w, u_short id) +{ + for (const ::block &b : w.blocks) if (b.fg == id && (b.state[2] & S_TOGGLE)) return true; + return false; +} + +bool world_jammer(const std::string &world_name, u_short id) +{ + auto w = std::ranges::find(worlds, world_name, &::world::name); + return w != worlds.end() && world_jammer(*w, id); +} + +/* the owner punches a jammer: switch it. @return always false, so the punch still counts as damage */ +bool jammer_punch(ENetEvent &event, ::world &w, ::gamePacket &gamePacket) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + const int x = static_cast(gamePacket.punch.x), y = static_cast(gamePacket.punch.y); + if (x < 0 || y < 0 || x >= static_cast(w.blocks.size() / 60) || y >= 60) return false; + ::block &b = w.blocks[cord(x, y)]; + if (!is_jammer(b.fg)) return false; + if (w.owner == 0) + { + tell(event.peer, "`4Jammers only work in a world with a World Lock.``"); + return false; + } + if (w.owner != pPeer->user_id && pPeer->role < DEVELOPER && !(b.state[2] & S_PUBLIC)) return false; // @note public: anyone may switch it + + static std::unordered_map last_switch{}; + const std::string key = std::format("{}:{}:{}", w.name, x, y); + const auto now = std::chrono::steady_clock::now(); + if (auto it = last_switch.find(key); it != last_switch.end() && now - it->second < std::chrono::milliseconds(250)) + return false; // @note switched moments ago: a duplicated punch + last_switch[key] = now; + + b.state[2] ^= S_TOGGLE; + const bool on = (b.state[2] & S_TOGGLE) != 0; + // @note no tile update here: the game flips the light itself on the punch + + const std::string msg = + (b.fg == SIGNAL) ? (on ? "`2Signal Jammer on:`` this world is hidden from the world list." : "`4Signal Jammer off:`` this world shows in the world list again.") : + (b.fg == PUNCH) ? (on ? "`2Punch Jammer on:`` players can't be hit with items here." : "`4Punch Jammer off:`` players can be hit again.") : + (b.fg == ZOMBIE) ? (on ? "`2Zombie Jammer on:`` zombies can't spread here." : "`4Zombie Jammer off:`` zombies can spread again.") : + (on ? "`2Antigravity Generator on:`` low gravity - jump as much as you like!" : "`4Antigravity Generator off:`` gravity is back to normal."); + const bool gravity = (b.fg == ANTIGRAVITY); + peers(w.name, PEER_SAME_WORLD, [&](ENetPeer &p) + { + on::ConsoleMessage(&p, msg); + if (gravity && static_cast<::peer*>(p.data)->netid != 0) on::SetClothing(p); + }); + return false; +} + +/* items stopped by a Punch or Zombie Jammer. @return true if blocked */ +bool jammer_blocks(ENetEvent &event, const ::world &w, short item_id) +{ + static constexpr short harmful[]{ 274, 276, 338, 368, 614, 618, 764, 874, 962, 1368, 1988, 4754 }; + if (item_id == 764 && world_jammer(w, ZOMBIE)) + { + tell(event.peer, "`4A Zombie Jammer stops that in this world.``"); + return true; + } + if (std::ranges::find(harmful, item_id) != std::end(harmful) && world_jammer(w, PUNCH)) + { + tell(event.peer, "`4A Punch Jammer protects players in this world.``"); + return true; + } + return false; +} + + +/* " [JAMMED!]" (red) if a Signal, Punch or Zombie Jammer is on, else nothing */ +std::string jam_suffix(const std::string &world_name) +{ + auto w = std::ranges::find(worlds, world_name, &::world::name); + if (w == worlds.end()) return {}; + return (world_jammer(*w, 226) || world_jammer(*w, 1276) || world_jammer(*w, 1278)) ? " [`4JAMMED!``]" : ""; +} + +/* wrench: owner gets Public + Silence, others just the status */ +void jammer_wrench(ENetEvent &event, ::world &w, ::block &b, ::gamePacket &gamePacket) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + const ::item &it = id_to_item(b.fg); + const bool on = (b.state[2] & S_TOGGLE) != 0; + const bool owner = (w.owner != 0 && w.owner == pPeer->user_id) || pPeer->role >= DEVELOPER; + + std::string d = std::format( + "set_default_color|`o\n" + "add_label_with_icon|big|`w{}``|left|{}|\n" + "add_textbox|It is currently {}.|left|\n", + it.raw_name, it.id, on ? "`2ON``" : "`4OFF``"); + if (owner) + { + d += std::format( + "add_checkbox|checkbox_public|Usable by public|{}|\n" + "add_checkbox|checkbox_silence|Silence|{}|\n" + "embed_data|tilex|{}\n" + "embed_data|tiley|{}\n" + "end_dialog|jammer_edit|Cancel|OK|\n", + (b.state[2] & S_PUBLIC) ? 1 : 0, (b.state[3] & 0x02) ? 1 : 0, + static_cast(gamePacket.punch.x), static_cast(gamePacket.punch.y)); + } + else d += "end_dialog|jammer_edit|Close||\n"; + send_varlist(event.peer, { "OnDialogRequest", d }); +} + +void jammer_edit_return(ENetEvent &event, const ::hPipe &hPipe) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + auto w = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + if (w == worlds.end()) return; + if (!((w->owner != 0 && w->owner == pPeer->user_id) || pPeer->role >= DEVELOPER)) return; + + const std::string sx = hPipe["tilex"], sy = hPipe["tiley"]; + if (sx.empty() || sy.empty()) return; + const int x = std::atoi(sx.c_str()), y = std::atoi(sy.c_str()); + if (x < 0 || y < 0 || x >= static_cast(w->blocks.size() / 60) || y >= 60) return; + ::block &b = w->blocks[cord(x, y)]; + if (!is_jammer(b.fg)) return; + + if (hPipe["checkbox_public"] == "1") b.state[2] |= S_PUBLIC; else b.state[2] &= ~S_PUBLIC; + if (hPipe["checkbox_silence"] == "1") b.state[3] |= 0x02; else b.state[3] &= ~0x02; // @note "alternate mode" flag = silenced + send_tile_update(event, { .id = b.fg, .punch = ::pos{ x, y } }, b, *w); + tell(event.peer, "`2Settings saved.``"); +} \ No newline at end of file diff --git a/include/commands/jammers.hpp b/include/commands/jammers.hpp new file mode 100644 index 00000000..1282e3a8 --- /dev/null +++ b/include/commands/jammers.hpp @@ -0,0 +1,10 @@ +#pragma once + +extern bool is_jammer(u_short id); +extern bool world_jammer(const ::world &w, u_short id); +extern bool world_jammer(const std::string &world_name, u_short id); +extern bool jammer_punch(ENetEvent &event, ::world &w, ::gamePacket &gamePacket); +extern bool jammer_blocks(ENetEvent &event, const ::world &w, short item_id); +extern std::string jam_suffix(const std::string &world_name); +extern void jammer_wrench(ENetEvent &event, ::world &w, ::block &b, ::gamePacket &gamePacket); +extern void jammer_edit_return(ENetEvent &event, const ::hPipe &hPipe); diff --git a/include/commands/kick.cpp b/include/commands/kick.cpp new file mode 100644 index 00000000..c817bc91 --- /dev/null +++ b/include/commands/kick.cpp @@ -0,0 +1,66 @@ +#include "pch.hpp" +#include "action/quit_to_exit.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "kick.hpp" + +void kick(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < MODERATOR) + { + on::ConsoleMessage(event.peer, "`4Only staff can use this.``"); + return; + } + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + if (arg.empty()) + { + on::ConsoleMessage(event.peer, "`4Usage: /kick {player}``"); + return; + } + + std::string lower_target = arg; + for (char &c : lower_target) c = std::tolower(static_cast(c)); + + bool found = false; + ENetPeer *kicked = nullptr; + peers(pPeer->recent_worlds.back(), PEER_SAME_WORLD, [&](ENetPeer &p) + { + ::peer *pTarget = static_cast<::peer*>(p.data); + if (!pTarget) return; + + std::string their_name = pTarget->growid; + for (char &c : their_name) c = std::tolower(static_cast(c)); + if (their_name != lower_target) return; + + if (pTarget->role >= pPeer->role) + { + on::ConsoleMessage(event.peer, "`4You cannot kick someone of equal or higher rank.``"); + found = true; + return; + } + + found = true; + kicked = &p; + }); + + if (kicked) // @note after the loop: leaving changes the list we were walking through + { + ::peer *pTarget = static_cast<::peer*>(kicked->data); + on::ConsoleMessage(kicked, "`4You have been kicked from this world.``"); + ENetEvent ev{}; + ev.peer = kicked; + action::quit_to_exit(ev, "", false); // @note back to the world menu, still connected + on::ConsoleMessage(event.peer, std::format("`2Kicked `w{}`` from the world.``", pTarget->growid)); + } + + if (!found) + { + on::ConsoleMessage(event.peer, std::format("`4`w{}`` is not in this world.``", arg)); + return; + } +} diff --git a/include/commands/kick.hpp b/include/commands/kick.hpp new file mode 100644 index 00000000..01758b6b --- /dev/null +++ b/include/commands/kick.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void kick(ENetEvent& event, const std::string_view text); diff --git a/include/commands/legendary.cpp b/include/commands/legendary.cpp new file mode 100644 index 00000000..70e2f408 --- /dev/null +++ b/include/commands/legendary.cpp @@ -0,0 +1,793 @@ +#include "pch.hpp" +#include +#include +#include "onVariant/ConsoleMessage.hpp" +#include "onVariant/SetBux.hpp" +#include "onVariant/CountryState.hpp" +#include "tools/bubble.hpp" +#include "database/database.hpp" +#include "database/items.hpp" +#include "database/world.hpp" +#include "lockaccess.hpp" +#include "action/join_request.hpp" +#include "action/quit_to_exit.hpp" +#include "onVariant/EmoticonDataChanged.hpp" +#include "onVariant/Spawn.hpp" +#include "onVariant/BillboardChange.hpp" +#include "onVariant/SetClothing.hpp" +#include "commands/jammers.hpp" +#include "commands/weather.hpp" +#include "tools/time.hpp" +#include "commands/bot.hpp" +#include "commands/curse.hpp" +#include "commands/lockaccess.hpp" +#include "legendary.hpp" +#include "flag.hpp" + +namespace +{ +/* ================= quest data (growtopiawiki.com/w/Legendary_Quest) ================= */ + +enum class kind : u_char { DELIVER, GEMS, BREAK, BREAK_RARITY, PLANT_RARITY, FRUIT_RARITY, XP, TREES, SEEDS }; + +struct step +{ + kind k; + long long amount; + std::vector names{}; // @note DELIVER: any one of these items +}; + +struct quest +{ + std::string title, reward, blurb; + int reward_fallback_id; // @note used if the reward's name isn't found in items.dat + std::vector steps; +}; + +step D(long long n, std::initializer_list names) { step s{ kind::DELIVER, n }; for (const char *x : names) s.names.emplace_back(x); return s; } +step G(long long n) { return { kind::GEMS, n }; } +step B(long long n) { return { kind::BREAK, n }; } +step BR(long long n) { return { kind::BREAK_RARITY, n }; } +step PR(long long n) { return { kind::PLANT_RARITY, n }; } +step FR(long long n) { return { kind::FRUIT_RARITY, n }; } +step XP(long long n) { return { kind::XP, n }; } +step T(long long n) { return { kind::TREES, n }; } // @note stands in for steps this server has no system for (PvP, surgery, villains, star voyages) +step S(long long n) { return { kind::SEEDS, n }; } // @note stands in for providers, Geiger Counter, fish training +step ORB() { return D(1, { "Legendary Orb" }); } + +const std::vector &quests() +{ + static const std::vector q{ + { "Quest For Honor", "", "the `9Legendary Title``: \"of Legend\" after your name", 0, { + D(2000, {"Sand"}), T(100), B(5000), D(600, {"Display Box"}), PR(50000), D(50, {"Growtoken"}), D(3, {"Golden Diaper"}), XP(10000), + D(1000, {"Tombstone"}), G(100000), BR(100000), T(500), S(1000), D(3, {"Golden Heart Crystal"}), FR(100000), + D(1, {"Growie Award", "Neptune's Crown"}), D(3, {"Super Fireworks"}), D(10, {"Rainbow Wings"}), D(3, {"Birth Certificate"}), ORB() } }, + { "Quest For Fire", "Dragon of Legend", "the `9Dragon of Legend``", 1782, { + D(2000, {"Lava"}), T(100), B(5000), D(600, {"Dragon Gate"}), PR(50000), D(50, {"Growtoken"}), D(10, {"Dragon Hand"}), XP(10000), + D(1000, {"Dragon Tail"}), G(100000), BR(100000), T(500), S(1000), D(3, {"Fiesta Dragon"}), FR(100000), + D(1, {"Ultra Trophy 3000"}), D(1, {"Neptune's Pendant"}), D(1000, {"Rocket Thruster"}), D(3, {"Devil Wings"}), ORB() } }, + { "Quest Of Steel", "Legendbot-009", "the `9Legendbot-009``", 1780, { + D(2000, {"Chemical G"}), T(100), B(5000), D(600, {"Robot Wants Dubstep"}), PR(50000), D(50, {"Growtoken"}), D(3, {"Edison Zoomster"}), XP(10000), + D(1000, {"High Tech Block"}), G(100000), BR(100000), T(500), S(1000), D(3, {"Bride Of Reanimator Remote"}), FR(100000), + D(1, {"Mint Julep", "Neptune's Chariot"}), D(5, {"Kerjigger"}), D(5, {"Doohickey"}), D(2, {"Thingamabob"}), ORB() } }, + { "Quest Of The Heavens", "Legendary Wings", "the `9Legendary Wings``", 1784, { + D(1000, {"Clouds"}), T(100), B(5000), D(600, {"Fairy Wings"}), PR(50000), D(50, {"Growtoken"}), D(3, {"Bubble Wings"}), XP(10000), + D(800, {"Crimson Eagle Wings"}), G(100000), BR(100000), T(500), S(1000), D(20, {"Rainbow Wings"}), FR(100000), + D(3, {"Golden Angel Wings"}), D(100, {"Ripper Wings"}), D(1, {"Phoenix Wings", "Neptune's Trident"}), D(50, {"Parrot Wings"}), ORB() } }, + { "Quest For The Blade", "Legendary Katana", "the `9Legendary Katana``", 2592, { + D(1000, {"Iron Bars"}), T(100), B(5000), D(600, {"Golden Sword"}), PR(50000), D(50, {"Growtoken"}), D(3, {"Heavenly Scythe"}), XP(10000), + D(800, {"Headsman's Axe"}), G(100000), BR(100000), T(500), S(1000), D(20, {"Flamesaber"}), FR(100000), + D(1, {"Neptune's Gauntlet"}), D(10, {"Much-Too-Small Yellow Shirt"}), D(20, {"Flame Scythe"}), D(20, {"Crystal Glaive"}), ORB() } }, + { "Quest For Candour", "Whip of Truth", "the `9Whip of Truth``", 6026, { + D(2000, {"Secret Of Growtopia"}), T(100), B(10000), D(1000, {"Mind-Ghost-In-A-Jar"}), PR(100000), D(50, {"Growtoken"}), D(10, {"Super Squirt Gun Jetpack"}), XP(20000), + D(5, {"Soul Stone"}), G(140000), BR(200000), T(500), S(1000), D(3, {"Celestial Lance"}), FR(200000), + D(5, {"Ring Of Shrinking"}), D(3, {"Golden Talaria"}), D(1, {"Summer Event Player Medal: Gold", "Winter Event Player Medal: Gold", "Spring Event Player Medal: Gold"}), + D(3, {"Ancestral Totem of Wisdom"}), ORB() } }, + { "Quest For The Sky", "Legendary Dragon Knight's Wings", "the `9Legendary Dragon Knight's Wings``", 7734, { + D(2000, {"Obsidian"}), T(100), B(10000), D(1000, {"Knight Helmet"}), PR(100000), D(50, {"Growtoken"}), D(10, {"Blanket Cape"}), XP(20000), + D(800, {"Blazing Electro Wings"}), G(140000), BR(200000), T(500), S(1000), D(10, {"Autumn Wings"}), FR(200000), + D(1, {"Golden Dragon Statue", "Neptune's Armor"}), D(5, {"Chaos Dragon"}), D(1, {"Draconic Wings"}), D(10, {"Dragon Knight's Chestplate"}), ORB() } }, + { "Quest Of The Owl", "Legendary Owl", "the `9Legendary Owl``", 11142, { + D(2000, {"Clouds Wallpaper"}), T(100), B(10000), D(50, {"Owl Mask"}), PR(100000), D(50, {"Growtoken"}), D(50, {"Alaskan King Crab Crown"}), XP(20000), + D(10, {"Sun Shooter Bow"}), G(100000), BR(200000), T(500), S(1000), D(1, {"Golden Silk Scarf", "Neptune's Weather Machine - Atlantis"}), FR(200000), + D(2, {"Snow Leopard Tail"}), D(3, {"Ultraviolet Aura"}), D(1, {"Lil Growpeep's Baaaa Blaster"}), D(1, {"Draconic Soul Aura"}), ORB() } }, + { "Quest Of The Mech", "Legendary Destroyer", "the `9Legendary Destroyer``", 11140, { + D(2000, {"Dwarven Background"}), T(100), B(10000), D(150, {"Lost Startopian Helmet"}), PR(100000), D(50, {"Growtoken"}), D(15, {"Monster Truck"}), XP(20000), + D(10, {"Matrix Aura"}), G(100000), BR(200000), T(500), S(1000), D(3, {"Digger's Spade"}), FR(200000), + D(10, {"Ambu-Lance"}), D(5, {"Mining Mech"}), D(1, {"Phoenix Armor"}), D(1, {"Volcanic Cape"}), ORB() } }, + }; + return q; +} + +/* ================= item names -> ids (from the server's own items.dat) ================= */ + +std::string key_of(const std::string &s) +{ + std::string k{}; + for (char c : s) if (std::isalnum(static_cast(c))) k += static_cast(std::tolower(static_cast(c))); + return k; +} + +int item_id(const std::string &name) +{ + static std::unordered_map ids{}; + if (ids.empty()) + for (const ::item &it : items) + if (it.id != 0 && !it.raw_name.empty()) ids.try_emplace(key_of(it.raw_name), static_cast(it.id)); + auto it = ids.find(key_of(name)); + return it == ids.end() ? 0 : it->second; +} + +int wizard_id() { static const int id = [] { const int i = item_id("Legendary Wizard"); return i ? i : 1790; }(); return id; } + +int reward_id(const quest &q) +{ + if (q.reward.empty()) return 0; + const int id = item_id(q.reward); + return id ? id : q.reward_fallback_id; +} + +/* ================= per-player state ================= */ + +enum title_bits : int { LEGEND_UNLOCKED = 1, LEGEND_ON = 2, DR_UNLOCKED = 4, DR_ON = 8 }; + +struct lstate +{ + int quest{ -1 }; // @note -1 = none + int step{}; // @note 0-based + long long progress{}; + int titles{}; + bool dirty{}; + bool loaded{}; +}; +std::map states{}; + +void ensure_table() +{ + static bool done = false; + if (done) return; + done = true; + mysql_query(db, "CREATE TABLE IF NOT EXISTS legendary (uid INT PRIMARY KEY, quest INT NOT NULL DEFAULT -1, step INT NOT NULL DEFAULT 0, " + "progress BIGINT NOT NULL DEFAULT 0, titles INT NOT NULL DEFAULT 0)"); +} + +lstate &state_of(int uid) +{ + lstate &s = states[uid]; + if (s.loaded) return s; + s.loaded = true; + ensure_table(); + if (mysql_query(db, std::format("SELECT quest, step, progress, titles FROM legendary WHERE uid = {}", uid).c_str()) == 0) + if (MYSQL_RES *res = mysql_store_result(db)) + { + if (MYSQL_ROW r = mysql_fetch_row(res)) + { + s.quest = r[0] ? std::atoi(r[0]) : -1; + s.step = r[1] ? std::atoi(r[1]) : 0; + s.progress = r[2] ? std::atoll(r[2]) : 0; + s.titles = r[3] ? std::atoi(r[3]) : 0; + } + mysql_free_result(res); + } + if (s.quest >= static_cast(quests().size())) s.quest = -1; + return s; +} + +void save(int uid) +{ + lstate &s = state_of(uid); + ensure_table(); + mysql_query(db, std::format("REPLACE INTO legendary (uid, quest, step, progress, titles) VALUES ({}, {}, {}, {}, {})", uid, s.quest, s.step, s.progress, s.titles).c_str()); + s.dirty = false; +} + +std::string num(long long n) +{ + std::string s = std::to_string(n), out{}; + for (std::size_t i = 0; i < s.size(); ++i) + { + if (i != 0 && (s.size() - i) % 3 == 0) out += ','; + out += s[i]; + } + return out; +} + +std::string step_text(const step &st) +{ + switch (st.k) + { + case kind::DELIVER: + { + std::string names{}; + for (std::size_t i = 0; i < st.names.size(); ++i) names += (i == 0 ? "" : (i + 1 == st.names.size() ? " or " : ", ")) + ("`w" + st.names[i] + "``"); + return std::format("Deliver {} {}.", num(st.amount), names); + } + case kind::GEMS: return std::format("Deliver {} Gems.", num(st.amount)); + case kind::BREAK: return std::format("Break {} blocks.", num(st.amount)); + case kind::BREAK_RARITY: return std::format("Break {} rarity worth of blocks.", num(st.amount)); + case kind::PLANT_RARITY: return std::format("Plant seeds that add up to {} rarity.", num(st.amount)); + case kind::FRUIT_RARITY: return std::format("Collect {} rarity worth of fruit from trees.", num(st.amount)); + case kind::XP: return std::format("Earn {} XP.", num(st.amount)); + case kind::TREES: return std::format("Harvest {} trees.", num(st.amount)); + case kind::SEEDS: return std::format("Plant {} seeds.", num(st.amount)); + } + return ""; +} + +bool item_missing(const step &st) +{ + if (st.k != kind::DELIVER) return false; + for (const std::string &n : st.names) if (item_id(n)) return false; + return true; +} + +int owned(const ::peer &p, int id) +{ + for (const ::slot &s : p.slots) if (s.id == id) return s.count; + return 0; +} + +void broadcast(const std::string &text) +{ + peers("", peer_condition::PEER_ALL, [&](ENetPeer &p) + { + const ::peer *o = static_cast<::peer*>(p.data); + if (o && !o->growid.empty()) on::ConsoleMessage(&p, text); + }); +} + +/* where each player last talked to a wizard (it vanishes when a quest is turned in there) */ +struct spot { std::string world; int x, y; }; +std::map talked_at{}; + +/* completes the current step; at the end of the quest hands out the reward */ +void complete_step(ENetEvent &event, bool at_wizard) +{ + ::peer *p = static_cast<::peer*>(event.peer->data); + lstate &s = state_of(p->user_id); + const quest &q = quests()[s.quest]; + + s.progress = 0; + ++s.step; + send_action(*event.peer, "play_sfx", "file|audio/levelup2.wav\ndelayMS|0"); + + if (s.step < static_cast(q.steps.size())) + { + on::ConsoleMessage(event.peer, std::format("`9Legendary Quest:`` step `w{}/20`` complete! Next: {}", s.step, step_text(q.steps[s.step]))); + send_varlist(event.peer, { "OnTextOverlay", std::format("`9Step {} of {} complete!``", s.step, q.title) }); + save(p->user_id); + return; + } + + // the whole quest is done + const int finished = s.quest; + s.quest = -1; + s.step = 0; + if (q.reward.empty()) + { + s.titles |= LEGEND_UNLOCKED | LEGEND_ON; + if (p->netid != 0) + if (auto w = std::ranges::find(worlds, p->recent_worlds.back(), &::world::name); w != worlds.end()) refresh_display_names(*w); + on::CountryState(event); + } + else if (const int id = reward_id(q); id > 0) + modify_item_inventory(event, ::slot(static_cast(id), 1)); + save(p->user_id); + + send_varlist(event.peer, { "OnAddNotification", "interface/large/special_event.rttex", std::format("`9You completed the {}!``", q.title), "audio/cumbia_horns.wav", 0u }); + broadcast(std::format("`9** {} has completed the {} and earned {}! **``", p->growid, q.title, quests()[finished].blurb)); + + // the wizard it was turned in at vanishes (so don't give others access to yours) + if (at_wizard) + if (auto t = talked_at.find(p->user_id); t != talked_at.end() && p->netid != 0 && t->second.world == p->recent_worlds.back()) + if (auto w = std::ranges::find(worlds, t->second.world, &::world::name); w != worlds.end()) + { + const int x = t->second.x, y = t->second.y; + if (y >= 0 && cord(x, y) < static_cast(w->blocks.size()) && w->blocks[cord(x, y)].fg == wizard_id()) + { + ::block &b = w->blocks[cord(x, y)]; + b.fg = 0; + ::gamePacket gp{}; + gp.punch = ::pos{ x, y }; + send_tile_update(event, gp, b, *w); + on::ConsoleMessage(event.peer, "`oThe Legendary Wizard vanishes in a puff of smoke.``"); + } + } +} + +void counter(ENetEvent &event, kind k, long long n) +{ + if (n <= 0 || event.peer == nullptr || event.peer->data == nullptr) return; + ::peer *p = static_cast<::peer*>(event.peer->data); + if (p->user_id == 0) return; + lstate &s = state_of(p->user_id); + if (s.quest < 0) return; + const step &st = quests()[s.quest].steps[s.step]; + if (st.k != k) return; + s.progress += n; + s.dirty = true; + if (s.progress >= st.amount) complete_step(event, false); +} + +/* ================= wizard dialogs ================= */ + +void send_dialog(ENetEvent &event, const std::string &body) +{ + send_varlist(event.peer, { "OnDialogRequest", "set_default_color|`o\n" + body }); +} + +void greeting(ENetEvent &event) +{ + std::string d = std::format("add_label_with_icon|big|`9The Legendary Wizard``|left|{}|\n", wizard_id()); + d += "add_textbox|`oGreetings, traveler! I am the Legendary Wizard. Should you wish to embark on a Legendary Quest, simply choose one below.``|left|\nadd_spacer|small|\n"; + for (std::size_t i = 0; i < quests().size(); ++i) d += std::format("add_button|q_{}|`9{}``|noflags|0|0|\n", i, quests()[i].title); + d += "end_dialog|legendary_wizard|No Thanks||\n"; + send_dialog(event, d); +} + +void describe(ENetEvent &event, int qi) +{ + const quest &q = quests()[qi]; + std::string d = std::format("add_label_with_icon|big|`9{}``|left|{}|\n", q.title, wizard_id()); + d += std::format("add_textbox|`oThis quest will challenge every fiber of your Growtopian being. It will cost you thousands of gems, weeks or months of time, and possibly your friends and family." + "Every quest has 20 steps to complete, and each step alone is probably more than most Growtopians could manage." + "But the rewards are also vast. If you complete this quest, you will earn {}!" + "These quest rewards are `5Untradeable``, and you will truly be a Legendary Growtopian if you complete a quest." + "You may turn in your quests at any Legendary Wizard you have access to (we're in a union), but I will vanish permanently if somebody turns in their final quest step to me, so don't let other people access me!``|left|\n", q.blurb); + d += "add_spacer|small|\n"; + d += std::format("add_button|begin_{}|`9Begin the {}!``|noflags|0|0|\n", qi, q.title); + d += "add_button|back|Back|noflags|0|0|\nend_dialog|legendary_wizard|No Thanks||\n"; + send_dialog(event, d); +} + +void step_view(ENetEvent &event) +{ + ::peer *p = static_cast<::peer*>(event.peer->data); + lstate &s = state_of(p->user_id); + const quest &q = quests()[s.quest]; + const step &st = q.steps[s.step]; + + std::string d = std::format("add_label_with_icon|big|`9{}``|left|{}|\n", q.title, wizard_id()); + d += std::format("add_textbox|`o(Step {}/20)``|left|\nadd_spacer|small|\nadd_textbox|`o{}``|left|\n", s.step + 1, step_text(st)); + if (s.step == 1 || s.step == 11 || s.step == 12) + d += "add_smalltext|`o(This server has no PvP, surgery, villains, providers or fish training, so this step stands in for the original one.)``|left|\n"; + d += "add_spacer|small|\n"; + d += std::format("add_textbox|`oCurrent progress: `w{}/{}``|left|\n", num(s.progress), num(st.amount)); + + if (item_missing(st)) + d += "add_textbox|`4That item doesn't exist in this server's items.dat.``|left|\nadd_button|skip|`oSkip this step``|noflags|0|0|\n"; + else if (st.k == kind::DELIVER) + { + long long have = 0; + std::string name{}; + for (const std::string &n : st.names) if (const int id = item_id(n); id && owned(*p, id) > 0) { have = owned(*p, id); name = n; break; } + const long long give = std::min(have, st.amount - s.progress); + if (give > 0) d += std::format("add_button|deliver|`oDeliver {} {}``|noflags|0|0|\n", num(give), name); + else d += "add_smalltext|`oYou don't have any of that with you.``|left|\n"; + } + else if (st.k == kind::GEMS) + { + const long long give = std::min(p->gems, st.amount - s.progress); + if (give > 0) d += std::format("add_button|deliver|`oDeliver {} Gems``|noflags|0|0|\n", num(give)); + else d += "add_smalltext|`oYou don't have any gems with you.``|left|\n"; + } + else d += "add_smalltext|`oThis step counts up by itself while you play - come back when it's done, or keep an eye on the chat.``|left|\n"; + + d += "add_spacer|small|\nadd_button|give_up|`oGive up this quest``|noflags|0|0|\nend_dialog|legendary_wizard|Goodbye!||\n"; + send_dialog(event, d); +} + +bool can_use_wizard(const ::peer &p, const ::world &w) +{ + if (p.role >= MODERATOR || w.owner == 0 || w.owner == p.user_id) return true; + return std::ranges::find(w.access, p.user_id) != w.access.end(); +} +} + +/* ================= titles ================= */ + +void legend_load(const ::peer &p) { if (p.user_id) (void)state_of(p.user_id); } + +void legend_save(const ::peer &p) +{ + if (!p.user_id) return; + if (auto it = states.find(p.user_id); it != states.end()) + { + if (it->second.dirty) save(p.user_id); + states.erase(it); + } +} + +void legend_tick(std::time_t now) +{ + static std::time_t next = 0; + if (now < next) return; + next = now + 60; + for (auto &[uid, s] : states) if (s.dirty) save(uid); +} + +namespace +{ +/* ---- the titles ---- + * flag: what the game itself draws (tested on the 5.58 client). "" = no game look, done with name colours */ +enum more_title_bits : int { G4G_UNLOCKED = 16, G4G_ON = 32, MENTOR_UNLOCKED = 64, MENTOR_ON = 128, + PARTY_UNLOCKED = 256, PARTY_ON = 512, MAXLVL_UNLOCKED = 1024, MAXLVL_HIDDEN = 2048 }; + +struct title_def +{ + const char *key; // @note /title {key} + const char *label; // @note shown in the Title window and in messages + const char *desc; + int unlocked; + int on; // @note the bit that turns it on, or hides it when 'inverted' + const char *flag; // @note OnCountryState flag + bool inverted; // @note on unless the bit is set (level 125 is on by default) +}; + +enum title_index { T_LEGEND, T_DR, T_MAXLVL, T_G4G, T_MENTOR, T_PARTY }; +constexpr title_def title_defs[] = { + { "legend", "`9of Legend``", "Legendary look and \"of Legend\" after your name", LEGEND_UNLOCKED, LEGEND_ON, "legend", false }, + { "dr", "`4Dr.``", "red name with \"Dr.\" in front", DR_UNLOCKED, DR_ON, "doctor", false }, + { "maxlevel", "`1Level 125``", "pulsing blue name", MAXLVL_UNLOCKED, MAXLVL_HIDDEN, "maxLevel", true }, + { "g4g", "`2Grow4Good``", "pastel name and the Grow4Good badge", G4G_UNLOCKED, G4G_ON, "donor", false }, + { "mentor", "`6Mentor``", "gold name and the mentor medal", MENTOR_UNLOCKED, MENTOR_ON, "master", false }, + { "party", "`5Party Animal``", "purple, yellow and green letters", PARTY_UNLOCKED, PARTY_ON, "", false }, +}; +constexpr const char *title_list = "legend, dr, maxlevel, g4g, mentor, party"; + +const title_def *find_title(std::string k) +{ + for (char &c : k) c = static_cast(std::tolower(static_cast(c))); + if (k == "legendary") k = "legend"; + else if (k == "doctor") k = "dr"; + else if (k == "grow4good") k = "g4g"; + else if (k == "level125" || k == "125" || k == "max") k = "maxlevel"; + else if (k == "partyanimal") k = "party"; + for (const title_def &d : title_defs) if (k == d.key) return &d; + return nullptr; +} + +bool has_title(const ::peer &p, int t, const title_def &d) +{ + if (t & d.unlocked) return true; + return d.unlocked == MAXLVL_UNLOCKED && p.level.front() >= 125; // @note level 125 earns it by itself +} + +bool title_on(const ::peer &p, int t, const title_def &d) +{ + if (!has_title(p, t, d)) return false; + return d.inverted ? !(t & d.on) : (t & d.on) != 0; +} + +void set_title_on(int &t, const title_def &d, bool enable) +{ + if (d.inverted == enable) t &= ~d.on; + else t |= d.on; +} + +bool any_title(const ::peer &p, int t) +{ + for (const title_def &d : title_defs) if (has_title(p, t, d)) return true; + return false; +} + +bool any_title_on(const ::peer &p, int t) +{ + for (const title_def &d : title_defs) if (title_on(p, t, d)) return true; + return false; +} + +/* Party Animal: every letter purple, yellow, green in turn */ +std::string party_colours(const std::string &s) +{ + static constexpr char cols[] = { '5', '9', '2' }; + std::string out{}; + std::size_t n = 0; + for (std::size_t i = 0; i < s.size(); ++i) + { + if (s[i] == '`') { ++i; continue; } // @note drop the old colour codes + if (s[i] != ' ') { out += '`'; out += cols[n++ % 3]; } + out += s[i]; + } + return out + "``"; +} +} + +std::string title_name(const ::peer &p, const std::string &base) +{ + if (p.user_id == 0) return base; + const int t = state_of(p.user_id).titles; + std::string name = base; + if (title_on(p, t, title_defs[T_DR])) // @note red comes from the doctor flag; "Dr." goes right before the name, after colour code and @ + { + std::size_t at = (name.size() >= 2 && name[0] == '`') ? 2 : 0; + if (name.size() > at && name[at] == '@') ++at; + name.insert(at, "Dr."); + } + if (title_on(p, t, title_defs[T_LEGEND])) + { + while (name.ends_with("``")) name.resize(name.size() - 2); + name += " of Legend``"; + } + if (title_on(p, t, title_defs[T_PARTY])) name = party_colours(name); + return name; +} + +std::string country_state_for(const ::peer &p) +{ + std::string s = flag_of(p); // @note /flag + const int t = p.user_id ? state_of(p.user_id).titles : 0; + for (const title_def &d : title_defs) + if (*d.flag && title_on(p, t, d)) s += std::string("|") + d.flag; + return s; +} + +void titles_open(ENetEvent &event) +{ + ::peer *p = static_cast<::peer*>(event.peer->data); + const int t = state_of(p->user_id).titles; + std::string d = "add_label_with_icon|big|`wTitles``|left|1794|\nadd_spacer|small|\n"; + for (const title_def &x : title_defs) + if (has_title(*p, t, x)) d += std::format("add_checkbox|t_{}|{} - {}|{}\n", x.key, x.label, x.desc, title_on(*p, t, x) ? 1 : 0); + if (!any_title(*p, t)) + d += "add_textbox|`oYou haven't earned any titles yet. Complete the `9Quest For Honor`` at a Legendary Wizard to earn the Legendary title.``|left|\n"; + d += "add_spacer|small|\nend_dialog|title_edit|Cancel|OK|\n"; + send_dialog(event, d); +} + +/* shows a title change to everyone in the world: the new name first, then the flags. + * The game redraws the title look from that pair (same as entering a world); no respawn */ +static void apply_titles(ENetPeer &who) +{ + ::peer *p = static_cast<::peer*>(who.data); + ENetEvent ev{}; + ev.peer = &who; + if (p->netid == 0) { on::CountryState(ev); return; } + + auto w = std::ranges::find(worlds, p->recent_worlds.back(), &::world::name); + if (w == worlds.end()) { on::CountryState(ev); return; } + p->display_growid = display_name_for(*p, *w); // @note /nick included + + const bool hidden = (p->state & S_INVISIBLE) == S_INVISIBLE; + peers(w->name, PEER_SAME_WORLD, [&](ENetPeer &o) + { + const bool self = &o == &who; + if (!self && hidden) return; + send_varlist(&o, { "OnNameChanged", p->display_growid }, p->netid); + if (self) on::CountryState(ev); + else on::CountryStateOf(o, *p); + }); +} + +void titles_return(ENetEvent &event, const ::hPipe &hPipe) +{ + ::peer *p = static_cast<::peer*>(event.peer->data); + lstate &s = state_of(p->user_id); + const int before = s.titles; + for (const title_def &x : title_defs) + { + if (!has_title(*p, s.titles, x)) continue; + const std::string box = std::string("t_") + x.key; + const std::string v = hPipe[box.c_str()]; + if (v == "1") set_title_on(s.titles, x, true); + else if (v == "0") set_title_on(s.titles, x, false); + } + if (s.titles == before) return; + save(p->user_id); + apply_titles(*event.peer); +} + +std::string title_quick_button(const ::peer &p) +{ + const int t = state_of(p.user_id).titles; + if (!any_title(p, t)) return ""; + return any_title_on(p, t) ? "add_button|title_toggle|`wTitle: `2ON`` - tap to turn off``|noflags|0|0|\n" + : "add_button|title_toggle|`wTitle: `4OFF`` - tap to turn on``|noflags|0|0|\n"; +} + +/* all your titles on or all off */ +static void set_all_titles(ENetEvent &event, bool enable) +{ + ::peer *p = static_cast<::peer*>(event.peer->data); + lstate &s = state_of(p->user_id); + if (!any_title(*p, s.titles)) { on::ConsoleMessage(event.peer, "`oYou don't have any titles yet.``"); return; } + const int before = s.titles; + for (const title_def &x : title_defs) if (has_title(*p, s.titles, x)) set_title_on(s.titles, x, enable); + if (s.titles == before) return; + save(p->user_id); + apply_titles(*event.peer); + on::ConsoleMessage(event.peer, enable ? "`2Your titles are on.``" : "`oYour titles are off.``"); +} + +void title_toggle(ENetEvent &event) +{ + ::peer *p = static_cast<::peer*>(event.peer->data); + set_all_titles(event, !any_title_on(*p, state_of(p->user_id).titles)); +} + +/* /title on|off - all your own titles + * /title {title} - switch one of your own titles + * /title {player} {title} [off] - developers give or take a title */ +void title_cmd(ENetEvent &event, const std::string_view text) +{ + ::peer *me = static_cast<::peer*>(event.peer->data); + + std::vector args{}; + for (const std::string &a : readch(std::string{ text }, ' ')) if (!a.empty()) args.push_back(a); + if (args.size() == 2 && (args[1] == "on" || args[1] == "off")) { set_all_titles(event, args[1] == "on"); return; } + if (args.size() == 2) + { + if (const title_def *x = find_title(args[1])) + { + lstate &st = state_of(me->user_id); + if (!has_title(*me, st.titles, *x)) { on::ConsoleMessage(event.peer, std::format("`4You don't have the {} title.``", x->label)); return; } + const bool now_on = !title_on(*me, st.titles, *x); + set_title_on(st.titles, *x, now_on); + save(me->user_id); + apply_titles(*event.peer); + on::ConsoleMessage(event.peer, std::format("`o{} is now {}.", x->label, now_on ? "`2on``" : "`4off``")); + return; + } + } + if (me->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, std::format("`4Usage: /title {{title}}`` to switch one title, `4/title on`` or `4/title off`` for all. Titles: {}", title_list)); + return; + } + const title_def *x = args.size() >= 3 ? find_title(args[2]) : nullptr; + if (!x) + { + on::ConsoleMessage(event.peer, std::format("`4Usage: /title {{player}} {{title}} [off]`` - titles: {}", title_list)); + return; + } + const bool off = args.size() >= 4 && args[3] == "off"; + + std::string want = args[1]; + for (char &c : want) c = static_cast(std::tolower(static_cast(c))); + ENetPeer *target = nullptr; + peers("", peer_condition::PEER_ALL, [&](ENetPeer &p) + { + const ::peer *t = static_cast<::peer*>(p.data); + if (!t || t->growid.empty()) return; + std::string n = t->growid; + for (char &c : n) c = static_cast(std::tolower(static_cast(c))); + if (n == want) target = &p; + }); + if (!target) { on::ConsoleMessage(event.peer, std::format("`4`w{}`` is not online.``", args[1])); return; } + + ::peer *t = static_cast<::peer*>(target->data); + lstate &s = state_of(t->user_id); + if (off) + { + s.titles &= ~x->unlocked; + set_title_on(s.titles, *x, false); // @note a level 125 player keeps the title, but it stays hidden + } + else + { + s.titles |= x->unlocked; + set_title_on(s.titles, *x, true); + } + save(t->user_id); + apply_titles(*target); + on::ConsoleMessage(event.peer, std::format("`2{} the {} title {} `w{}``.``", off ? "Took" : "Gave", x->label, off ? "from" : "to", t->growid)); + if (target != event.peer) + on::ConsoleMessage(target, off ? std::format("`oYour {} title was removed.``", x->label) + : std::format("`2You earned the {} title!`` Wrench yourself and tap Title, or type `w/title {}``.", x->label, x->key)); +} + +/* ================= wizard ================= */ + +bool wizard_wrench(ENetEvent &event, ::world &world, int x, int y) +{ + if (y < 0 || cord(x, y) >= static_cast(world.blocks.size()) || world.blocks[cord(x, y)].fg != wizard_id()) return false; + ::peer *p = static_cast<::peer*>(event.peer->data); + if (!can_use_wizard(*p, world)) + { + send_varlist(event.peer, { "OnTalkBubble", p->netid, "`wThe Legendary Wizard only talks to people with access to this world.``", 0u }); + return true; + } + talked_at[p->user_id] = { world.name, x, y }; + if (state_of(p->user_id).quest < 0) greeting(event); + else step_view(event); + return true; +} + +void wizard_return(ENetEvent &event, const ::hPipe &hPipe) +{ + ::peer *p = static_cast<::peer*>(event.peer->data); + const std::string btn = hPipe["buttonClicked"]; + if (btn.empty()) return; + + // still standing near a wizard they may use? + auto t = talked_at.find(p->user_id); + if (t == talked_at.end() || p->netid == 0 || t->second.world != p->recent_worlds.back()) return; + auto w = std::ranges::find(worlds, t->second.world, &::world::name); + if (w == worlds.end() || cord(t->second.x, t->second.y) >= static_cast(w->blocks.size()) || w->blocks[cord(t->second.x, t->second.y)].fg != wizard_id()) return; + + lstate &s = state_of(p->user_id); + + if (s.quest < 0) + { + if (btn == "back") { greeting(event); return; } + if (btn.starts_with("q_")) { const int qi = std::atoi(btn.c_str() + 2); if (qi >= 0 && qi < static_cast(quests().size())) describe(event, qi); return; } + if (btn.starts_with("begin_")) + { + const int qi = std::atoi(btn.c_str() + 6); + if (qi < 0 || qi >= static_cast(quests().size())) return; + if (p->level.front() < 40 && p->role < MODERATOR) + { + send_dialog(event, std::format("add_label_with_icon|big|`9The Legendary Wizard``|left|{}|\nadd_textbox|`oYou are not ready yet, young one. Come back when you are at least `wlevel 40``.``|left|\nend_dialog|legendary_wizard||OK|\n", wizard_id())); + return; + } + s.quest = qi; + s.step = 0; + s.progress = 0; + save(p->user_id); + on::ConsoleMessage(event.peer, std::format("`9You have begun the {}!`` Good luck, you'll need it.", quests()[qi].title)); + step_view(event); + } + return; + } + + const quest &q = quests()[s.quest]; + const step &st = q.steps[s.step]; + + if (btn == "give_up") + { + send_dialog(event, std::format("add_label_with_icon|big|`9{}``|left|{}|\nadd_textbox|`4Are you sure?`` All progress on this quest will be lost, and anything you delivered is gone.|left|\n" + "add_button|give_up_yes|`4Yes, give up``|noflags|0|0|\nadd_button|back|No, keep going|noflags|0|0|\nend_dialog|legendary_wizard|||\n", q.title, wizard_id())); + return; + } + if (btn == "give_up_yes") + { + on::ConsoleMessage(event.peer, std::format("`oYou gave up the {}.``", q.title)); + s.quest = -1; s.step = 0; s.progress = 0; + save(p->user_id); + return; + } + if (btn == "back") { step_view(event); return; } + if (btn == "skip" && item_missing(st)) { complete_step(event, true); if (s.quest >= 0) step_view(event); return; } + if (btn != "deliver") return; + + if (st.k == kind::GEMS) + { + const long long give = std::min(p->gems, st.amount - s.progress); + if (give <= 0) return; + p->gems -= static_cast(give); + on::SetBux(event); + s.progress += give; + } + else if (st.k == kind::DELIVER) + { + long long give = 0; + for (const std::string &n : st.names) + if (const int id = item_id(n); id && owned(*p, id) > 0) + { + give = std::min(owned(*p, id), st.amount - s.progress); + modify_item_inventory(event, ::slot(static_cast(id), static_cast(-give))); + break; + } + if (give <= 0) return; + s.progress += give; + } + else return; + + s.dirty = true; + if (s.progress >= st.amount) complete_step(event, true); + else save(p->user_id); + if (s.quest >= 0) step_view(event); +} + +/* ================= counters ================= */ + +void legend_on_break(ENetEvent &event, int rarity) { counter(event, kind::BREAK, 1); counter(event, kind::BREAK_RARITY, rarity); } +void legend_on_plant(ENetEvent &event, int rarity) { counter(event, kind::SEEDS, 1); counter(event, kind::PLANT_RARITY, rarity); } +void legend_on_harvest(ENetEvent &event, int fruit_rarity) { counter(event, kind::TREES, 1); counter(event, kind::FRUIT_RARITY, fruit_rarity); } +void legend_on_xp(ENetEvent &event, int xp) { counter(event, kind::XP, xp); } + +bool staff_only_item(int id) +{ + if (id == 274 || id == 276 || id == 278 || id == 732) return true; // @note Freeze, Fire, Curse and Ban Wand + for (const quest &q : quests()) if (!q.reward.empty() && reward_id(q) == id) return true; + return false; +} diff --git a/include/commands/legendary.hpp b/include/commands/legendary.hpp new file mode 100644 index 00000000..0a298e87 --- /dev/null +++ b/include/commands/legendary.hpp @@ -0,0 +1,24 @@ +#pragma once + +/* ---- titles: "of Legend" (Quest For Honor) and "Dr." ---- */ +extern void legend_load(const ::peer &p); // @note at login +extern void legend_save(const ::peer &p); // @note at logout +extern void legend_tick(std::time_t now); // @note saves quest progress now and then +extern std::string title_name(const ::peer &p, const std::string &base); // @note adds " of Legend" when that title is on +extern std::string country_state_for(const ::peer &p); // @note "se|maxLevel|doctor" +extern void titles_open(ENetEvent &event); // @note wrench yourself > Title +extern void titles_return(ENetEvent &event, const ::hPipe &hPipe); +extern std::string title_quick_button(const ::peer &p); // @note one-tap on/off in your own wrench menu +extern void title_toggle(ENetEvent &event); +extern void title_cmd(ENetEvent &event, const std::string_view text); // @note /title {player} legend|dr + +/* ---- Legendary Quests at the Legendary Wizard ---- */ +extern bool wizard_wrench(ENetEvent &event, ::world &world, int x, int y); // @return true if that tile is a Legendary Wizard +extern void wizard_return(ENetEvent &event, const ::hPipe &hPipe); +extern void legend_on_break(ENetEvent &event, int rarity); +extern void legend_on_plant(ENetEvent &event, int rarity); +extern void legend_on_harvest(ENetEvent &event, int fruit_rarity); +extern void legend_on_xp(ENetEvent &event, int xp); + +/* items normal players can't pick in /find (wands, quest rewards) */ +extern bool staff_only_item(int id); diff --git a/include/commands/list.cpp b/include/commands/list.cpp new file mode 100644 index 00000000..41d10462 --- /dev/null +++ b/include/commands/list.cpp @@ -0,0 +1,41 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/world.hpp" +#include "list.hpp" + +/* /list -> everyone online and which world they're in */ +void list_cmd(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < MODERATOR) + { + on::ConsoleMessage(event.peer, "`4Only staff can use this.``"); + return; + } + + std::vector lines; + peers("", peer_condition::PEER_ALL, [&lines](ENetPeer &p) + { + ::peer *pOthers = static_cast<::peer*>(p.data); + if (!pOthers || pOthers->growid.empty()) return; + + const std::string where = (pOthers->netid == 0 || pOthers->recent_worlds.back().empty()) + ? "`o(world menu)" + : std::format("`oin `2{}``", pOthers->recent_worlds.back()); + + const char *tag = (pOthers->role >= DEVELOPER) ? "`6" : (pOthers->role >= MODERATOR) ? "`5" : "`w"; + + lines.emplace_back(std::format("{}{}`` {}", tag, pOthers->growid, where)); + }); + + if (lines.empty()) + { + on::ConsoleMessage(event.peer, "`4Nobody is online.``"); + return; + } + + on::ConsoleMessage(event.peer, std::format("`o--- `2{} online`o ---", lines.size())); + for (const std::string &line : lines) + on::ConsoleMessage(event.peer, line); +} diff --git a/include/commands/list.hpp b/include/commands/list.hpp new file mode 100644 index 00000000..dce3f047 --- /dev/null +++ b/include/commands/list.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void list_cmd(ENetEvent& event, const std::string_view text); diff --git a/include/commands/lockaccess.cpp b/include/commands/lockaccess.cpp new file mode 100644 index 00000000..c3eae18f --- /dev/null +++ b/include/commands/lockaccess.cpp @@ -0,0 +1,129 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "tools/bubble.hpp" +#include "database/database.hpp" +#include "database/world.hpp" +#include "legendary.hpp" +#include "lockaccess.hpp" + +static std::string base_name(const ::peer &p, const ::world *w) +{ + if (p.role >= DEVELOPER) return std::format("`6@{}``", p.growid); + if (p.role >= MODERATOR) return std::format("`5@{}``", p.growid); + if (w && w->owner != 0 && p.user_id == w->owner) return std::format("`2{}``", p.growid); + if (w && p.user_id != 0 && std::ranges::find(w->access, p.user_id) != w->access.end()) return std::format("`^{}``", p.growid); // @note light green, same as mod chat + return std::format("`w{}``", p.growid); +} + +std::string display_name_for(const ::peer &p, const ::world &w) { return title_name(p, p.nickname.empty() ? base_name(p, &w) : std::format("`w{}``", p.nickname)); } // @note a /nick keeps its titles + +std::string chat_name(const ::peer &p) +{ + if (!p.nickname.empty()) return p.display_growid; // @note disguised with /nick + const ::world *here = nullptr; + if (p.netid != 0) + if (auto w = std::ranges::find(worlds, p.recent_worlds.back(), &::world::name); w != worlds.end()) here = &*w; + return title_name(p, base_name(p, here)); +} + +void refresh_display_names(::world &w) +{ + peers(w.name, PEER_SAME_WORLD, [&](ENetPeer &p) + { + ::peer *t = static_cast<::peer*>(p.data); + if (!t || !t->nickname.empty()) return; + const std::string name = display_name_for(*t, w); + if (name == t->display_growid) return; + t->display_growid = name; + peers(w.name, PEER_SAME_WORLD, [&](ENetPeer &o) { send_varlist(&o, { "OnNameChanged", name }, t->netid); }); + }); +} + +/* GrowID for an account number: online players first, else the database */ +std::string access_name_of(int uid) +{ + std::string name{}; + peers("", peer_condition::PEER_ALL, [&](ENetPeer &p) + { + const ::peer *t = static_cast<::peer*>(p.data); + if (t && t->user_id == uid) name = t->growid; + }); + if (!name.empty()) return name; + + ::hStmt hStmt{ "SELECT growid FROM peer WHERE uid = ? LIMIT 1" }; + MYSQL_BIND param = make_bind_in(uid); + hStmt.bind_param(¶m); + u_long length = 0; + MYSQL_BIND result = make_bind_out(name); + result.length = &length; + mysql_stmt_bind_result(hStmt.pStmt, &result); + hStmt.execute(); + hStmt.fetch(); + name.resize(length); + return name.empty() ? std::format("user #{}", uid) : name; +} + +bool lock_access_add(ENetEvent &event, ::world &w, int uid) +{ + if (uid == 0) return false; + if (uid == w.owner) { tell(event.peer, "`4They already own this world.``"); return false; } + const std::string name = access_name_of(uid); + if (std::ranges::find(w.access, uid) != w.access.end()) { tell(event.peer, std::format("`w{}`` already has access.", name)); return false; } + auto slot = std::ranges::find(w.access, 0); + if (slot == w.access.end()) { tell(event.peer, "`4The access list is full (20 people).``"); return false; } + + *slot = uid; + refresh_display_names(w); // @note their name turns light green for everyone right now + + on::ConsoleMessage(event.peer, std::format("`2`w{}`` now has access to `w{}``.``", name, w.name)); + peers("", peer_condition::PEER_ALL, [&](ENetPeer &p) + { + const ::peer *t = static_cast<::peer*>(p.data); + if (t && t->user_id == uid && &p != event.peer) on::ConsoleMessage(&p, std::format("`2You've been given access to `w{}``!`` You can build and break there now.", w.name)); + }); + return true; +} + +bool lock_access_remove(ENetEvent &event, ::world &w, int uid) +{ + auto slot = std::ranges::find(w.access, uid); + if (uid == 0 || slot == w.access.end()) return false; + *slot = 0; + refresh_display_names(w); + on::ConsoleMessage(event.peer, std::format("`oRemoved `w{}``'s access to `w{}``.``", access_name_of(uid), w.name)); + return true; +} + +std::string lock_access_rows(const ::world &w) +{ + std::string rows{}; + for (int uid : w.access) + if (uid != 0) rows += std::format("add_checkbox|checkbox_{}|{}|1\n", uid, access_name_of(uid)); + if (rows.empty()) return "add_label|small|Currently, you're the only one with access.``|left\n"; + return "add_label|small|`wPlayers with access`` (uncheck a name to remove it):``|left\n" + rows; +} + +void lock_access_apply(ENetEvent &event, ::world &w, const ::hPipe &hPipe) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (w.owner != pPeer->user_id && pPeer->role < DEVELOPER) return; + + /* unchecked names lose access */ + const std::array before = w.access; + for (int uid : before) + if (uid != 0 && hPipe[std::format("checkbox_{}", uid)] == "0") lock_access_remove(event, w, uid); + + /* "Add" picked a player */ + const std::string pick = hPipe["playerNetID"]; + if (pick.empty() || pick == "0") return; + const int netid = std::atoi(pick.c_str()); + + ::peer *target = nullptr; + peers(w.name, PEER_SAME_WORLD, [&](ENetPeer &p) + { + ::peer *o = static_cast<::peer*>(p.data); + if (o && o->netid == netid) target = o; + }); + if (!target) { tell(event.peer, "`4That player isn't in this world anymore.``"); return; } + lock_access_add(event, w, target->user_id); +} diff --git a/include/commands/lockaccess.hpp b/include/commands/lockaccess.hpp new file mode 100644 index 00000000..768d22f7 --- /dev/null +++ b/include/commands/lockaccess.hpp @@ -0,0 +1,13 @@ +#pragma once + +/* who a player is in a world: the one place that decides the colour of a name */ +extern std::string display_name_for(const ::peer &p, const ::world &w); +extern void refresh_display_names(::world &w); +extern std::string chat_name(const ::peer &p); // @note the name as it looks where they stand right now (/msg) // @note recolours everyone in the world and tells everyone, no rejoin needed + +/* World Lock access list (used by the wrench menu and /access) */ +extern std::string access_name_of(int uid); +extern bool lock_access_add(ENetEvent &event, ::world &w, int uid); +extern bool lock_access_remove(ENetEvent &event, ::world &w, int uid); +extern std::string lock_access_rows(const ::world &w); +extern void lock_access_apply(ENetEvent &event, ::world &w, const ::hPipe &hPipe); diff --git a/include/commands/maintenance.cpp b/include/commands/maintenance.cpp new file mode 100644 index 00000000..5bc1c1ac --- /dev/null +++ b/include/commands/maintenance.cpp @@ -0,0 +1,42 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/world.hpp" +#include "maintenance.hpp" + +bool gMaintenance = false; + +/* /maintenance -> blocks non-staff from logging in */ +void maintenance(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + gMaintenance = !gMaintenance; + + const std::string line = gMaintenance + ? "`4** Server is going into maintenance **``" + : "`2** Maintenance mode is off **``"; + + peers("", peer_condition::PEER_ALL, [&line](ENetPeer &p) + { + on::ConsoleMessage(&p, line); + }); + + if (gMaintenance) // @note everyone who isn't staff is disconnected right away + peers("", peer_condition::PEER_ALL, [&](ENetPeer &p) + { + const ::peer *t = static_cast<::peer*>(p.data); + if (!t || &p == event.peer || t->role >= MODERATOR) return; + on::ConsoleMessage(&p, "`4The server is going into maintenance.`` You've been disconnected - try again later."); + enet_peer_disconnect_later(&p, 0); + }); + + on::ConsoleMessage(event.peer, gMaintenance + ? "`4Maintenance mode `wON``. Only staff can log in.``" + : "`2Maintenance mode `wOFF``.``"); +} diff --git a/include/commands/maintenance.hpp b/include/commands/maintenance.hpp new file mode 100644 index 00000000..28114f94 --- /dev/null +++ b/include/commands/maintenance.hpp @@ -0,0 +1,3 @@ +#pragma once +extern bool gMaintenance; +extern void maintenance(ENetEvent& event, const std::string_view text); diff --git a/include/commands/mod.cpp b/include/commands/mod.cpp new file mode 100644 index 00000000..18d6f5b9 --- /dev/null +++ b/include/commands/mod.cpp @@ -0,0 +1,106 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "onVariant/NameChanged.hpp" +#include "database/database.hpp" +#include "database/world.hpp" +#include "mod.hpp" + +void mod(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + // text arrives as "mod {name} {level}" - drop the command word + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + if (arg.empty()) + { + on::ConsoleMessage(event.peer, "`4Usage: /mod {name} {0=player, 1=mod, 2=dev}``"); + return; + } + + std::string target_name{}, level_str{}; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) + { + target_name = arg.substr(0, sp); + level_str = arg.substr(sp + 1); + } + else + { + on::ConsoleMessage(event.peer, "`4Usage: /mod {name} {0=player, 1=mod, 2=dev}``"); + return; + } + + std::erase_if(level_str, [](char c){ return !std::isdigit(static_cast(c)); }); + if (level_str.empty()) + { + on::ConsoleMessage(event.peer, "`4Level must be 0, 1 or 2.``"); + return; + } + + signed level{}; + try { level = std::stoi(level_str); } + catch (...) { on::ConsoleMessage(event.peer, "`4Level must be 0, 1 or 2.``"); return; } + + if (level < 0 || level > 2) + { + on::ConsoleMessage(event.peer, "`4Level must be 0, 1 or 2.``"); + return; + } + + // lowercase the target since growid is stored lowercase-insensitive + std::string lower_name = target_name; + for (char &c : lower_name) c = std::tolower(static_cast(c)); + + // make sure the account exists + { + ::hStmt hStmt{ "SELECT 1 FROM peer WHERE growid = ? LIMIT 1" }; + MYSQL_BIND param = make_bind_in(lower_name); + hStmt.bind_param(¶m); + hStmt.execute(); + if (mysql_stmt_store_result(hStmt.pStmt) || mysql_stmt_num_rows(hStmt.pStmt) == 0) + { + on::ConsoleMessage(event.peer, std::format("`4No account named `w{}`` exists.``", target_name)); + return; + } + } + + // write the new role + { + ::hStmt hStmt{ "UPDATE peer SET role = ? WHERE growid = ?" }; + MYSQL_BIND params[2] = { make_bind_in(level), make_bind_in(lower_name) }; + hStmt.bind_param(params); + hStmt.execute(); + } + + static constexpr const char *labels[3] = { "Player", "Moderator", "Developer" }; + + // if they're online, update them live + bool online = false; + peers("", peer_condition::PEER_ALL, [&](ENetPeer &p) + { + ::peer *pTarget = static_cast<::peer*>(p.data); + if (!pTarget) return; + + std::string their_name = pTarget->growid; + for (char &c : their_name) c = std::tolower(static_cast(c)); + if (their_name != lower_name) return; + + online = true; + pTarget->role = static_cast(level); + pTarget->display_growid = (pTarget->role >= DEVELOPER) ? std::format("`6@{}``", pTarget->growid) : (pTarget->role >= MODERATOR) ? std::format("`5@{}``", pTarget->growid) : std::format("`w{}``", pTarget->growid); + + on::ConsoleMessage(&p, std::format("`2You are now a `w{}``.``", labels[level])); + }); + + on::ConsoleMessage(event.peer, + std::format("`2Set `w{}`` to `w{}``{}.``", target_name, labels[level], + online ? "" : " (offline, applies on next login)")); +} diff --git a/include/commands/mod.hpp b/include/commands/mod.hpp new file mode 100644 index 00000000..444efbfa --- /dev/null +++ b/include/commands/mod.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void mod(ENetEvent& event, const std::string_view text); diff --git a/include/commands/motd.cpp b/include/commands/motd.cpp new file mode 100644 index 00000000..238a5146 --- /dev/null +++ b/include/commands/motd.cpp @@ -0,0 +1,53 @@ +#include "pch.hpp" +#include +#include "onVariant/ConsoleMessage.hpp" +#include "database/world.hpp" +#include "motd.hpp" + +std::string gMotd{}; + +/* /motd -> shows the current message of the day + * /motd {text} -> sets it (dev) + * /motd clear -> removes it + */ +void motd(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + while (!arg.empty() && std::isspace(static_cast(arg.back()))) arg.pop_back(); + + if (arg.empty()) + { + if (gMotd.empty()) on::ConsoleMessage(event.peer, "`oNo message of the day is set."); + else on::ConsoleMessage(event.peer, std::format("`5[MOTD]`` `o{}", gMotd)); + return; + } + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can change the MOTD.``"); + return; + } + + if (arg == "clear") + { + gMotd.clear(); + std::ofstream("motd.txt"); + on::ConsoleMessage(event.peer, "`2Message of the day cleared.``"); + return; + } + + gMotd = arg; + { std::ofstream ostrm("motd.txt"); ostrm << gMotd; } + + peers("", peer_condition::PEER_ALL, [](ENetPeer &p) + { + on::ConsoleMessage(&p, std::format("`5[MOTD]`` `o{}", gMotd)); + }); + + on::ConsoleMessage(event.peer, "`2Message of the day updated.``"); +} diff --git a/include/commands/motd.hpp b/include/commands/motd.hpp new file mode 100644 index 00000000..42625dea --- /dev/null +++ b/include/commands/motd.hpp @@ -0,0 +1,3 @@ +#pragma once +extern std::string gMotd; +extern void motd(ENetEvent& event, const std::string_view text); diff --git a/include/commands/msg.cpp b/include/commands/msg.cpp new file mode 100644 index 00000000..3e68bf86 --- /dev/null +++ b/include/commands/msg.cpp @@ -0,0 +1,87 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/world.hpp" +#include "lockaccess.hpp" +#include "msg.hpp" + +static void deliver(ENetEvent& event, const std::string &target, const std::string &body) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + std::string lower_target = target; + for (char &c : lower_target) c = std::tolower(static_cast(c)); + + bool found = false; + peers("", peer_condition::PEER_ALL, [&](ENetPeer &p) + { + ::peer *pTarget = static_cast<::peer*>(p.data); + if (!pTarget || pTarget->growid.empty()) return; + + std::string their_name = pTarget->growid; + for (char &c : their_name) c = std::tolower(static_cast(c)); + if (their_name != lower_target) return; + if (pTarget->user_id == pPeer->user_id) return; + + found = true; + pTarget->last_msg_from = pPeer->growid; + + on::ConsoleMessage(&p, std::format("`6[from {}`6]`` `o{}", chat_name(*pPeer), body)); + on::ConsoleMessage(event.peer, std::format("`6[to {}`6]`` `o{}", chat_name(*pTarget), body)); + }); + + if (!found) + on::ConsoleMessage(event.peer, std::format("`4`w{}`` is not online.``", target)); +} + +/* /msg {player} {message} */ +void msg(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + std::string target{}, body{}; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) + { + target = arg.substr(0, sp); + body = arg.substr(sp + 1); + } + + while (!body.empty() && std::isspace(static_cast(body.back()))) body.pop_back(); + + if (target.empty() || body.empty()) + { + on::ConsoleMessage(event.peer, "`4Usage: /msg {player} {message}``"); + return; + } + + deliver(event, target, body); +} + +/* /reply {message} -> answers whoever last messaged you */ +void reply(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->last_msg_from.empty()) + { + on::ConsoleMessage(event.peer, "`4Nobody has messaged you yet.``"); + return; + } + + std::string body{ text }; + if (const std::size_t sp = body.find(' '); sp != std::string::npos) body = body.substr(sp + 1); + else body.clear(); + + while (!body.empty() && std::isspace(static_cast(body.back()))) body.pop_back(); + + if (body.empty()) + { + on::ConsoleMessage(event.peer, "`4Usage: /reply {message}``"); + return; + } + + deliver(event, pPeer->last_msg_from, body); +} diff --git a/include/commands/msg.hpp b/include/commands/msg.hpp new file mode 100644 index 00000000..0a0ef48a --- /dev/null +++ b/include/commands/msg.hpp @@ -0,0 +1,3 @@ +#pragma once +extern void msg(ENetEvent& event, const std::string_view text); +extern void reply(ENetEvent& event, const std::string_view text); diff --git a/include/commands/nick.cpp b/include/commands/nick.cpp new file mode 100644 index 00000000..005ee5f0 --- /dev/null +++ b/include/commands/nick.cpp @@ -0,0 +1,70 @@ +#include "pch.hpp" +#include "onVariant/NameChanged.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "onVariant/CountryState.hpp" +#include "database/world.hpp" +#include "nick.hpp" + +extern std::string display_name_for(const ::peer &p, const ::world &w); // @note commands/lockaccess.cpp +extern std::string title_name(const ::peer &p, const std::string &base); // @note commands/legendary.cpp + +/* the name to show: real name or /nick, with titles (Dr., of Legend, Party colours) */ +static std::string shown_name(const ::peer &p) +{ + if (p.netid != 0) + if (auto w = std::ranges::find(worlds, p.recent_worlds.back(), &::world::name); w != worlds.end()) + return display_name_for(p, *w); + if (!p.nickname.empty()) return title_name(p, std::format("`w{}``", p.nickname)); + return title_name(p, p.role >= DEVELOPER ? std::format("`6@{}``", p.growid) : std::format("`w{}``", p.growid)); +} + +/* new name + title flags to you and everyone in the world (the Legendary look needs both) */ +static void send_name(ENetEvent &event) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + on::NameChanged(event); + on::CountryState(event); + if (pPeer->netid == 0) return; + peers(pPeer->recent_worlds.back(), PEER_SAME_WORLD, [pPeer](ENetPeer &p) + { + ::peer *o = static_cast<::peer*>(p.data); + if (!o || o->user_id == pPeer->user_id) return; + send_varlist(&p, { "OnNameChanged", pPeer->display_growid }, pPeer->netid); + on::CountryStateOf(p, *pPeer); + }); +} + +void nick(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + if (arg.empty()) + { + pPeer->nickname.clear(); + pPeer->display_growid = shown_name(*pPeer); + send_name(event); + on::ConsoleMessage(event.peer, "`2Name reset.``"); + return; + } + + if (arg.length() > 24) + { + on::ConsoleMessage(event.peer, "`4That name is too long.``"); + return; + } + + pPeer->nickname = arg; + pPeer->display_growid = shown_name(*pPeer); + send_name(event); + on::ConsoleMessage(event.peer, std::format("`2You are now `w{}``. Use `$/nick`` alone to reset.``", arg)); +} \ No newline at end of file diff --git a/include/commands/nick.hpp b/include/commands/nick.hpp new file mode 100644 index 00000000..fbc112b7 --- /dev/null +++ b/include/commands/nick.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void nick(ENetEvent& event, const std::string_view text); diff --git a/include/commands/nuke.cpp b/include/commands/nuke.cpp new file mode 100644 index 00000000..12de2f5b --- /dev/null +++ b/include/commands/nuke.cpp @@ -0,0 +1,41 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "tools/create_dialog.hpp" +#include "database/world.hpp" +#include "nuke.hpp" + +/* /nuke -> asks for confirmation, the work happens in nuke_confirm */ +void nuke(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + + auto world = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + if (world == worlds.end()) return; + + ::create_dialog dialog; + send_varlist(event.peer, { + "OnDialogRequest", + dialog + .set_default_color("`o") + .add_label_with_icon("big", "`4Nuke World``", 1490 /* nuke-ish icon */) + .add_spacer("small") + .add_textbox(std::format("This will remove `4every block`` in `w{}``.", world->name)) + .add_textbox("The main door and its bedrock will be kept.") + .add_spacer("small") + .add_smalltext("`4This cannot be undone.``") + .add_quick_exit() + .end_dialog("nuke_confirm", "Cancel", "Nuke it") + }); +} diff --git a/include/commands/nuke.hpp b/include/commands/nuke.hpp new file mode 100644 index 00000000..a79ede4a --- /dev/null +++ b/include/commands/nuke.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void nuke(ENetEvent& event, const std::string_view text); diff --git a/include/commands/particle.cpp b/include/commands/particle.cpp new file mode 100644 index 00000000..4a542478 --- /dev/null +++ b/include/commands/particle.cpp @@ -0,0 +1,59 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/world.hpp" +#include "particle.hpp" + +/* /particle {effect} -> fires effect id at your position (colour 0x0e) + * /particle {effect} {colour} -> same, with a specific colour byte + * handy for finding the id you want; values are shown in decimal and hex. + */ +void particle(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + if (arg.empty()) + { + on::ConsoleMessage(event.peer, "`4Usage: /particle {effect} [colour]``"); + return; + } + + std::string effect_str{}, colour_str{}; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) + { + effect_str = arg.substr(0, sp); + colour_str = arg.substr(sp + 1); + } + else effect_str = arg; + + auto parse = [](std::string s, signed fallback) -> signed + { + std::erase_if(s, [](char c){ return !std::isdigit(static_cast(c)); }); + if (s.empty()) return fallback; + try { return std::stoi(s); } catch (...) { return fallback; } + }; + + const signed effect = parse(effect_str, 0); + const signed colour = parse(colour_str, 0x0e); + + send_particle_effect(event, pPeer->pos, { colour, effect }); + + on::ConsoleMessage(event.peer, + std::format("`2Effect `w{}`` (0x{:02x}) with colour `w{}`` (0x{:02x}).``", + effect, effect, colour, colour)); +} diff --git a/include/commands/particle.hpp b/include/commands/particle.hpp new file mode 100644 index 00000000..0283ca2e --- /dev/null +++ b/include/commands/particle.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void particle(ENetEvent& event, const std::string_view text); diff --git a/include/commands/playeractions.cpp b/include/commands/playeractions.cpp new file mode 100644 index 00000000..2f1d973f --- /dev/null +++ b/include/commands/playeractions.cpp @@ -0,0 +1,29 @@ +#include "pch.hpp" +#include "tools/bubble.hpp" +#include "trade.hpp" +#include "friendsys.hpp" +#include "pull.hpp" +#include "staff.hpp" +#include "legendary.hpp" +#include "playeractions.hpp" + +bool wrench_action(ENetEvent &event, const ::hPipe &hPipe) +{ + const std::string btn = hPipe["buttonClicked"]; + if (staff_popup(event, hPipe)) return true; + if (btn == "title_edit") { titles_open(event); return true; } // @note wrench yourself > Title + if (btn == "title_toggle") { title_toggle(event); return true; } // @note quick on/off button + if (btn != "trade" && btn != "friend_add" && btn != "pull_player") return false; + + ::peer *me = static_cast<::peer*>(event.peer->data); + const int netid = std::atoi(hPipe["netID"].c_str()); + ENetPeer *target = nullptr; + if (me->netid != 0) + peers(me->recent_worlds.back(), PEER_SAME_WORLD, [&](ENetPeer &p) { const ::peer *t = static_cast<::peer*>(p.data); if (t && t->netid == netid) target = &p; }); + if (!target) { tell(event.peer, "`4That player isn't here anymore.``"); return true; } + + if (btn == "trade") trade_request(event, *target); + else if (btn == "friend_add") friend_request(event, *target); + else pull_player(event, *target); + return true; +} diff --git a/include/commands/playeractions.hpp b/include/commands/playeractions.hpp new file mode 100644 index 00000000..93a26af7 --- /dev/null +++ b/include/commands/playeractions.hpp @@ -0,0 +1,4 @@ +#pragma once + +/* buttons of the player wrench menu: trade, add friend, pull and the staff tools. @return true if handled */ +extern bool wrench_action(ENetEvent &event, const ::hPipe &hPipe); diff --git a/include/commands/private.cpp b/include/commands/private.cpp new file mode 100644 index 00000000..9a3f663e --- /dev/null +++ b/include/commands/private.cpp @@ -0,0 +1,63 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/world.hpp" +#include "private.hpp" + +/* /private -> toggles the current world between public and private + * /private 1 -> force private + * /private 0 -> force public + */ +void private_cmd(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < MODERATOR) + { + on::ConsoleMessage(event.peer, "`4Only staff can use this.``"); + return; + } + + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + + auto world = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + if (world == worlds.end()) return; + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + bool make_private; + if (arg.empty()) + { + make_private = static_cast(world->is_public); // toggle + } + else + { + std::erase_if(arg, [](char c){ return !std::isdigit(static_cast(c)); }); + if (arg.empty()) + { + on::ConsoleMessage(event.peer, "`4Usage: /private or /private {0|1}``"); + return; + } + make_private = (std::stoi(arg) != 0); + } + + world->is_public = !make_private; + + // keep the world lock tile's flag in sync + for (::block &block : world->blocks) + { + if (block.fg == 0) continue; + if (world->is_public) + block.state[2] |= S_PUBLIC; + else block.state[2] &= ~S_PUBLIC; + } + + on::ConsoleMessage(event.peer, + std::format("`2`w{}`` is now {}.``", + world->name, make_private ? "`4PRIVATE" : "`$PUBLIC")); +} diff --git a/include/commands/private.hpp b/include/commands/private.hpp new file mode 100644 index 00000000..f58a2a0a --- /dev/null +++ b/include/commands/private.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void private_cmd(ENetEvent& event, const std::string_view text); diff --git a/include/commands/pull.cpp b/include/commands/pull.cpp new file mode 100644 index 00000000..f4e18ea0 --- /dev/null +++ b/include/commands/pull.cpp @@ -0,0 +1,88 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "tools/bubble.hpp" +#include "database/world.hpp" +#include "pull.hpp" + +static std::string lower_of(std::string s) +{ + for (char &c : s) c = static_cast(std::tolower(static_cast(c))); + return s; +} + +ENetPeer *find_in_world(const std::string &world, const std::string &name, bool &ambiguous) +{ + ambiguous = false; + const std::string want = lower_of(name); + ENetPeer *exact = nullptr, *prefix = nullptr; + int prefixes = 0; + peers(world, PEER_SAME_WORLD, [&](ENetPeer &p) + { + const ::peer *t = static_cast<::peer*>(p.data); + if (!t || t->growid.empty()) return; + const std::string have = lower_of(t->growid); + if (have == want) exact = &p; + else if (want.size() >= 3 && have.starts_with(want)) { prefix = &p; ++prefixes; } + }); + if (exact) return exact; + if (prefixes > 1) ambiguous = true; + return prefixes == 1 ? prefix : nullptr; +} + +bool pull_player(ENetEvent &event, ENetPeer &target) +{ + ::peer *me = static_cast<::peer*>(event.peer->data); + ::peer *t = static_cast<::peer*>(target.data); + if (me->netid == 0) return false; + + auto world = std::ranges::find(worlds, me->recent_worlds.back(), &::world::name); + const bool staff = me->role >= MODERATOR; + const bool owner = world != worlds.end() && world->owner != 0 && world->owner == me->user_id; + if (!staff && !owner) + { + tell(event.peer, "`4Only the world owner can pull players.``"); + return false; + } + if (t == me) { tell(event.peer, "`4You can't pull yourself.``"); return false; } + if (t->netid == 0 || t->recent_worlds.back() != me->recent_worlds.back()) + { + tell(event.peer, "`4They have to be in this world.``"); + return false; + } + if (t->role >= MODERATOR && t->role >= me->role) + { + tell(event.peer, "`4You can't pull staff.``"); + return false; + } + + t->pos = me->pos; + send_varlist(&target, { "OnSetPos", CL_Vec2f{ me->pos.x, me->pos.y } }, t->netid); + const std::string who = (me->role >= DEVELOPER) ? "`6Dev``" : (me->role >= MODERATOR) ? "`5Mod``" : "`2world owner``"; + send_varlist(&target, { "OnAddNotification", "interface/atomic_button.rttex", std::format("`wYou were pulled by a {}!``", who), "audio/hub_open.wav", 0u }); // @note popup like /summon + on::ConsoleMessage(&target, std::format("`5You were pulled by a {}.``", who)); + on::ConsoleMessage(event.peer, std::format("`2Pulled `w{}``.``", t->growid)); + return true; +} + +/* /pull {player} - the first letters are enough */ +void pull_cmd(ENetEvent &event, const std::string_view text) +{ + ::peer *me = static_cast<::peer*>(event.peer->data); + if (me->netid == 0) { tell(event.peer, "`4You must be in a world.``"); return; } + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + while (!arg.empty() && std::isspace(static_cast(arg.back()))) arg.pop_back(); + if (arg.empty()) { tell(event.peer, "`4Usage: /pull {player}`` - the first 3 letters are enough."); return; } + + bool ambiguous = false; + ENetPeer *found = find_in_world(me->recent_worlds.back(), arg, ambiguous); + if (!found) + { + tell(event.peer, ambiguous ? "`4More than one player starts with that - type more letters.``" + : std::format("`4Nobody called `w{}`` is in this world.``", arg)); + return; + } + pull_player(event, *found); +} diff --git a/include/commands/pull.hpp b/include/commands/pull.hpp new file mode 100644 index 00000000..9e756ac0 --- /dev/null +++ b/include/commands/pull.hpp @@ -0,0 +1,8 @@ +#pragma once + +/* a player in this world by exact name, or by the first letters (3 or more, must be unique) */ +extern ENetPeer *find_in_world(const std::string &world, const std::string &name, bool &ambiguous); + +/* pulls a player in the same world to where you stand (world owners, mods and devs) */ +extern bool pull_player(ENetEvent &event, ENetPeer &target); +extern void pull_cmd(ENetEvent &event, const std::string_view text); diff --git a/include/commands/punch.cpp b/include/commands/punch.cpp index 6fa2d9aa..4d628010 100644 --- a/include/commands/punch.cpp +++ b/include/commands/punch.cpp @@ -2,10 +2,103 @@ #include "onVariant/SetClothing.hpp" #include "onVariant/ConsoleMessage.hpp" +#include "database/items.hpp" #include "punch.hpp" +/* punch effects by item name, for items not in get_punch_id's switch. + * names are compared lowercase, letters and digits only. */ +static std::string punch_norm(std::string_view s) +{ + std::string out{}; + for (char c : s) + if (std::isalnum(static_cast(c))) out += static_cast(std::tolower(static_cast(c))); + return out; +} + +static u_char punch_by_name(u_int item_id) +{ + static std::unordered_map by_id{}; + static bool built = false; + if (!built) + { + built = true; + static const std::pair table[]{ + {1, "Cyclopean Visor"}, {2, "Heart Bow"}, {3, "Tommy Gun"}, {4, "Elvish Longbow"}, + {5, "Sawed-Off Shotgun"}, {6, "Dragon Hand"}, {7, "Reanimator Remote"}, + {8, "Death Ray"}, {9, "Six Shooter"}, {10, "Focused Eyes"}, {11, "Ice Dragon Hand"}, + {13, "Atomic Shadow Scythe"}, {14, "Pet Leprechaun"}, {15, "Battle Trout"}, + {16, "Fiesta Dragon"}, {17, "Squirt Gun"}, {18, "Keytar"}, {19, "Flamethrower"}, + {20, "Legendbot-009"}, {21, "Dragon of Legend"}, {22, "Zeus' Lightning Bolt"}, {23, "Violet Protodrake Leash"}, {24, "Ring of Force"}, {25, "Ice Calf Leash"}, {28, "Ecto Pack"}, {29, "Carrot Sword"}, {30, "Claw Glove"}, + {31, "Cosmic Unicorn Bracelet"}, {32, "Black Crystal Dragon"}, {33, "Mighty Snow Rod"}, {34, "Tiny Tank"}, + {35, "Crystal Glaive"}, {36, "Heavenly Scythe"}, {37, "Heartbreaker Hammer"}, {38, "Diamond Dragon"}, + {39, "Burning Eyes"}, {40, "Diamond Horns"}, {41, "Marshmallow Basket"}, {42, "Flame Scythe"}, + {43, "Legendary Katana"}, {44, "Electric Bow"}, {45, "Pineapple Launcher"}, {46, "Demonic Arm"}, + {47, "The Gungnir"}, {49, "Poseidon's Trident"}, + {50, "Wizard's Staff"}, {53, "Tennis Racquet"}, + {54, "Baseball Glove"}, {55, "Basketball"}, {57, "Fire Hose"}, + {58, "Soul Orb"}, {59, "Strawberry Slime"}, {60, "Axe of Winter"}, {62, "T-Shirt Cannon"}, + {64, "Party Blaster"}, {65, "Serpent Staff"}, {66, "Spring Bouquet"}, + {68, "Toy Lock-Bot"}, {69, "Neutron Gun"}, {71, "Solsascarf"}, {72, "Skull Launcher"}, + {73, "AK-8087"}, {76, "Adventurer's Whip"}, {77, "Burning Hands"}, + {78, "Balloon Launcher"}, {80, "Rayman's Fist"} + }; + + std::unordered_map by_name{}; + for (const auto &[fx, name] : table) by_name.emplace(punch_norm(name), fx); + + std::vector matched{}; + for (const ::item &it : items) + if (auto f = by_name.find(punch_norm(it.raw_name)); f != by_name.end()) + { + by_id.emplace(static_cast(it.id), f->second); + matched.emplace_back(f->first); + } + + for (const auto &[fx, name] : table) + if (std::ranges::find(matched, punch_norm(name)) == matched.end()) + (void)0; // @note debug line removed + (void)0; // @note debug line removed + } + const auto f = by_id.find(item_id); + return (f != by_id.end()) ? f->second : 0; +} +/* item -> punch effect, from GrowServer (github.com/StileDevs/GrowServer, MIT License). checked first. */ +static const std::unordered_map punch_table{ + {8006,19}, {8008,19}, {8010,19}, {8012,19}, {4748,40}, {2596,43}, {4746,75}, {7216,94}, {9348,119}, {8372,121}, + {10330,178}, {11116,220}, {11250,228}, {11814,241}, {1780,20}, {7408,61}, {6892,87}, {11760,237}, {11548,242}, {11552,242}, + {11704,245}, {11706,245}, {2220,34}, {2878,52}, {2880,52}, {6298,84}, {6966,88}, {7384,98}, {7950,113}, {9136,139}, + {9138,140}, {11308,200}, {10890,202}, {10990,205}, {11140,225}, {10128,171}, {10336,39}, {11142,223}, {11506,245}, {11508,245}, + {11562,245}, {11768,245}, {11882,245}, {1204,10}, {138,1}, {1952,26}, {2476,39}, {5002,19}, {5006,56}, {7402,100}, + {7836,112}, {7838,112}, {7840,112}, {7842,112}, {8816,128}, {8818,128}, {8820,128}, {8822,128}, {366,2}, {1464,2}, + {472,3}, {594,4}, {10130,4}, {5424,4}, {5456,4}, {4136,4}, {768,5}, {900,6}, {7760,6}, {9272,6}, + {7758,6}, {910,7}, {930,8}, {1010,8}, {6382,8}, {1016,9}, {1378,11}, {1484,13}, {1512,14}, {1648,14}, + {1542,15}, {1576,16}, {1676,17}, {7504,17}, {1710,18}, {4644,18}, {1714,18}, {1712,18}, {6044,18}, {1570,18}, + {1748,19}, {1782,21}, {1804,22}, {1868,23}, {1998,23}, {1874,24}, {1946,25}, {2800,25}, {2854,26}, {1956,27}, + {2908,29}, {6312,29}, {8554,29}, {3162,29}, {4956,29}, {3466,29}, {4166,29}, {4506,29}, {2952,29}, {3932,29}, + {3934,29}, {8732,29}, {3108,29}, {1980,30}, {2066,31}, {11082,31}, {11080,31}, {11078,31}, {2212,32}, {2218,33}, + {2266,35}, {2386,36}, {2388,37}, {2450,38}, {2512,41}, {2572,42}, {2592,43}, {9396,43}, {2720,44}, {2752,45}, + {2754,46}, {2756,47}, {2802,49}, {2866,50}, {2876,51}, {2906,53}, {4170,53}, {2886,54}, {2890,55}, {2910,56}, + {3066,57}, {3124,58}, {3168,59}, {3214,60}, {9194,60}, {3238,61}, {3274,62}, {3300,64}, {3418,65}, {3476,66}, + {3596,67}, {3686,68}, {3716,69}, {4474,72}, {4464,73}, {4778,76}, {6026,76}, {4996,77}, {3680,77}, {4840,78}, + {5480,80}, {6110,81}, {6308,82}, {6310,83}, {6756,85}, {7044,86}, {7088,89}, {11020,89}, {7098,90}, {9032,90}, + {9738,92}, {3166,93}, {9340,95}, {7392,96}, {7414,99}, {7424,101}, {7470,102}, {7488,103}, {7586,104}, {7646,104}, + {7650,105}, {6804,106}, {7568,107}, {7570,107}, {7572,107}, {7574,107}, {7668,108}, {7660,109}, {9060,109}, {7736,111}, + {9116,111}, {9118,111}, {7826,111}, {7828,111}, {11440,111}, {11442,111}, {11312,111}, {7830,111}, {7832,111}, {10670,111}, + {9120,111}, {9122,111}, {10680,111}, {10626,111}, {10578,111}, {10334,111}, {11380,111}, {11326,111}, {7912,111}, {11298,111}, + {10498,111}, {8002,114}, {8022,116}, {8036,118}, {8038,120}, {8910,129}, {8942,130}, {8944,131}, {5276,131}, {8432,132}, + {8434,132}, {8436,132}, {8950,132}, {8946,133}, {8960,134}, {9058,136}, {9082,137}, {9304,137}, {9066,138}, {9256,144}, + {9236,145}, {9342,146}, {9378,148}, {9410,150}, {9606,152}, {9716,153}, {10064,168}, {10046,169}, {10050,170}, {10388,180}, + {10442,184}, {10506,185}, {10652,188}, {10676,191}, {10694,193}, {10714,194}, {10724,195}, {10722,196}, {10888,199}, {10886,200}, + {10922,203}, {10998,206}, {10952,207}, {11000,208}, {11006,209}, {11052,211}, {10960,212}, {10956,213}, {10958,214}, {10954,215}, + {11076,216}, {11084,217}, {11118,218}, {11120,219}, {11158,221}, {11162,222}, {11248,226}, {11240,227}, {11284,229}, {11292,231}, + {11316,234}, {11354,236}, {11464,237}, {11438,237}, {11716,237}, {11718,237}, {11674,237}, {11630,237}, {11786,237}, {11872,237}, + {1960,28}, {5206,79}, {7196,95}, {9006,135}, {9172,141}, {10210,172}, {10754,197}, {11046,210}, {11314,233}, {1440,12}, + {4208,39}, {9462,151}, {4150,31}, {4290,71}, {7192,91}, {7136,92}, {9254,143}, {9376,149}, {11232,224}, {11324,235}, + {11818,248}, {11876,248} +}; u_char get_punch_id(u_int item_id) { + if (const auto t = punch_table.find(item_id); t != punch_table.end()) return t->second; switch (item_id) { case 138: case 2976: return 1; // @note https://growtopia.fandom.com/wiki/Mods/Eye_Beam @@ -24,7 +117,7 @@ u_char get_punch_id(u_int item_id) case 3066: case 5206: case 7504: case 10288: return 17; // @note https://growtopia.fandom.com/wiki/Mods/Fire_Hose case 2636: case 2908: case 3070: case 3108: case 3466: case 2952: return 29; // @note https://growtopia.fandom.com/wiki/Mods/Slasher - default: return 0; + default: return punch_by_name(item_id); } } diff --git a/include/commands/removeblock.cpp b/include/commands/removeblock.cpp new file mode 100644 index 00000000..4549c4b9 --- /dev/null +++ b/include/commands/removeblock.cpp @@ -0,0 +1,56 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/world.hpp" +#include "removeblock.hpp" + +/* /removeblock -> clears the tile you are standing on */ +void removeblock(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + + auto world = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + if (world == worlds.end()) return; + + const ::pos tile = pPeer->pos.by_32(true); + const signed x = static_cast(tile.x); + const signed y = static_cast(tile.y); + + if (x < 0 || x >= 100 || y < 0 || y >= 60) + { + on::ConsoleMessage(event.peer, "`4That is outside the world.``"); + return; + } + + ::block &block = world->blocks[cord(x, y)]; + const auto removed_fg = block.fg; + const auto removed_bg = block.bg; + + if (removed_fg == 0 && removed_bg == 0) + { + on::ConsoleMessage(event.peer, std::format("`4Nothing at `w{}``, `w{}``.``", x, y)); + return; + } + + block.reset(); + + send_tile_update(event, { + .id = 0, + .punch = ::pos{ x, y } + }, block, *world); + + on::ConsoleMessage(event.peer, + std::format("`2Removed block at `w{}``, `w{}`` (fg `w{}``, bg `w{}``).``", + x, y, removed_fg, removed_bg)); +} diff --git a/include/commands/removeblock.hpp b/include/commands/removeblock.hpp new file mode 100644 index 00000000..f541e015 --- /dev/null +++ b/include/commands/removeblock.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void removeblock(ENetEvent& event, const std::string_view text); diff --git a/include/commands/renameworld.cpp b/include/commands/renameworld.cpp new file mode 100644 index 00000000..219b92e3 --- /dev/null +++ b/include/commands/renameworld.cpp @@ -0,0 +1,78 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/database.hpp" +#include "database/world.hpp" +#include "renameworld.hpp" + +/* /renameworld {newname} -> renames the world you're standing in */ +void renameworld(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + + auto world = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + if (world == worlds.end()) return; + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + while (!arg.empty() && std::isspace(static_cast(arg.back()))) arg.pop_back(); + + if (arg.empty() || arg.length() > 24) + { + on::ConsoleMessage(event.peer, "`4Usage: /renameworld {newname} (max 24 characters)``"); + return; + } + + for (char &c : arg) c = std::toupper(static_cast(c)); + + for (char c : arg) + { + if (!std::isalnum(static_cast(c))) + { + on::ConsoleMessage(event.peer, "`4Letters and numbers only.``"); + return; + } + } + + if (std::ranges::find(worlds, arg, &::world::name) != worlds.end()) + { + on::ConsoleMessage(event.peer, "`4A world with that name is already loaded.``"); + return; + } + + const std::string old_name = world->name; + + // rename the row, then the in-memory copy + { + ::hStmt hStmt{ "UPDATE world SET name = ? WHERE name = ?" }; + MYSQL_BIND params[2] = { make_bind_in(arg), make_bind_in(old_name) }; + hStmt.bind_param(params); + hStmt.execute(); + } + + world->name = arg; + + // everyone standing here needs their world reference updated + peers(old_name, PEER_SAME_WORLD, [&arg, &old_name](ENetPeer &p) + { + ::peer *pOthers = static_cast<::peer*>(p.data); + if (!pOthers) return; + if (pOthers->recent_worlds.back() == old_name) pOthers->recent_worlds.back() = arg; + on::ConsoleMessage(&p, std::format("`5This world is now called `w{}``.``", arg)); + }); + + printf("[renameworld] %s -> %s\n", old_name.c_str(), arg.c_str()); +} diff --git a/include/commands/renameworld.hpp b/include/commands/renameworld.hpp new file mode 100644 index 00000000..c226b1ce --- /dev/null +++ b/include/commands/renameworld.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void renameworld(ENetEvent& event, const std::string_view text); diff --git a/include/commands/reset.cpp b/include/commands/reset.cpp new file mode 100644 index 00000000..6196507d --- /dev/null +++ b/include/commands/reset.cpp @@ -0,0 +1,46 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "worldreload.hpp" +#include "database/world.hpp" +#include "reset.hpp" + +void reset(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can reset a world.``"); + return; + } + + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world to reset it.``"); + return; + } + + const std::string world_name = pPeer->recent_worlds.back(); + + auto world = std::ranges::find(worlds, world_name, &::world::name); + if (world == worlds.end()) + { + on::ConsoleMessage(event.peer, "`4World not found.``"); + return; + } + + // wipe the world back to a freshly generated state + world->blocks.clear(); + world->objects.clear(); + world->signs.clear(); + world->trees.clear(); + world->owner = 0; + world->access.fill(0); + world->last_object_uid = 0; + generate_world(*world); + + on::ConsoleMessage(event.peer, std::format("`2World `w{}`` has been reset.``", world_name)); + + // everyone in the world re-enters so they all receive the new map + reload_world_all(world_name); +} diff --git a/include/commands/reset.hpp b/include/commands/reset.hpp new file mode 100644 index 00000000..2d079797 --- /dev/null +++ b/include/commands/reset.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void reset(ENetEvent& event, const std::string_view text); diff --git a/include/commands/restart.cpp b/include/commands/restart.cpp new file mode 100644 index 00000000..1c65edb9 --- /dev/null +++ b/include/commands/restart.cpp @@ -0,0 +1,43 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/world.hpp" +#include "restart.hpp" + +/* /restart -> warns everyone, saves, and shuts the server down cleanly */ +void restart(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + while (!arg.empty() && std::isspace(static_cast(arg.back()))) arg.pop_back(); + + if (arg != "confirm") + { + on::ConsoleMessage(event.peer, + "`4This will shut the server down. Type `w/restart confirm`` to go ahead.``"); + return; + } + + const std::string line = std::format( + "`4** `wServer shutting down`` `4** `oSaving worlds, back shortly.``"); + + peers("", peer_condition::PEER_ALL, [&line](ENetPeer &p) + { + on::ConsoleMessage(&p, line); + }); + + // save every loaded world before we go + for (::world &w : worlds) w.save(); + + printf("[restart] requested by %s\n", pPeer->growid.c_str()); + raise(SIGINT); // @note same path as pressing Ctrl+C +} diff --git a/include/commands/restart.hpp b/include/commands/restart.hpp new file mode 100644 index 00000000..1f2c8f06 --- /dev/null +++ b/include/commands/restart.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void restart(ENetEvent& event, const std::string_view text); diff --git a/include/commands/saveworld.cpp b/include/commands/saveworld.cpp new file mode 100644 index 00000000..08b87020 --- /dev/null +++ b/include/commands/saveworld.cpp @@ -0,0 +1,29 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/world.hpp" +#include "saveworld.hpp" + +/* /save -> writes the current world to the database without leaving it */ +void saveworld(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + + auto world = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + if (world == worlds.end()) return; + + world->save(); + + on::ConsoleMessage(event.peer, std::format("`2Saved `w{}``.``", world->name)); +} diff --git a/include/commands/saveworld.hpp b/include/commands/saveworld.hpp new file mode 100644 index 00000000..bd2aad6f --- /dev/null +++ b/include/commands/saveworld.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void saveworld(ENetEvent& event, const std::string_view text); diff --git a/include/commands/sb.cpp b/include/commands/sb.cpp index 76ca40b6..7ad1a2c0 100644 --- a/include/commands/sb.cpp +++ b/include/commands/sb.cpp @@ -1,24 +1,29 @@ #include "pch.hpp" #include "onVariant/ConsoleMessage.hpp" - +#include "jammers.hpp" #include "sb.hpp" void sb(ENetEvent& event, const std::string_view text) { - const std::string message{ text.substr(sizeof("sb ")-1) }; + if (text.size() <= sizeof("sb ") - 1) return; + const std::string message{ text.substr(sizeof("sb ") - 1) }; ::peer *pPeer = static_cast<::peer*>(event.peer->data); auto world = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); if (world == worlds.end()) return; - std::string display = pPeer->recent_worlds.back(); - peers("", PEER_ALL, [&event, &pPeer, message, display](ENetPeer& peer) - { - on::ConsoleMessage(event.peer, - std::format( - "CP:0_PL:0_OID:_CT:[SB]_ `5** from ({}```5) in [```${}```5] ** : ```${}``", - pPeer->display_growid, display, message - ) - ); - }); + // @note mods and devs always show as JAMMED, and so does a world with a jammer + const bool jammed = pPeer->role >= MODERATOR || !jam_suffix(world->name).empty(); + const std::string where = jammed ? "`4JAMMED!" : world->name; + const char *chat_color = (pPeer->role >= DEVELOPER) ? "`5" : (pPeer->role >= MODERATOR) ? "`^" : "`w"; // @note same colour as normal chat + const std::string name = // @note rank colour, not the world colour (owner green etc.) + !pPeer->nickname.empty() ? std::format("`w{}``", pPeer->nickname) : + pPeer->role >= DEVELOPER ? std::format("`6@{}``", pPeer->growid) : + pPeer->role >= MODERATOR ? std::format("`5@{}``", pPeer->growid) : + std::format("`w{}``", pPeer->growid); + const std::string line = std::format( + "CP:0_PL:0_OID:_CT:[SB]_ `5** from ({}```5) in [```${}```5] ** : ``{}{}``", + name, where, chat_color, message); + + peers("", PEER_ALL, [&line](ENetPeer &peer) { on::ConsoleMessage(&peer, line); }); // @note to everyone online, not just you } \ No newline at end of file diff --git a/include/commands/scrolls.cpp b/include/commands/scrolls.cpp new file mode 100644 index 00000000..9ed1b180 --- /dev/null +++ b/include/commands/scrolls.cpp @@ -0,0 +1,100 @@ +#include "pch.hpp" +#include +#include "onVariant/ConsoleMessage.hpp" +#include "tools/bubble.hpp" +#include "tools/random.hpp" +#include "database/items.hpp" +#include "buffs.hpp" +#include "scrolls.hpp" + +static constexpr short SECRET = 1050; // @note Secret Of Growtopia +static constexpr short MAP_FRAGMENT = 7590; + +static int count_of(const ::peer &p, short id) +{ + for (const ::slot &s : p.slots) if (s.id == id) return s.count; + return 0; +} + +/* half written tips, half real facts from items.dat */ +static std::string random_secret() +{ + static const char *lines[] = { + "Lock your world before you build anything you'd miss.", + "Nobody who asks to 'hold' your items plans to give them back.", + "Rarer trees give more XP when you harvest them.", + "Two seeds spliced together can grow something neither could alone.", + "The Chemical Combiner rewards the curious.", + "Punching blocks is honest work. Punching friends is less so.", + "Lava doesn't care how rare your clothes are.", + "Some treasure chests are buried deeper than others.", + "Type /? to see what you can do.", + "Growganoth remembers every offering.", + "If you can read this, you just threw a secret to the winds.", + "The deepest caves hide golden hoards.", + "Beach treasure lies under the sand, not in the sea.", + "Snowy peaks guard the oldest secrets.", + "Wizards never explain their wands.", + "A hundred scraps of paper can point the way to treasure.", + "Fertilizer is patience in a bottle.", + "Shh. This one stays between us.", + }; + static const std::vector from_file = [] + { + std::vector v{}; + std::ifstream in("secrets.txt"); + for (std::string line; std::getline(in, line); ) + { + while (!line.empty() && (line.back() == '\r' || line.back() == ' ')) line.pop_back(); + if (!line.empty()) v.push_back(line); + } + (void)0; // @note debug line removed + return v; + }(); + if (RandomRange(0, 2) == 0) + return from_file.empty() ? std::string{ lines[RandomRange(0, static_cast(std::size(lines)))] } + : from_file[RandomRange(0, static_cast(from_file.size()))]; + + for (int tries = 0; tries < 50; ++tries) + { + const ::item &it = items[RandomRange(0, static_cast(items.size()))]; + if (it.raw_name.empty() || it.type == type::SEED) continue; + switch (RandomRange(0, 3)) + { + case 0: if (it.rarity > 0 && it.rarity < 999) return std::format("{} has a rarity of {}.", it.raw_name, it.rarity); break; + case 1: if (it.splice[0] && it.splice[1]) return std::format("{} can be spliced from {} and {}.", it.raw_name, id_to_item(it.splice[0]).raw_name, id_to_item(it.splice[1]).raw_name); break; + case 2: if (!it.splice[0]) return std::format("{} can't be created by splicing seeds!", it.raw_name); break; + } + } + return lines[0]; +} + +bool scroll_use(ENetEvent &event, const ::item &item) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (item.id == SECRET) + { + modify_item_inventory(event, ::slot(SECRET, -1)); + pPeer->add_xp(event, consumable_xp(*pPeer)); + on::ConsoleMessage(event.peer, std::format("`1The Secret Of Growtopia reveals:`` `w{}``", random_secret())); + return true; + } + if (item.id == MAP_FRAGMENT) + { + const int have = count_of(*pPeer, MAP_FRAGMENT); + if (have < 100) + { + tell(event.peer, std::format("`4You need `w100`` Map Fragments to piece together a map (you have {}).``", have)); + return true; + } + const int roll = RandomRange(0, 100); + const short map = roll < 60 ? 7602 /*Treasure Map*/ : roll < 69 ? 7604 /*Map of Tyr*/ : roll < 78 ? 7606 /*Map of Odin*/ + : roll < 87 ? 7608 /*Map of Thor*/ : roll < 96 ? 7610 /*Map of Frigg*/ : 7612 /*Legendary Map - Untouched*/; + modify_item_inventory(event, ::slot(MAP_FRAGMENT, -100)); + modify_item_inventory(event, ::slot(map, 1)); + tell(event.peer, std::format("`2You pieced together a `w{}``!``", id_to_item(static_cast(map)).raw_name)); + return true; + } + return false; +} diff --git a/include/commands/scrolls.hpp b/include/commands/scrolls.hpp new file mode 100644 index 00000000..841d4ea2 --- /dev/null +++ b/include/commands/scrolls.hpp @@ -0,0 +1,3 @@ +#pragma once + +extern bool scroll_use(ENetEvent &event, const ::item &item); diff --git a/include/commands/search.cpp b/include/commands/search.cpp new file mode 100644 index 00000000..7f636d45 --- /dev/null +++ b/include/commands/search.cpp @@ -0,0 +1,57 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/items.hpp" +#include "search.hpp" + +/* /search {text} -> lists items whose name contains that text, with their ids */ +void search_cmd(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + while (!arg.empty() && std::isspace(static_cast(arg.back()))) arg.pop_back(); + + if (arg.length() < 2) + { + on::ConsoleMessage(event.peer, "`4Usage: /search {at least 2 characters}``"); + return; + } + + std::string needle = arg; + for (char &c : needle) c = std::tolower(static_cast(c)); + + std::size_t found = 0; + for (const ::item &it : items) + { + if (it.raw_name.empty()) continue; + + std::string hay = it.raw_name; + for (char &c : hay) c = std::tolower(static_cast(c)); + + if (hay.find(needle) == std::string::npos) continue; + + ++found; + if (found > 30) + { + on::ConsoleMessage(event.peer, "`o...more matches, narrow your search."); + break; + } + + on::ConsoleMessage(event.peer, + std::format("`w{}`` `o- id `2{}`` type `w{}``", it.raw_name, (int)it.id, (int)it.type)); + } + + if (found == 0) + on::ConsoleMessage(event.peer, std::format("`4Nothing matching `w{}``.``", arg)); + else + on::ConsoleMessage(event.peer, std::format("`o{} match{}.", found, found == 1 ? "" : "es")); +} diff --git a/include/commands/search.hpp b/include/commands/search.hpp new file mode 100644 index 00000000..93daa144 --- /dev/null +++ b/include/commands/search.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void search_cmd(ENetEvent& event, const std::string_view text); diff --git a/include/commands/setowner.cpp b/include/commands/setowner.cpp new file mode 100644 index 00000000..f50eb326 --- /dev/null +++ b/include/commands/setowner.cpp @@ -0,0 +1,72 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "onVariant/NameChanged.hpp" +#include "database/database.hpp" +#include "database/world.hpp" +#include "lockaccess.hpp" +#include "setowner.hpp" + +/* /setowner {player} -> hands this world to that account + * /setowner -> clears ownership + */ +void setowner(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + + auto world = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + if (world == worlds.end()) return; + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + while (!arg.empty() && std::isspace(static_cast(arg.back()))) arg.pop_back(); + + if (arg.empty()) + { + world->owner = 0; + refresh_display_names(*world); + on::ConsoleMessage(event.peer, std::format("`2`w{}`` has no owner now.``", world->name)); + return; + } + + std::string lower_name = arg; + for (char &c : lower_name) c = std::tolower(static_cast(c)); + + // look the account up by name + signed uid = 0; + { + ::hStmt hStmt{ "SELECT uid FROM peer WHERE growid = ? LIMIT 1" }; + MYSQL_BIND param = make_bind_in(lower_name); + hStmt.bind_param(¶m); + + MYSQL_BIND result = make_bind_out(uid); + mysql_stmt_bind_result(hStmt.pStmt, &result); + hStmt.execute(); + hStmt.fetch(); + } + + if (uid == 0) + { + on::ConsoleMessage(event.peer, std::format("`4No account named `w{}`` exists.``", arg)); + return; + } + + world->owner = uid; + refresh_display_names(*world); + + on::ConsoleMessage(event.peer, + std::format("`2`w{}`` now belongs to `w{}``.``", world->name, arg)); +} diff --git a/include/commands/setowner.hpp b/include/commands/setowner.hpp new file mode 100644 index 00000000..0283cd00 --- /dev/null +++ b/include/commands/setowner.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void setowner(ENetEvent& event, const std::string_view text); diff --git a/include/commands/setspawn.cpp b/include/commands/setspawn.cpp new file mode 100644 index 00000000..9232bfaa --- /dev/null +++ b/include/commands/setspawn.cpp @@ -0,0 +1,32 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/world.hpp" +#include "setspawn.hpp" + +void setspawn(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + + auto world = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + if (world == worlds.end()) return; + + world->spawn = pPeer->pos; + pPeer->rest_pos = pPeer->pos; + + const ::pos tile = pPeer->pos.by_32(true); + on::ConsoleMessage(event.peer, + std::format("`2Spawn set to `w{}``, `w{}``.``", + static_cast(tile.x), static_cast(tile.y))); +} diff --git a/include/commands/setspawn.hpp b/include/commands/setspawn.hpp new file mode 100644 index 00000000..f6613717 --- /dev/null +++ b/include/commands/setspawn.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void setspawn(ENetEvent& event, const std::string_view text); diff --git a/include/commands/showplayer.cpp b/include/commands/showplayer.cpp new file mode 100644 index 00000000..d88cae00 --- /dev/null +++ b/include/commands/showplayer.cpp @@ -0,0 +1,75 @@ +#include "pch.hpp" +#include "onVariant/Spawn.hpp" +#include "onVariant/SetClothing.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/world.hpp" +#include "showplayer.hpp" + +/* /showplayer -> reappear from /invis with a particle burst */ +void showplayer(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + + const bool was_hidden = (pPeer->state & S_INVISIBLE) == S_INVISIBLE; + if (!was_hidden) + { + on::ConsoleMessage(event.peer, "`oYou're already visible.``"); + return; + } + pPeer->state &= ~(S_INVISIBLE | S_GHOST); // @note /invis also turned on ghost - turn both off + + // put the sprite back for everyone else + peers(pPeer->recent_worlds.back(), PEER_SAME_WORLD, [&event, pPeer](ENetPeer& peer) + { + ::peer *pOthers = static_cast<::peer*>(peer.data); + if (pOthers->user_id == pPeer->user_id) + { + on::SetClothing(peer); + return; + } + + on::Spawn(peer, pPeer->netid, pPeer->user_id, pPeer->pos, + pPeer->display_growid, pPeer->country, + pPeer->role, pPeer->role >= DEVELOPER, false); + on::SetClothing(peer, *pPeer); // @note clothes + normal colour, otherwise they show up grey + send_varlist(&peer, { "OnSetPos", CL_Vec2f{ pPeer->pos.x, pPeer->pos.y } }, pPeer->netid); + }); + + // burst of particles at your feet, seen by the whole world + const ::pos burst = pPeer->pos; + // three rings at increasing radius, offset so they interleave + const float radii[3] = { 40.0f, 70.0f, 100.0f }; + for (int r = 0; r < 3; ++r) + { + const int count = 10 + r * 4; + for (int i = 0; i < count; ++i) + { + const float angle = (3.14159265f * 2.0f) * (static_cast(i) / count) + + (r * 0.35f); + const ::pos at{ burst.x + std::cos(angle) * radii[r], + burst.y + std::sin(angle) * radii[r] }; + send_particle_effect(event, at, { 0x0e, 0x03 }); + } + } + send_particle_effect(event, burst, { 0x0e, 0x03 }); + + // mod-reveal sound, heard by everyone in the world + peers(pPeer->recent_worlds.back(), PEER_SAME_WORLD, [pPeer](ENetPeer &p) + { + send_varlist(&p, { "OnPlayPositioned", "audio/magic.wav" }, pPeer->netid); + }); + + on::ConsoleMessage(event.peer, "`2You reappear in a shower of sparks.``"); +} diff --git a/include/commands/showplayer.hpp b/include/commands/showplayer.hpp new file mode 100644 index 00000000..0f9b9437 --- /dev/null +++ b/include/commands/showplayer.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void showplayer(ENetEvent& event, const std::string_view text); diff --git a/include/commands/shutdown.cpp b/include/commands/shutdown.cpp new file mode 100644 index 00000000..d36543c7 --- /dev/null +++ b/include/commands/shutdown.cpp @@ -0,0 +1,89 @@ +#include "pch.hpp" +#include +#include "onVariant/ConsoleMessage.hpp" +#include "database/world.hpp" +#include "shutdown.hpp" + +static std::time_t shutdown_at = 0; // @note 0 = no shutdown planned +static long long last_announced = -1; + +static void announce(const std::string &text, bool overlay) +{ + peers("", peer_condition::PEER_ALL, [&](ENetPeer &p) + { + const ::peer *t = static_cast<::peer*>(p.data); + if (!t || t->growid.empty()) return; + on::ConsoleMessage(&p, text); + if (overlay) send_varlist(&p, { "OnTextOverlay", text }); + }); +} + +static std::string readable(long long secs) +{ + if (secs >= 120) return std::format("{} minutes", (secs + 30) / 60); + if (secs == 60) return "1 minute"; + return std::format("{} second{}", secs, secs == 1 ? "" : "s"); +} + +/* /shutdown [seconds] countdown (default 60) then saves everything and stops the server + * /shutdown cancel stops the countdown */ +void shutdown_cmd(ENetEvent &event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + while (!arg.empty() && std::isspace(static_cast(arg.back()))) arg.pop_back(); + + if (arg == "cancel") + { + if (shutdown_at == 0) { on::ConsoleMessage(event.peer, "`4No shutdown is planned.``"); return; } + shutdown_at = 0; + last_announced = -1; + announce("`2** The shutdown was cancelled **``", true); + return; + } + + long long secs = 60; + if (!arg.empty()) + { + if (!std::ranges::all_of(arg, [](char c) { return std::isdigit(static_cast(c)) != 0; }) || arg.size() > 5) + { + on::ConsoleMessage(event.peer, "`4Usage: /shutdown [seconds]`` or `w/shutdown cancel``"); + return; + } + secs = std::clamp(std::stoll(arg), 5LL, 3600LL); + } + + shutdown_at = std::time(nullptr) + secs; + last_announced = secs; + printf("[shutdown] %s started a countdown: %lld seconds\n", pPeer->growid.c_str(), secs); + announce(std::format("`4** Server shutting down in {}! ** `oPlease finish up.``", readable(secs)), true); +} + +void shutdown_tick(std::time_t now) +{ + if (shutdown_at == 0) return; + const long long left = static_cast(shutdown_at) - static_cast(now); + + if (left <= 0) + { + announce("`4** Server shutting down now ** `oSaving worlds, back shortly.``", true); + for (::world &w : worlds) w.save(); + shutdown_at = 0; + printf("[shutdown] countdown finished\n"); + raise(SIGINT); // @note same path as pressing Ctrl+C + return; + } + + static constexpr long long marks[]{ 3600, 1800, 900, 600, 300, 120, 60, 30, 15, 10, 5, 4, 3, 2, 1 }; + if (std::ranges::find(marks, left) == std::end(marks) || left == last_announced) return; + last_announced = left; + announce(std::format("`4** Server shutting down in {}! **``", readable(left)), left <= 10); +} diff --git a/include/commands/shutdown.hpp b/include/commands/shutdown.hpp new file mode 100644 index 00000000..84efa8a6 --- /dev/null +++ b/include/commands/shutdown.hpp @@ -0,0 +1,4 @@ +#pragma once + +extern void shutdown_cmd(ENetEvent &event, const std::string_view text); +extern void shutdown_tick(std::time_t now); // @note once a second: announces the countdown and shuts down at 0 diff --git a/include/commands/skin.cpp b/include/commands/skin.cpp index 5b79aee8..0310d072 100644 --- a/include/commands/skin.cpp +++ b/include/commands/skin.cpp @@ -9,6 +9,12 @@ void skin(ENetEvent& event, const std::string_view text) const std::string id{ text.substr(sizeof("skin ")-1) }; ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + try { pPeer->skin_color = (u_int)stoul(id); diff --git a/include/commands/spawnall.cpp b/include/commands/spawnall.cpp new file mode 100644 index 00000000..db6b2b1d --- /dev/null +++ b/include/commands/spawnall.cpp @@ -0,0 +1,41 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/world.hpp" +#include "spawnall.hpp" + +/* /spawnall -> sends everyone in the world back to the spawn door */ +void spawnall(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < MODERATOR) + { + on::ConsoleMessage(event.peer, "`4Only staff can use this.``"); + return; + } + + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + + auto world = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + if (world == worlds.end()) return; + + const ::pos spawn = world->spawn; + + std::size_t moved = 0; + peers(world->name, PEER_SAME_WORLD, [&](ENetPeer &p) + { + ::peer *pTarget = static_cast<::peer*>(p.data); + if (!pTarget) return; + if (pTarget->role >= MODERATOR) return; // @note mods and devs stay where they are + + pTarget->pos = spawn; + send_varlist(&p, { "OnSetPos", CL_Vec2f{ spawn.x, spawn.y } }, pTarget->netid); + ++moved; + }); + + on::ConsoleMessage(event.peer, std::format("`2Sent `w{}`` player{} to spawn.``", moved, moved == 1 ? "" : "s")); +} diff --git a/include/commands/spawnall.hpp b/include/commands/spawnall.hpp new file mode 100644 index 00000000..11fc3528 --- /dev/null +++ b/include/commands/spawnall.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void spawnall(ENetEvent& event, const std::string_view text); diff --git a/include/commands/sphere.cpp b/include/commands/sphere.cpp new file mode 100644 index 00000000..dc7849cc --- /dev/null +++ b/include/commands/sphere.cpp @@ -0,0 +1,84 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/world.hpp" +#include "sphere.hpp" + +/* /sphere {itemID} {radius} -> draws a filled circle of blocks around you */ +void sphere(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + + auto world = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + if (world == worlds.end()) return; + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + std::string id_str{}, radius_str{}; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) + { + id_str = arg.substr(0, sp); + radius_str = arg.substr(sp + 1); + } + else + { + on::ConsoleMessage(event.peer, "`4Usage: /sphere {itemID} {radius}``"); + return; + } + + auto digits = [](std::string s){ std::erase_if(s, [](char c){ return !std::isdigit(static_cast(c)); }); return s; }; + id_str = digits(id_str); + radius_str = digits(radius_str); + + if (id_str.empty() || radius_str.empty()) + { + on::ConsoleMessage(event.peer, "`4Usage: /sphere {itemID} {radius}``"); + return; + } + + signed id{}, radius{}; + try { id = std::stoi(id_str); radius = std::stoi(radius_str); } + catch (...) { on::ConsoleMessage(event.peer, "`4Those are not valid numbers.``"); return; } + + radius = std::clamp(radius, 1, 25); + + const ::pos tile = pPeer->pos.by_32(true); + const signed cx = static_cast(tile.x); + const signed cy = static_cast(tile.y); + + std::size_t changed = 0; + for (signed dy = -radius; dy <= radius; ++dy) + { + for (signed dx = -radius; dx <= radius; ++dx) + { + if (dx * dx + dy * dy > radius * radius) continue; + + const signed x = cx + dx; + const signed y = cy + dy; + if (x < 0 || x >= 100 || y < 0 || y >= 60) continue; + + ::block &b = world->blocks[cord(x, y)]; + if (b.fg == 6 || b.fg == 8) continue; + + b.fg = static_cast(id); + send_tile_update(event, { .id = static_cast(id), .punch = ::pos{ x, y } }, b, *world); + ++changed; + } + } + + on::ConsoleMessage(event.peer, + std::format("`2Placed `w{}`` blocks in a radius of `w{}``.``", changed, radius)); +} diff --git a/include/commands/sphere.hpp b/include/commands/sphere.hpp new file mode 100644 index 00000000..dbf2f4be --- /dev/null +++ b/include/commands/sphere.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void sphere(ENetEvent& event, const std::string_view text); diff --git a/include/commands/staff.cpp b/include/commands/staff.cpp new file mode 100644 index 00000000..27aa3ccf --- /dev/null +++ b/include/commands/staff.cpp @@ -0,0 +1,139 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "tools/bubble.hpp" +#include "commands/__command.hpp" +#include "commands/curse.hpp" +#include "staff.hpp" + +static std::string rank_name(int role) { return role >= DEVELOPER ? "Developer" : role >= MODERATOR ? "Moderator" : "Player"; } + +static std::string clean(std::string s) +{ + for (char &c : s) if (c == '|' || c == '\n' || c == '\r') c = ' '; + return s; +} + +/* "30" = minutes, or 45s, 30m, 2h, 7d, 1w. @return seconds, -1 if invalid */ +static long long duration(std::string s) +{ + while (!s.empty() && std::isspace(static_cast(s.back()))) s.pop_back(); + if (s.empty()) return -1; + long long mult = 60; + const char unit = static_cast(std::tolower(static_cast(s.back()))); + if (std::isalpha(static_cast(unit))) + { + switch (unit) { case 's': mult = 1; break; case 'm': mult = 60; break; case 'h': mult = 3600; break; + case 'd': mult = 86400; break; case 'w': mult = 604800; break; default: return -1; } + s.pop_back(); + } + if (s.empty() || s.size() > 6 || !std::ranges::all_of(s, [](char c) { return std::isdigit(static_cast(c)) != 0; })) return -1; + return std::stoll(s) * mult; +} + +static ENetPeer *by_netid(const std::string &world, int netid) +{ + ENetPeer *hit = nullptr; + peers(world, PEER_SAME_WORLD, [&](ENetPeer &p) { const ::peer *t = static_cast<::peer*>(p.data); if (t && t->netid == netid) hit = &p; }); + return hit; +} + +static void run(ENetEvent &event, const std::string &text) +{ + const std::string name = text.substr(0, text.find(' ')); + if (auto it = cmd_pool.find(name); it != cmd_pool.end()) it->second(event, text); + else tell(event.peer, "`4That command doesn't exist.``"); +} + +std::string staff_wrench_rows(const ::peer &viewer, const ::peer &t, ENetPeer &tp) +{ + const long long days = t.created_at > 0 ? (static_cast(std::time(nullptr)) - static_cast(t.created_at)) / 86400 : 0; + std::string s = "add_spacer|small|\nadd_label|big|`wStaff``|left|\n"; + s += std::format("add_smalltext|Account `w#{}`` - {} - created `w{}`` days ago|left|\n", t.user_id, rank_name(t.role), days); + s += std::format("add_smalltext|Level `w{}`` - Gems `w{}`` - Tile `w{}``, `w{}``|left|\n", t.level.front(), t.gems, t.pos.by_32(true).x_int(), t.pos.by_32(true).y_int()); + if (viewer.role >= DEVELOPER) + { + char ip[64]{}; + enet_address_get_host_ip(&tp.address, ip, sizeof(ip)); + s += std::format("add_smalltext|IP `w{}``|left|\n", ip); + } + std::string status{}; + if (t.curse_until > std::time(nullptr)) status += std::format("`4Cursed`` ({}) ", curse_time_left(t)); + if (t.frozen || (t.state & S_FROZEN)) status += "`1Frozen`` "; + if (t.state & S_DUCT_TAPE) status += "`oDuct taped`` "; + if (t.god_mode) status += "`2God mode`` "; + s += std::format("add_smalltext|Status: {}|left|\n", status.empty() ? "normal" : status); + + if (t.role >= viewer.role) return s + "add_smalltext|`oYou can't punish someone of equal or higher rank.``|left|\n"; + + /* one step: fill in the time and reason, then press Curse or Ban */ + s += "add_text_input|staff_time|Time (30m, 2h, 1d, 1w):|30m|6|\n" + "add_text_input|staff_reason|Reason:||60|\n" + "add_button|staff_curse|`4Curse``|noflags|0|0|\n" + "add_button|staff_ban|`4Ban``|noflags|0|0|\n"; + if (t.curse_until > std::time(nullptr)) s += "add_button|staff_uncurse|Lift curse|noflags|0|0|\n"; + s += "add_button|staff_kick|Kick from world|noflags|0|0|\n" + "add_button|staff_freeze|Freeze / unfreeze|noflags|0|0|\n" + "add_button|staff_ducttape|Duct tape / remove|noflags|0|0|\n" + "add_button|staff_summon|Summon to me|noflags|0|0|\n" + "add_button|staff_warpto|Warp to their world|noflags|0|0|\n"; + return s; +} + +bool staff_popup(ENetEvent &event, const ::hPipe &hPipe) +{ + const std::string btn = hPipe["buttonClicked"]; + if (!btn.starts_with("staff_")) return false; + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (pPeer->role < MODERATOR) return true; + + ENetPeer *tp = by_netid(pPeer->recent_worlds.back(), std::atoi(hPipe["netID"].c_str())); + if (!tp) { tell(event.peer, "`4That player isn't here anymore.``"); return true; } + ::peer *t = static_cast<::peer*>(tp->data); + const std::string name = t->growid; + if (btn == "staff_panel") // @note the separate staff window; its buttons come back here through "popup" + { + send_varlist(event.peer, { "OnDialogRequest", std::format( + "set_default_color|`o\n" + "add_label_with_icon|big|`wStaff: {}``|left|732|\n" + "embed_data|netID|{}\n" + "{}" + "add_spacer|small|\n" + "end_dialog|popup|Close||\n", t->display_growid, t->netid, staff_wrench_rows(*pPeer, *t, *tp)) }); + return true; + } + if (t->role >= pPeer->role) { tell(event.peer, "`4You can't do that to someone of equal or higher rank.``"); return true; } + + std::string time = hPipe["staff_time"], reason = clean(hPipe["staff_reason"]); + while (!time.empty() && std::isspace(static_cast(time.back()))) time.pop_back(); + if (time.empty()) time = "30m"; + if (reason.empty()) reason = "No reason given"; + + if (btn == "staff_curse") + { + printf("[staff] %s curses %s for %s: %s\n", pPeer->growid.c_str(), name.c_str(), time.c_str(), reason.c_str()); + run(event, std::format("curse {} {} {}", name, time, reason)); + } + else if (btn == "staff_ban") + { + const long long secs = duration(time); + if (secs <= 0) { tell(event.peer, "`4Invalid time.`` Use e.g. 30m, 2h, 1d or 1w."); return true; } + t->ban_until = std::time(nullptr) + std::min(secs, 315360000LL); + t->mysql_update("ban_until", static_cast(t->ban_until)); + peers("", peer_condition::PEER_ALL, [&](ENetPeer &p) + { + const ::peer *o = static_cast<::peer*>(p.data); + if (o && !o->growid.empty()) on::ConsoleMessage(&p, std::format("`#** `$The Ancient Ones`` have `4banned`` `w{}``! `#**``", name)); + }); + on::ConsoleMessage(tp, std::format("`4You have been banned`` for `w{}``. Reason: `w{}``", time, reason)); + on::ConsoleMessage(event.peer, std::format("`2Banned `w{}`` for {}.``", name, time)); + printf("[staff] %s banned %s for %s: %s\n", pPeer->growid.c_str(), name.c_str(), time.c_str(), reason.c_str()); + enet_peer_disconnect_later(tp, 0); + } + else if (btn == "staff_uncurse") run(event, "uncurse " + name); + else if (btn == "staff_kick") run(event, "kick " + name); + else if (btn == "staff_freeze") run(event, "freeze " + name); + else if (btn == "staff_ducttape") run(event, "ducttape " + name); + else if (btn == "staff_summon") run(event, "summon " + name); + else if (btn == "staff_warpto") run(event, "warpto " + name); + return true; +} diff --git a/include/commands/staff.hpp b/include/commands/staff.hpp new file mode 100644 index 00000000..c10a22f8 --- /dev/null +++ b/include/commands/staff.hpp @@ -0,0 +1,5 @@ +#pragma once + +/* the staff part of the wrench menu when a mod/dev wrenches a player */ +extern std::string staff_wrench_rows(const ::peer &viewer, const ::peer &target, ENetPeer &target_peer); +extern bool staff_popup(ENetEvent &event, const ::hPipe &hPipe); // @return true if it was a staff button diff --git a/include/commands/stats.cpp b/include/commands/stats.cpp new file mode 100644 index 00000000..294b1f89 --- /dev/null +++ b/include/commands/stats.cpp @@ -0,0 +1,44 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/world.hpp" +#include "stats.hpp" + +/* /stats -> server and world numbers */ +void stats(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < MODERATOR) + { + on::ConsoleMessage(event.peer, "`4Only staff can use this.``"); + return; + } + + const std::size_t online = peers().size(); + const std::size_t loaded = worlds.size(); + + on::ConsoleMessage(event.peer, "`o--- `2Server stats`o ---"); + on::ConsoleMessage(event.peer, std::format("`oPlayers online: `w{}``", online)); + on::ConsoleMessage(event.peer, std::format("`oWorlds loaded: `w{}``", loaded)); + + if (pPeer->netid != 0) + { + auto world = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + if (world != worlds.end()) + { + std::size_t solid = 0, background = 0; + for (const ::block &b : world->blocks) + { + if (b.fg != 0) ++solid; + if (b.bg != 0) ++background; + } + + on::ConsoleMessage(event.peer, std::format("`o--- `2{}`o ---", world->name)); + on::ConsoleMessage(event.peer, std::format("`oBlocks: `w{}`` foreground, `w{}`` background", solid, background)); + on::ConsoleMessage(event.peer, std::format("`oVisitors here: `w{}``", world->visitors)); + on::ConsoleMessage(event.peer, std::format("`oOwner id: `w{}``", world->owner)); + on::ConsoleMessage(event.peer, std::format("`oPublic: `w{}``", world->is_public ? "yes" : "no")); + on::ConsoleMessage(event.peer, std::format("`oNuked: `w{}``", world->nuked ? "yes" : "no")); + } + } +} diff --git a/include/commands/stats.hpp b/include/commands/stats.hpp new file mode 100644 index 00000000..533d2cba --- /dev/null +++ b/include/commands/stats.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void stats(ENetEvent& event, const std::string_view text); diff --git a/include/commands/summon.cpp b/include/commands/summon.cpp new file mode 100644 index 00000000..cb424609 --- /dev/null +++ b/include/commands/summon.cpp @@ -0,0 +1,97 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "tools/bubble.hpp" +#include "action/join_request.hpp" +#include "action/quit_to_exit.hpp" +#include "database/world.hpp" +#include "summon.hpp" + +/* /summon {player} -> brings a player to where YOU stand, from any world */ +void summon(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < MODERATOR) + { + on::ConsoleMessage(event.peer, "`4Only staff can use this.``"); + return; + } + + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + while (!arg.empty() && std::isspace(static_cast(arg.back()))) arg.pop_back(); + + if (arg.empty()) + { + on::ConsoleMessage(event.peer, "`4Usage: /summon {player}``"); + return; + } + + std::string lower_target = arg; + for (char &c : lower_target) c = std::tolower(static_cast(c)); + + const std::string my_world = pPeer->recent_worlds.back(); + const ::pos my_pos = pPeer->pos; + + bool found = false; + ENetPeer *target_peer = nullptr; + peers("", peer_condition::PEER_ALL, [&](ENetPeer &p) + { + ::peer *pTarget = static_cast<::peer*>(p.data); + if (!pTarget || pTarget->growid.empty()) return; // @note in a world or in the world menu, both count + + std::string their_name = pTarget->growid; + for (char &c : their_name) c = std::tolower(static_cast(c)); + if (their_name == lower_target) target_peer = &p; + }); + + if (target_peer) + { + found = true; + ENetPeer &p = *target_peer; + ::peer *pTarget = static_cast<::peer*>(p.data); + + if (pTarget != pPeer && pTarget->role >= pPeer->role) + { + tell(event.peer, "`4You can't summon someone of equal or higher rank.``"); + return; + } + if (pTarget->curse_until > std::time(nullptr) && my_world != "HELL") + { + tell(event.peer, "`4That player is cursed - they can't leave HELL.``"); + return; + } + + if (pTarget->netid != 0 && pTarget->recent_worlds.back() == my_world) + { + pTarget->pos = my_pos; + send_varlist(&p, { "OnSetPos", CL_Vec2f{ my_pos.x, my_pos.y } }, pTarget->netid); + } + else + { + ENetEvent ev{}; + ev.peer = &p; + arrive_at(pTarget->user_id, my_pos); // @note they appear where you stand, not at the door + if (pTarget->netid != 0) action::quit_to_exit(ev, "", true); // @note in the world menu there's nothing to leave + action::join_request(ev, "", my_world); + } + + // an info popup like the event ones; it doesn't say who + const std::string who = (pPeer->role >= DEVELOPER) ? "`6Dev``" : "`5Mod``"; + send_varlist(&p, { "OnAddNotification", "interface/atomic_button.rttex", std::format("`wYou were summoned by a {}!``", who), "audio/hub_open.wav", 0u }); // @note popup without the event picture + on::ConsoleMessage(&p, std::format("`5You were summoned by a {}.``", who)); + + on::ConsoleMessage(event.peer, std::format("`2Summoned `w{}``.``", pTarget->growid)); + } + + if (!found) + on::ConsoleMessage(event.peer, std::format("`4`w{}`` is not online.``", arg)); +} diff --git a/include/commands/summon.hpp b/include/commands/summon.hpp new file mode 100644 index 00000000..a7a34a4a --- /dev/null +++ b/include/commands/summon.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void summon(ENetEvent& event, const std::string_view text); diff --git a/include/commands/swap.cpp b/include/commands/swap.cpp new file mode 100644 index 00000000..d2094924 --- /dev/null +++ b/include/commands/swap.cpp @@ -0,0 +1,66 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/world.hpp" +#include "swap.hpp" + +/* /swap {player} -> trade positions with someone in your world */ +void swap_cmd(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < MODERATOR) + { + on::ConsoleMessage(event.peer, "`4Only staff can use this.``"); + return; + } + + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + while (!arg.empty() && std::isspace(static_cast(arg.back()))) arg.pop_back(); + + if (arg.empty()) + { + on::ConsoleMessage(event.peer, "`4Usage: /swap {player}``"); + return; + } + + std::string lower_target = arg; + for (char &c : lower_target) c = std::tolower(static_cast(c)); + + bool found = false; + peers(pPeer->recent_worlds.back(), PEER_SAME_WORLD, [&](ENetPeer &p) + { + ::peer *pTarget = static_cast<::peer*>(p.data); + if (!pTarget) return; + + std::string their_name = pTarget->growid; + for (char &c : their_name) c = std::tolower(static_cast(c)); + if (their_name != lower_target) return; + if (pTarget->user_id == pPeer->user_id) return; + + found = true; + + const ::pos mine = pPeer->pos; + const ::pos theirs = pTarget->pos; + + pPeer->pos = theirs; + pTarget->pos = mine; + + send_varlist(event.peer, { "OnSetPos", CL_Vec2f{ theirs.x, theirs.y } }, pPeer->netid); + send_varlist(&p, { "OnSetPos", CL_Vec2f{ mine.x, mine.y } }, pTarget->netid); + + on::ConsoleMessage(&p, std::format("`5`w{}`` swapped places with you.``", pPeer->growid)); + on::ConsoleMessage(event.peer, std::format("`2Swapped places with `w{}``.``", pTarget->growid)); + }); + + if (!found) + on::ConsoleMessage(event.peer, std::format("`4`w{}`` is not in this world.``", arg)); +} diff --git a/include/commands/swap.hpp b/include/commands/swap.hpp new file mode 100644 index 00000000..4b6d9266 --- /dev/null +++ b/include/commands/swap.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void swap_cmd(ENetEvent& event, const std::string_view text); diff --git a/include/commands/tp.cpp b/include/commands/tp.cpp new file mode 100644 index 00000000..fb145f8b --- /dev/null +++ b/include/commands/tp.cpp @@ -0,0 +1,67 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/world.hpp" +#include "tp.hpp" + +/* /tp {x} {y} -> teleports you to those tile coordinates in this world */ +void tp(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < MODERATOR) + { + on::ConsoleMessage(event.peer, "`4Only staff can use this.``"); + return; + } + + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + std::string x_str{}, y_str{}; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) + { + x_str = arg.substr(0, sp); + y_str = arg.substr(sp + 1); + } + else + { + on::ConsoleMessage(event.peer, "`4Usage: /tp {x} {y}``"); + return; + } + + auto digits = [](std::string s){ std::erase_if(s, [](char c){ return !std::isdigit(static_cast(c)); }); return s; }; + x_str = digits(x_str); + y_str = digits(y_str); + + if (x_str.empty() || y_str.empty()) + { + on::ConsoleMessage(event.peer, "`4Usage: /tp {x} {y}``"); + return; + } + + signed x{}, y{}; + try { x = std::stoi(x_str); y = std::stoi(y_str); } + catch (...) { on::ConsoleMessage(event.peer, "`4Those are not valid numbers.``"); return; } + + if (x < 0 || x >= 100 || y < 0 || y >= 60) + { + on::ConsoleMessage(event.peer, "`4That is outside the world.``"); + return; + } + + pPeer->pos = ::pos{ x * 32.0f, y * 32.0f }; + + send_varlist(event.peer, { + "OnSetPos", + CL_Vec2f{ pPeer->pos.x, pPeer->pos.y } + }, pPeer->netid); + + on::ConsoleMessage(event.peer, std::format("`2Teleported to `w{}``, `w{}``.``", x, y)); +} diff --git a/include/commands/tp.hpp b/include/commands/tp.hpp new file mode 100644 index 00000000..1b20f3bd --- /dev/null +++ b/include/commands/tp.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void tp(ENetEvent& event, const std::string_view text); diff --git a/include/commands/trade.cpp b/include/commands/trade.cpp new file mode 100644 index 00000000..e93702c4 --- /dev/null +++ b/include/commands/trade.cpp @@ -0,0 +1,501 @@ +#include "pch.hpp" +#include +#include +#include "onVariant/ConsoleMessage.hpp" +#include "onVariant/SetClothing.hpp" +#include "tools/bubble.hpp" +#include "database/items.hpp" +#include "database/world.hpp" +#include "pull.hpp" +#include "trade.hpp" + +namespace +{ +struct offer { short id; int count; }; + +struct tstate +{ + int partner{}; // @note user id we want to trade with (0 = none) + bool open{}; // @note both sides agreed, window is live + bool accepted{}; + bool confirmed{}; + std::vector items{}; + std::chrono::steady_clock::time_point last_change{}; +}; +std::map states{}; // @note {user id, state} + +std::string clean(std::string s) +{ + for (char &c : s) if (c == '|' || c == '\n' || c == '\r') c = ' '; + return s; +} + +::peer *me_of(ENetEvent &event) { return static_cast<::peer*>(event.peer->data); } + +/* someone in the same world as `me` by user id */ +ENetPeer *peer_by_uid(const ::peer &me, int uid) +{ + ENetPeer *hit = nullptr; + if (me.netid == 0 || uid == 0) return nullptr; + peers(me.recent_worlds.back(), PEER_SAME_WORLD, [&](ENetPeer &p) + { + const ::peer *t = static_cast<::peer*>(p.data); + if (t && t->user_id == uid) hit = &p; + }); + return hit; +} + +int owned(const ::peer &p, short id) +{ + for (const ::slot &s : p.slots) if (s.id == id) return s.count; + return 0; +} + +std::string slots_text(const tstate &s) +{ + std::string out{}; + for (const offer &o : s.items) out += std::format("add_slot|{}|{}\n", o.id, o.count); + return out; +} + +std::string name_of(const ::peer &p) { return p.display_growid.empty() ? p.growid : p.display_growid; } + +/* the trade window of `to`: their own offer and the partner's offer */ +void send_status(ENetPeer &to, const ::peer &owner, const tstate &s, bool mine, const std::string &flags) +{ + const std::string text = mine ? "`oSelect an item from the inventory.``" : std::format("`w{}``'s offer.``", name_of(owner)); + send_varlist(&to, { "OnTradeStatus", owner.netid, "", text, slots_text(s) + flags }); +} + +/* items changed: both windows refresh and both "accept" ticks reset */ +void refresh(ENetPeer &a, ENetPeer &b, bool announce) +{ + ::peer *A = static_cast<::peer*>(a.data), *B = static_cast<::peer*>(b.data); + tstate &sa = states[A->user_id], &sb = states[B->user_id]; + sa.accepted = sb.accepted = false; + sa.confirmed = sb.confirmed = false; + const std::string flags = "\nlocked|0\nreset_locks|1\naccepted|0\n"; + send_status(a, *A, sa, true, flags); + send_status(a, *B, sb, false, flags); + send_status(b, *B, sb, true, flags); + send_status(b, *A, sa, false, flags); + if (announce) + { + send_varlist(&a, { "OnTextOverlay", "The deal has changed" }); + send_varlist(&b, { "OnTextOverlay", "The deal has changed" }); + send_action(a, "play_sfx", "file|audio/tile_removed.wav\ndelayMS|0"); + send_action(b, "play_sfx", "file|audio/tile_removed.wav\ndelayMS|0"); + } +} + +/* someone ticked or unticked "accept" */ +void send_accepts(ENetPeer &a, ENetPeer &b) +{ + ::peer *A = static_cast<::peer*>(a.data), *B = static_cast<::peer*>(b.data); + const tstate &sa = states[A->user_id], &sb = states[B->user_id]; + for (ENetPeer *to : { &a, &b }) + { + send_status(*to, *A, sa, to == &a, std::format("\nlocked|1\naccepted|{}\n", sa.accepted ? 1 : 0)); + send_status(*to, *B, sb, to == &b, std::format("\nlocked|1\naccepted|{}\n", sb.accepted ? 1 : 0)); + } +} + +void reset_state(int uid) { states.erase(uid); } + +/* closes the window for `p`. text shown as overlay + console (empty = nothing) */ +void close_for(ENetPeer *p, const std::string &text) +{ + if (!p || !p->data) return; + send_varlist(p, { "OnForceTradeEnd" }); + if (!text.empty()) + { + on::ConsoleMessage(p, text); + send_varlist(p, { "OnTextOverlay", text }); + } + reset_state(static_cast<::peer*>(p->data)->user_id); +} + +bool tradable(short id, std::string &why) +{ + if (id <= 0 || id >= static_cast(items.size())) { why = "That's not an item."; return false; } + const ::item &it = id_to_item(static_cast(id)); + if (it.type == type::FIST || it.type == type::WRENCH || id == 18 || id == 32) { why = "You'd be sorry if you lost that!"; return false; } + const bool wand = (id == 274 || id == 276 || id == 278 || id == 732); // @note Freeze, Fire, Curse and Ban Wand can be traded + if ((it.cat & CAT_UNTRADEABLE) && !wand) { why = "That item can't be traded."; return false; } + return true; +} + +/* true if giving `out` and getting `in` still fits the backpack; `which` gets the item that overflows */ +bool fits(const ::peer &p, const std::vector &in, const std::vector &out, short &which) +{ + int used = 0; + for (const ::slot &s : p.slots) if (s.count > 0) ++used; + for (const offer &o : in) + { + int after = owned(p, o.id) + o.count; + for (const offer &x : out) if (x.id == o.id) after -= x.count; + if (after > 200) { which = o.id; return false; } + if (owned(p, o.id) <= 0) ++used; + } + for (const offer &o : out) if (owned(p, o.id) - o.count <= 0) --used; + which = 0; + return used <= p.slot_size; +} + +std::string confirm_rows(const std::vector &list) +{ + if (list.empty()) return "add_textbox|`4Nothing!``|left|\n"; + std::string s{}; + for (const offer &o : list) + s += std::format("add_label_with_icon|small|(`w{}``) {}|left|{}|\n", o.count, clean(id_to_item(static_cast(o.id)).raw_name), o.id); + return s; +} + +void confirm_dialog(ENetPeer &to, const tstate &mine, const tstate &theirs) +{ + std::string d = "set_default_color|`o\nadd_label_with_icon|big|`wTrade Confirmation``|left|1366|\nadd_spacer|small|\n"; + d += "add_textbox|`4You'll give:``|left|\nadd_spacer|small|\n" + confirm_rows(mine.items); + d += "add_spacer|small|\nadd_textbox|`2You'll get:``|left|\nadd_spacer|small|\n" + confirm_rows(theirs.items); + if (theirs.items.empty()) + d += "add_spacer|small|\nadd_textbox|`4SCAM WARNING: ``You are about to do a trade without receiving anything in return. Once you do the trade you cannot get the items back.|left|\n" + "add_textbox|`4Do you really want to do this?``|left|\n"; + d += "add_spacer|small|\nadd_button|accept|Do The Trade!|noflags|0|0|\nadd_button|back|Cancel|noflags|0|0|\nend_dialog|trade_confirm|||\n"; + send_varlist(&to, { "OnDialogRequest", d }); +} + +void execute(ENetPeer &a, ENetPeer &b) +{ + ::peer *P[2] = { static_cast<::peer*>(a.data), static_cast<::peer*>(b.data) }; + ENetPeer *pp[2] = { &a, &b }; + const std::vector give[2] = { states[P[0]->user_id].items, states[P[1]->user_id].items }; + + for (int s = 0; s < 2; ++s) + for (const offer &o : give[s]) + if (owned(*P[s], o.id) < o.count) + { + close_for(pp[0], "`4The trade failed:`` an item isn't there anymore."); + close_for(pp[1], "`4The trade failed:`` an item isn't there anymore."); + return; + } + + for (int s = 0; s < 2; ++s) + { + ENetEvent ev{}; + ev.peer = pp[s]; + bool clothes = false; + for (const offer &o : give[s]) + { + for (float c : P[s]->clothing) if (static_cast(c) == o.id && owned(*P[s], o.id) - o.count <= 0) clothes = true; + modify_item_inventory(ev, ::slot(o.id, static_cast(-o.count))); + } + for (const offer &o : give[1 - s]) modify_item_inventory(ev, ::slot(o.id, static_cast(o.count))); + if (clothes) { P[s]->update_effects(); on::SetClothing(*pp[s]); } // @note gave away something they were wearing + } + + // for show: every traded item flies from the giver to the receiver, for everyone in the world + for (int s = 0; s < 2; ++s) + for (const offer &o : give[s]) + { + ::gamePacket gp{}; + gp.type = 0x13 | (3 << 24); // @note PACKET_ITEM_EFFECT, style 3 = trade + gp.netid = P[1 - s]->netid; // @note to + gp.uid = P[s]->netid; // @note from + gp.id = 150; // @note flight time in ms + gp.punch = ::pos{ static_cast(o.id), static_cast(o.id) }; + const ::blob fx = compress_state(gp); + peers(P[0]->recent_worlds.back(), PEER_SAME_WORLD, [&](ENetPeer &p) { send_data(p, fx); }); + } + + auto list = [](const std::vector &l) + { + std::string s{}; + for (const offer &o : l) s += std::format("{}{} {}", s.empty() ? "" : ", ", o.count, id_to_item(static_cast(o.id)).raw_name); + return s.empty() ? std::string{ "nothing" } : s; + }; + const std::string line = std::format("`1{}`` traded {} to `1{}`` for {}.", P[0]->growid, list(give[0]), P[1]->growid, list(give[1])); + peers(P[0]->recent_worlds.back(), PEER_SAME_WORLD, [&](ENetPeer &p) { on::ConsoleMessage(&p, line); }); + for (int s = 0; s < 2; ++s) + { + send_varlist(pp[s], { "OnTextOverlay", "`2Trade complete!``" }); + send_action(*pp[s], "play_sfx", "file|audio/keypad_hit.wav\ndelayMS|0"); + reset_state(P[s]->user_id); + } +} + +/* my own window only (the other player hasn't opened theirs yet) */ +void show_own(ENetPeer &p) +{ + ::peer *me = static_cast<::peer*>(p.data); + send_status(p, *me, states[me->user_id], true, "\nlocked|0\nreset_locks|1\naccepted|0\n"); +} + +/* true while my trade window is open, whether or not the other side has joined */ +bool window_open(const ::peer &me) +{ + auto it = states.find(me.user_id); + return it != states.end() && it->second.partner != 0; +} + +/* the partner of `me` if the window is live */ +ENetPeer *partner_of(::peer &me) +{ + auto it = states.find(me.user_id); + if (it == states.end() || !it->second.open) return nullptr; + ENetPeer *p = peer_by_uid(me, it->second.partner); + if (!p) return nullptr; + auto other = states.find(static_cast<::peer*>(p->data)->user_id); + if (other == states.end() || other->second.partner != me.user_id) return nullptr; + return p; +} + +void add_offer(ENetEvent &event, short id, int count) +{ + ::peer *me = me_of(event); + if (!window_open(*me)) return; + ENetPeer *other = partner_of(*me); // @note nullptr while the other player hasn't opened their window yet + tstate &s = states[me->user_id]; + auto update = [&] { if (other) refresh(*event.peer, *other, true); else show_own(*event.peer); }; + + count = std::min(count, owned(*me, id)); + if (count <= 0) { std::erase_if(s.items, [&](const offer &o) { return o.id == id; }); update(); return; } + + auto it = std::ranges::find(s.items, id, &offer::id); + if (it == s.items.end()) + { + if (s.items.size() >= 4) // @note wiki: 4 different stacks at most + { + send_varlist(event.peer, { "OnTextOverlay", "You can only trade 4 different items at once." }); + send_action(*event.peer, "play_sfx", "file|audio/cant_place_tile.wav\ndelayMS|0"); + return; + } + s.items.push_back({ id, count }); + } + else it->count = count; + update(); +} +} + +/* ---- starting ---- */ + +void trade_request(ENetEvent &event, ENetPeer &target) +{ + ::peer *me = me_of(event); + ::peer *t = static_cast<::peer*>(target.data); + + if (me == t) { on::ConsoleMessage(event.peer, "`oYou trade all your stuff to yourself in exchange for all your stuff."); return; } + if (me->netid == 0 || t->netid == 0 || me->recent_worlds.back() != t->recent_worlds.back()) { tell(event.peer, "`4You can only trade with players in the same world.``"); return; } + if (me->curse_until > std::time(nullptr)) { tell(event.peer, "`4You can't trade while cursed.``"); return; } + if (auto it = states.find(t->user_id); it != states.end() && it->second.open && it->second.partner != me->user_id) + { + send_varlist(event.peer, { "OnTalkBubble", me->netid, "`wThat person is busy.``", 0u }); + return; + } + if (auto mine = states.find(me->user_id); mine != states.end() && mine->second.open) close_for(event.peer, ""); // @note leave an old window first + + tstate &s = states[me->user_id]; + s = tstate{}; + s.partner = t->user_id; + send_varlist(event.peer, { "OnStartTrade", name_of(*t), t->netid }); // @note the client opens the window and answers with trade_started +} + +void trade_cmd(ENetEvent &event, const std::string_view text) +{ + ::peer *me = me_of(event); + if (me->netid == 0) { tell(event.peer, "`4You must be in a world.``"); return; } + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + while (!arg.empty() && std::isspace(static_cast(arg.back()))) arg.pop_back(); + if (arg.empty()) { tell(event.peer, "`4Usage: /trade {player}``"); return; } + + bool ambiguous = false; + ENetPeer *found = find_in_world(me->recent_worlds.back(), arg, ambiguous); + if (!found) + { + on::ConsoleMessage(event.peer, ambiguous ? "`4Oops:`` More than one player starts with that - type more letters." + : std::format("`4Oops:`` There is nobody currently in this world with a name starting with `w{}``.", arg)); + return; + } + trade_request(event, *found); +} + +void trade_end_for(const ::peer &p, const std::string &why) +{ + for (auto &[uid, s] : states) + if (s.partner == p.user_id && uid != p.user_id) + { + ENetPeer *other = nullptr; + peers("", peer_condition::PEER_ALL, [&](ENetPeer &x) { const ::peer *t = static_cast<::peer*>(x.data); if (t && t->user_id == uid) other = &x; }); + if (other && s.open) close_for(other, std::format("`6[```4Trade canceled: `w{}`` {}`4!```6]``", p.growid, why)); + else if (other) reset_state(uid); + break; + } + states.erase(p.user_id); +} + +/* ---- client packets ---- */ + +void action::trade_started(ENetEvent &event, const std::string &header) +{ + ::peer *me = me_of(event); + const int netid = std::atoi(::hPipe{ header }["netid"].c_str()); + auto mine = states.find(me->user_id); + if (mine == states.end() || me->netid == 0) return; + + ENetPeer *other = nullptr; + peers(me->recent_worlds.back(), PEER_SAME_WORLD, [&](ENetPeer &p) { const ::peer *t = static_cast<::peer*>(p.data); if (t && t->netid == netid) other = &p; }); + if (!other) { close_for(event.peer, "`4That player left the world.``"); return; } + ::peer *O = static_cast<::peer*>(other->data); + + auto theirs = states.find(O->user_id); + if (theirs != states.end() && theirs->second.partner == me->user_id) + { + // both asked each other: the trade is live + mine->second.open = theirs->second.open = true; + refresh(*event.peer, *other, false); + return; + } + + on::ConsoleMessage(other, std::format("`#TRADE ALERT:`` {} `owants to trade with you! To start, use the `wWrench`` on that person's wrench icon, or type `w/trade {}``", name_of(*me), me->growid)); + send_action(*other, "play_sfx", "file|audio/cash_register.wav\ndelayMS|0"); +} + +void action::mod_trade(ENetEvent &event, const std::string &header) +{ + ::peer *me = me_of(event); + if (!window_open(*me)) return; // @note works before the other player has opened their window too + + const short id = static_cast(std::atoi(::hPipe{ header }["itemID"].c_str())); + std::string why{}; + if (!tradable(id, why)) + { + send_varlist(event.peer, { "OnTextOverlay", why }); + send_action(*event.peer, "play_sfx", "file|audio/cant_place_tile.wav\ndelayMS|0"); + return; + } + tstate &s = states[me->user_id]; + const auto now = std::chrono::steady_clock::now(); + if (now - s.last_change < std::chrono::milliseconds(500)) + { + send_varlist(event.peer, { "OnTextOverlay", "Slow down! Please wait a second between adding and removing items" }); + return; + } + s.last_change = now; + + const int have = owned(*me, id); + if (have <= 0) return; + if (have == 1) { add_offer(event, id, 1); return; } + + const ::item &it = id_to_item(static_cast(id)); + send_varlist(event.peer, { "OnDialogRequest", std::format( + "set_default_color|`o\n" + "add_label_with_icon|big|`2Trade`` `w{}``|left|{}|\n" + "add_textbox|`2Trade how many?``|left|\n" + "add_text_input|count||{}|5|\n" + "embed_data|itemID|{}\n" + "end_dialog|trade_add|Cancel|OK|\n", clean(it.raw_name), id, have, id) }); +} + +void action::rem_trade(ENetEvent &event, const std::string &header) +{ + ::peer *me = me_of(event); + if (!window_open(*me)) return; + const short id = static_cast(std::atoi(::hPipe{ header }["itemID"].c_str())); + std::erase_if(states[me->user_id].items, [&](const offer &o) { return o.id == id; }); + if (ENetPeer *other = partner_of(*me)) refresh(*event.peer, *other, true); + else show_own(*event.peer); +} + +void action::trade_accept(ENetEvent &event, const std::string &header) +{ + ::peer *me = me_of(event); + ENetPeer *other = partner_of(*me); + if (!other) return; + ::peer *O = static_cast<::peer*>(other->data); + tstate &sm = states[me->user_id], &so = states[O->user_id]; + + sm.accepted = ::hPipe{ header }["status"] == "1"; + send_accepts(*event.peer, *other); + if (!sm.accepted || !so.accepted) return; + + // both accepted: check both backpacks first + short which = 0; + for (auto [p, P, in, out] : { std::tuple{ event.peer, me, &so.items, &sm.items }, std::tuple{ other, O, &sm.items, &so.items } }) + { + if (!fits(*P, *in, *out, which)) + { + const std::string text = which + ? std::format("`4Oops - {} `4is carrying too many {} and can't fit that many in their backpack.", name_of(*P), id_to_item(static_cast(which)).raw_name) + : std::format("`w{}`w needs more backpack room first!", name_of(*P)); + send_varlist(event.peer, { "OnTextOverlay", text }); + send_varlist(other, { "OnTextOverlay", text }); + sm.accepted = so.accepted = false; + send_accepts(*event.peer, *other); + return; + } + } + + // last step: both see what they give and get + send_varlist(event.peer, { "OnForceTradeEnd" }); + send_varlist(other, { "OnForceTradeEnd" }); + confirm_dialog(*event.peer, sm, so); + confirm_dialog(*other, so, sm); +} + +void action::trade_cancel(ENetEvent &event, const std::string &header) +{ + ::peer *me = me_of(event); + auto it = states.find(me->user_id); + if (it == states.end()) return; + ENetPeer *other = peer_by_uid(*me, it->second.partner); + const bool was_open = it->second.open; + reset_state(me->user_id); + if (other && was_open) + { + auto theirs = states.find(static_cast<::peer*>(other->data)->user_id); + if (theirs != states.end() && theirs->second.partner == me->user_id) + close_for(other, std::format("{} `whas canceled the trade", name_of(*me))); + } +} + +/* ---- dialogs ---- */ + +void trade_return(ENetEvent &event, const ::hPipe &hPipe) +{ + ::peer *me = me_of(event); + const std::string dialog = hPipe["dialog_name"]; + + if (dialog == "trade_add") + { + const short id = static_cast(std::atoi(hPipe["itemID"].c_str())); + const int count = std::atoi(hPipe["count"].c_str()); + if (count < 0) { send_varlist(event.peer, { "OnTextOverlay", "Whatever scammer. Bet you liked the game more before this cool trade system!" }); return; } + std::string why{}; + if (count == 0 || !tradable(id, why)) return; + add_offer(event, id, std::min(count, 200)); + return; + } + + if (dialog != "trade_confirm") return; + auto mine = states.find(me->user_id); + if (mine == states.end() || !mine->second.open) { send_varlist(event.peer, { "OnTextOverlay", "The other person left the trade!" }); return; } + ENetPeer *other = peer_by_uid(*me, mine->second.partner); + auto theirs = other ? states.find(static_cast<::peer*>(other->data)->user_id) : states.end(); + + if (hPipe["buttonClicked"] != "accept" || !other || theirs == states.end() || theirs->second.partner != me->user_id) + { + const std::string text = std::format("`6[```4Trade canceled by {}`4!```6]``", name_of(*me)); + close_for(event.peer, text); + if (other && theirs != states.end() && theirs->second.partner == me->user_id) + { + send_varlist(other, { "OnDialogRequest", "set_default_color|`o\nadd_label_with_icon|big|`wTrade``|left|1366|\nadd_textbox|" + clean(text) + "|left|\nend_dialog|trade_done||OK|\n" }); + close_for(other, text); + } + return; + } + + mine->second.confirmed = true; + if (theirs->second.confirmed) execute(*event.peer, *other); + else on::ConsoleMessage(event.peer, "`oWaiting for the other player to confirm...``"); +} diff --git a/include/commands/trade.hpp b/include/commands/trade.hpp new file mode 100644 index 00000000..792c8b21 --- /dev/null +++ b/include/commands/trade.hpp @@ -0,0 +1,17 @@ +#pragma once + +/* native Growtopia trading: wrench > Trade or /trade {player} opens the real trade window */ +extern void trade_request(ENetEvent &event, ENetPeer &target); +extern void trade_cmd(ENetEvent &event, const std::string_view text); +extern void trade_end_for(const ::peer &p, const std::string &why); // @note leaving a world or the game cancels the trade + +/* packets the client sends while the trade window is open */ +namespace action +{ + extern void trade_started(ENetEvent &event, const std::string &header); + extern void mod_trade(ENetEvent &event, const std::string &header); + extern void rem_trade(ENetEvent &event, const std::string &header); + extern void trade_accept(ENetEvent &event, const std::string &header); + extern void trade_cancel(ENetEvent &event, const std::string &header); +} +extern void trade_return(ENetEvent &event, const ::hPipe &hPipe); // @note dialogs "trade_add" and "trade_confirm" diff --git a/include/commands/unban.cpp b/include/commands/unban.cpp new file mode 100644 index 00000000..ea277ce3 --- /dev/null +++ b/include/commands/unban.cpp @@ -0,0 +1,54 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/database.hpp" +#include "unban.hpp" + +/* /unban {player} -> lifts a ban (wrench Ban, Ban Wand) right away */ +void unban_cmd(ENetEvent &event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (pPeer->role < MODERATOR) + { + on::ConsoleMessage(event.peer, "`4Only staff can use this.``"); + return; + } + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + while (!arg.empty() && std::isspace(static_cast(arg.back()))) arg.pop_back(); + if (arg.empty() || arg.size() > 18 || !std::ranges::all_of(arg, [](char c) { return std::isalnum(static_cast(c)) != 0; })) + { + on::ConsoleMessage(event.peer, "`4Usage: /unban {player}``"); + return; + } + + // look the account up (names are letters and numbers only, checked above) + int uid = 0, until = 0; + std::string name{}; + if (mysql_query(db, std::format("SELECT uid, ban_until, growid FROM peer WHERE growid = '{}' LIMIT 1", arg).c_str()) == 0) + if (MYSQL_RES *res = mysql_store_result(db)) + { + if (MYSQL_ROW r = mysql_fetch_row(res)) + { + uid = r[0] ? std::atoi(r[0]) : 0; + until = r[1] ? std::atoi(r[1]) : 0; + name = r[2] ? r[2] : arg; + } + mysql_free_result(res); + } + if (uid == 0) + { + on::ConsoleMessage(event.peer, std::format("`4No account named `w{}`` exists.``", arg)); + return; + } + if (until <= std::time(nullptr)) + { + on::ConsoleMessage(event.peer, std::format("`w{}`` isn't banned.", name)); + return; + } + + mysql_query(db, std::format("UPDATE peer SET ban_until = 0 WHERE uid = {}", uid).c_str()); + printf("[staff] %s unbanned %s\n", pPeer->growid.c_str(), name.c_str()); + on::ConsoleMessage(event.peer, std::format("`2Unbanned `w{}``. They can log in again.``", name)); +} diff --git a/include/commands/unban.hpp b/include/commands/unban.hpp new file mode 100644 index 00000000..6e952b66 --- /dev/null +++ b/include/commands/unban.hpp @@ -0,0 +1,3 @@ +#pragma once + +extern void unban_cmd(ENetEvent &event, const std::string_view text); // @note /unban {player} diff --git a/include/commands/undo.cpp b/include/commands/undo.cpp new file mode 100644 index 00000000..dab6c84d --- /dev/null +++ b/include/commands/undo.cpp @@ -0,0 +1,70 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/world.hpp" +#include "undo.hpp" + +/* /undo -> reverts your last block change + * /undo {n} -> reverts the last n changes (max 50) + */ +void undo(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + + auto world = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + if (world == worlds.end()) return; + + printf("[undo] history size=%zu world=%s\n", pPeer->block_history.size(), world->name.c_str()); + if (pPeer->block_history.empty()) + { + on::ConsoleMessage(event.peer, "`4Nothing to undo.``"); + return; + } + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + signed count = 1; + if (!arg.empty()) + { + std::erase_if(arg, [](char c){ return !std::isdigit(static_cast(c)); }); + if (!arg.empty()) + { + try { count = std::stoi(arg); } catch (...) { count = 1; } + } + } + count = std::clamp(count, 1, 50); + + std::size_t reverted = 0; + while (reverted < static_cast(count) && !pPeer->block_history.empty()) + { + const ::block_change change = pPeer->block_history.back(); + pPeer->block_history.pop_back(); + + if (change.world != world->name) continue; // only undo in the world it happened + + if (change.x < 0 || change.x >= 100 || change.y < 0 || change.y >= 60) continue; + + ::block &b = world->blocks[cord(change.x, change.y)]; + b.fg = change.old_fg; + b.bg = change.old_bg; + + send_tile_update(event, { .id = b.fg, .punch = ::pos{ change.x, change.y } }, b, *world); + ++reverted; + } + + on::ConsoleMessage(event.peer, + std::format("`2Reverted `w{}`` block change{}.``", reverted, reverted == 1 ? "" : "s")); +} diff --git a/include/commands/undo.hpp b/include/commands/undo.hpp new file mode 100644 index 00000000..0cc74a86 --- /dev/null +++ b/include/commands/undo.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void undo(ENetEvent& event, const std::string_view text); diff --git a/include/commands/undress.cpp b/include/commands/undress.cpp new file mode 100644 index 00000000..9e77eb12 --- /dev/null +++ b/include/commands/undress.cpp @@ -0,0 +1,19 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "onVariant/SetClothing.hpp" +#include "database/world.hpp" +#include "undress.hpp" + +/* /undress -> takes off everything you're wearing */ +void undress(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + for (float &slot : pPeer->clothing) slot = 0.0f; + + pPeer->update_effects(); + on::SetClothing(*event.peer); + peers(pPeer->recent_worlds.back(), PEER_SAME_WORLD, [pPeer](ENetPeer &p) { ::peer *o = static_cast<::peer*>(p.data); if (o && o->user_id != pPeer->user_id) on::SetClothing(p, *pPeer); }); + + on::ConsoleMessage(event.peer, "`2All clothing removed.``"); +} diff --git a/include/commands/undress.hpp b/include/commands/undress.hpp new file mode 100644 index 00000000..2618b246 --- /dev/null +++ b/include/commands/undress.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void undress(ENetEvent& event, const std::string_view text); diff --git a/include/commands/unnuke.cpp b/include/commands/unnuke.cpp new file mode 100644 index 00000000..ce05aa71 --- /dev/null +++ b/include/commands/unnuke.cpp @@ -0,0 +1,38 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/world.hpp" +#include "unnuke.hpp" + +/* /unnuke -> reopens the world you are standing in */ +void unnuke(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + + auto world = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + if (world == worlds.end()) return; + + if (!world->nuked) + { + on::ConsoleMessage(event.peer, std::format("`4`w{}`` is not nuked.``", world->name)); + return; + } + + world->nuked = false; + + peers(world->name, PEER_SAME_WORLD, [&world](ENetPeer &p) + { + on::ConsoleMessage(&p, std::format("`5[```2{}`` `5is open again!```5]``", world->name)); + }); +} diff --git a/include/commands/unnuke.hpp b/include/commands/unnuke.hpp new file mode 100644 index 00000000..7614fafe --- /dev/null +++ b/include/commands/unnuke.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void unnuke(ENetEvent& event, const std::string_view text); diff --git a/include/commands/uptime.cpp b/include/commands/uptime.cpp new file mode 100644 index 00000000..fb8ffdcc --- /dev/null +++ b/include/commands/uptime.cpp @@ -0,0 +1,27 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "uptime.hpp" + +std::time_t gServer_start_time = std::time(nullptr); + +/* /uptime -> how long the server has been running */ +void uptime(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + const std::time_t now = std::time(nullptr); + const long long seconds = static_cast(now - gServer_start_time); + + const long long days = seconds / 86400; + const long long hours = (seconds % 86400) / 3600; + const long long minutes = (seconds % 3600) / 60; + const long long secs = seconds % 60; + + std::string out{}; + if (days > 0) out += std::format("`w{}``d ", days); + if (hours > 0) out += std::format("`w{}``h ", hours); + if (minutes > 0) out += std::format("`w{}``m ", minutes); + out += std::format("`w{}``s", secs); + + on::ConsoleMessage(event.peer, std::format("`oServer has been up for {}.", out)); +} diff --git a/include/commands/uptime.hpp b/include/commands/uptime.hpp new file mode 100644 index 00000000..4e647a11 --- /dev/null +++ b/include/commands/uptime.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void uptime(ENetEvent& event, const std::string_view text); diff --git a/include/commands/useitems.cpp b/include/commands/useitems.cpp new file mode 100644 index 00000000..98b8858f --- /dev/null +++ b/include/commands/useitems.cpp @@ -0,0 +1,409 @@ +#include "pch.hpp" +#include "tools/bubble.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "onVariant/SetClothing.hpp" +#include "onVariant/Action.hpp" +#include "action/respawn.hpp" +#include "commands/sb.hpp" +#include "tools/time.hpp" +#include "tools/random.hpp" +#include "database/items.hpp" +#include "database/world.hpp" +#include "punch.hpp" +#include "jammers.hpp" +#include "useitems.hpp" +#include "buffs.hpp" + +namespace fx +{ + constexpr int HORNS = 0x40; + constexpr int HALO = 0x80; + constexpr int STINK = 0x4000; + constexpr int SPARKLE = 0x8000; + constexpr int ZOMBIE = 0x10000; +} + +/* ---------------------------------------------------------- read by SetClothing */ + +int item_effect_state(const ::peer &p) +{ + const std::time_t now = std::time(nullptr); + int s = 0; + for (const auto &[flag, ends] : p.item_states) if (ends > now) s |= flag; + return s; +} + +u_int item_skin(const ::peer &p) +{ + return (p.tint_until > std::time(nullptr)) ? p.tint : p.skin_color; +} + +::pos item_speed(const ::peer &p) +{ + if (p.move_until > std::time(nullptr)) return p.move; + ::pos s{ 250.0f, 1000.0f }; + if (buff_active(p, buff::SPEEDY)) s.x = 350.0f; + if (buff_active(p, buff::HIGH_JUMP)) s.y = 750.0f; + if (auto w = std::ranges::find(worlds, p.recent_worlds.back(), &::world::name); w != worlds.end() && w->base_weather == 7) s.y = std::min(s.y, 600.0f); // @note Mars + if (world_jammer(p.recent_worlds.back(), 4992)) s.y = std::min(s.y, 250.0f); // @note Antigravity Generator + return s; +} + +/* ---------------------------------------------------------- helpers */ + +static void add_state(::peer &p, int flag, int seconds) +{ + const std::time_t ends = std::time(nullptr) + seconds; + for (auto &e : p.item_states) if (e.first == flag) { e.second = ends; return; } + p.item_states.emplace_back(flag, ends); +} + +static void refresh(ENetPeer &p) +{ + if (static_cast<::peer*>(p.data)->netid != 0) on::SetClothing(p); +} + +static void bubble(const ::peer &who, const std::string &text) +{ + const ::peer &s = speaker(who); // @note used on another player: they say it + peers(s.recent_worlds.back(), PEER_SAME_WORLD, [&](ENetPeer &p) { send_varlist(&p, { "OnTalkBubble", s.netid, text, 0u }); }); +} + +static void world_msg(const ::peer &who, const std::string &text) +{ + peers(who.recent_worlds.back(), PEER_SAME_WORLD, [&](ENetPeer &p) { on::ConsoleMessage(&p, text); }); +} + +static const char *pick(std::initializer_list lines) +{ + return *(lines.begin() + RandomRange(0, static_cast(lines.size()))); +} + +static ENetPeer *player_at(const std::string &world, int x, int y, const ::peer *skip) +{ + ENetPeer *hit = nullptr; + peers(world, PEER_SAME_WORLD, [&](ENetPeer &p) + { + ::peer *t = static_cast<::peer*>(p.data); + if (!t || t == skip || hit) return; + const int x0 = static_cast(std::floor(t->pos.x / 32.0f)); + const int x1 = static_cast(std::floor((t->pos.x + 19.0f) / 32.0f)); + const int y0 = static_cast(std::floor(t->pos.y / 32.0f)); + const int y1 = static_cast(std::floor((t->pos.y + 29.0f) / 32.0f)); + if (x >= x0 && x <= x1 && y >= y0 && y <= y1) hit = &p; + }); + return hit; +} + +/* grow the tree at (x, y) by `add` seconds. @return true if it grew */ +static bool grow_tree(ENetEvent &event, ::world &world, int x, int y, u_int add, bool announce) +{ + ::block &block = world.blocks[cord(x, y)]; + const ::item &seed = id_to_item(block.fg); + if (seed.type != type::SEED) + { + if (announce) tell(event.peer, "`4Use that on a tree.``"); + return false; + } + auto tree = std::ranges::find(world.trees, ::pos{ x, y }, &::tree::pos); + if (tree == world.trees.end()) return false; + + const u_int now = static_cast(ticks()); + const u_int full = static_cast(seed.tick); + const u_int elapsed = now - tree->tick; + if (elapsed >= full) + { + if (announce) on::ConsoleMessage(event.peer, "`oThat tree is already fully grown.``"); + return false; + } + tree->tick = now - std::min(elapsed + add, full); + send_tile_update(event, { .id = block.fg, .punch = ::pos{ x, y } }, block, world); + return true; +} + +/* ---------------------------------------------------------- usage */ + +bool item_use(ENetEvent &event, ::world &world, const ::item &item, ::gamePacket &gamePacket) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + const short id = static_cast(item.id); + const int x = static_cast(gamePacket.punch.x); + const int y = static_cast(gamePacket.punch.y); + const std::time_t now = std::time(nullptr); + auto use_one = [&] { modify_item_inventory(event, ::slot(id, -1)); pPeer->add_xp(event, consumable_xp(*pPeer)); }; + + /* ---- tree sprays ---- */ + static const std::unordered_map tree_sprays{ {228, 3600u}, {10672, 3600u}, {1778, 86400u}, {5764, 86400u}, {1062, 2592000u} }; + if (auto s = tree_sprays.find(id); s != tree_sprays.end()) + { + if (!grow_tree(event, world, x, y, s->second, true)) return true; + use_one(); + on::ConsoleMessage(event.peer, std::format("`2You sprayed the `w{}``!``", id_to_item(world.blocks[cord(x, y)].fg).raw_name)); + return true; + } + if (id == 13574 || id == 12600) // @note Deluxe / Ultra World Spray + { + const u_int add = (id == 12600) ? 2592000u : 86400u; + int grown = 0; + for (const ::tree &t : world.trees) + if (grow_tree(event, world, static_cast(t.pos.x), static_cast(t.pos.y), add, false)) ++grown; + use_one(); + world_msg(*pPeer, std::format("{} `2sprayed the world - `w{}`` trees grew!``", pPeer->display_growid, grown)); + return true; + } + + /* ---- celebrations: a fireworks show on the tile ---- */ + if (id == 1066 || id == 1406 || id == 1826 || id == 2236 || id == 4370) + { + use_one(); + const ::pos at{ x * 32.0f + 16.0f, y * 32.0f + 16.0f }; + for (int i = 0; i < 3; ++i) fireworks(event, at); + return true; + } + + /* ---- used on yourself / a tile ---- */ + switch (id) + { + case 752: use_one(); bubble(*pPeer, pick({ "`wHeads!``", "`wTails!``" })); return true; // Flipping Coin + case 3536: use_one(); on::ConsoleMessage(event.peer, "`oYou feel exactly the same.``"); return true; // Homeopathic Medicine + case 4574: use_one(); bubble(*pPeer, pick({ "*gags*", "Why would anyone cook this...", "`4BLEGH!``" })); return true; // Disgusting Mess + case 4752: use_one(); bubble(*pPeer, "YUM!"); return true; // Super Pineapple + case 4378: use_one(); bubble(*pPeer, "YUM!"); on::Action(event, "dance"); return true; // Party Cake + case 4366: // Party Screamer + { + if (pPeer->curse_until > now) { tell(event.peer, "`4You can't do that while cursed.``"); return true; } + use_one(); + sb(event, std::string{ "sb " } + pick({ "PARTY TIME!!!", "WOOOOO! LET'S GET THIS PARTY STARTED!", "EVERYBODY DANCE NOW!", "THE PARTY IS OFF THE CHIZZY!!" })); + return true; + } + case 4766: // Cherry + { + use_one(); + pPeer->tint = 0x3F3FFFFFu; pPeer->tint_item = 4766; + pPeer->tint_until = now + 600; + refresh(*event.peer); + bubble(*pPeer, "YUM!"); + return true; + } + case 782: // Antidote + { + use_one(); + std::erase_if(pPeer->item_states, [](const auto &e) { return e.first == fx::ZOMBIE || e.first == fx::STINK; }); + pPeer->tint_until = 0; + pPeer->move_until = 0; + refresh(*event.peer); + on::ConsoleMessage(event.peer, "`2You feel cured!``"); + return true; + } + case 3064: // Water Balloon + { + use_one(); + const int width = static_cast(world.blocks.size() / 60); + int out = 0; + for (int dy = -1; dy <= 1; ++dy) + for (int dx = -1; dx <= 1; ++dx) + { + const int bx = x + dx, by = y + dy; + if (bx < 0 || by < 0 || bx >= width || by >= 60) continue; + ::block &b = world.blocks[cord(bx, by)]; + if (!(b.state[3] & S_FIRE)) continue; + ::gamePacket gp{ .punch = ::pos{ bx, by } }; + remove_fire(event, gp, b, world); + ++out; + } + bubble(*pPeer, out ? "*SPLASH!*" : "*splash*"); + return true; + } + } + + /* ---- used on a player ---- */ + static constexpr std::array targeted{ 126, 128, 196, 338, 368, 384, 388, 614, 616, 618, 764, 874, 962, 1368 }; + if (std::ranges::find(targeted, id) == targeted.end()) return false; + + ENetPeer *found = player_at(world.name, x, y, pPeer); + if (!found) found = player_at(world.name, x, y, nullptr); + if (!found) + { + tell(event.peer, "`4Use that on a player.``"); + return true; + } + ::peer *t = static_cast<::peer*>(found->data); + if (t->role > pPeer->role) + { + tell(event.peer, "`4That player is immune.``"); + return true; + } + use_one(); + const std::string me = pPeer->display_growid, them = t->display_growid; + + switch (id) + { + case 126: add_state(*t, fx::HORNS, 3600); refresh(*found); world_msg(*pPeer, std::format("{} `4gave`` {} `4devil horns!``", me, them)); break; + case 128: add_state(*t, fx::HALO, 3600); refresh(*found); world_msg(*pPeer, std::format("{} `2gave`` {} `2a golden halo!``", me, them)); break; + case 196: t->tint = 0xE63014FFu; t->tint_item = 196; t->tint_until = now + 3600; refresh(*found); bubble(*t, "`1*covered in blueberry*``"); break; + case 338: t->move = ::pos{ 250.0f, -300.0f }; t->move_item = 338; t->move_until = now + 3; refresh(*found); bubble(*t, "Wheee!"); break; + case 368: t->move = ::pos{ 120.0f, 1000.0f }; t->move_item = 368; t->move_until = now + 10; refresh(*found); bubble(*t, "*splat*"); break; + case 384: bubble(*t, "`4<3``"); world_msg(*pPeer, std::format("{} `4is now the Valentine of`` {}`4!``", them, me)); break; + case 388: add_state(*t, fx::SPARKLE, 600); refresh(*found); bubble(*t, "*smells... distinctive*"); break; + case 614: add_state(*t, fx::STINK, 600); refresh(*found); bubble(*t, "*stinks*"); break; + case 616: bubble(*t, "`2*hugs the cuddly bunny*``"); world_msg(*pPeer, std::format("{} `2tossed a Cuddly Bunny to`` {}`2!``", me, them)); break; + case 618: + { + ENetEvent ev{}; + ev.peer = found; + action::respawn(ev, ""); + world_msg(*pPeer, std::format("{}`4's Psychotic Bunny attacked`` {}`4!``", me, them)); + break; + } + case 764: add_state(*t, fx::ZOMBIE, 600); refresh(*found); bubble(*t, "`2Braaains...``"); break; + case 874: + case 962: bubble(*t, "*SPLAT*"); world_msg(*pPeer, std::format("{} `othrew a`` `w{}`` `oat`` {}`o!``", me, item.raw_name, them)); break; + case 1368: + { + if (!(t->frozen && t->freeze_until == 0)) + { + t->frozen = true; + t->state |= S_FROZEN; + t->freeze_until = now + 10; + refresh(*found); + send_varlist(found, { "OnSetFreezeState", 1u }, t->netid); + } + bubble(*t, "`1*frozen solid*``"); + break; + } + } + return true; +} + +/* runs once a second: ends item effects */ +void useitems_tick(std::time_t now) +{ + peers("", peer_condition::PEER_ALL, [now](ENetPeer &p) + { + ::peer *t = static_cast<::peer*>(p.data); + if (!t || t->growid.empty()) return; + + bool changed = false; + const std::size_t before = t->item_states.size(); + std::erase_if(t->item_states, [now](const auto &e) { return e.second <= now; }); + if (t->item_states.size() != before) changed = true; + if (t->tint_until != 0 && now >= t->tint_until) { t->tint_until = 0; changed = true; } + if (t->move_until != 0 && now >= t->move_until) { t->move_until = 0; changed = true; } + if (changed && t->netid != 0) on::SetClothing(p); + }); +} + + +/* ---------------------------------------------------------- wrench menu */ + +static std::string left_str(std::time_t ends) +{ + long long s = static_cast(ends) - static_cast(std::time(nullptr)); + if (s < 0) s = 0; + const long long d = s / 86400, h = s % 86400 / 3600, m = s % 3600 / 60, sec = s % 60; + if (d) return std::format("{} days, {} hours, {} mins, {} secs", d, h, m, sec); + if (h) return std::format("{} hours, {} mins, {} secs", h, m, sec); + if (m) return std::format("{} mins, {} secs", m, sec); + return std::format("{} secs", sec); +} + +/* "Effect (x left)" lines for the wrench menu, empty when nothing is active */ +std::string item_effects_wrench(const ::peer &p) +{ + const std::time_t now = std::time(nullptr); + std::string out{}; + auto line = [&](const char *label, int icon, std::time_t ends) + { + out += std::format("add_label_with_icon|small|`w{}`` (`w{}`` left)|left|{}|\n", label, left_str(ends), icon); + }; + + for (const auto &[flag, ends] : p.item_states) + { + if (ends <= now) continue; + switch (flag) + { + case fx::HORNS: line("Devil Horns", 126, ends); break; + case fx::HALO: line("Golden Halo", 128, ends); break; + case fx::STINK: line("Stinky", 614, ends); break; + case fx::SPARKLE: line("Perfumed", 388, ends); break; + case fx::ZOMBIE: line("Infected", 764, ends); break; + case 0x40000: line("Haunted", 1988, ends); break; + } + } + if (p.tint_until > now) + line(p.tint_item == 4766 ? "Cherry Red" : p.tint_item == 540 ? "Green Beer Green" : "Blueberry Blue", p.tint_item ? p.tint_item : 196, p.tint_until); + if (p.move_until > now) + line(p.move_item == 338 ? "Floating" : "Muddy", p.move_item ? p.move_item : 368, p.move_until); + if (p.frozen) + { + if (p.freeze_until > now) line("Frozen", 274, p.freeze_until); + else out += "add_label_with_icon|small|`wFrozen``|left|274|\n"; + } + return out; +} + +/* items with the Double Jump mod, from growtopiawiki.com/w/Mods/Double_Jump (all slots), sorted */ +static const std::vector double_jump_items{ + 156, 362, 678, 736, 818, 820, 1166, 1206, 1460, 1550, 1574, 1672, 1674, 1738, 1780, 1784, + 1824, 1934, 1936, 1938, 1958, 1970, 2158, 2160, 2162, 2164, 2166, 2168, 2254, 2256, 2258, 2260, + 2262, 2264, 2390, 2392, 2438, 2538, 2642, 2722, 2778, 3104, 3112, 3114, 3120, 3134, 3144, 3442, + 3512, 3858, 4184, 4412, 4414, 4534, 4628, 4970, 4972, 4986, 5020, 5322, 5738, 5754, 6004, 6144, + 6284, 6334, 6694, 6758, 6818, 6842, 7084, 7104, 7150, 7196, 7304, 7350, 7414, 7416, 7502, 7582, + 7584, 7648, 7670, 7676, 7678, 7680, 7682, 7696, 7734, 7834, 7910, 7914, 7916, 8024, 8026, 8194, + 8286, 8302, 8308, 8362, 8366, 8552, 8576, 8578, 8580, 8582, 8586, 8588, 8620, 8862, 8914, 9006, + 9008, 9010, 9012, 9014, 9064, 9094, 9114, 9132, 9172, 9182, 9184, 9190, 9210, 9320, 9322, 9344, + 9352, 9394, 9408, 9422, 9428, 9446, 9610, 9656, 9712, 9730, 9732, 9760, 10148, 10168, 10170, 10172, + 10178, 10180, 10182, 10184, 10186, 10188, 10210, 10332, 10338, 10424, 10426, 10496, 10500, 10502, 10534, 10576, + 10632, 10634, 10644, 10666, 10674, 10684, 10754, 10806, 10892, 10952, 10954, 10956, 10958, 10960, 11016, 11048, + 11050, 11134, 11142, 11238, 11292, 11294, 11300, 11302, 11318, 11350, 11352, 11356, 11378, 11462, 11478, 11480, + 11504, 11506, 11508, 11544, 11548, 11552, 11554, 11556, 11558, 11560, 11588, 11660, 11662, 11664, 11666, 11714, + 11724, 11766, 11790, 11792, 11818, 11870, 11906, 11908, 11988, 11992, 12174, 12186, 12224, 12234, 12246, 12248, + 12304, 12306, 12350, 12354, 12376, 12380, 12388, 12390, 12406, 12414, 12418, 12432, 12434, 12630, 12632, 12634, + 12638, 12640, 12646, 12648, 12650, 12842, 12848, 12856, 12864, 12866, 12868, 12872, 12874, 12892, 12996, 13002, + 13022, 13024, 13062, 13068, 13106, 13108, 13112, 13140, 13162, 13196, 13228, 13266, 13268, 13328, 13372, 13396, + 13398, 13408, 13412, 13414, 13418, 13424, 13432, 13434, 13458, 13460, 13500, 13608, 13692, 13714, 13716, 13718, + 13836, 13838, 13840, 13892, 13894, 13896, 13898, 13900, 13936, 13938, 13960, 13968, 14004, 14006, 14028, 14052, + 14068, 14070, 14086, 14146, 14148, 14196, 14232, 14234, 14238, 14266, 14288, 14290, 14390, 14506, 14550, 14552, + 14566, 14596, 14604, 14612, 14632, 14694, 14720, 14722, 14800, 14830, 14836, 14866, 14990, 14992, 15020, 15104, + 15106, 15196, 15232, 15236, 15238, 15280, 15284, 15288, 15290, 15336, 15338, 15380, 15392, 15416, 15460, 15466, + 15522, 15548, 15550, 15596, 15654, 15694, 15696, 15702, 15718, 15726, 15780, 15782, 15798, 15800, 15826, 15840, + 15842, 15850, 15852, 15890, 15934, 15936, 15944, 15946, 15980, 15982, 15984, 15992, 16012, 16048, 16134, 16180, + 16212, 16216, 16218, 16262, 16268, 16270, 16314, 16316, 16362, 16364, +}; + +/* S_DOUBLE_JUMP if anything worn has the Double Jump mod */ +int double_jump_state(const ::peer &p) +{ + if (!p.recent_worlds.empty() && world_jammer(p.recent_worlds.back(), 4992)) return S_DOUBLE_JUMP; // @note Antigravity Generator + for (float cloth : p.clothing) + if (std::ranges::binary_search(double_jump_items, static_cast(cloth))) return S_DOUBLE_JUMP; + return 0; +} + +/* clothing mods, states and world effects for the wrench menu */ +std::string mods_wrench(const ::peer &p) +{ + std::string out{}; + auto line = [&](const std::string &label, int icon) + { + out += std::format("add_label_with_icon|small|`w{}``|left|{}|\n", label, icon); + }; + + for (float cloth : p.clothing) + { + const u_short id = static_cast(cloth); + if (id == 0) continue; + const std::string &name = id_to_item(id).raw_name; + if (std::ranges::binary_search(double_jump_items, id)) line(std::format("Double Jump: {}", name), id); + if (get_punch_id(id) != 0) line(std::format("Punch Effect: {}", name), id); + if (id == 12412) line("Punch Range: Wizard's Wand", id); + if (id == 1956) line("Punch Damage: Chaos Cursed Wand", id); + } + + if (p.god_mode) line("God Mode: nothing can hurt you", 18); + if (p.state & S_GHOST) line("Ghost: walk through anything", 18); + if (p.state & S_DUCT_TAPE) line("Duct Tape: mmmfff!", 408); + if (auto w = std::ranges::find(worlds, p.recent_worlds.back(), &::world::name); w != worlds.end() && w->base_weather == 7) + line("Low Gravity: Mars", 1136); + return out; +} \ No newline at end of file diff --git a/include/commands/useitems.hpp b/include/commands/useitems.hpp new file mode 100644 index 00000000..94d5f233 --- /dev/null +++ b/include/commands/useitems.hpp @@ -0,0 +1,12 @@ +#pragma once + +extern bool item_use(ENetEvent &event, ::world &world, const ::item &item, ::gamePacket &gamePacket); +extern void useitems_tick(std::time_t now); + +/* read by SetClothing */ +extern int item_effect_state(const ::peer &p); +extern u_int item_skin(const ::peer &p); +extern ::pos item_speed(const ::peer &p); +extern std::string item_effects_wrench(const ::peer &p); +extern int double_jump_state(const ::peer &p); +extern std::string mods_wrench(const ::peer &p); diff --git a/include/commands/wands.cpp b/include/commands/wands.cpp new file mode 100644 index 00000000..9b8ea160 --- /dev/null +++ b/include/commands/wands.cpp @@ -0,0 +1,188 @@ +#include "pch.hpp" +#include "tools/bubble.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "onVariant/SetClothing.hpp" +#include "action/join_request.hpp" +#include "action/quit_to_exit.hpp" +#include "action/respawn.hpp" +#include "database/items.hpp" +#include "database/world.hpp" +#include "events.hpp" +#include "useitems.hpp" +#include "buffs.hpp" +#include "godmode.hpp" +#include "dropqueue.hpp" +#include "euphoria.hpp" +#include "shutdown.hpp" +#include "legendary.hpp" +#include "wands.hpp" + +static constexpr short FREEZE_WAND = 274; +static constexpr short FIRE_WAND = 276; +static constexpr short CURSE_WAND = 278; +static constexpr short BAN_WAND = 732; + +/* the player standing on tile (x, y) in this world (not counting skip), or nullptr */ +static ENetPeer *player_at(const std::string &world, int x, int y, const ::peer *skip) +{ + ENetPeer *hit = nullptr; + peers(world, PEER_SAME_WORLD, [&](ENetPeer &p) + { + ::peer *t = static_cast<::peer*>(p.data); + if (!t || t == skip || hit) return; + const int x0 = static_cast(std::floor(t->pos.x / 32.0f)); + const int x1 = static_cast(std::floor((t->pos.x + 19.0f) / 32.0f)); + const int y0 = static_cast(std::floor(t->pos.y / 32.0f)); + const int y1 = static_cast(std::floor((t->pos.y + 29.0f) / 32.0f)); + if (x >= x0 && x <= x1 && y >= y0 && y <= y1) hit = &p; + }); + return hit; +} + +static void announce(const std::string &text) +{ + peers("", peer_condition::PEER_ALL, [&](ENetPeer &p) + { + ::peer *o = static_cast<::peer*>(p.data); + if (o && !o->growid.empty()) on::ConsoleMessage(&p, text); + }); +} + +bool wand_use(ENetEvent& event, ::world &world, const ::item &item, ::gamePacket &gamePacket) +{ + const short id = static_cast(item.id); + if (id != FREEZE_WAND && id != FIRE_WAND && id != CURSE_WAND && id != BAN_WAND) return false; + + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (pPeer->role < MODERATOR) + { + tell(event.peer, "`4Only staff can use this.``"); + return true; + } + ENetPeer *found = player_at(world.name, static_cast(gamePacket.punch.x), static_cast(gamePacket.punch.y), pPeer); + if (!found) found = player_at(world.name, static_cast(gamePacket.punch.x), static_cast(gamePacket.punch.y), nullptr); // @note yourself + if (!found) + { + tell(event.peer, "`4Use that on a player.``"); + return true; + } + ::peer *pTarget = static_cast<::peer*>(found->data); + + if (pTarget->role > pPeer->role) + { + tell(event.peer, "`4That player is immune to your wand.``"); + return true; + } + + const std::string name = pTarget->growid; + const std::time_t now = std::time(nullptr); + + switch (id) + { + case FIRE_WAND: + { + ENetEvent ev{}; + ev.peer = found; + action::respawn(ev, ""); + on::ConsoleMessage(found, std::format("`w{}`` burned you with a `4Fire Wand``!", pPeer->growid)); + on::ConsoleMessage(event.peer, std::format("`2You burned `w{}``.``", name)); + break; + } + case FREEZE_WAND: + { + if (pTarget->frozen && pTarget->freeze_until == 0) + { + on::ConsoleMessage(event.peer, std::format("`w{}`` is already frozen.", name)); + return true; + } + pTarget->frozen = true; + pTarget->state |= S_FROZEN; + pTarget->freeze_until = now + 10; + on::SetClothing(*found); + send_varlist(found, { "OnSetFreezeState", 1u }, pTarget->netid); + on::ConsoleMessage(found, std::format("`w{}`` froze you with a `1Freeze Wand``!", pPeer->growid)); + on::ConsoleMessage(event.peer, std::format("`2You froze `w{}`` for 10 seconds.``", name)); + break; + } + case CURSE_WAND: + { + pTarget->curse_until = now + 600; + pTarget->mysql_update("curse_until", static_cast(pTarget->curse_until)); + announce(std::format("`#** `$The Ancient Ones`` have used `#Curse`` on `w{}``! `#**``", name)); + tell(found, "`4You have been cursed`` for `w10 mins``. Reason: `wCurse Wand``"); + on::ConsoleMessage(event.peer, std::format("`2Cursed `w{}`` for 10 minutes.``", name)); + if (pTarget->recent_worlds.back() != "HELL") + { + ENetEvent ev{}; + ev.peer = found; + action::quit_to_exit(ev, "", true); + action::join_request(ev, "", "HELL"); + } + else on::SetClothing(*found); + break; + } + case BAN_WAND: + { + pTarget->ban_until = now + 600; + pTarget->mysql_update("ban_until", static_cast(pTarget->ban_until)); + announce(std::format("`#** `$The Ancient Ones`` have `4banned`` `w{}``! `#**``", name)); + tell(found, "`4You have been banned`` for `w10 minutes``."); + on::ConsoleMessage(event.peer, std::format("`2Banned `w{}`` for 10 minutes.``", name)); + enet_peer_disconnect_later(found, 0); + break; + } + } + modify_item_inventory(event, ::slot(id, -1)); // @note wands are used up + return true; +} + +/* runs about once a second: ends wand freezes and expired curses */ +void tick_timers() +{ + static std::time_t last = 0; + const std::time_t now = std::time(nullptr); + dropqueue_pump(); + leave_pump(); // @note every loop, not just once a second + if (now == last) return; + last = now; + events_tick(now); + euphoria_tick(now); + shutdown_tick(now); + legend_tick(now); + useitems_tick(now); + buffs_tick(now); + god_rejoin_tick(now); + + peers("", peer_condition::PEER_ALL, [now](ENetPeer &p) + { + ::peer *t = static_cast<::peer*>(p.data); + if (!t || t->growid.empty()) return; + + if (t->freeze_until != 0 && now >= t->freeze_until) + { + t->freeze_until = 0; + t->frozen = false; + t->state &= ~S_FROZEN; + if (t->netid != 0) + { + on::SetClothing(p); + send_varlist(&p, { "OnSetFreezeState", 0u }, t->netid); + } + on::ConsoleMessage(&p, "`2You can move again.``"); + } + + if (t->curse_until != 0 && now >= t->curse_until) + { + t->curse_until = 0; + t->mysql_update("curse_until", static_cast(0)); + if (t->netid != 0) on::SetClothing(p); + on::ConsoleMessage(&p, "`2Your curse has worn off!`` Sending you back..."); + if (t->netid != 0 && t->recent_worlds.back() == "HELL") + { + ENetEvent ev{}; + ev.peer = &p; + action::quit_to_exit(ev, "", false); // @note back to the world menu + } + } + }); +} diff --git a/include/commands/wands.hpp b/include/commands/wands.hpp new file mode 100644 index 00000000..e97f2307 --- /dev/null +++ b/include/commands/wands.hpp @@ -0,0 +1,4 @@ +#pragma once + +extern bool wand_use(ENetEvent& event, ::world &world, const ::item &item, ::gamePacket &gamePacket); +extern void tick_timers(); diff --git a/include/commands/warp.cpp b/include/commands/warp.cpp index 1b4404a7..80aa23e9 100644 --- a/include/commands/warp.cpp +++ b/include/commands/warp.cpp @@ -3,10 +3,16 @@ #include "action/quit_to_exit.hpp" #include "onVariant/ConsoleMessage.hpp" +#include "action/respawn.hpp" #include "warp.hpp" void warp(ENetEvent& event, const std::string_view text) { + if (::peer *pCursed = static_cast<::peer*>(event.peer->data); pCursed->curse_until > std::time(nullptr) && pCursed->recent_worlds.back() == "HELL") + { + action::respawn(event, ""); + return; + } std::string world_name{ text.substr(strlen("warp ")) }; for (char &c : world_name) c = std::toupper(c); // @note start -> START diff --git a/include/commands/warpto.cpp b/include/commands/warpto.cpp new file mode 100644 index 00000000..46ccff77 --- /dev/null +++ b/include/commands/warpto.cpp @@ -0,0 +1,60 @@ +#include "pch.hpp" +#include "action/join_request.hpp" +#include "action/quit_to_exit.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "warpto.hpp" + +void warpto(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < MODERATOR) + { + on::ConsoleMessage(event.peer, "`4Only staff can use this.``"); + return; + } + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + if (arg.empty()) + { + on::ConsoleMessage(event.peer, "`4Usage: /warpto {player}``"); + return; + } + + std::string lower_target = arg; + for (char &c : lower_target) c = std::tolower(static_cast(c)); + + std::string target_world{}; + ::pos target_pos{}; + peers("", peer_condition::PEER_ALL, [&](ENetPeer &p) + { + ::peer *pTarget = static_cast<::peer*>(p.data); + if (!pTarget || pTarget->netid == 0) return; + + std::string their_name = pTarget->growid; + for (char &c : their_name) c = std::tolower(static_cast(c)); + if (their_name == lower_target) { target_world = pTarget->recent_worlds.back(); target_pos = pTarget->pos; } + }); + + if (target_world.empty()) + { + on::ConsoleMessage(event.peer, std::format("`4`w{}`` is not online or not in a world.``", arg)); + return; + } + + if (target_world == pPeer->recent_worlds.back()) + { + pPeer->pos = target_pos; // @note same world: just move there + peers(target_world, PEER_SAME_WORLD, [&](ENetPeer &p) { send_varlist(&p, { "OnSetPos", CL_Vec2f{ target_pos.x, target_pos.y } }, pPeer->netid); }); + on::ConsoleMessage(event.peer, std::format("`2Warped to `w{}```.``", arg)); + return; + } + + send_action(*event.peer, "log", std::format("msg|Warping to `5{}``...", target_world)); + action::quit_to_exit(event, "", true); + arrive_at(static_cast(pPeer->user_id), target_pos); // @note land where they stand + action::join_request(event, "", target_world); +} diff --git a/include/commands/warpto.hpp b/include/commands/warpto.hpp new file mode 100644 index 00000000..415f6595 --- /dev/null +++ b/include/commands/warpto.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void warpto(ENetEvent& event, const std::string_view text); diff --git a/include/commands/wear.cpp b/include/commands/wear.cpp new file mode 100644 index 00000000..e574bf4c --- /dev/null +++ b/include/commands/wear.cpp @@ -0,0 +1,48 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "onVariant/SetClothing.hpp" +#include "database/items.hpp" +#include "database/world.hpp" +#include "wear.hpp" + +/* /wear {itemID} -> equips a clothing item straight onto your character */ +void wear(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + std::erase_if(arg, [](char c){ return !std::isdigit(static_cast(c)); }); + if (arg.empty()) + { + on::ConsoleMessage(event.peer, "`4Usage: /wear {itemID}``"); + return; + } + + signed id{}; + try { id = std::stoi(arg); } + catch (...) { on::ConsoleMessage(event.peer, "`4That is not a valid item id.``"); return; } + + const ::item &it = id_to_item(static_cast(id)); + + if (it.cloth_type == clothing::NONE) + { + on::ConsoleMessage(event.peer, "`4That item can't be worn.``"); + return; + } + + pPeer->clothing[it.cloth_type] = static_cast(id); + pPeer->update_effects(); + on::SetClothing(*event.peer); + peers(pPeer->recent_worlds.back(), PEER_SAME_WORLD, [pPeer](ENetPeer &p) { ::peer *o = static_cast<::peer*>(p.data); if (o && o->user_id != pPeer->user_id) on::SetClothing(p, *pPeer); }); + + on::ConsoleMessage(event.peer, std::format("`2Now wearing item `w{}``.``", id)); +} diff --git a/include/commands/wear.hpp b/include/commands/wear.hpp new file mode 100644 index 00000000..d78a4270 --- /dev/null +++ b/include/commands/wear.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void wear(ENetEvent& event, const std::string_view text); diff --git a/include/commands/whois.cpp b/include/commands/whois.cpp new file mode 100644 index 00000000..8be04609 --- /dev/null +++ b/include/commands/whois.cpp @@ -0,0 +1,89 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/database.hpp" +#include "database/world.hpp" +#include "whois.hpp" + +/* /whois {player} -> account details for anyone, online or not */ +void whois(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < MODERATOR) + { + on::ConsoleMessage(event.peer, "`4Only staff can use this.``"); + return; + } + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + while (!arg.empty() && std::isspace(static_cast(arg.back()))) arg.pop_back(); + + if (arg.empty()) + { + on::ConsoleMessage(event.peer, "`4Usage: /whois {player}``"); + return; + } + + std::string lower_name = arg; + for (char &c : lower_name) c = std::tolower(static_cast(c)); + + signed uid = 0, role = 0, gems = 0; + { + ::hStmt hStmt{ "SELECT uid FROM peer WHERE growid = ? LIMIT 1" }; + MYSQL_BIND param = make_bind_in(lower_name); + hStmt.bind_param(¶m); + MYSQL_BIND result = make_bind_out(uid); + mysql_stmt_bind_result(hStmt.pStmt, &result); + hStmt.execute(); + hStmt.fetch(); + } + + if (uid == 0) + { + on::ConsoleMessage(event.peer, std::format("`4No account named `w{}`` exists.``", arg)); + return; + } + + { + ::hStmt hStmt{ "SELECT role FROM peer WHERE uid = ? LIMIT 1" }; + MYSQL_BIND param = make_bind_in(uid); + hStmt.bind_param(¶m); + MYSQL_BIND result = make_bind_out(role); + mysql_stmt_bind_result(hStmt.pStmt, &result); + hStmt.execute(); + hStmt.fetch(); + } + + { + ::hStmt hStmt{ "SELECT gems FROM peer WHERE uid = ? LIMIT 1" }; + MYSQL_BIND param = make_bind_in(uid); + hStmt.bind_param(¶m); + MYSQL_BIND result = make_bind_out(gems); + mysql_stmt_bind_result(hStmt.pStmt, &result); + hStmt.execute(); + hStmt.fetch(); + } + + static constexpr const char *labels[3] = { "Player", "Moderator", "Developer" }; + const char *label = (role >= 0 && role <= 2) ? labels[role] : "Unknown"; + + // are they online? + std::string where = "`4offline``"; + peers("", peer_condition::PEER_ALL, [&](ENetPeer &p) + { + ::peer *pTarget = static_cast<::peer*>(p.data); + if (!pTarget || pTarget->user_id != uid) return; + where = (pTarget->netid == 0) + ? "`2online`` `o(world menu)" + : std::format("`2online`` `oin `2{}``", pTarget->recent_worlds.back()); + }); + + on::ConsoleMessage(event.peer, std::format("`o--- `2{}`o ---", arg)); + on::ConsoleMessage(event.peer, std::format("`oUser id: `w{}``", uid)); + on::ConsoleMessage(event.peer, std::format("`oRole: `w{}``", label)); + on::ConsoleMessage(event.peer, std::format("`oGems: `w{}``", gems)); + on::ConsoleMessage(event.peer, std::format("`oStatus: {}", where)); +} diff --git a/include/commands/whois.hpp b/include/commands/whois.hpp new file mode 100644 index 00000000..d5e736ac --- /dev/null +++ b/include/commands/whois.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void whois(ENetEvent& event, const std::string_view text); diff --git a/include/commands/worldreload.cpp b/include/commands/worldreload.cpp new file mode 100644 index 00000000..4007c6e4 --- /dev/null +++ b/include/commands/worldreload.cpp @@ -0,0 +1,14 @@ +#include "pch.hpp" +#include "action/join_request.hpp" +#include "action/quit_to_exit.hpp" +#include "worldreload.hpp" + +void reload_world_all(const std::string &name) +{ + peers(name, PEER_SAME_WORLD, [&](ENetPeer &p) + { + ENetEvent fake{ .peer = &p }; + action::quit_to_exit(fake, "", true); + action::join_request(fake, "", name); + }); +} diff --git a/include/commands/worldreload.hpp b/include/commands/worldreload.hpp new file mode 100644 index 00000000..6719fae6 --- /dev/null +++ b/include/commands/worldreload.hpp @@ -0,0 +1,4 @@ +#pragma once + +/* everybody in the world leaves and enters again, so they all get the changed world (no need to rejoin by hand) */ +extern void reload_world_all(const std::string &name); diff --git a/include/commands/worlds.cpp b/include/commands/worlds.cpp new file mode 100644 index 00000000..ef5bc57f --- /dev/null +++ b/include/commands/worlds.cpp @@ -0,0 +1,55 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "database/database.hpp" +#include "database/world.hpp" +#include "worlds.hpp" + +/* /worlds -> lists every world stored in the database */ +void worlds_cmd(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < MODERATOR) + { + on::ConsoleMessage(event.peer, "`4Only staff can use this.``"); + return; + } + + ::hStmt hStmt{ "SELECT name FROM world ORDER BY name" }; + hStmt.execute(); + + char buffer[32]{}; + u_long length = 0; + MYSQL_BIND result{}; + result.buffer_type = MYSQL_TYPE_STRING; + result.buffer = buffer; + result.buffer_length = sizeof(buffer); + result.length = &length; + mysql_stmt_bind_result(hStmt.pStmt, &result); + + std::vector names; + while (mysql_stmt_fetch(hStmt.pStmt) == 0) + { + names.emplace_back(buffer, length); + memset(buffer, 0, sizeof(buffer)); + } + + if (names.empty()) + { + on::ConsoleMessage(event.peer, "`4No worlds stored yet.``"); + return; + } + + // which of them are loaded right now + std::string line{}; + for (const std::string &n : names) + { + const bool loaded = std::ranges::find(worlds, n, &::world::name) != worlds.end(); + if (!line.empty()) line += "`o, "; + line += loaded ? std::format("`2{}``", n) : std::format("`w{}``", n); + } + + on::ConsoleMessage(event.peer, + std::format("`o{} world{}: {}", names.size(), names.size() == 1 ? "" : "s", line)); + on::ConsoleMessage(event.peer, "`o(`2green`o = currently loaded)"); +} diff --git a/include/commands/worlds.hpp b/include/commands/worlds.hpp new file mode 100644 index 00000000..61f0fb60 --- /dev/null +++ b/include/commands/worlds.hpp @@ -0,0 +1,2 @@ +#pragma once +extern void worlds_cmd(ENetEvent& event, const std::string_view text); diff --git a/include/commands/worldtools.cpp b/include/commands/worldtools.cpp new file mode 100644 index 00000000..052751f7 --- /dev/null +++ b/include/commands/worldtools.cpp @@ -0,0 +1,574 @@ +#include "pch.hpp" +#include "tools/bubble.hpp" +#include +#include "onVariant/ConsoleMessage.hpp" +#include "onVariant/NameChanged.hpp" +#include "action/join_request.hpp" +#include "action/quit_to_exit.hpp" +#include "tools/create_dialog.hpp" +#include "tools/string.hpp" +#include "database/database.hpp" +#include "database/items.hpp" +#include "database/world.hpp" +#include "dropqueue.hpp" +#include "worldtools_use.hpp" + +static constexpr short BIRTH_CERTIFICATE = 1280; +static constexpr short CHANGE_OF_ADDRESS = 2580; +static constexpr short LOCK_MOVER = 3560; +static constexpr short DOOR_MOVER = 1404; +static constexpr short GROWPEDIA = 6336; +static constexpr short FLASHBACK_FLAN = 6916; + +static int count_of(const ::peer &p, short id) +{ + for (const ::slot &s : p.slots) if (s.id == id) return s.count; + return 0; +} + +static std::string trimmed(std::string s) +{ + while (!s.empty() && std::isspace(static_cast(s.back()))) s.pop_back(); + while (!s.empty() && std::isspace(static_cast(s.front()))) s.erase(0, 1); + return s; +} + +static std::string lower(std::string s) +{ + for (char &c : s) c = static_cast(std::tolower(static_cast(c))); + return s; +} + +/* everyone in the world re-enters it, so they see changes to doors/locks */ +static void reload_world_for_everyone(const std::string &name) +{ + peers(name, PEER_SAME_WORLD, [&](ENetPeer &p) + { + ENetEvent fake{ .peer = &p }; + action::quit_to_exit(fake, "", true); + action::join_request(fake, "", name); + }); +} + +bool tool_use(ENetEvent &event, ::world &world, const ::item &item, ::gamePacket &gamePacket) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + const short id = static_cast(item.id); + const int x = static_cast(gamePacket.punch.x); + const int y = static_cast(gamePacket.punch.y); + const int width = static_cast(world.blocks.size() / 60); + + switch (id) + { + case GROWPEDIA: + { + send_varlist(event.peer, { "OnDialogRequest", + ::create_dialog() + .set_default_color("`o") + .add_label_with_icon("big", "`wGrowpedia``", GROWPEDIA) + .add_spacer("small") + .add_textbox("Welcome to Growtopia! A few tips to get you started:") + .add_smalltext("- Punch blocks to break them and collect seeds and gems.") + .add_smalltext("- Plant a seed on top of another seed to splice them into something new.") + .add_smalltext("- Get a `wWorld Lock`` to protect your world.") + .add_smalltext("- Type `w/?`` to see the commands you can use.") + .end_dialog("growpedia", "Close", "") }); + return true; + } + case FLASHBACK_FLAN: + { + if (x < 0 || y < 0 || x >= width || y >= 60) return true; + if (world.owner != pPeer->user_id) + { + tell(event.peer, "`4You can only use that in a world you own.``"); + return true; + } + ::block &b = world.blocks[cord(x, y)]; + const bool fg = b.fg != 0; + const short target = fg ? b.fg : b.bg; + if (target == 0) + { + tell(event.peer, "`4Use that on a block.``"); + return true; + } + const ::item &t = id_to_item(target); + if (target == 8 || t.type == type::MAIN_DOOR || t.type == type::LOCK || t.type == type::SEED || t.type == type::STRONG || get_type(t) != 0) + { + tell(event.peer, "`4That block can't be rewound.``"); + return true; + } + modify_item_inventory(event, ::slot(id, -1)); + modify_item_inventory(event, ::slot(target, 1)); + if (fg) b.fg = 0; else b.bg = 0; + send_tile_update(event, ::gamePacket{ .punch = ::pos{ x, y } }, b, world); + on::ConsoleMessage(event.peer, std::format("`2Time rewinds... the `w{}`` is back in your backpack!``", t.raw_name)); + return true; + } + case DOOR_MOVER: + { + if (x < 0 || y < 0 || x >= width || y + 1 >= 60) return true; + if (world.owner != pPeer->user_id) + { + tell(event.peer, "`4You can only use that in a world you own.``"); + return true; + } + if (world.blocks[cord(x, y)].fg != 0 || world.blocks[cord(x, y + 1)].fg != 0) + { + tell(event.peer, "`4There's no room to put the door there! You need 2 empty spaces vertically.``"); + return true; + } + + ::pos old_pos{ -1, -1 }; + for (std::size_t i = 0; i < world.blocks.size(); ++i) + if (world.blocks[i].fg == 6) // @note Main Door + { + const int ox = static_cast(i % width), oy = static_cast(i / width); + old_pos = ::pos{ ox, oy }; + world.blocks[i].fg = 0; + if (oy + 1 < 60 && world.blocks[cord(ox, oy + 1)].fg == 8) world.blocks[cord(ox, oy + 1)].fg = 0; + break; + } + const ::pos new_pos{ x, y }; + std::erase_if(world.doors, [&](const ::door &d) { return d.pos == new_pos; }); // @note clear any leftover record + auto door = std::ranges::find(world.doors, old_pos, &::door::pos); + if (door != world.doors.end()) door->pos = new_pos; + else world.doors.emplace_back(::door("EXIT", "", "", new_pos)); + + world.blocks[cord(x, y)].fg = 6; + world.blocks[cord(x, y + 1)].fg = 8; + world.spawn = new_pos.by_32(false); + on::ConsoleMessage(event.peer, std::format("`oDoor moved from (`w{},{}``) to (`w{},{}``).``", static_cast(old_pos.x), static_cast(old_pos.y), x, y)); + + modify_item_inventory(event, ::slot(id, -1)); + const std::string name = world.name; + reload_world_for_everyone(name); + return true; + } case LOCK_MOVER: + { + if (x < 0 || y < 0 || x >= width || y >= 60) return true; + if (world.owner != pPeer->user_id) + { + tell(event.peer, "`4You can only use that in a world you own.``"); + return true; + } + ::block &dest = world.blocks[cord(x, y)]; + if (dest.fg != 0) + { + tell(event.peer, "`4There's no room to put the lock there!``"); + return true; + } + ::block *lock = nullptr; + for (::block &b : world.blocks) + if (id_to_item(b.fg).type == type::LOCK && !is_tile_lock(b.fg)) { lock = &b; break; } + if (!lock) + { + tell(event.peer, "`4This world has no World Lock to move.``"); + return true; + } + dest.fg = lock->fg; + lock->fg = 0; + modify_item_inventory(event, ::slot(id, -1)); + const std::string name = world.name; + reload_world_for_everyone(name); + return true; + } + case BIRTH_CERTIFICATE: + { + const std::time_t now = std::time(nullptr); + const long long wait = 60LL * 86400 - (static_cast(now) - static_cast(pPeer->renamed_at)); + if (pPeer->renamed_at != 0 && wait > 0) + { + tell(event.peer, std::format("`4You can only change your name once every 60 days.`` Try again in `w{}`` days.", (wait + 86399) / 86400)); + return true; + } + send_varlist(event.peer, { "OnDialogRequest", + ::create_dialog() + .set_default_color("`o") + .add_label_with_icon("big", "`wBirth Certificate``", BIRTH_CERTIFICATE) + .add_textbox("Choose your new GrowID. It must be 3-18 letters and numbers, and not already taken. Your name changes right away.") + .add_text_input("name", "New GrowID:", std::string{}, 18) + .end_dialog("birth_certificate", "Cancel", "Change Name") }); + return true; + } + case CHANGE_OF_ADDRESS: + { + if (world.owner != pPeer->user_id) + { + tell(event.peer, "`4You must own this world to swap its name.``"); + return true; + } + send_varlist(event.peer, { "OnDialogRequest", + ::create_dialog() + .set_default_color("`o") + .add_label_with_icon("big", "`wChange of Address``", CHANGE_OF_ADDRESS) + .add_textbox(std::format("Enter the name of another world you own. It will swap names with `w{}``. You must be alone here, and nobody can be in the other world.", world.name)) + .add_text_input("world", "Other world:", std::string{}, 24) + .end_dialog("change_address", "Cancel", "Swap Names") }); + return true; + } + } + return false; +} + +void birth_certificate_return(ENetEvent &event, const ::hPipe &hPipe) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (count_of(*pPeer, BIRTH_CERTIFICATE) < 1) return; + + const std::string name = trimmed(hPipe["name"]); + if (name.size() < 3 || name.size() > 18 || !alnum(name)) + { + tell(event.peer, "`4Names must be 3-18 letters and numbers only.``"); + return; + } + if (lower(name) == lower(pPeer->growid)) + { + tell(event.peer, "`4That's already your name!``"); + return; + } + if (pPeer->exists(name)) + { + tell(event.peer, "`4That name is already taken.``"); + return; + } + + const std::string old = pPeer->growid; + { + ::hStmt h{ "UPDATE peer SET growid = ? WHERE growid = ?" }; + MYSQL_BIND params[2] = { make_bind_in(name), make_bind_in(old) }; + h.bind_param(params); + h.execute(); + } + { + ::hStmt h{ "UPDATE renames SET new_name = ? WHERE new_name = ?" }; // @note older names now point to the newest + MYSQL_BIND params[2] = { make_bind_in(name), make_bind_in(old) }; + h.bind_param(params); + h.execute(); + } + { + ::hStmt h{ "DELETE FROM renames WHERE old_name = ?" }; // @note the new name is a real account now + MYSQL_BIND param = make_bind_in(name); + h.bind_param(¶m); + h.execute(); + } + { + ::hStmt h{ "REPLACE INTO renames (old_name, new_name) VALUES (?, ?)" }; + MYSQL_BIND params[2] = { make_bind_in(old), make_bind_in(name) }; + h.bind_param(params); + h.execute(); + } pPeer->growid = name; + const std::time_t now = std::time(nullptr); + pPeer->renamed_at = now; + pPeer->mysql_update("renamed_at", static_cast(now)); + modify_item_inventory(event, ::slot(BIRTH_CERTIFICATE, -1)); + + send_varlist(event.peer, { "SetHasGrowID", 1, name.c_str(), pPeer->password.c_str() }); + on::ConsoleMessage(event.peer, std::format("`2Your GrowID is now `w{}``!``", name)); + printf("[rename] %s -> %s\n", old.c_str(), name.c_str()); + if (pPeer->nickname.empty()) + { + auto w = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + pPeer->display_growid = + (pPeer->role >= DEVELOPER) ? std::format("`6@{}``", name) : + (pPeer->role >= MODERATOR) ? std::format("`5@{}``", name) : + (w != worlds.end() && w->owner == pPeer->user_id) ? std::format("`2{}``", name) : + std::format("`w{}``", name); + } + if (pPeer->netid != 0) on::NameChanged(event); +} + +void change_address_return(ENetEvent &event, const ::hPipe &hPipe) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (pPeer->netid == 0 || count_of(*pPeer, CHANGE_OF_ADDRESS) < 1) return; + + std::string other = trimmed(hPipe["world"]); + for (char &c : other) c = static_cast(std::toupper(static_cast(c))); + if (other.empty() || other.size() > 24 || !alnum(other)) + { + tell(event.peer, "`4That's not a valid world name.``"); + return; + } + const std::string current = pPeer->recent_worlds.back(); + if (other == current) + { + tell(event.peer, "`4Pick a different world.``"); + return; + } + auto cur = std::ranges::find(worlds, current, &::world::name); + if (cur == worlds.end()) return; + if (cur->owner != pPeer->user_id) + { + tell(event.peer, "`4You must own this world.``"); + return; + } + if (cur->visitors > 1) + { + tell(event.peer, "`4Everyone else has to leave this world first.``"); + return; + } + if (std::ranges::find(worlds, other, &::world::name) != worlds.end()) + { + tell(event.peer, std::format("`4Nobody can be in `w{}`` while you swap names.``", other)); + return; + } + if (!cur->exists(other)) + { + tell(event.peer, std::format("`4The world `w{}`` doesn't exist.``", other)); + return; + } + int other_owner = 0; + { + ::world probe(other); // @note loads it just to read the owner, saved again when it goes out of scope + other_owner = probe.owner; + } + if (other_owner != pPeer->user_id) + { + tell(event.peer, std::format("`4You must own `w{}`` too.``", other)); + return; + } + + modify_item_inventory(event, ::slot(CHANGE_OF_ADDRESS, -1)); + action::quit_to_exit(event, "", true); // @note unloads and saves this world + + auto rename = [](const std::string &from, const std::string &to) + { + ::hStmt h{ "UPDATE world SET name = ? WHERE name = ?" }; + MYSQL_BIND params[2] = { make_bind_in(to), make_bind_in(from) }; + h.bind_param(params); + h.execute(); + }; + const std::string tmp = "~SWAP~"; + rename(current, tmp); + rename(other, current); + rename(tmp, other); + printf("[address] swapped %s <-> %s\n", current.c_str(), other.c_str()); + + action::join_request(event, "", current); + on::ConsoleMessage(event.peer, std::format("`2Done!`` `w{}`` and `w{}`` have swapped names.", current, other)); +} + + +/* /tile [x y] -> what the server has stored at a tile (developers) */ +void tile_cmd(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (pPeer->role < DEVELOPER || pPeer->netid == 0) return; + + auto w = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + if (w == worlds.end()) return; + const int width = static_cast(w->blocks.size() / 60); + + int x = static_cast((pPeer->pos.x + 10.0f) / 32.0f); + int y = static_cast((pPeer->pos.y + 15.0f) / 32.0f) + 1; // @note the tile under your feet + std::string args{ text }; + if (const std::size_t sp = args.find(' '); sp != std::string::npos) + { + args = args.substr(sp + 1); + const std::size_t sp2 = args.find(' '); + if (sp2 != std::string::npos) { x = std::atoi(args.substr(0, sp2).c_str()); y = std::atoi(args.substr(sp2 + 1).c_str()); } + } + if (x < 0 || y < 0 || x >= width || y >= 60) + { + tell(event.peer, "`4Outside the world.``"); + return; + } + const ::block &b = w->blocks[cord(x, y)]; + on::ConsoleMessage(event.peer, std::format("`oTile (`w{},{}``): block `w{}`` ({}), background `w{}`` ({})``", + x, y, b.fg, id_to_item(b.fg).raw_name, b.bg, id_to_item(b.bg).raw_name)); +} + +/* /dumpitems -> writes every non-seed item to items_list.txt (developers) */ +void dumpitems_cmd(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (pPeer->role < DEVELOPER) return; + + std::ofstream out("items_list.txt"); + int n = 0; + for (const ::item &it : items) + { + if (it.type == type::SEED || it.raw_name.empty()) continue; + out << it.id << '|' << it.raw_name << '|' << static_cast(it.type) << '\n'; + ++n; + } + on::ConsoleMessage(event.peer, std::format("`2Wrote `w{}`` items to items_list.txt``", n)); +} + +/* worlds that /deleteallworlds never touches */ +static std::vector worlds_to_keep(const std::vector &extra) +{ + std::vector keep{ "HELL", "START" }; + for (const ::world &w : worlds) keep.push_back(w.name); + for (const std::string &e : extra) keep.push_back(e); + return keep; +} + +static std::string marks_for(std::size_t n) +{ + std::string marks{}; + for (std::size_t i = 0; i < n; ++i) marks += (i ? ", ?" : "?"); + return marks; +} + +static int count_deletable(const std::vector &keep) +{ + std::vector params{}; + for (const std::string &k : keep) params.push_back(make_bind_in(k)); + int count = 0; + ::hStmt h{ std::format("SELECT COUNT(*) FROM world WHERE name NOT IN ({})", marks_for(keep.size())).c_str() }; + h.bind_param(params.data()); + u_long length = 0; + MYSQL_BIND result = make_bind_out(count); + result.length = &length; + mysql_stmt_bind_result(h.pStmt, &result); + h.execute(); + h.fetch(); + return count; +} + +static unsigned long long delete_worlds(const std::vector &keep) +{ + std::vector params{}; + for (const std::string &k : keep) params.push_back(make_bind_in(k)); + ::hStmt h{ std::format("DELETE FROM world WHERE name NOT IN ({})", marks_for(keep.size())).c_str() }; + h.bind_param(params.data()); + h.execute(); + return mysql_stmt_affected_rows(h.pStmt); +} + +static std::unordered_map> pending_keep{}; // @note growid -> extra names to keep + +/* /deleteallworlds [NAMES TO KEEP...] -> asks for confirmation in a menu (developers) */ +void deleteallworlds_cmd(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (pPeer->role < DEVELOPER) return; + + std::vector extra{}; + { + std::string rest{ text }; + const std::size_t sp = rest.find(' '); + rest = (sp == std::string::npos) ? std::string{} : rest.substr(sp + 1); + std::string word{}; + for (char ch : rest + ' ') + { + if (std::isspace(static_cast(ch))) { if (!word.empty()) { extra.push_back(word); word.clear(); } } + else word += static_cast(std::toupper(static_cast(ch))); + } + } + + const std::vector keep = worlds_to_keep(extra); + const int count = count_deletable(keep); + if (count == 0) + { + on::ConsoleMessage(event.peer, "`oThere are no worlds to delete.``"); + return; + } + pending_keep[pPeer->growid] = extra; + + std::string kept{}; + for (const std::string &k : keep) kept += (kept.empty() ? "" : ", ") + k; + send_varlist(event.peer, { "OnDialogRequest", + ::create_dialog() + .set_default_color("`o") + .add_label_with_icon("big", "`4Delete All Worlds``", 1402) + .add_spacer("small") + .add_textbox(std::format("This will delete `w{}`` worlds forever. This can't be undone!", count)) + .add_smalltext(std::format("Kept: `w{}``", kept)) + .end_dialog("delete_all_worlds", "Cancel", "Delete Worlds") }); +} + +/* the "Delete Worlds" button */ +void delete_all_worlds_return(ENetEvent &event, const ::hPipe &hPipe) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (pPeer->role < DEVELOPER) return; + + auto it = pending_keep.find(pPeer->growid); + if (it == pending_keep.end()) return; + const std::vector extra = it->second; + pending_keep.erase(it); + + const unsigned long long deleted = delete_worlds(worlds_to_keep(extra)); + printf("[deleteallworlds] %s deleted %llu worlds\n", pPeer->growid.c_str(), deleted); + on::ConsoleMessage(event.peer, std::format("`2Deleted `w{}`` worlds.``", deleted)); +} + +/* /fillworld -> menu to fill every empty tile with a dropped item (developers) */ +void fillworld_cmd(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (pPeer->role < DEVELOPER) return; + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + send_varlist(event.peer, { "OnDialogRequest", + ::create_dialog() + .set_default_color("`o") + .add_label_with_icon("big", "`wFill World With Drops``", 112) + .add_textbox("Choose an item, and a dropped stack of it is placed on every empty tile in this world.") + .add_text_input("item", "Item (ID or exact name):", std::string{}, 40) + .add_text_input("amount", "Amount per drop (1-200):", std::string{ "1" }, 3) + .add_text_input("spacing", "Every Nth tile (1 = every tile):", std::string{ "1" }, 2) + .end_dialog("fill_world", "Cancel", "Fill World") }); +} + +/* the "Fill World" button */ +void fill_world_return(ENetEvent &event, const ::hPipe &hPipe) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (pPeer->role < DEVELOPER || pPeer->netid == 0) return; + + const std::string what = trimmed(hPipe["item"]); + short id = 0; + if (!what.empty() && std::ranges::all_of(what, [](char ch) { return std::isdigit(static_cast(ch)) != 0; })) + id = static_cast(std::atoi(what.c_str())); + else + for (const ::item &it : items) + if (!it.raw_name.empty() && lower(it.raw_name) == lower(what)) { id = static_cast(it.id); break; } + if (id <= 0 || id >= static_cast(items.size()) || id_to_item(static_cast(id)).raw_name.empty()) + { + tell(event.peer, "`4No item found with that ID or name.``"); + return; + } + + const int amount = std::clamp(std::atoi(hPipe["amount"].c_str()), 1, 200); + const int spacing = std::clamp(std::atoi(hPipe["spacing"].c_str()), 1, 20); + + auto w = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + if (w == worlds.end()) return; + + const int width = static_cast(w->blocks.size() / 60); + std::vector> spots{}; + for (int y = 0; y < 60 && spots.size() < 6000; ++y) + for (int x = 0; x < width && spots.size() < 6000; ++x) + if (x % spacing == 0 && y % spacing == 0 && w->blocks[cord(x, y)].fg == 0) spots.emplace_back(x, y); + const int placed = static_cast(spots.size()); + + for (const auto &[x, y] : spots) queue_add_drop(w->name, static_cast(id), static_cast(amount), ::pos{ x * 32.0f + 8.0f, y * 32.0f + 8.0f }); // @note sent in batches + + const std::string item_name = id_to_item(static_cast(id)).raw_name; + on::ConsoleMessage(event.peer, std::format("`2Dropped `w{}`` stacks of `w{}x {}``.``", placed, amount, item_name)); +} + +/* /cleardrops -> removes every dropped item in your world (developers) */ +void cleardrops_cmd(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (pPeer->role < DEVELOPER) return; + if (pPeer->netid == 0) + { + on::ConsoleMessage(event.peer, "`4You must be in a world.``"); + return; + } + auto w = std::ranges::find(worlds, pPeer->recent_worlds.back(), &::world::name); + if (w == worlds.end()) return; + + const std::size_t removed = w->objects.size(); + dropqueue_cancel_adds(w->name); // @note stop a fill that's still on its way + for (const ::object &o : w->objects) queue_remove_drop(w->name, static_cast(o.uid), pPeer->growid); // @note removed in batches + on::ConsoleMessage(event.peer, std::format("`2Removed `w{}`` dropped items.``", removed)); +} \ No newline at end of file diff --git a/include/commands/worldtools.hpp b/include/commands/worldtools.hpp new file mode 100644 index 00000000..500f1253 --- /dev/null +++ b/include/commands/worldtools.hpp @@ -0,0 +1,11 @@ +#pragma once + +extern void birth_certificate_return(ENetEvent &event, const ::hPipe &hPipe); +extern void change_address_return(ENetEvent &event, const ::hPipe &hPipe); +extern void tile_cmd(ENetEvent& event, const std::string_view text); +extern void dumpitems_cmd(ENetEvent& event, const std::string_view text); +extern void deleteallworlds_cmd(ENetEvent& event, const std::string_view text); +extern void delete_all_worlds_return(ENetEvent &event, const ::hPipe &hPipe); +extern void fillworld_cmd(ENetEvent& event, const std::string_view text); +extern void fill_world_return(ENetEvent &event, const ::hPipe &hPipe); +extern void cleardrops_cmd(ENetEvent& event, const std::string_view text); diff --git a/include/commands/worldtools_use.hpp b/include/commands/worldtools_use.hpp new file mode 100644 index 00000000..41c75c91 --- /dev/null +++ b/include/commands/worldtools_use.hpp @@ -0,0 +1,4 @@ +#pragma once +#include "worldtools.hpp" + +extern bool tool_use(ENetEvent &event, ::world &world, const ::item &item, ::gamePacket &gamePacket); diff --git a/include/commands/xp.cpp b/include/commands/xp.cpp new file mode 100644 index 00000000..f1395a4e --- /dev/null +++ b/include/commands/xp.cpp @@ -0,0 +1,77 @@ +#include "pch.hpp" +#include "onVariant/ConsoleMessage.hpp" +#include "onVariant/SetBux.hpp" +#include "xp.hpp" + +/* /xp {amount} -> grants yourself XP (dev, for testing) */ +void xp_cmd(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + std::erase_if(arg, [](char c){ return !std::isdigit(static_cast(c)); }); + if (arg.empty()) + { + on::ConsoleMessage(event.peer, "`4Usage: /xp {amount}``"); + return; + } + + signed amount{}; + try { amount = std::stoi(arg); } + catch (...) { on::ConsoleMessage(event.peer, "`4That is not a valid number.``"); return; } + + amount = std::clamp(amount, 1, 60000); + + pPeer->add_xp(event, static_cast(amount)); + + on::ConsoleMessage(event.peer, + std::format("`2Gained `w{}`` XP. Now level `w{}`` with `w{}`` XP.``", + amount, pPeer->level.front(), pPeer->level.back())); +} + +/* /level {n} -> jumps straight to a level (dev, for testing) */ +void level_cmd(ENetEvent& event, const std::string_view text) +{ + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + + if (pPeer->role < DEVELOPER) + { + on::ConsoleMessage(event.peer, "`4Only developers can use this.``"); + return; + } + + std::string arg{ text }; + if (const std::size_t sp = arg.find(' '); sp != std::string::npos) arg = arg.substr(sp + 1); + else arg.clear(); + + std::erase_if(arg, [](char c){ return !std::isdigit(static_cast(c)); }); + if (arg.empty()) + { + on::ConsoleMessage(event.peer, + std::format("`oYou are level `w{}`` with `w{}`` XP. Usage: /level {{1-125}}", + pPeer->level.front(), pPeer->level.back())); + return; + } + + signed target{}; + try { target = std::stoi(arg); } + catch (...) { on::ConsoleMessage(event.peer, "`4That is not a valid number.``"); return; } + + target = std::clamp(target, 1, 125); + + pPeer->level.front() = static_cast(target); + pPeer->level.back() = 0; + + send_varlist(event.peer, { "OnPlayerLeveledUp", pPeer->level.front() }); + + on::ConsoleMessage(event.peer, std::format("`2You are now level `w{}``.``", target)); +} diff --git a/include/commands/xp.hpp b/include/commands/xp.hpp new file mode 100644 index 00000000..9135fb6e --- /dev/null +++ b/include/commands/xp.hpp @@ -0,0 +1,3 @@ +#pragma once +extern void xp_cmd(ENetEvent& event, const std::string_view text); +extern void level_cmd(ENetEvent& event, const std::string_view text); diff --git a/include/database/database.cpp b/include/database/database.cpp index 8ee121ef..d7f82273 100644 --- a/include/database/database.cpp +++ b/include/database/database.cpp @@ -26,6 +26,20 @@ void create_table_if_not_exist() { std::fprintf(stderr, "%s\n", mysql_error(db)); } + + /* things added later: created here so nobody has to run SQL by hand (errors are ignored, they just mean "already there") */ + static constexpr const char *upgrades[]{ + "ALTER TABLE world ADD COLUMN IF NOT EXISTS access VARCHAR(400) NOT NULL DEFAULT ''", + "ALTER TABLE world ADD COLUMN IF NOT EXISTS treasure VARCHAR(2000) NOT NULL DEFAULT ''", + "ALTER TABLE world ADD COLUMN IF NOT EXISTS weather INT NOT NULL DEFAULT 0", + "ALTER TABLE peer ADD COLUMN IF NOT EXISTS curse_until INT NOT NULL DEFAULT 0", + "ALTER TABLE peer ADD COLUMN IF NOT EXISTS ban_until INT NOT NULL DEFAULT 0", + "ALTER TABLE peer ADD COLUMN IF NOT EXISTS god INT NOT NULL DEFAULT 0", + "ALTER TABLE peer ADD COLUMN IF NOT EXISTS renamed_at INT NOT NULL DEFAULT 0", + "CREATE TABLE IF NOT EXISTS renames (old_name VARCHAR(64) PRIMARY KEY, new_name VARCHAR(64) NOT NULL)", + "CREATE TABLE IF NOT EXISTS friends (uid INT NOT NULL, friend_uid INT NOT NULL, PRIMARY KEY (uid, friend_uid))", + }; + for (const char *sql : upgrades) mysql_query(db, sql); } void mysql_connect() diff --git a/include/database/items.cpp b/include/database/items.cpp index c77b8f97..e394f032 100644 --- a/include/database/items.cpp +++ b/include/database/items.cpp @@ -2,6 +2,8 @@ #include #include +#include +#include #include "items.hpp" std::vector<::item> items; @@ -14,6 +16,8 @@ const ::item &id_to_item(u_short id) noexcept // @note std::out_of_range is hand } +std::vector harmful_type_pos{}; // @note /god items.dat +u_int im_file_offset{}; std::vector im_data(sizeof(::gamePacket)/*inital packet*/, 0x00); template @@ -48,6 +52,7 @@ void decode_items() im_data = compress_state(::gamePacket{ .type = 0x10,/*PACKET_SEND_ITEM_DATABASE_DATA*/ .state = state::S_EXTENDED, .size = size }).data(); u_int pos = im_data.size(); // @note sizeof(::gamePacket) + im_file_offset = pos; im_data.resize(pos + size); // @note resize to fit binary data std::ifstream("items.dat", std::ios::binary) @@ -64,11 +69,18 @@ void decode_items() ::item item; shift_pos(im_data, pos, item.id); pos += 2; // @note downside im.id to 2 bit (short) + const u_int prop_at = pos; // @note where this item's property bytes sit in the raw data + const u_int item_start = pos; shift_pos(im_data, pos, item.property); + if (item.id == 8) { item.property = 0; memset(&im_data[prop_at], 0, pos - prop_at); } shift_pos(im_data, pos, item.cat); + const u_int type_at = pos; + const u_int type_pos = pos; shift_pos(im_data, pos, item.type); + if (item.type == type::DEADLY || item.type == type::FIRE_PAIN || item.type == type::STING_PAIN) harmful_type_pos.emplace_back(type_pos); + if (item.id == 8) { item.type = 17; im_data[type_at] = 17; } pos += sizeof(u_char); short len = *(reinterpret_cast(&im_data[pos])); @@ -96,6 +108,12 @@ void decode_items() shift_pos(im_data, pos, item.hit_reset); + if (item.id == 156 || item.id == 678 || item.id == 1206) + { + for (u_int b = item_start; b < item_start + 240 && b < im_data.size(); ++b) printf("%02x ", im_data[b]); + printf("\n"); + } + if (item.type == type::CLOTHING) { u_char cloth_type{}; @@ -103,6 +121,7 @@ void decode_items() } else pos += 1; // @note assign nothing if (item.type == type::AURA) item.cloth_type = clothing::ANCES; + const u_int rarity_at = pos; shift_pos(im_data, pos, item.rarity); pos += sizeof(u_char); @@ -205,5 +224,6 @@ void decode_items() items.emplace_back(item); } - printf("items.dat parsed successfully!\n"); + for (::item &it : items) if (it.id == 8) it.property = 0; + for (::item &it : items) if (it.id == 8) it.property = 0; } diff --git a/include/database/peer.cpp b/include/database/peer.cpp index 329c4308..42cd66da 100644 --- a/include/database/peer.cpp +++ b/include/database/peer.cpp @@ -3,11 +3,13 @@ #include "world.hpp" #include "onVariant/SetClothing.hpp" #include "onVariant/CountryState.hpp" +#include "commands/legendary.hpp" #include "onVariant/ConsoleMessage.hpp" #include "commands/punch.hpp" #include "tools/string.hpp" #include "peer.hpp" +#include "commands/buffs.hpp" bool peer::exists(const std::string &growid) { @@ -89,6 +91,23 @@ void peer::mysql_select_all() this->growid = this->mysql_select("growid"); this->password = this->mysql_select("password"); this->created_at = this->mysql_select("created_at", "UNIX_TIMESTAMP"); + this->gems = this->mysql_select("gems"); + this->role = static_cast(this->mysql_select("role")); + this->level.front() = static_cast(this->mysql_select("level")); + this->level.back() = static_cast(this->mysql_select("xp")); + this->curse_until = static_cast(this->mysql_select("curse_until")); + this->ban_until = static_cast(this->mysql_select("ban_until")); + this->god_mode = this->mysql_select("god") != 0; + this->renamed_at = static_cast(this->mysql_select("renamed_at")); + if (this->level.front() == 0) this->level.front() = 1; + { + std::vector cloth(this->clothing.size() * sizeof(float)); + cloth = this->mysql_select>("clothing"); + (void)0; // @note debug line removed + if (cloth.size() >= this->clothing.size() * sizeof(float)) + memcpy(this->clothing.data(), cloth.data(), this->clothing.size() * sizeof(float)); + this->update_effects(); // @note restore punch effect from the loaded clothing + } auto blob = this->mysql_select>("inventory"); const u_char *u8 = blob.data(); @@ -117,11 +136,32 @@ ::blob peer::serialize_inventory() const return blob; } +/* Birth Certificate: if this name was renamed, the name it has now (empty if not) */ +static std::string renamed_to(const std::string &old) +{ + std::string value{}; + ::hStmt hStmt{ "SELECT new_name FROM renames WHERE old_name = ? LIMIT 1" }; + MYSQL_BIND param = make_bind_in(old); + hStmt.bind_param(¶m); + + u_long length = 0; + MYSQL_BIND result = make_bind_out(value); + result.length = &length; + mysql_stmt_bind_result(hStmt.pStmt, &result); + + hStmt.execute(); + hStmt.fetch(); + value.resize(length); + return value; +} void peer::load(const std::string &growid, const std::string &password) { - if (!this->exists(growid)) + this->growid = growid; + if (!this->exists(this->growid)) + if (const std::string moved = renamed_to(this->growid); !moved.empty()) this->growid = moved; // @note logged in with an old name + if (!this->exists(this->growid)) { - this->mysql_insert("growid", growid); + this->mysql_insert("growid", this->growid); this->mysql_update("password", password); this->slots.resize(3ull); // @note since it's pre-determined we don't need do peer::emplace, and less iteration @@ -129,6 +169,15 @@ void peer::load(const std::string &growid, const std::string &password) this->slots[1ull] = ::slot{32, 1}; // @note Wrench this->slots[2ull] = ::slot{9640, 1}; // @note My First World Lock this->mysql_update>("inventory", this->serialize_inventory().data()); + this->mysql_update("gems", this->gems); + this->mysql_update("role", static_cast(this->role)); + this->mysql_update("level", static_cast(this->level.front())); + this->mysql_update("xp", static_cast(this->level.back())); this->mysql_update("curse_until", static_cast(this->curse_until)); + { + std::vector cloth(this->clothing.size() * sizeof(float)); + memcpy(cloth.data(), this->clothing.data(), cloth.size()); + this->mysql_update>("clothing", cloth); + } } this->mysql_select_all(); } @@ -136,6 +185,15 @@ void peer::load(const std::string &growid, const std::string &password) peer::~peer() { this->mysql_update>("inventory", this->serialize_inventory().data()); + this->mysql_update("gems", this->gems); + this->mysql_update("role", static_cast(this->role)); + this->mysql_update("level", static_cast(this->level.front())); + this->mysql_update("xp", static_cast(this->level.back())); this->mysql_update("curse_until", static_cast(this->curse_until)); + { + std::vector cloth(this->clothing.size() * sizeof(float)); + memcpy(cloth.data(), this->clothing.data(), cloth.size()); + this->mysql_update>("clothing", cloth); + } } u_short peer::emplace(::slot slot) @@ -157,6 +215,8 @@ u_short peer::emplace(::slot slot) void peer::add_xp(ENetEvent &event, u_short value) { + value = buff_xp(*this, value); + legend_on_xp(event, value); // @note Legendary Quest "Earn XP" steps u_short &lvl = this->level.front(); u_short &xp = this->level.back() += value; // @note factor the new xp amount @@ -168,6 +228,24 @@ void peer::add_xp(ENetEvent &event, u_short value) xp -= xp_formula; lvl++; + // every level: gems that scale with how far you've come + this->gems += 100 * lvl; + send_varlist(event.peer, { "OnSetBux", this->gems, 1, 1 }); + + // every 5 levels: four more inventory slots + if (lvl % 5 == 0 && this->slot_size < 200) + { + this->slot_size = std::min(this->slot_size + 4, 200); + send_varlist(event.peer, { "OnConsoleMessage", + std::format("`2Your backpack now holds `w{}`` items!``", this->slot_size) }); + } + + // milestone items + if (lvl == 10) modify_item_inventory(event, ::slot{ 242, 1 }); // World Key + if (lvl == 25) modify_item_inventory(event, ::slot{ 1486, 1 }); // Growtoken-ish reward + if (lvl == 75) modify_item_inventory(event, ::slot{ 6216, 1 }); // rare cosmetic + if (lvl == 100) modify_item_inventory(event, ::slot{ 9640, 5 }); // World Locks + if (lvl == 50) { modify_item_inventory(event, ::slot{1400, 1}); // @note Mini Growtopian @@ -186,6 +264,17 @@ void peer::add_xp(ENetEvent &event, u_short value) void peer::update_effects() { this->punch_effect = 0; + + static constexpr int double_jump_items[] = { 156, 394, 678, 1424, 2216, 2588, 3184, 4720 }; + bool can_double_jump = false; + for (float c2 : this->clothing) + { + const int id = static_cast(c2); + for (int dj : double_jump_items) if (id == dj) { can_double_jump = true; break; } + if (can_double_jump) break; + } + if (can_double_jump) this->state |= S_DOUBLE_JUMP; + else this->state &= ~S_DOUBLE_JUMP; for (float cloth : this->clothing) { u_char punch_id = get_punch_id((u_int)cloth); @@ -218,7 +307,22 @@ std::vector peers(const std::string &world, peer_condition condition, void safe_disconnect_peers(int code) { - peers("", peer_condition::PEER_ALL, [](ENetPeer &p) { enet_peer_disconnect(&p, 0); }); + peers("", peer_condition::PEER_ALL, [](ENetPeer &p) { + if (::peer *pPeer = static_cast<::peer*>(p.data); pPeer && !pPeer->growid.empty()) + { + pPeer->mysql_update>("inventory", pPeer->serialize_inventory().data()); + pPeer->mysql_update("gems", pPeer->gems); + pPeer->mysql_update("role", static_cast(pPeer->role)); + pPeer->mysql_update("level", static_cast(pPeer->level.front())); + pPeer->mysql_update("xp", static_cast(pPeer->level.back())); pPeer->mysql_update("curse_until", static_cast(pPeer->curse_until)); + { + std::vector cloth(pPeer->clothing.size() * sizeof(float)); + memcpy(cloth.data(), pPeer->clothing.data(), cloth.size()); + pPeer->mysql_update>("clothing", cloth); + } + } + enet_peer_disconnect(&p, 0); + }); enet_host_flush(host); enet_host_destroy(host); diff --git a/include/database/peer.hpp b/include/database/peer.hpp index b70cf498..1186883e 100644 --- a/include/database/peer.hpp +++ b/include/database/peer.hpp @@ -22,6 +22,8 @@ struct slot { }; /* x, y */ +struct block_change { std::string world; int x; int y; short old_fg; short old_bg; }; + struct pos { pos() = default; pos(float _x, float _y) : x(_x), y(_y) {} @@ -67,9 +69,13 @@ enum pstate : int { S_GHOST = 0x00000001, S_DOUBLE_JUMP = 0x00000002, - S_DUCT_TAPE = 0x00002000 + S_DUCT_TAPE = 0x00002000, + S_FROZEN = 0x00000800, + S_INVISIBLE = 0x00000024 }; +struct active_buff { u_char id{}; std::time_t ends{}; u_short item{}; }; + class peer { public: ~peer(); @@ -94,9 +100,24 @@ class peer { u_char role{}; std::array clothing{}; // @note peer's clothing {id, clothing::} u_char punch_effect{}; // @note last equipped clothing that has a effect. supporting 0-255 effects. + bool god_mode{}; // @note /god, immune to damage + bool frozen{}; // @note /freeze + bool instant_break{}; // @note /breakall + std::time_t curse_until{}; // @note /curse, unix time the curse ends (0 = not cursed) + std::time_t freeze_until{}; // @note Freeze Wand, unix time the freeze ends (0 = none) + std::time_t ban_until{}; // @note Ban Wand, unix time the ban ends (0 = not banned) + std::vector> item_states{}; // @note effects from items: {state flag, ends} + std::vector<::active_buff> buffs{}; // @note timed buffs from consumables + std::string god_rejoin{}; ::pos god_rejoin_pos{}; std::time_t god_rejoin_at{}; // @note world to return to after /god reloads items.dat + std::time_t renamed_at{}; // @note Birth Certificate, last rename + u_int tint{}; std::time_t tint_until{}; short tint_item{}; // @note Blueberry / Cherry skin color + ::pos move{ 250.0f, 1000.0f }; std::time_t move_until{}; short move_item{}; // @note Mud Glob / Das Red Balloon {speed, gravity} + std::string last_msg_from{}; // @note /reply + std::vector<::block_change> block_history{}; // @note /undo // @note last equipped clothing that has a effect. supporting 0-255 effects. int netid{}; // @note peer's netid is world identity. this will be useful for many packet sending - std::string display_growid{}; // @note use this for displaying growid, never use ::growid + std::string display_growid{}; + std::string nickname{}; // @note /nick disguise, empty = not disguised // @note use this for displaying growid, never use ::growid std::string country{}; u_int skin_color{ 2527912447 }; diff --git a/include/database/world.cpp b/include/database/world.cpp index db245468..9b00ba9f 100644 --- a/include/database/world.cpp +++ b/include/database/world.cpp @@ -1,4 +1,5 @@ #include "pch.hpp" +#include "commands/buffs.hpp" #include "tools/random.hpp" #include "tools/time.hpp" #include "onVariant/ConsoleMessage.hpp" @@ -137,7 +138,7 @@ ::blob world::serialize() blob.u8(this->lock_state); blob.i32(this->owner); blob.i32(access); - /* @todo access list */ + for (int uid : this->access) if (uid != 0) blob.i32(uid); // @note the game expects the list right after the count } else if (type == '\x04'/*seed*/) { @@ -155,7 +156,7 @@ ::blob world::serialize() blob.i32(0x00); blob.i32(0x00); - blob.i32(this->last_object_uid); + blob.i32(static_cast(this->objects.size())); // @note number of drops blob.i32(this->last_object_uid); for (const ::object &object : this->objects) { @@ -237,6 +238,21 @@ void world::mysql_select_all() { this->name = this->mysql_select("name"); this->owner = this->mysql_select("owner"); + this->base_weather = this->mysql_select("weather"); + this->treasure_spots = this->mysql_select("treasure"); + { + const std::string list = this->mysql_select("access"); // @note "12,34,56" + std::size_t i = 0; + for (std::size_t at = 0; at < list.size() && i < this->access.size(); ) + { + std::size_t comma = list.find(',', at); + if (comma == std::string::npos) comma = list.size(); + const int uid = std::atoi(list.substr(at, comma - at).c_str()); + if (uid != 0) this->access[i++] = uid; + at = comma + 1; + } + } + this->nuked = this->mysql_select("nuked") != 0; { this->trees.clear(); ::blob blob = this->mysql_select<::blob>("blocks"); @@ -253,7 +269,7 @@ void world::mysql_select_all() blob.read_u8(block.state[2], pos); blob.read_u8(block.state[3], pos); - if (block.fg != 0 || block.fg!=2||block.fg!=4||block.fg!=8||block.fg!=14) // @note so we can save time + if (block.fg != 0) // @note so we can save time if (char type = get_type(id_to_item(block.fg)); type > '\x00') { const ::pos block_pos{i % x, i / x}; @@ -287,9 +303,9 @@ void world::mysql_select_all() const u_char *u8 = blob.data(); // @note i did not have the brain capacity to reinterpret it. t-t (memcpy is safer anyways...) int i{}; - memcpy(&this->last_object_uid, u8, sizeof(u_int)); i += sizeof(u_int); // @todo real gt has this as 8 bits not just 4. + if (blob.size() >= sizeof(u_int)) { memcpy(&this->last_object_uid, u8, sizeof(u_int)); } i += sizeof(u_int); // @todo real gt has this as 8 bits not just 4. - objects.resize(this->last_object_uid); + objects.resize(blob.size() >= sizeof(u_int) ? (blob.size() - sizeof(u_int)) / 16 : 0); // @note count from the saved data, not the last drop number for (::object &object : this->objects) { memcpy(&object.id, u8 + i, sizeof(u_short)); i += sizeof(u_short); @@ -314,8 +330,21 @@ world::world(const std::string &name) : name(name)/*DEFAULT*/ } } world::~world() +{ + this->save(); +} + +void world::save() { this->mysql_update("owner", this->owner); + this->mysql_update("weather", static_cast(this->base_weather)); + this->mysql_update("treasure", this->treasure_spots); + { + std::string list{}; + for (int uid : this->access) if (uid != 0) list += (list.empty() ? "" : ",") + std::to_string(uid); + this->mysql_update("access", list); + } + this->mysql_update("nuked", static_cast(this->nuked ? 1 : 0)); { ::blob blob; @@ -417,6 +446,7 @@ void tile_apply_damage(ENetEvent &event, ::gamePacket gamePacket, block &block, ::peer *pPeer = static_cast<::peer*>(event.peer->data); (block.fg == 0) ? ++block.hits[1] : ++block.hits[0]; + if (pPeer->clothing[clothing::HAND] == 1956 || buff_active(*pPeer, buff::PUNCH)) (block.fg == 0) ? ++block.hits[1] : ++block.hits[0]; // @note Chaos Cursed Wand gamePacket.type = (value << 24) | 0x000008; // @note 0x{}000008 gamePacket.id = 6; // @note idk exactly gamePacket.netid = pPeer->netid; @@ -430,7 +460,7 @@ u_short modify_item_inventory(ENetEvent &event, ::slot slot) ::gamePacket gamePacket{.id = slot.id}; if (slot.count < 0) gamePacket.type = (slot.count*-1 << 16) | 0x000d; // @noote 0x00{}000d else gamePacket.type = (slot.count << 24) | 0x000d; // @noote 0x{}00000d - state_visuals(*event.peer, std::move(gamePacket)); + send_data(*event.peer, compress_state(gamePacket)); // @note only the player whose backpack changes - state_visuals sent it to the whole world return pPeer->emplace(::slot(slot.id, slot.count)); } @@ -539,6 +569,8 @@ void send_tile_update(ENetEvent &event, ::gamePacket gamePacket, ::block &block, blob.u8(world.lock_state); blob.i32(world.owner); blob.i32(access); + for (int uid : world.access) if (uid != 0) blob.i32(uid); // @note the game expects the list right after the count + break; } case type::SEED: { diff --git a/include/database/world.hpp b/include/database/world.hpp index 7a8bc112..77f9d38c 100644 --- a/include/database/world.hpp +++ b/include/database/world.hpp @@ -53,7 +53,7 @@ struct block ::blob to_blob() const; }; -#define cord(x,y) (y * 100 + x) +#define cord(x,y) ((y) * 100 + (x)) // @note brackets so cord(x, y + 1) works struct door { @@ -144,6 +144,7 @@ class world template T mysql_select(const std::string &column, const std::string &arg = ""); void mysql_select_all(); + void save(); // @note writes blocks + objects to MariaDB std::string name{}; @@ -152,9 +153,10 @@ class world bool is_public{}; // @note checks if world is public to break/place u_char lock_state{0x00}; // @note uses lock_state:: u_char minimum_entry_level{1}; // @note minimal level required to enter a world + bool nuked{}; // @note nuked worlds are staff-only // @note minimal level required to enter a world u_char visitors{}; // @note the current number of peers in a world, excluding invisable peers - u_char netid_counter{}; // @note a number that only increases, this value resets during ~world() + u_int netid_counter{}; // // @note a number that only increases, this value resets during ~world() std::vector<::block> blocks; // @note all blocks, size of 1D meaning (6000) instead of 2D (100, 60) u_int last_object_uid{0}; @@ -167,6 +169,8 @@ class world ::pos spawn{}; // @note position of main door ::pos weather{}; + int base_weather{0}; // @note blast worlds: background weather id + std::string treasure_spots{}; // @note Beach Blast chests that still hold treasure, as "x,y;x,y;" ::blob serialize(); }; diff --git a/include/eventType/receive.cpp b/include/eventType/receive.cpp index a49a70af..e8371a6d 100644 --- a/include/eventType/receive.cpp +++ b/include/eventType/receive.cpp @@ -11,7 +11,7 @@ void receive(ENetEvent& event) case 2: case 3: { std::string header{data.begin() + 4, data.end() - 1}; - puts(header.c_str()); + if (header.find("ltoken|") == std::string::npos && header.find("password") == std::string::npos) puts(header.c_str()); // @note never print logins (they contain passwords) std::ranges::replace(header, '\n', '|'); const std::vector pipes = readch(header, '|'); @@ -39,7 +39,6 @@ void receive(ENetEvent& event) i->second(event, std::move(gamePacket)); break; } - default: printf("received unqiue: %d\n", data[0ull]); } enet_packet_destroy(event.packet); } diff --git a/include/gamePacket/_gamePacket.cpp b/include/gamePacket/_gamePacket.cpp index d42ac6cb..723354fc 100644 --- a/include/gamePacket/_gamePacket.cpp +++ b/include/gamePacket/_gamePacket.cpp @@ -17,5 +17,12 @@ std::unordered_map> gamePa {0x0a, std::bind(&item_activate, std::placeholders::_1, std::placeholders::_2)}, {0x0b, std::bind(&item_activate_object, std::placeholders::_1, std::placeholders::_2)}, {0x15, std::bind(&ping_reply, std::placeholders::_1, std::placeholders::_2)}, - {0x1a, std::bind(&disconnect, std::placeholders::_1, std::placeholders::_2)} + {0x1a, std::bind(&disconnect, std::placeholders::_1, std::placeholders::_2)}, + {0x12, [](ENetEvent &event, ::gamePacket gamePacket) // @note PACKET_SET_ICON_STATE: the "..." bubble while typing + { + ::peer *pPeer = static_cast<::peer*>(event.peer->data); + if (!pPeer || pPeer->netid == 0) return; + gamePacket.netid = pPeer->netid; + state_visuals(*event.peer, std::move(gamePacket)); + }} }; \ No newline at end of file diff --git a/include/gamePacket/item_activate_object.cpp b/include/gamePacket/item_activate_object.cpp index d34c7de9..c440de38 100644 --- a/include/gamePacket/item_activate_object.cpp +++ b/include/gamePacket/item_activate_object.cpp @@ -1,6 +1,7 @@ #include "pch.hpp" #include "onVariant/SetBux.hpp" #include "onVariant/ConsoleMessage.hpp" +#include "commands/events.hpp" #include "item_activate_object.hpp" @@ -12,31 +13,35 @@ void item_activate_object(ENetEvent& event, ::gamePacket gamePacket) if (world == worlds.end()) return; auto object = std::ranges::find(world->objects, gamePacket.id, &::object::uid); - if (object->id != 112/*gem*/) - { - const ::item &item = id_to_item(object->id); + if (object == world->objects.end()) return; // @note already picked up (the game can ask twice) + + const u_int uid = object->uid; + const u_short id = object->id; + const ::pos pos = object->pos; + u_short left = 0; - u_short remember = object->count; - object->count = pPeer->emplace(::slot(object->id, object->count)); // @return remains after reaching 200 - if (object->count > 0) - { - add_object(event, ::slot(object->id, object->count), object->pos, *world); - } - u_short collected = remember - object->count; - if (collected ==/*unsigned*/ 0) return; // @todo + if (id != 112/*gem*/) + { + const ::item &item = id_to_item(id); + const u_short remember = object->count; + left = pPeer->emplace(::slot(static_cast(id), static_cast(remember))); // @return remains after reaching 200 + const u_short collected = remember - left; + if (collected == 0) return; // @note backpack full: leave it where it is on::ConsoleMessage(event.peer, (item.rarity >= 999) ? std::format("Collected `w{} {}``.", collected, item.raw_name) : std::format("Collected `w{} {}``. Rarity: `w{}``", collected, item.raw_name, item.rarity) ); } - else + else { pPeer->gems += object->count; - object->count = 0; on::SetBux(event); } - remove_object(event, object->uid); - if (object->count == 0) world->objects.erase(object); -} \ No newline at end of file + event_object_taken(event, *world, uid, static_cast(id)); + remove_object(event, uid); + world->objects.erase(object); // @note removed before anything new is added to the list + + if (left > 0) add_object(event, ::slot(static_cast(id), static_cast(left)), pos, *world); // @note the rest stays on the ground +} diff --git a/include/gamePacket/movement.cpp b/include/gamePacket/movement.cpp index 610af27f..ba00d88b 100644 --- a/include/gamePacket/movement.cpp +++ b/include/gamePacket/movement.cpp @@ -1,6 +1,7 @@ #include "pch.hpp" #include "action/respawn.hpp" +#include "commands/buffs.hpp" #include "movement.hpp" void movement(ENetEvent& event, ::gamePacket gamePacket) @@ -11,7 +12,7 @@ void movement(ENetEvent& event, ::gamePacket gamePacket) pPeer->facing_left = gamePacket.state & state::S_MOVE_LEFT; /* add fireproof only take away 1 hp instead of 2 */ - if (gamePacket.state & state::S_LAVA_HIT) pPeer->pain_hp -= 2; + if ((gamePacket.state & state::S_LAVA_HIT) && !pPeer->god_mode) pPeer->pain_hp -= 2; if (pPeer->pain_hp <= 0) action::respawn(event, ""), pPeer->pain_hp = 10; gamePacket.netid = pPeer->netid; diff --git a/include/gamePacket/tile_activate.cpp b/include/gamePacket/tile_activate.cpp index 4ad5d08a..09e4662d 100644 --- a/include/gamePacket/tile_activate.cpp +++ b/include/gamePacket/tile_activate.cpp @@ -1,6 +1,7 @@ #include "pch.hpp" #include "action/join_request.hpp" #include "action/quit_to_exit.hpp" +#include "commands/cooking.hpp" #include "tile_activate.hpp" void tile_activate(ENetEvent& event, ::gamePacket gamePacket) @@ -14,6 +15,11 @@ void tile_activate(ENetEvent& event, ::gamePacket gamePacket) const ::item &item = id_to_item(block.fg); switch (item.type) { + case type::CHEMICAL_COMBINER: // @note E-Z Cook Oven and Chemical Combiner + { + cooking_use(event, *world, (int)gamePacket.punch.x, (int)gamePacket.punch.y); + break; + } case type::MAIN_DOOR: { action::quit_to_exit(event, "", false); diff --git a/include/gamePacket/tile_change.cpp b/include/gamePacket/tile_change.cpp index 51209099..c25e2ee8 100644 --- a/include/gamePacket/tile_change.cpp +++ b/include/gamePacket/tile_change.cpp @@ -1,9 +1,15 @@ +#include "commands/consumables.hpp" +#include "commands/cooking.hpp" #include "pch.hpp" #include "onVariant/NameChanged.hpp" #include "onVariant/SetClothing.hpp" #include "onVariant/Action.hpp" #include "onVariant/ConsoleMessage.hpp" #include "commands/weather.hpp" +#include "commands/growscan.hpp" +#include "commands/jammers.hpp" +#include "commands/lockaccess.hpp" +#include "commands/legendary.hpp" #include "item_activate.hpp" #include "tools/random.hpp" #include "tools/time.hpp" @@ -12,6 +18,9 @@ #include "action/join_request.hpp" #include "item_activate_object.hpp" +#include "commands/cooking.hpp" +#include "commands/buffs_items.hpp" +#include "commands/blasts.hpp" #include "tile_change.hpp" void tile_change(ENetEvent& event, ::gamePacket gamePacket) @@ -36,11 +45,19 @@ void tile_change(ENetEvent& event, ::gamePacket gamePacket) if (!(item.cat & CAT_PUBLIC)) // @note if block is public skip validating if peer is owner or access if ((world->owner && !world->is_public && !pPeer->role) && - (pPeer->user_id != world->owner && std::ranges::find(world->access, pPeer->user_id) == world->access.end())) return; + (pPeer->user_id != world->owner && std::ranges::find(world->access, pPeer->user_id) == world->access.end())) + { + if (gamePacket.id == 18 && item.type == type::LOCK && !is_tile_lock(item.id)) // @note punching the World Lock: say whose it is + throw std::runtime_error(std::format("`5[```w{}`` `$World Locked`` by `w{}```5]``", world->name, access_name_of(world->owner))); + return; + } bool tile_update{}; bool lock_visuals{}; // @todo this looks sloppy + if (item.type == type::CONSUMEABLE && consumable_use(event, *world, item, gamePacket)) return; // @note consumables.cpp + if (gamePacket.id == 18 && blast_chest_punch(event, *world, block, gamePacket)) return; // @note one-punch treasure chests + if (gamePacket.id == 18) jammer_punch(event, *world, gamePacket); // @note jammers if (gamePacket.id == 18) // @note punching a block { static bool punch{}; // @note true if tile_change has been called within this inital (punch) @@ -116,14 +133,28 @@ void tile_change(ENetEvent& event, ::gamePacket gamePacket) } switch (item.type) { + case type::CHEMICAL_COMBINER: + { + if (cooking_use(event, *world, (int)gamePacket.punch.x, (int)gamePacket.punch.y)) return; + break; + } case type::STRONG: throw std::runtime_error("It's too strong to break."); case type::MAIN_DOOR: throw std::runtime_error("(stand over and punch to use)"); case type::LOCK: { if (is_tile_lock(item.id)) break; // @todo seperate area for 'range_lock' - if (world->owner != pPeer->user_id) - throw std::runtime_error(std::format("`5[```w{}`` `$World Locked`` by (null)`5]``", world->name)); // @todo add owner name + if (world->owner != pPeer->user_id && pPeer->role < DEVELOPER) // @note developers can break anyone's World Lock + { + std::string lock_owner{}; + peers("", peer_condition::PEER_ALL, [&lock_owner, &world](ENetPeer &q) + { + ::peer *o = static_cast<::peer*>(q.data); + if (o && o->user_id == world->owner) lock_owner = o->growid; + }); + if (lock_owner.empty()) lock_owner = access_name_of(world->owner); // @note offline owner: from the database + throw std::runtime_error(std::format("`5[```w{}`` `$World Locked`` by `w{}```5]``", world->name, lock_owner)); + } break; } case type::PROVIDER: @@ -181,7 +212,10 @@ void tile_change(ENetEvent& event, ::gamePacket gamePacket) if (ticks() - tree->tick >= item.tick) // @todo limit this check. { block.hits[0] = 99; - add_drop(event, ::slot(item.id - 1, RandomRange(1, tree->fruit*3)), gamePacket.punch.by_32(), *world); // @note fruit (from tree) + const short fruit = static_cast(RandomRange(1, tree->fruit*3)); + add_drop(event, ::slot(item.id - 1, fruit), gamePacket.punch.by_32(), *world); // @note fruit (from tree) + const int fruit_rarity = id_to_item(item.id - 1).rarity; + legend_on_harvest(event, (fruit_rarity < 999 ? fruit_rarity : 0) * fruit); // @note Legendary Quest } break; } @@ -204,7 +238,7 @@ void tile_change(ENetEvent& event, ::gamePacket gamePacket) case type::TOGGLEABLE_ANIMATED_BLOCK: case type::CHEST: { - block.state[2] ^= S_TOGGLE; + if (!is_jammer(item.id)) block.state[2] ^= S_TOGGLE; break; } case type::RANDOM: @@ -222,20 +256,35 @@ void tile_change(ENetEvent& event, ::gamePacket gamePacket) break; } } + if ((block.fg == 8 || block.bg == 8) && pPeer->role < DEVELOPER) + { + send_varlist(event.peer, { "OnTalkBubble", pPeer->netid, std::string("It's too strong to break."), 0u, 1 }); + return; + } + if (pPeer->block_history.size() >= 50) pPeer->block_history.erase(pPeer->block_history.begin()); + pPeer->block_history.push_back(::block_change{ world->name, (int)gamePacket.punch.x, (int)gamePacket.punch.y, block.fg, block.bg }); tile_apply_damage(event, std::move(gamePacket), block, apply_damage_value); - if (block.hits[0] >= item.hits) block.fg = 0, block.hits[0] = 0; + if ((block.fg == 8 || block.bg == 8) && pPeer->role >= DEVELOPER) + { + if (block.fg == 8) block.fg = 0, block.hits[0] = 0; + else block.bg = 0, block.hits[1] = 0; + } + else if (pPeer->instant_break && block.fg != 0) block.fg = 0, block.hits[0] = 0; + else if (pPeer->instant_break && block.bg != 0) block.bg = 0, block.hits[1] = 0; + else if (block.hits[0] >= item.hits) block.fg = 0, block.hits[0] = 0; else if (block.hits[1] >= item.hits) block.bg = 0, block.hits[1] = 0; else return; /* @todo update these changes with tile_update() */ block.state[2] = 0x00; // @note reset tile direction block.state[3] &= ~S_VANISH; // @note remove paint + blast_treasure_break(event, *world, item, gamePacket); // @note chest / hidden treasure loot if (item.id == 392/*Heartstone*/ || item.id == 3402/*GBC*/ || item.id == 9350/*Super GBC*/) { short reward = - (!RandomRange(0, 99)) ? 1458 : // @note GHC + (!RandomRange(0, buff_active(*pPeer, buff::PURE_LOVE) ? 49 : 99)) ? 1458 : // @note GHC (double chance with Pure Love Essence) (!RandomRange(0, 20)) ? 362 : // @note Angel Wings (!RandomRange(0, 8)) ? 366 : // @note Heartbow (!RandomRange(0, 8)) ? 1470 : // @note Ruby Necklace @@ -263,10 +312,9 @@ void tile_change(ENetEvent& event, ::gamePacket gamePacket) } else if (item.type == type::LOCK && !is_tile_lock(item.id)) { - pPeer->display_growid = std::format("`w{}``", pPeer->growid); - on::NameChanged(event); - world->owner = 0; // @todo have a seperate thing for 'range_lock' + world->access.fill(0); // @note the access list belonged to the lock + refresh_display_names(*world); // @note everyone in the world sees the new colours right now } if (item.cat == CAT_RETURN) @@ -274,9 +322,10 @@ void tile_change(ENetEvent& event, ::gamePacket gamePacket) int uid = add_object(event, ::slot(item.id, 1), gamePacket.pos, *world); item_activate_object(event, ::gamePacket{.id = uid, .punch = gamePacket.punch}); } - else if (u_char(item.property) & 04) { } // @note "This item never drops any seeds."; should it drop a block? + else if (item.id == 8 || item.id == 7372 || item.id == 14908 || item.id == 14910 || item.id == 16206 || item.id == 16208 || (u_char(item.property) & 04)) { } // @note bedrock drops nothing // @note "This item never drops any seeds."; should it drop a block? else // @note normal break (drop gem, seed, block & give XP) { + buff_on_break(event, *world, item, gamePacket); if (item.type != type::SEED) { /* gem drop */ /* if greater than 1, assume it's a farmable.*/ @@ -301,6 +350,7 @@ void tile_change(ENetEvent& event, ::gamePacket gamePacket) else if (!RandomRange(0, (rarity_to_gem > 1) ? 4 : 8)) add_drop(event, ::slot(item.id, 1), gamePacket.punch.by_32(), *world); } /* ~gem drop */ + if (item.type != type::SEED) legend_on_break(event, item.rarity < 999 ? item.rarity : 0); // @note Legendary Quest pPeer->add_xp(event, std::trunc(1.0f + item.rarity / 5.0f)); } } // @note delete im, id @@ -505,6 +555,9 @@ void tile_change(ENetEvent& event, ::gamePacket gamePacket) } else if (gamePacket.id == 32) { + if (item.type == type::GROWSCAN) { growscan_open(event, *world); return; } // @note Growscan 9000 + if (wizard_wrench(event, *world, static_cast(gamePacket.punch.x), static_cast(gamePacket.punch.y))) return; // @note Legendary Wizard + if (is_jammer(block.fg)) { jammer_wrench(event, *world, block, gamePacket); return; } // @note jammers + Antigravity Generator switch (item.type) { case type::LOCK: @@ -523,7 +576,7 @@ void tile_change(ENetEvent& event, ::gamePacket gamePacket) "embed_data|tilex|{}\n" "embed_data|tiley|{}\n" "add_spacer|small|\n" - "add_label|small|Currently, you're the only one with access.``|left\n" + "{}" "add_spacer|small|\n" "add_player_picker|playerNetID|`wAdd``|\n" "add_checkbox|checkbox_public|Allow anyone to Build and Break|{}\n" @@ -538,7 +591,7 @@ void tile_change(ENetEvent& event, ::gamePacket gamePacket) "add_button|changecat|`wCategory: None``|noflags|0|0|\n" "add_button|getKey|Get World Key|noflags|0|0|\n" "end_dialog|lock_edit|Cancel|OK|\n", - item.raw_name, item.id, gamePacket.punch.x, gamePacket.punch.y, to_char(world->is_public), (world->lock_state & DISABLE_MUSIC) ? "1" : "0", world->minimum_entry_level + item.raw_name, item.id, gamePacket.punch.x, gamePacket.punch.y, lock_access_rows(*world), to_char(world->is_public), (world->lock_state & DISABLE_MUSIC) ? "1" : "0", world->minimum_entry_level ) }); } @@ -717,8 +770,7 @@ void tile_change(ENetEvent& event, ::gamePacket gamePacket) world->owner = pPeer->user_id; lock_visuals = true; - pPeer->display_growid = std::format("`2{}``", pPeer->growid); - on::NameChanged(event); + refresh_display_names(*world); // @note the owner's name turns green for everyone in the world right now if (std::ranges::find(pPeer->my_worlds, world->name) == pPeer->my_worlds.end()) { std::ranges::rotate(pPeer->my_worlds, pPeer->my_worlds.begin() + 1); @@ -736,7 +788,8 @@ void tile_change(ENetEvent& event, ::gamePacket gamePacket) } case type::SEED: { - world->trees.emplace_back(ticks(), RandomRange(1, 3), gamePacket.punch); + world->trees.emplace_back(ticks() - buff_tree_head_start(*pPeer, item), RandomRange(1, 3), gamePacket.punch); + legend_on_plant(event, item.rarity < 999 ? item.rarity : 0); // @note Legendary Quest block.state[2] = 0x11; // @todo tile_update = true; break; @@ -753,6 +806,8 @@ void tile_change(ENetEvent& event, ::gamePacket gamePacket) break; } } + if (pPeer->block_history.size() >= 50) pPeer->block_history.erase(pPeer->block_history.begin()); + pPeer->block_history.push_back(::block_change{ world->name, (int)gamePacket.punch.x, (int)gamePacket.punch.y, block.fg, block.bg }); block.state[2] |= (pPeer->facing_left) ? S_LEFT : S_RIGHT; (item.type == type::BACKGROUND) ? block.bg = gamePacket.id : block.fg = gamePacket.id; pPeer->emplace(::slot(item.id, -1)); diff --git a/include/https/https.cpp b/include/https/https.cpp index 80e8534a..dfb0b1e1 100644 --- a/include/https/https.cpp +++ b/include/https/https.cpp @@ -1,6 +1,31 @@ #include "pch.hpp" #include "https.hpp" #include "server_data.hpp" +#include + +/* which address a player gets: on this PC 127.0.0.1, on the home network (or a VPN) the address they + reached us on, over the internet the public address in public_ip.txt */ +static bool is_private(const u_char *b) +{ + return b[0] == 10 || b[0] == 127 || (b[0] == 172 && b[1] >= 16 && b[1] <= 31) || (b[0] == 192 && b[1] == 168) || + (b[0] == 169 && b[1] == 254) || (b[0] == 100 && b[1] >= 64 && b[1] <= 127); +} +static std::string ip_text(const u_char *b) { return std::format("{}.{}.{}.{}", b[0], b[1], b[2], b[3]); } +static std::string public_ip() +{ + std::ifstream f("public_ip.txt"); + std::string ip{}; + std::getline(f, ip); + while (!ip.empty() && std::isspace(static_cast(ip.back()))) ip.pop_back(); + return ip; +} +static std::string make_response(const std::string &server_ip) +{ + const std::string content = std::format( + "server|{}\nport|{}\ntype|{}\ntype2|{}\n#maint|{}\nloginurl|{}\nmeta|{}\nRTENDMARKERBS1001", + server_ip, gServer_data.port, gServer_data.type, gServer_data.type2, gServer_data.maint, gServer_data.loginurl, gServer_data.meta); + return std::format("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", content.size(), content); +} #include @@ -8,7 +33,6 @@ #include #include #else - #include #include #include #include @@ -24,18 +48,14 @@ /* cross-platform socket close */ static void cross_close(SOCKET fd) { - int ret = #ifdef _WIN32 - closesocket(fd) + closesocket(fd); #else // @note unix - close(fd) + close(fd); #endif - ; // ending of ret. hehe some silly code ;) - - if (ret == SOCKET_ERROR) printf("socket close error.\n"); } -/* cross-platform WSA error log, fallback on linux with strerror() */ +/* cross-platform error log */ static void cross_log(const std::string &message) { #ifdef _WIN32 @@ -65,6 +85,8 @@ void https::listener() ERR_print_errors_fp(stderr); } + SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION); + #ifdef SIGPIPE // @note unix std::signal(SIGPIPE, SIG_IGN); #endif @@ -127,39 +149,63 @@ void https::listener() /* https://learn.microsoft.com/en-us/windows/win32/api/winsock2/nf-winsock2-accept */ SOCKET fd = accept(socket, reinterpret_cast(&addr), &addrlen); if (fd == INVALID_SOCKET) continue; + + const u_char *cb = reinterpret_cast(&addr.sin_addr); + const std::string client_ip = ip_text(cb); + std::string server_ip = gServer_data.server; + { + sockaddr_in local{}; + decltype(addrlen) ll = sizeof(local); + if (getsockname(fd, reinterpret_cast(&local), &ll) == 0) + server_ip = ip_text(reinterpret_cast(&local.sin_addr)); // @note the address they reached us on + if (!is_private(cb)) { const std::string pub = public_ip(); if (!pub.empty()) server_ip = pub; } + } - /* https://docs.openssl.org/3.0/man3/SSL_new/#return-values */ SSL *ssl = SSL_new(ctx); - if (!ssl || !SSL_up_ref(ssl)) continue; - /* https://docs.openssl.org/3.0/man3/SSL_set_fd/#return-values */ - /* https://docs.openssl.org/3.0/man3/SSL_accept/#return-values */ - else if (SSL_set_fd(ssl, fd) != 1 || SSL_accept(ssl) <= 0) { - //int ret = SSL_get_error(ssl, 3); - ERR_print_errors_fp(stderr); + if (!ssl) { + cross_close(fd); + continue; } - else { - char buf[213]; // @note size of growtopia's POST request. - const int length{ sizeof(buf) }; - - int rbytes = SSL_read(ssl, buf, length); - if (rbytes <= 0) ERR_print_errors_fp(stderr); // @todo support retryable - else if (rbytes == length) // @note save time instead of doing >0 + if (SSL_set_fd(ssl, fd) != 1) { + cross_close(fd); + continue; + } + if (SSL_accept(ssl) > 0) + { + char buf[4096]; // @note any size of request + std::string request{}; // @note read the whole request (headers + body) before answering + for (int tries = 0; tries < 16 && request.size() < 16384; ++tries) { - printf("%s\n", buf); // @note to confirm the peer connected. else you could also see if loginurl dashboard appears. + const int n = SSL_read(ssl, buf, sizeof(buf)); + if (n <= 0) break; + request.append(buf, static_cast(n)); + const std::size_t end = request.find("\r\n\r\n"); + if (end == std::string::npos) continue; + std::string head = request.substr(0, end); + for (char &ch : head) ch = static_cast(std::tolower(static_cast(ch))); + std::size_t need = end + 4; + if (const std::size_t cl = head.find("content-length:"); cl != std::string::npos) + need += static_cast(std::atoi(head.c_str() + cl + 15)); + if (request.size() >= need) break; + } + const int length = static_cast(request.size()); - int wbytes = SSL_write(ssl, response.c_str(), response.size()); - if (wbytes <= 0) ERR_print_errors_fp(stderr); // @todo support retryable + if (length > 0) + { + // @note full request printing removed + std::string content = request; + + if (content.find("POST /growtopia/server_data.php HTTP/1.1") != std::string_view::npos) + { + { const std::string r = make_response(server_ip); SSL_write(ssl, r.c_str(), static_cast(r.size())); remember_address(client_ip, server_ip); std::printf("[login] %s -> %s\n", client_ip.c_str(), server_ip.c_str()); } + } } + else ERR_print_errors_fp(stderr); // @note we don't accept growtopia GET. this error is normal if appears. } + else ERR_print_errors_fp(stderr); - /* "It can also occur when not all data was read using SSL_read()." */ - if (SSL_shutdown(ssl) <0) - { - //int ret = SSL_get_error(ssl, 3); - ERR_print_errors_fp(stderr); - } + SSL_shutdown(ssl); SSL_free(ssl); - if (shutdown(fd, 2) == SOCKET_ERROR) cross_log("failed to shutdown socket"); // @todo unsure if WSA can handle this. cross_close(fd); } } diff --git a/include/https/server_data.cpp b/include/https/server_data.cpp index e012815b..0ae571ca 100644 --- a/include/https/server_data.cpp +++ b/include/https/server_data.cpp @@ -1,5 +1,7 @@ #include "pch.hpp" #include +#include +#include #include "server_data.hpp" @@ -42,3 +44,31 @@ void ::server_data::init() this->meta = pipes[13]; } // @note delete str, pipes } // @note close file + + +static std::mutex address_mutex{}; +static std::unordered_map address_of{}; // @note {player ip, address they were given} + +void remember_address(const std::string &client_ip, const std::string &server_ip) +{ + std::lock_guard lock(address_mutex); + address_of[client_ip] = server_ip; +} + +std::string server_for_peer(const ENetPeer &p) +{ + char buf[64]{}; + if (enet_address_get_host_ip(&p.address, buf, sizeof(buf)) == 0) + { + std::string ip = buf; + if (ip.starts_with("::ffff:")) ip = ip.substr(7); + if (ip == "127.0.0.1" || ip == "::1") return "127.0.0.1"; + std::lock_guard lock(address_mutex); + if (auto it = address_of.find(ip); it != address_of.end()) return it->second; + } + std::ifstream f("public_ip.txt"); // @note fallback for players we didn't see at the first step + std::string pub{}; + std::getline(f, pub); + while (!pub.empty() && std::isspace(static_cast(pub.back()))) pub.pop_back(); + return pub.empty() ? gServer_data.server : pub; +} \ No newline at end of file diff --git a/include/https/server_data.hpp b/include/https/server_data.hpp index 0dabb8ea..fb58cbb7 100644 --- a/include/https/server_data.hpp +++ b/include/https/server_data.hpp @@ -14,3 +14,7 @@ class server_data void init(); }; extern ::server_data gServer_data; + +/* the address a player got at login, reused when the game is told where to reconnect */ +extern void remember_address(const std::string &client_ip, const std::string &server_ip); +extern std::string server_for_peer(const ENetPeer &p); diff --git a/include/onVariant/Action.cpp b/include/onVariant/Action.cpp index a0c791ef..c90d2eff 100644 --- a/include/onVariant/Action.cpp +++ b/include/onVariant/Action.cpp @@ -1,19 +1,26 @@ #include "pch.hpp" #include "Action.hpp" -void on::Action(ENetEvent& event, const std::string_view text) +void on::Action(ENetEvent& event, const std::string_view text) { ::peer *pPeer = static_cast<::peer*>(event.peer->data); - std::string_view to_slang = - (text == "facepalm") ? "fp" : - (text == "shrug") ? "idk" : - (text == "foldarms") ? "fold" : - (text == "fa") ? "fold" : + std::string_view to_slang = + (text == "facepalm") ? "fp" : + (text == "shrug") ? "idk" : + (text == "foldarms") ? "fold" : + (text == "fa") ? "fold" : (text == "stubborn") ? "fold" : text; + const std::string action = '/' + std::string(to_slang); - send_varlist(event.peer, { - "OnAction", - ('/' + std::string(to_slang)) - }, pPeer->netid); -} + if (pPeer->netid == 0 || pPeer->recent_worlds.empty() || (pPeer->state & S_INVISIBLE) == S_INVISIBLE) + { + send_varlist(event.peer, { "OnAction", action }, pPeer->netid); + return; + } + // @note everyone in the world sees the emote, not just you + peers(pPeer->recent_worlds.back(), PEER_SAME_WORLD, [&](ENetPeer &p) + { + send_varlist(&p, { "OnAction", action }, pPeer->netid); + }); +} \ No newline at end of file diff --git a/include/onVariant/CountryState.cpp b/include/onVariant/CountryState.cpp index e06135da..9624805c 100644 --- a/include/onVariant/CountryState.cpp +++ b/include/onVariant/CountryState.cpp @@ -1,14 +1,25 @@ #include "pch.hpp" +#include "commands/legendary.hpp" #include "CountryState.hpp" void on::CountryState(ENetEvent& event) { ::peer *pPeer = static_cast<::peer*>(event.peer->data); - - send_varlist(event.peer, { - "OnCountryState", - /* @todo add |showGuild when we add guild system; also add peer's real country */ - std::format("{}{}", pPeer->country, (pPeer->level[0] == 125) ? "|maxLevel" : "") - }, pPeer->netid); -} \ No newline at end of file + const std::string state = country_state_for(*pPeer); // @note country + maxLevel + doctor + + if (pPeer->netid == 0) + { + send_varlist(event.peer, { "OnCountryState", state }, pPeer->netid); + return; + } + peers(pPeer->recent_worlds.back(), PEER_SAME_WORLD, [&](ENetPeer &p) + { + send_varlist(&p, { "OnCountryState", state }, pPeer->netid); + }); +} + +void on::CountryStateOf(ENetPeer &to, const ::peer &who) +{ + send_varlist(&to, { "OnCountryState", country_state_for(who) }, who.netid); +} diff --git a/include/onVariant/CountryState.hpp b/include/onVariant/CountryState.hpp index 42a99981..e372dab2 100644 --- a/include/onVariant/CountryState.hpp +++ b/include/onVariant/CountryState.hpp @@ -2,5 +2,6 @@ namespace on { - extern void CountryState(ENetEvent& event); + extern void CountryState(ENetEvent& event); // @note this player's flags, to everyone in their world + extern void CountryStateOf(ENetPeer &to, const ::peer &who); // @note someone's flags, to one player } diff --git a/include/onVariant/RequestWorldSelectMenu.cpp b/include/onVariant/RequestWorldSelectMenu.cpp index ff4b151d..72abad4b 100644 --- a/include/onVariant/RequestWorldSelectMenu.cpp +++ b/include/onVariant/RequestWorldSelectMenu.cpp @@ -1,6 +1,7 @@ #include "pch.hpp" #include "onVariant/ConsoleMessage.hpp" +#include "commands/jammers.hpp" #include "RequestWorldSelectMenu.hpp" void on::RequestWorldSelectMenu(ENetEvent& event) @@ -23,7 +24,7 @@ void on::RequestWorldSelectMenu(ENetEvent& event) std::vector popular_names{}; for (const ::world &world : worlds) - if (world.visitors > 0) popular_names.emplace_back(world.name); // @todo only fetch top 10 worlds, instead of all the worlds with people. + if (world.visitors > 0 && !world_jammer(world, 226)) popular_names.emplace_back(world.name); // @note Signal Jammer hides it // @todo only fetch top 10 worlds, instead of all the worlds with people. send_varlist(event.peer, { "OnRequestWorldSelectMenu", diff --git a/include/onVariant/SetClothing.cpp b/include/onVariant/SetClothing.cpp index 6d3ebd29..02d87be5 100644 --- a/include/onVariant/SetClothing.cpp +++ b/include/onVariant/SetClothing.cpp @@ -1,28 +1,93 @@ #include "pch.hpp" +#include "commands/useitems.hpp" #include "SetClothing.hpp" +/* /invis: your own game shows only eyes, mouth, name and flag (no clothes, see-through skin). + * With /ghost on too the game would tint the face purple, so then it uses see-through skin instead. + * State flag 0x4 = The One Ring look: the game draws only eyes and mouth over the normal skin. + * The invisible marker itself stays on the server; sent to the game it tints the face purple */ + +/* sends "who" appearance to the single client "to" */ +void on::SetClothing(ENetPeer &to, ::peer &who) +{ + const bool invis = (who.state & S_INVISIBLE) == S_INVISIBLE; + auto worn = [&](auto slot) { auto v = who.clothing[slot]; if (invis) v = 0; return v; }; + + send_varlist(&to, { + "OnSetClothing", + CL_Vec3f{worn(clothing::HAIR), worn(clothing::SHIRT), worn(clothing::LEGS)}, + CL_Vec3f{worn(clothing::FEET), worn(clothing::FACE), worn(clothing::HAND)}, + CL_Vec3f{worn(clothing::BACK), worn(clothing::HEAD), worn(clothing::CHARM)}, + invis ? ((who.state & S_GHOST) ? 0xFFFFFF00u : 0xFFFFFFFFu) : (who.state & S_GHOST) ? -140 : (who.frozen || (who.state & S_FROZEN)) ? 0xFFB47880u : item_skin(who), + CL_Vec3f{worn(clothing::ANCES), 0.0f, 0.0f} + }, who.netid); + + ::gamePacket gamePacket { + .type = 0x14 | ((0x808000 + who.punch_effect) << 8), + .netid = who.netid, + .count = 125.0f, + .id = (who.state & ~S_DOUBLE_JUMP & ~S_INVISIBLE) | ((invis && !(who.state & S_GHOST)) ? 0x4 : 0) | double_jump_state(who) | ((who.curse_until > std::time(nullptr)) ? 0x3000 : 0) | item_effect_state(who), + .pos = ::pos{ 1200.0f, 200.0f }, + .speed = item_speed(who), + .punch = ::pos{ who.hair_color, 0x00000000u } + }; + send_data(to, compress_state(gamePacket)); +} + void on::SetClothing(ENetPeer &peer) { ::peer *pPeer = static_cast<::peer*>(peer.data); + const bool invis = (pPeer->state & S_INVISIBLE) == S_INVISIBLE; + auto worn = [&](auto slot) { auto v = pPeer->clothing[slot]; if (invis) v = 0; return v; }; send_varlist(&peer, { - "OnSetClothing", - CL_Vec3f{pPeer->clothing[clothing::HAIR], pPeer->clothing[clothing::SHIRT], pPeer->clothing[clothing::LEGS]}, - CL_Vec3f{pPeer->clothing[clothing::FEET], pPeer->clothing[clothing::FACE], pPeer->clothing[clothing::HAND]}, - CL_Vec3f{pPeer->clothing[clothing::BACK], pPeer->clothing[clothing::HEAD], pPeer->clothing[clothing::CHARM]}, - (pPeer->state & S_GHOST) ? -140 : pPeer->skin_color, - CL_Vec3f{pPeer->clothing[clothing::ANCES], 0.0f, 0.0f} + "OnSetClothing", + CL_Vec3f{worn(clothing::HAIR), worn(clothing::SHIRT), worn(clothing::LEGS)}, + CL_Vec3f{worn(clothing::FEET), worn(clothing::FACE), worn(clothing::HAND)}, + CL_Vec3f{worn(clothing::BACK), worn(clothing::HEAD), worn(clothing::CHARM)}, + invis ? ((pPeer->state & S_GHOST) ? 0xFFFFFF00u : 0xFFFFFFFFu) : (pPeer->state & S_GHOST) ? -140 : (pPeer->frozen || (pPeer->state & S_FROZEN)) ? 0xFFB47880u : item_skin(*pPeer), + CL_Vec3f{worn(clothing::ANCES), 0.0f, 0.0f} }, pPeer->netid); ::gamePacket gamePacket { - .type = 0x14 | ((0x808000 + pPeer->punch_effect) << 8), // @note 0x8080{}14 - PACKET_SET_CHARACTER_STATE + .type = 0x14 | ((0x808000 + ((pPeer->clothing[clothing::HAND] == 12412) ? 0x200000 : 0) + pPeer->punch_effect) << 8), // @note 0x8080{}14 - PACKET_SET_CHARACTER_STATE .netid = pPeer->netid, .count = 125.0f, // @note gtnoob has this as 'waterspeed' - .id = pPeer->state, // @note 04 invisible (only eyes/mouth), 08 no arms, 16 no face, 32 invisible (only legs/arms), 64 devil horns, 128 angel halo, 2048 frozen, 4096 gray skin?,8192 ducttape, 16384 Onion effect, 32768 stars effect, 65536 zombie, 131072 hit by lava, 262144 shadow effect, 524288 irradiated effect, 1048576 spotlight, 2097152 pineapple thingy + .id = (pPeer->state & ~S_DOUBLE_JUMP & ~S_INVISIBLE) | ((invis && !(pPeer->state & S_GHOST)) ? 0x4 : 0) | double_jump_state(*pPeer) | ((pPeer->curse_until > std::time(nullptr)) ? 0x3000 : 0) | item_effect_state(*pPeer), .pos = ::pos{ 1200.0f, 200.0f }, // @todo magic numbers - .speed = ::pos{ 250.0f, 1000.0f }, // @todo magic numbers + .speed = item_speed(*pPeer), // @todo magic numbers .punch = ::pos{ pPeer->hair_color, 0x00000000u } // @todo can this even be unsigned? - }; state_visuals(peer, std::move(gamePacket)); // @todo handle for 'p' + + /* the state above already reaches everyone, but OnSetClothing (skin colour, clothes) only went to this player */ + if (pPeer->netid != 0 && (pPeer->state & S_INVISIBLE) != S_INVISIBLE) + peers(pPeer->recent_worlds.back(), PEER_SAME_WORLD, [&peer, pPeer](ENetPeer &other) + { + if (&other != &peer) on::SetClothing(other, *pPeer); + }); +} + +/* minimal version for bots: default clothing, just needs a netid */ +void on::SetClothing(ENetPeer &to, int netid, u_int skin, int state, const std::array *cloth) +{ + send_varlist(&to, { + "OnSetClothing", + CL_Vec3f{cloth ? (*cloth)[0] : 0.0f, cloth ? (*cloth)[1] : 0.0f, cloth ? (*cloth)[2] : 0.0f}, + CL_Vec3f{cloth ? (*cloth)[3] : 0.0f, cloth ? (*cloth)[4] : 0.0f, cloth ? (*cloth)[5] : 0.0f}, + CL_Vec3f{cloth ? (*cloth)[6] : 0.0f, cloth ? (*cloth)[7] : 0.0f, cloth ? (*cloth)[8] : 0.0f}, + (state & S_GHOST) ? (u_int)(-140) : skin, + CL_Vec3f{cloth ? (*cloth)[9] : 0.0f, 0.0f, 0.0f} + }, netid); + + ::gamePacket gp{ + .type = 0x14 | (0x808000 << 8), + .netid = netid, + .count = 125.0f, + .id = state, + .pos = ::pos{ 1200.0f, 200.0f }, + .speed = ::pos{ 250.0f, 1000.0f }, + .punch = ::pos{ 0.0f, 0.0f } + }; + send_data(to, compress_state(gp)); } \ No newline at end of file diff --git a/include/onVariant/SetClothing.hpp b/include/onVariant/SetClothing.hpp index e7592c9b..f5d914d4 100644 --- a/include/onVariant/SetClothing.hpp +++ b/include/onVariant/SetClothing.hpp @@ -3,4 +3,6 @@ namespace on { extern void SetClothing(ENetPeer &peer); + extern void SetClothing(ENetPeer &to, ::peer &who); + extern void SetClothing(ENetPeer &to, int netid, u_int skin, int state = 0, const std::array *cloth = nullptr); // @note for bots - no peer needed } diff --git a/include/proton/Variant.cpp b/include/proton/Variant.cpp index 9ea30a14..29c2624d 100644 --- a/include/proton/Variant.cpp +++ b/include/proton/Variant.cpp @@ -115,20 +115,19 @@ void send_varlist(ENetPeer *peer, VariantList vlist, int netid, int delay) u_int size = 0; u_char *pMem = vlist.SerializeToMem(&size, NULL); - ::blob data = compress_state(::gamePacket{ + std::vector data = compress_state(::gamePacket{ .type = 01, // @note PACKET_CALL_FUNCTION .netid = netid, .state = state::S_EXTENDED, .id = delay, .size = size - }); + }).data(); u_int pos = data.size(); // @note sizeof(::gamePacket) data.resize(pos + size); - u_char *raw = data.data().data(); - memcpy(raw + pos, pMem, size); + memcpy(data.data() + pos, pMem, size); delete[] pMem; - ENetPacket *packet = enet_packet_create(raw, data.size(), ENET_PACKET_FLAG_RELIABLE); + ENetPacket *packet = enet_packet_create(data.data(), data.size(), ENET_PACKET_FLAG_RELIABLE); if (enet_peer_send(peer, 0, packet)) enet_packet_destroy(packet); } \ No newline at end of file diff --git a/include/tools/bubble.hpp b/include/tools/bubble.hpp new file mode 100644 index 00000000..cacd4b94 --- /dev/null +++ b/include/tools/bubble.hpp @@ -0,0 +1,14 @@ +#pragma once +#include "onVariant/ConsoleMessage.hpp" + +/* a talk bubble above the player's own head (only they see it). outside a world it goes to chat instead. */ +inline void tell(ENetPeer *peer, const std::string &text) +{ + ::peer *p = static_cast<::peer*>(peer->data); + if (p && p->netid != 0) send_varlist(peer, { "OnTalkBubble", p->netid, text, 0u }); + else on::ConsoleMessage(peer, text); +} + +/* while a consumable is used on another player (set in consumables.cpp), that player's head says the text */ +inline ::peer *g_consume_target = nullptr; +inline const ::peer &speaker(const ::peer &user) { return (g_consume_target && &user != g_consume_target) ? *g_consume_target : user; } \ No newline at end of file diff --git a/include/tools/create_dialog.cpp b/include/tools/create_dialog.cpp index e28419d4..9c5d6736 100644 --- a/include/tools/create_dialog.cpp +++ b/include/tools/create_dialog.cpp @@ -126,4 +126,9 @@ std::string create_dialog::end_dialog(std::string btn_id, std::string btn_close, { _d.append(std::format("end_dialog|{}|{}|{}|\n", btn_id, btn_close, btn_return)); return _d; -} \ No newline at end of file +} +create_dialog& create_dialog::add_raw(std::string raw) +{ + _d.append(raw); + return *this; +} diff --git a/include/tools/create_dialog.hpp b/include/tools/create_dialog.hpp index f5637c8f..9c7fa232 100644 --- a/include/tools/create_dialog.hpp +++ b/include/tools/create_dialog.hpp @@ -10,6 +10,7 @@ class create_dialog create_dialog& add_label_with_icon(std::string size, std::string label, int icon); create_dialog& add_label_with_ele_icon(std::string size, std::string label, int icon, u_char element); create_dialog& add_textbox(std::string label); + create_dialog& add_raw(std::string raw); create_dialog& add_text_input(std::string id, std::string label, short set_value, short length); create_dialog& add_text_input(std::string id, std::string label, std::string set_value, short length); create_dialog& add_smalltext(std::string label); diff --git a/main.cpp b/main.cpp index 0dd8e331..4c993663 100644 --- a/main.cpp +++ b/main.cpp @@ -1,6 +1,6 @@ /* @copyright gurotopia (c) 2024-05-25 - @version parent SHA: 8f2b5977fd057b99e2e2a6f0e65170145decc159 2026-9-30 + @version parent SHA: 7d52e21d120625f1011bed6b844a9409d07fedc1 2026-8-31 */ #include "include/pch.hpp" #include "include/eventType/_eventType.hpp" @@ -11,6 +11,9 @@ #include "include/database/database_config.hpp" // @note load_database_config(), gDatabase_config #include "include/automate/holiday.hpp" // @note holiday #include +#include +#include "commands/motd.hpp" +#include "commands/wands.hpp" namespace { @@ -25,6 +28,9 @@ int main() std::signal(SIGHUP, signal_handler); // @note PuTTY, SSH problems #endif + /* libary version checker */ + std::printf("openssl/openssl %s\n", OpenSSL_version(OPENSSL_VERSION_STRING)); + mysql_library_init(0, NULL, NULL); enet_initialize(); { @@ -41,6 +47,7 @@ int main() host->checksum = enet_crc32; enet_host_compress_with_range_coder(host); + { std::ifstream istrm("motd.txt"); if (istrm) std::getline(istrm, gMotd); } gDb_config.init(); mysql_connect(); decode_items(); // @note reads items.dat into legible class members (id, item name, ect) @@ -48,12 +55,20 @@ int main() check_for_holiday(); // @note check for any holidays using local time (your VPS or local time) - @todo thread loop so it can change the holiday without restarting ENetEvent event{}; - while (!gSignal) - while (enet_host_service(host, &event, 1000/*ms*/) > 0) - if (const auto i = eventType_pool.find(event.type); i != eventType_pool.end()) - i->second(event); + while (!gSignal) + { + while (enet_host_service(host, &event, 50/*ms*/) > 0) + { + if (const auto i = eventType_pool.find(event.type); i != eventType_pool.end()) + i->second(event); + tick_timers(); + } + tick_timers(); + } safe_disconnect_peers(gSignal); + worlds.clear(); + worlds.clear(); // @note destructors save every world to MariaDB mysql_close(db); // @note deletes db (MYSQL* allocation) mysql_library_end(); From 534433f595c9d16a751d37a5a38e774bb1b9a4c2 Mon Sep 17 00:00:00 2001 From: expanderbult Date: Sat, 3 Oct 2026 23:49:06 +0200 Subject: [PATCH 2/5] Include in restart.cpp (Linux build fix) --- include/commands/restart.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/include/commands/restart.cpp b/include/commands/restart.cpp index 1c65edb9..c54fc4fb 100644 --- a/include/commands/restart.cpp +++ b/include/commands/restart.cpp @@ -1,4 +1,5 @@ #include "pch.hpp" +#include // @note std::signal / SIGINT / SIGTERM (needed on Linux) #include "onVariant/ConsoleMessage.hpp" #include "database/world.hpp" #include "restart.hpp" From f5f9ce37f72a2626633eb03bf09ca12005bc3470 Mon Sep 17 00:00:00 2001 From: expanderbult Date: Sun, 4 Oct 2026 00:01:49 +0200 Subject: [PATCH 3/5] Include in pch.hpp on non-Windows (Linux build fix) --- include/pch.hpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/include/pch.hpp b/include/pch.hpp index 2c60c304..af58a68f 100644 --- a/include/pch.hpp +++ b/include/pch.hpp @@ -4,6 +4,11 @@ #include "enet/enet.h" // @version SHA: 2b22def89210ca86b729a22a94a60bbacc9667f2 25-03-22 | https://github.com/ZTzTopia/enet +#ifdef _WIN32 +#else + #include // @note Windows already gets it through its own headers +#endif + #include #include #include From 574388102642d344123c1d80c1d31fb3bc61e915 Mon Sep 17 00:00:00 2001 From: expanderbult Date: Sun, 4 Oct 2026 00:03:57 +0200 Subject: [PATCH 4/5] Remove duplicate include from restart.cpp (it's in pch.hpp) --- include/commands/restart.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/include/commands/restart.cpp b/include/commands/restart.cpp index c54fc4fb..1c65edb9 100644 --- a/include/commands/restart.cpp +++ b/include/commands/restart.cpp @@ -1,5 +1,4 @@ #include "pch.hpp" -#include // @note std::signal / SIGINT / SIGTERM (needed on Linux) #include "onVariant/ConsoleMessage.hpp" #include "database/world.hpp" #include "restart.hpp" From 3883ec57890842f56baa48e7ba1e43d297ee1806 Mon Sep 17 00:00:00 2001 From: expanderbult Date: Sun, 4 Oct 2026 00:13:40 +0200 Subject: [PATCH 5/5] Restore #include in https.cpp so SIGPIPE is ignored on Linux --- include/https/https.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/include/https/https.cpp b/include/https/https.cpp index dfb0b1e1..81796133 100644 --- a/include/https/https.cpp +++ b/include/https/https.cpp @@ -33,6 +33,7 @@ static std::string make_response(const std::string &server_ip) #include #include #else + #include #include #include #include