diff --git a/data/modules/scripts/blessings/blessings.lua b/data/modules/scripts/blessings/blessings.lua
index adfa364e7e1..e061501a330 100644
--- a/data/modules/scripts/blessings/blessings.lua
+++ b/data/modules/scripts/blessings/blessings.lua
@@ -21,7 +21,7 @@ Blessings.Credits = {
Blessings.Config = {
AdventurerBlessingLevel = configManager.getNumber(configKeys.ADVENTURERSBLESSING_LEVEL), -- Free full bless until level
- HasToF = false, -- Enables/disables twist of fate
+ HasToF = not configManager.getBoolean(configKeys.TOGGLE_SERVER_IS_RETRO), -- Enables/disables twist of fate
InquisitonBlessPriceMultiplier = 1.1, -- Bless price multiplied by henricus
SkulledDeathLoseStoreItem = configManager.getBoolean(configKeys.SKULLED_DEATH_LOSE_STORE_ITEM), -- Destroy all items on store when dying with red/blackskull
InventoryGlowOnFiveBless = configManager.getBoolean(configKeys.INVENTORY_GLOW), -- Glow in yellow inventory items when the player has 5 or more bless,
@@ -142,7 +142,7 @@ Blessings.sendBlessDialog = function(player)
msg:addU16(Blessings.BitWiseTable[v.id])
msg:addByte(player:getBlessingCount(v.id))
if player:getClient().version > 1200 then
- msg:addByte(0) -- Store Blessings Count
+ msg:addByte(player:getBlessingCount(v.id, true)) -- Store Blessings Count
end
end
end
diff --git a/data/modules/scripts/gamestore/gamestore.lua b/data/modules/scripts/gamestore/gamestore.lua
index f000c4079af..ed17c456c0d 100644
--- a/data/modules/scripts/gamestore/gamestore.lua
+++ b/data/modules/scripts/gamestore/gamestore.lua
@@ -135,7 +135,7 @@ GameStore.Categories = {
icons = { "Blood_of_the_Mountain.png" },
name = "Blood of the Mountain",
price = 25,
- blessid = 8,
+ blessid = 7,
count = 1,
id = GameStore.SubActions.BLESSING_BLOOD,
description = "Reduces your character's chance to lose any items as well as the amount of your character's experience and skill loss upon death:\n\n• 1 blessing = 8.00% less Skill / XP loss, 30% equipment protection\n• 2 blessing = 16.00% less Skill / XP loss, 55% equipment protection\n• 3 blessing = 24.00% less Skill / XP loss, 75% equipment protection\n• 4 blessing = 32.00% less Skill / XP loss, 90% equipment protection\n• 5 blessing = 40.00% less Skill / XP loss, 100% equipment protection\n• 6 blessing = 48.00% less Skill / XP loss, 100% equipment protection\n• 7 blessing = 56.00% less Skill / XP loss, 100% equipment protection\n\n{character} \n{limit|5} \n{info} added directly to the Record of Blessings \n{info} characters with a red or black skull will always lose all equipment upon death",
@@ -154,7 +154,7 @@ GameStore.Categories = {
icons = { "Heart_of_the_Mountain.png" },
name = "Heart of the Mountain",
price = 25,
- blessid = 7,
+ blessid = 8,
count = 1,
id = GameStore.SubActions.BLESSING_HEART,
description = "Reduces your character's chance to lose any items as well as the amount of your character's experience and skill loss upon death:\n\n• 1 blessing = 8.00% less Skill / XP loss, 30% equipment protection\n• 2 blessing = 16.00% less Skill / XP loss, 55% equipment protection\n• 3 blessing = 24.00% less Skill / XP loss, 75% equipment protection\n• 4 blessing = 32.00% less Skill / XP loss, 90% equipment protection\n• 5 blessing = 40.00% less Skill / XP loss, 100% equipment protection\n• 6 blessing = 48.00% less Skill / XP loss, 100% equipment protection\n• 7 blessing = 56.00% less Skill / XP loss, 100% equipment protection\n\n{character} \n{limit|5} \n{info} added directly to the Record of Blessings \n{info} characters with a red or black skull will always lose all equipment upon death",
diff --git a/data/modules/scripts/gamestore/init.lua b/data/modules/scripts/gamestore/init.lua
index 322a7749c93..433abf34492 100644
--- a/data/modules/scripts/gamestore/init.lua
+++ b/data/modules/scripts/gamestore/init.lua
@@ -47,8 +47,8 @@ GameStore.SubActions = {
BLESSING_SUNS = 6,
BLESSING_SPIRITUAL = 7,
BLESSING_EMBRACE = 8,
- BLESSING_HEART = 9,
- BLESSING_BLOOD = 10,
+ BLESSING_BLOOD = 9,
+ BLESSING_HEART = 10,
BLESSING_ALL_PVE = 11,
BLESSING_ALL_PVP = 12,
CHARM_EXPANSION = 13,
@@ -399,6 +399,17 @@ function parseRequestStoreOffers(playerId, msg)
player:updateUIExhausted()
end
+-- Used on cyclopedia store summary
+local function insertPlayerTransactionSummary(player, offer)
+ local id = offer.id
+ if offer.type == GameStore.OfferTypes.OFFER_TYPE_HOUSE then
+ id = offer.itemtype
+ elseif offer.type == GameStore.OfferTypes.OFFER_TYPE_BLESSINGS then
+ id = offer.blessid
+ end
+ player:createTransactionSummary(offer.type, math.max(1, offer.count or 1), id)
+end
+
function parseBuyStoreOffer(playerId, msg)
local player = Player(playerId)
local id = msg:getU32()
@@ -450,9 +461,7 @@ function parseBuyStoreOffer(playerId, msg)
-- Handled errors are thrown to indicate that the purchase has failed;
-- Handled errors have a code index and unhandled errors do not
local pcallOk, pcallError = pcall(function()
- if offer.type == GameStore.OfferTypes.OFFER_TYPE_ITEM then
- GameStore.processItemPurchase(player, offer.itemtype, offer.count or 1, offer.movable, offer.setOwner)
- elseif offer.type == GameStore.OfferTypes.OFFER_TYPE_ITEM_UNIQUE then
+ if offer.type == GameStore.OfferTypes.OFFER_TYPE_ITEM or offer.type == GameStore.OfferTypes.OFFER_TYPE_ITEM_UNIQUE then
GameStore.processItemPurchase(player, offer.itemtype, offer.count or 1, offer.movable, offer.setOwner)
elseif offer.type == GameStore.OfferTypes.OFFER_TYPE_INSTANT_REWARD_ACCESS then
GameStore.processInstantRewardAccess(player, offer.count)
@@ -466,11 +475,9 @@ function parseBuyStoreOffer(playerId, msg)
GameStore.processPremiumPurchase(player, offer.id)
elseif offer.type == GameStore.OfferTypes.OFFER_TYPE_STACKABLE then
GameStore.processStackablePurchase(player, offer.itemtype, offer.count, offer.name, offer.movable, offer.setOwner)
- elseif offer.type == GameStore.OfferTypes.OFFER_TYPE_HOUSE then
+ elseif offer.type == GameStore.OfferTypes.OFFER_TYPE_HOUSE or offer.type == GameStore.OfferTypes.OFFER_TYPE_ITEM_BED then
GameStore.processHouseRelatedPurchase(player, offer)
- elseif offer.type == GameStore.OfferTypes.OFFER_TYPE_OUTFIT then
- GameStore.processOutfitPurchase(player, offer.sexId, offer.addon)
- elseif offer.type == GameStore.OfferTypes.OFFER_TYPE_OUTFIT_ADDON then
+ elseif offer.type == GameStore.OfferTypes.OFFER_TYPE_OUTFIT or offer.type == GameStore.OfferTypes.OFFER_TYPE_OUTFIT_ADDON then
GameStore.processOutfitPurchase(player, offer.sexId, offer.addon)
elseif offer.type == GameStore.OfferTypes.OFFER_TYPE_MOUNT then
GameStore.processMountPurchase(player, offer.id)
@@ -504,8 +511,6 @@ function parseBuyStoreOffer(playerId, msg)
GameStore.processHirelingSkillPurchase(player, offer)
elseif offer.type == GameStore.OfferTypes.OFFER_TYPE_HIRELING_OUTFIT then
GameStore.processHirelingOutfitPurchase(player, offer)
- elseif offer.type == GameStore.OfferTypes.OFFER_TYPE_ITEM_BED then
- GameStore.processHouseRelatedPurchase(player, offer)
else
-- This should never happen by our convention, but just in case the guarding condition is messed up...
error({ code = 0, message = "This offer is unavailable [2]" })
@@ -523,6 +528,9 @@ function parseBuyStoreOffer(playerId, msg)
return queueSendStoreAlertToUser(alertMessage, 500, playerId)
end
+ if table.contains({ GameStore.OfferTypes.OFFER_TYPE_HOUSE, GameStore.OfferTypes.OFFER_TYPE_EXPBOOST, GameStore.OfferTypes.OFFER_TYPE_PREYBONUS, GameStore.OfferTypes.OFFER_TYPE_BLESSINGS, GameStore.OfferTypes.OFFER_TYPE_ALLBLESSINGS, GameStore.OfferTypes.OFFER_TYPE_INSTANT_REWARD_ACCESS }, offer.type) then
+ insertPlayerTransactionSummary(player, offer)
+ end
local configure = useOfferConfigure(offer.type)
if configure ~= GameStore.ConfigureOffers.SHOW_CONFIGURE then
if not player:makeCoinTransaction(offer) then
@@ -1827,6 +1835,7 @@ function GameStore.processHirelingPurchase(player, offer, productType, hirelingN
player:makeCoinTransaction(offer, hirelingName)
local message = "You have successfully bought " .. hirelingName
+ player:createTransactionSummary(offer.type, 1)
return addPlayerEvent(sendStorePurchaseSuccessful, 650, player:getId(), message)
-- If not, we ask him to do!
else
diff --git a/data/scripts/eventcallbacks/player/on_look.lua b/data/scripts/eventcallbacks/player/on_look.lua
index c4743052cfa..6b4be92553a 100644
--- a/data/scripts/eventcallbacks/player/on_look.lua
+++ b/data/scripts/eventcallbacks/player/on_look.lua
@@ -60,11 +60,12 @@ function callback.playerOnLook(player, thing, position, distance)
description = string.format("%s\nDecays to: %d", description, decayId)
end
elseif thing:isCreature() then
- local str = "%s\nHealth: %d / %d"
+ local str, pId = "%s\n%s\nHealth: %d / %d"
if thing:isPlayer() and thing:getMaxMana() > 0 then
+ pId = string.format("Player ID: %i", thing:getGuid())
str = string.format("%s, Mana: %d / %d", str, thing:getMana(), thing:getMaxMana())
end
- description = string.format(str, description, thing:getHealth(), thing:getMaxHealth()) .. "."
+ description = string.format(str, description, pId, thing:getHealth(), thing:getMaxHealth())
end
description = string.format("%s\nPosition: (%d, %d, %d)", description, position.x, position.y, position.z)
@@ -76,7 +77,7 @@ function callback.playerOnLook(player, thing, position, distance)
description = string.format("%s\nSpeed: %d", description, speed)
if thing:isPlayer() then
- description = string.format("%s\nIP: %s.", description, Game.convertIpToString(thing:getIp()))
+ description = string.format("%s\nIP: %s", description, Game.convertIpToString(thing:getIp()))
end
end
end
diff --git a/data/scripts/talkactions/god/create_npc.lua b/data/scripts/talkactions/god/create_npc.lua
index 4aeec3dde80..b6d0412d391 100644
--- a/data/scripts/talkactions/god/create_npc.lua
+++ b/data/scripts/talkactions/god/create_npc.lua
@@ -1,3 +1,6 @@
+-- To summon a temporary npc use /n npcname
+-- To summon a permanent npc use /n npcname,true
+
local createNpc = TalkAction("/n")
function createNpc.onSay(player, words, param)
@@ -9,11 +12,44 @@ function createNpc.onSay(player, words, param)
return true
end
+ local split = param:split(",")
+ local name = split[1]
+ local permanentStr = split[2]
+
local position = player:getPosition()
- local npc = Game.createNpc(param, position)
+ local npc = Game.createNpc(name, position)
if npc then
npc:setMasterPos(position)
position:sendMagicEffect(CONST_ME_MAGIC_RED)
+
+ if permanentStr and permanentStr == "true" then
+ local mapName = configManager.getString(configKeys.MAP_NAME)
+ local mapNpcsPath = mapName .. "-npc.xml"
+ local filePath = string.format("%s/world/%s", DATA_DIRECTORY, mapNpcsPath)
+ local npcsFile = io.open(filePath, "r")
+ if not npcsFile then
+ player:sendTextMessage(MESSAGE_EVENT_ADVANCE, "There was an error when trying to add permanent NPC. NPC File not found.")
+ return true
+ end
+ local fileContent = npcsFile:read("*all")
+ npcsFile:close()
+ local endTag = ""
+ if not fileContent:find(endTag, 1, true) then
+ player:sendTextMessage(MESSAGE_EVENT_ADVANCE, "There was an error when trying to add permanent NPC. The NPC file format is incorrect. Missing end tag " .. endTag .. ".")
+ return true
+ end
+ local textToAdd = string.format('\t\n\t\t\n\t', position.x, position.y, position.z, name, position.z)
+ local newFileContent = fileContent:gsub(endTag, textToAdd .. "\n" .. endTag)
+ npcsFile = io.open(filePath, "w")
+ if not npcsFile then
+ player:sendTextMessage(MESSAGE_EVENT_ADVANCE, "There was an error when trying to write to the NPC file.")
+ return true
+ end
+ npcsFile:write(newFileContent)
+ npcsFile:close()
+
+ player:sendTextMessage(MESSAGE_EVENT_ADVANCE, "Permanent NPC added successfully.")
+ end
else
player:sendCancelMessage("There is not enough room.")
position:sendMagicEffect(CONST_ME_POFF)
diff --git a/src/creatures/CMakeLists.txt b/src/creatures/CMakeLists.txt
index af6ba129eac..c87a45a0aa0 100644
--- a/src/creatures/CMakeLists.txt
+++ b/src/creatures/CMakeLists.txt
@@ -23,6 +23,7 @@ target_sources(${PROJECT_NAME}_lib PRIVATE
players/player.cpp
players/achievement/player_achievement.cpp
players/cyclopedia/player_badge.cpp
+ players/cyclopedia/player_cyclopedia.cpp
players/cyclopedia/player_title.cpp
players/wheel/player_wheel.cpp
players/wheel/wheel_gems.cpp
diff --git a/src/creatures/players/achievement/player_achievement.cpp b/src/creatures/players/achievement/player_achievement.cpp
index 69d1d7ab1fa..cd0735ab54c 100644
--- a/src/creatures/players/achievement/player_achievement.cpp
+++ b/src/creatures/players/achievement/player_achievement.cpp
@@ -35,7 +35,7 @@ bool PlayerAchievement::add(uint16_t id, bool message /* = true*/, uint32_t time
addPoints(achievement.points);
int toSaveTimeStamp = timestamp != 0 ? timestamp : (OTSYS_TIME() / 1000);
getUnlockedKV()->set(achievement.name, toSaveTimeStamp);
- m_achievementsUnlocked.push_back({ achievement.id, toSaveTimeStamp });
+ m_achievementsUnlocked.emplace_back(achievement.id, toSaveTimeStamp);
m_achievementsUnlocked.shrink_to_fit();
return true;
}
@@ -80,7 +80,8 @@ bool PlayerAchievement::isUnlocked(uint16_t id) const {
}
uint16_t PlayerAchievement::getPoints() const {
- return m_player.kv()->scoped("achievements")->get("points")->getNumber();
+ auto kvScoped = m_player.kv()->scoped("achievements")->get("points");
+ return kvScoped ? static_cast(kvScoped->getNumber()) : 0;
}
void PlayerAchievement::addPoints(uint16_t toAddPoints) {
@@ -109,12 +110,12 @@ void PlayerAchievement::loadUnlockedAchievements() {
g_logger().debug("[{}] - Achievement {} found for player {}.", __FUNCTION__, achievementName, m_player.getName());
- m_achievementsUnlocked.push_back({ achievement.id, getUnlockedKV()->get(achievementName)->getNumber() });
+ m_achievementsUnlocked.emplace_back(achievement.id, getUnlockedKV()->get(achievementName)->getNumber());
}
}
void PlayerAchievement::sendUnlockedSecretAchievements() {
- std::vector> m_achievementsUnlocked;
+ std::vector> achievementsUnlocked;
uint16_t unlockedSecret = 0;
for (const auto &[achievId, achievCreatedTime] : getUnlockedAchievements()) {
Achievement achievement = g_game().getAchievementById(achievId);
@@ -126,10 +127,10 @@ void PlayerAchievement::sendUnlockedSecretAchievements() {
unlockedSecret++;
}
- m_achievementsUnlocked.push_back({ achievement, achievCreatedTime });
+ achievementsUnlocked.emplace_back(achievement, achievCreatedTime);
}
- m_player.sendCyclopediaCharacterAchievements(unlockedSecret, m_achievementsUnlocked);
+ m_player.sendCyclopediaCharacterAchievements(unlockedSecret, achievementsUnlocked);
}
const std::shared_ptr &PlayerAchievement::getUnlockedKV() {
diff --git a/src/creatures/players/achievement/player_achievement.hpp b/src/creatures/players/achievement/player_achievement.hpp
index d1073a9bf1e..e0c027e5808 100644
--- a/src/creatures/players/achievement/player_achievement.hpp
+++ b/src/creatures/players/achievement/player_achievement.hpp
@@ -31,11 +31,11 @@ class PlayerAchievement {
explicit PlayerAchievement(Player &player);
bool add(uint16_t id, bool message = true, uint32_t timestamp = 0);
bool remove(uint16_t id);
- bool isUnlocked(uint16_t id) const;
- uint16_t getPoints() const;
+ [[nodiscard]] bool isUnlocked(uint16_t id) const;
+ [[nodiscard]] uint16_t getPoints() const;
void addPoints(uint16_t toAddPoints);
void removePoints(uint16_t toRemovePoints);
- std::vector> getUnlockedAchievements() const;
+ [[nodiscard]] std::vector> getUnlockedAchievements() const;
void loadUnlockedAchievements();
void sendUnlockedSecretAchievements();
const std::shared_ptr &getUnlockedKV();
diff --git a/src/creatures/players/cyclopedia/player_cyclopedia.cpp b/src/creatures/players/cyclopedia/player_cyclopedia.cpp
new file mode 100644
index 00000000000..abbc920d322
--- /dev/null
+++ b/src/creatures/players/cyclopedia/player_cyclopedia.cpp
@@ -0,0 +1,185 @@
+/**
+ * Canary - A free and open-source MMORPG server emulator
+ * Copyright (©) 2019-2024 OpenTibiaBR
+ * Repository: https://github.com/opentibiabr/canary
+ * License: https://github.com/opentibiabr/canary/blob/main/LICENSE
+ * Contributors: https://github.com/opentibiabr/canary/graphs/contributors
+ * Website: https://docs.opentibiabr.com/
+ */
+
+#include "pch.hpp"
+
+#include "database/databasetasks.hpp"
+#include "creatures/players/player.hpp"
+#include "player_cyclopedia.hpp"
+#include "game/game.hpp"
+#include "kv/kv.hpp"
+
+PlayerCyclopedia::PlayerCyclopedia(Player &player) :
+ m_player(player) { }
+
+Summary PlayerCyclopedia::getSummary() {
+ return { getAmount(Summary_t::PREY_CARDS),
+ getAmount(Summary_t::INSTANT_REWARDS),
+ getAmount(Summary_t::HIRELINGS) };
+}
+
+void PlayerCyclopedia::loadSummaryData() {
+ DBResult_ptr result = g_database().storeQuery(fmt::format("SELECT COUNT(*) as `count` FROM `player_hirelings` WHERE `player_id` = {}", m_player.getGUID()));
+ auto kvScoped = m_player.kv()->scoped("summary")->scoped(g_game().getSummaryKeyByType(static_cast(Summary_t::HIRELINGS)));
+ if (result && !kvScoped->get("amount").has_value()) {
+ kvScoped->set("amount", result->getNumber("count"));
+ }
+}
+
+void PlayerCyclopedia::loadDeathHistory(uint16_t page, uint16_t entriesPerPage) {
+ Benchmark bm_check;
+ uint32_t offset = static_cast(page - 1) * entriesPerPage;
+ auto query = fmt::format("SELECT `time`, `level`, `killed_by`, `mostdamage_by`, (select count(*) FROM `player_deaths` WHERE `player_id` = {}) as `entries` FROM `player_deaths` WHERE `player_id` = {} AND `time` >= UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL 30 DAY)) ORDER BY `time` DESC LIMIT {}, {}", m_player.getGUID(), m_player.getGUID(), offset, entriesPerPage);
+
+ uint32_t playerID = m_player.getID();
+ std::function callback = [playerID, page, entriesPerPage](const DBResult_ptr &result, bool) {
+ std::shared_ptr player = g_game().getPlayerByID(playerID);
+ if (!player) {
+ return;
+ }
+
+ player->resetAsyncOngoingTask(PlayerAsyncTask_RecentDeaths);
+ if (!result) {
+ player->sendCyclopediaCharacterRecentDeaths(0, 0, {});
+ return;
+ }
+
+ auto pages = result->getNumber("entries");
+ pages += entriesPerPage - 1;
+ pages /= entriesPerPage;
+
+ std::vector entries;
+ entries.reserve(result->countResults());
+ do {
+ std::string killed_by = result->getString("killed_by");
+ std::string mostdamage_by = result->getString("mostdamage_by");
+
+ std::string cause = fmt::format("Died at Level {}", result->getNumber("level"));
+
+ if (!killed_by.empty()) {
+ cause.append(fmt::format(" by{}", formatWithArticle(killed_by)));
+ }
+
+ if (!mostdamage_by.empty()) {
+ cause.append(fmt::format("{}{}", !killed_by.empty() ? " and" : "", formatWithArticle(mostdamage_by)));
+ }
+
+ entries.emplace_back(cause, result->getNumber("time"));
+ } while (result->next());
+ player->sendCyclopediaCharacterRecentDeaths(page, static_cast(pages), entries);
+ };
+ g_databaseTasks().store(query, callback);
+ m_player.addAsyncOngoingTask(PlayerAsyncTask_RecentDeaths);
+
+ g_logger().debug("Loading death history from the player {} took {} milliseconds.", m_player.getName(), bm_check.duration());
+}
+
+void PlayerCyclopedia::loadRecentKills(uint16_t page, uint16_t entriesPerPage) {
+ Benchmark bm_check;
+
+ const std::string &escapedName = g_database().escapeString(m_player.getName());
+ uint32_t offset = static_cast(page - 1) * entriesPerPage;
+ auto query = fmt::format("SELECT `d`.`time`, `d`.`killed_by`, `d`.`mostdamage_by`, `d`.`unjustified`, `d`.`mostdamage_unjustified`, `p`.`name`, (select count(*) FROM `player_deaths` WHERE ((`killed_by` = {} AND `is_player` = 1) OR (`mostdamage_by` = {} AND `mostdamage_is_player` = 1))) as `entries` FROM `player_deaths` AS `d` INNER JOIN `players` AS `p` ON `d`.`player_id` = `p`.`id` WHERE ((`d`.`killed_by` = {} AND `d`.`is_player` = 1) OR (`d`.`mostdamage_by` = {} AND `d`.`mostdamage_is_player` = 1)) AND `time` >= UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL 70 DAY)) ORDER BY `time` DESC LIMIT {}, {}", escapedName, escapedName, escapedName, escapedName, offset, entriesPerPage);
+
+ uint32_t playerID = m_player.getID();
+ std::function callback = [playerID, page, entriesPerPage](const DBResult_ptr &result, bool) {
+ std::shared_ptr player = g_game().getPlayerByID(playerID);
+ if (!player) {
+ return;
+ }
+
+ player->resetAsyncOngoingTask(PlayerAsyncTask_RecentPvPKills);
+ if (!result) {
+ player->sendCyclopediaCharacterRecentPvPKills(0, 0, {});
+ return;
+ }
+
+ auto pages = result->getNumber("entries");
+ pages += entriesPerPage - 1;
+ pages /= entriesPerPage;
+
+ std::vector entries;
+ entries.reserve(result->countResults());
+ do {
+ std::string cause1 = result->getString("killed_by");
+ std::string cause2 = result->getString("mostdamage_by");
+ std::string name = result->getString("name");
+
+ uint8_t status = CYCLOPEDIA_CHARACTERINFO_RECENTKILLSTATUS_JUSTIFIED;
+ if (player->getName() == cause1) {
+ if (result->getNumber("unjustified") == 1) {
+ status = CYCLOPEDIA_CHARACTERINFO_RECENTKILLSTATUS_UNJUSTIFIED;
+ }
+ } else if (player->getName() == cause2) {
+ if (result->getNumber("mostdamage_unjustified") == 1) {
+ status = CYCLOPEDIA_CHARACTERINFO_RECENTKILLSTATUS_UNJUSTIFIED;
+ }
+ }
+
+ entries.emplace_back(fmt::format("Killed {}.", name), result->getNumber("time"), status);
+ } while (result->next());
+ player->sendCyclopediaCharacterRecentPvPKills(page, static_cast(pages), entries);
+ };
+ g_databaseTasks().store(query, callback);
+ m_player.addAsyncOngoingTask(PlayerAsyncTask_RecentPvPKills);
+
+ g_logger().debug("Loading recent kills from the player {} took {} milliseconds.", m_player.getName(), bm_check.duration());
+}
+
+void PlayerCyclopedia::updateStoreSummary(uint8_t type, uint16_t amount, const std::string &id) {
+ switch (type) {
+ case Summary_t::HOUSE_ITEMS:
+ case Summary_t::BLESSINGS:
+ insertValue(type, amount, id);
+ break;
+ case Summary_t::ALL_BLESSINGS:
+ for (int i = 1; i < 8; ++i) {
+ insertValue(static_cast(Summary_t::BLESSINGS), amount, fmt::format("{}", i));
+ }
+ break;
+ default:
+ updateAmount(type, amount);
+ break;
+ }
+}
+
+uint16_t PlayerCyclopedia::getAmount(uint8_t type) {
+ auto kvScope = m_player.kv()->scoped("summary")->scoped(g_game().getSummaryKeyByType(type))->get("amount");
+ return static_cast(kvScope ? kvScope->getNumber() : 0);
+}
+
+void PlayerCyclopedia::updateAmount(uint8_t type, uint16_t amount) {
+ auto oldAmount = getAmount(type);
+ m_player.kv()->scoped("summary")->scoped(g_game().getSummaryKeyByType(type))->set("amount", oldAmount + amount);
+}
+
+std::map PlayerCyclopedia::getResult(uint8_t type) const {
+ auto kvScope = m_player.kv()->scoped("summary")->scoped(g_game().getSummaryKeyByType(type));
+ std::map result; // ID, amount
+ for (const auto &scope : kvScope->keys()) {
+ size_t pos = scope.find('.');
+ if (pos == std::string::npos) {
+ g_logger().error("[{}] Invalid key format: {}", __FUNCTION__, scope);
+ continue;
+ }
+ std::string id = scope.substr(0, pos);
+ auto amount = kvScope->scoped(id)->get("amount");
+ result.emplace(std::stoll(id), static_cast(amount ? amount->getNumber() : 0));
+ }
+ return result;
+}
+
+void PlayerCyclopedia::insertValue(uint8_t type, uint16_t amount, const std::string &id) {
+ auto result = getResult(type);
+ auto it = result.find(std::stoll(id));
+ auto oldAmount = (it != result.end() ? it->second : 0);
+ auto newAmount = oldAmount + amount;
+ m_player.kv()->scoped("summary")->scoped(g_game().getSummaryKeyByType(type))->scoped(id)->set("amount", newAmount);
+ g_logger().debug("[{}] type: {}, id: {}, old amount: {}, added amount: {}, new amount: {}", __FUNCTION__, type, id, oldAmount, amount, newAmount);
+}
diff --git a/src/creatures/players/cyclopedia/player_cyclopedia.hpp b/src/creatures/players/cyclopedia/player_cyclopedia.hpp
new file mode 100644
index 00000000000..32c446cc368
--- /dev/null
+++ b/src/creatures/players/cyclopedia/player_cyclopedia.hpp
@@ -0,0 +1,46 @@
+/**
+ * Canary - A free and open-source MMORPG server emulator
+ * Copyright (©) 2019-2024 OpenTibiaBR
+ * Repository: https://github.com/opentibiabr/canary
+ * License: https://github.com/opentibiabr/canary/blob/main/LICENSE
+ * Contributors: https://github.com/opentibiabr/canary/graphs/contributors
+ * Website: https://docs.opentibiabr.com/
+ */
+
+#pragma once
+
+#include "creatures/creatures_definitions.hpp"
+#include "enums/player_cyclopedia.hpp"
+
+class Player;
+class KV;
+
+struct Summary {
+ uint16_t m_preyWildcards = 0;
+ uint16_t m_instantRewards = 0;
+ uint16_t m_hirelings = 0;
+
+ [[maybe_unused]] Summary(uint16_t mPreyWildcards, uint16_t mInstantRewards, uint16_t mHirelings) :
+ m_preyWildcards(mPreyWildcards), m_instantRewards(mInstantRewards), m_hirelings(mHirelings) { }
+};
+
+class PlayerCyclopedia {
+public:
+ explicit PlayerCyclopedia(Player &player);
+
+ Summary getSummary();
+
+ void loadSummaryData();
+ void loadDeathHistory(uint16_t page, uint16_t entriesPerPage);
+ void loadRecentKills(uint16_t page, uint16_t entriesPerPage);
+
+ void updateStoreSummary(uint8_t type, uint16_t amount = 1, const std::string &id = "");
+ uint16_t getAmount(uint8_t type);
+ void updateAmount(uint8_t type, uint16_t amount = 1);
+
+ [[nodiscard]] std::map getResult(uint8_t type) const;
+ void insertValue(uint8_t type, uint16_t amount = 1, const std::string &id = "");
+
+private:
+ Player &m_player;
+};
diff --git a/src/creatures/players/cyclopedia/player_title.cpp b/src/creatures/players/cyclopedia/player_title.cpp
index a6b44f3d3c4..7c348cbf79d 100644
--- a/src/creatures/players/cyclopedia/player_title.cpp
+++ b/src/creatures/players/cyclopedia/player_title.cpp
@@ -84,7 +84,8 @@ const std::vector> &PlayerTitle::getUnlockedTitles()
}
uint8_t PlayerTitle::getCurrentTitle() const {
- return static_cast(m_player.kv()->scoped("titles")->get("current-title")->getNumber());
+ auto title = m_player.kv()->scoped("titles")->get("current-title");
+ return title ? static_cast(title->getNumber()) : 0;
}
void PlayerTitle::setCurrentTitle(uint8_t id) {
diff --git a/src/creatures/players/player.cpp b/src/creatures/players/player.cpp
index 79653895bb8..07d048c6c3c 100644
--- a/src/creatures/players/player.cpp
+++ b/src/creatures/players/player.cpp
@@ -17,6 +17,7 @@
#include "creatures/players/wheel/player_wheel.hpp"
#include "creatures/players/achievement/player_achievement.hpp"
#include "creatures/players/cyclopedia/player_badge.hpp"
+#include "creatures/players/cyclopedia/player_cyclopedia.hpp"
#include "creatures/players/cyclopedia/player_title.hpp"
#include "creatures/players/storages/storages.hpp"
#include "game/game.hpp"
@@ -53,6 +54,7 @@ Player::Player(ProtocolGame_ptr p) :
m_wheelPlayer = std::make_unique(*this);
m_playerAchievement = std::make_unique(*this);
m_playerBadge = std::make_unique(*this);
+ m_playerCyclopedia = std::make_unique(*this);
m_playerTitle = std::make_unique(*this);
}
@@ -633,20 +635,6 @@ phmap::flat_hash_map> Player::getAllSlotItems() c
return itemMap;
}
-phmap::flat_hash_map Player::getBlessingNames() const {
- static phmap::flat_hash_map blessingNames = {
- { TWIST_OF_FATE, "Twist of Fate" },
- { WISDOM_OF_SOLITUDE, "The Wisdom of Solitude" },
- { SPARK_OF_THE_PHOENIX, "The Spark of the Phoenix" },
- { FIRE_OF_THE_SUNS, "The Fire of the Suns" },
- { SPIRITUAL_SHIELDING, "The Spiritual Shielding" },
- { EMBRACE_OF_TIBIA, "The Embrace of Tibia" },
- { BLOOD_OF_THE_MOUNTAIN, "Blood of the Mountain" },
- { HEARTH_OF_THE_MOUNTAIN, "Heart of the Mountain" },
- };
- return blessingNames;
-}
-
void Player::setTraining(bool value) {
for (const auto &[key, player] : g_game().getPlayers()) {
if (!this->isInGhostMode() || player->isAccessPlayer()) {
@@ -6621,7 +6609,7 @@ std::string Player::getBlessingsName() const {
}
});
- auto BlessingNames = getBlessingNames();
+ auto BlessingNames = g_game().getBlessingNames();
std::ostringstream os;
for (uint8_t i = 1; i <= 8; i++) {
if (hasBlessing(i)) {
@@ -8028,6 +8016,15 @@ const std::unique_ptr &Player::vip() const {
return m_playerVIP;
}
+// Cyclopedia
+std::unique_ptr &Player::cyclopedia() {
+ return m_playerCyclopedia;
+}
+
+const std::unique_ptr &Player::cyclopedia() const {
+ return m_playerCyclopedia;
+}
+
void Player::sendLootMessage(const std::string &message) const {
auto party = getParty();
if (!party) {
diff --git a/src/creatures/players/player.hpp b/src/creatures/players/player.hpp
index 89c9219dc55..09cb46149ec 100644
--- a/src/creatures/players/player.hpp
+++ b/src/creatures/players/player.hpp
@@ -36,6 +36,7 @@
#include "enums/object_category.hpp"
#include "enums/player_cyclopedia.hpp"
#include "creatures/players/cyclopedia/player_badge.hpp"
+#include "creatures/players/cyclopedia/player_cyclopedia.hpp"
#include "creatures/players/cyclopedia/player_title.hpp"
#include "creatures/players/vip/player_vip.hpp"
@@ -54,6 +55,7 @@ class Spell;
class PlayerWheel;
class PlayerAchievement;
class PlayerBadge;
+class PlayerCyclopedia;
class PlayerTitle;
class PlayerVIP;
class Spectators;
@@ -475,13 +477,18 @@ class Player final : public Creature, public Cylinder, public Bankable {
bool hasBlessing(uint8_t index) const {
return blessings[index - 1] != 0;
}
- uint8_t getBlessingCount(uint8_t index) const {
- if (index > 0 && index <= blessings.size()) {
- return blessings[index - 1];
- } else {
- g_logger().error("[{}] - index outside range 0-10.", __FUNCTION__);
- return 0;
+
+ uint8_t getBlessingCount(uint8_t index, bool storeCount = false) const {
+ if (!storeCount) {
+ if (index > 0 && index <= blessings.size()) {
+ return blessings[index - 1];
+ } else {
+ g_logger().error("[{}] - index outside range 0-10.", __FUNCTION__);
+ return 0;
+ }
}
+ auto amount = kv()->scoped("summary")->scoped("blessings")->scoped(fmt::format("{}", index))->get("amount");
+ return amount ? static_cast(amount->getNumber()) : 0;
}
std::string getBlessingsName() const;
@@ -1642,11 +1649,7 @@ class Player final : public Creature, public Cylinder, public Bankable {
client->sendCyclopediaCharacterRecentDeaths(page, pages, entries);
}
}
- void sendCyclopediaCharacterRecentPvPKills(
- uint16_t page, uint16_t pages,
- const std::vector<
- RecentPvPKillEntry> &entries
- ) {
+ void sendCyclopediaCharacterRecentPvPKills(uint16_t page, uint16_t pages, const std::vector &entries) {
if (client) {
client->sendCyclopediaCharacterRecentPvPKills(page, pages, entries);
}
@@ -2599,9 +2602,6 @@ class Player final : public Creature, public Cylinder, public Bankable {
// This get all players slot items
phmap::flat_hash_map> getAllSlotItems() const;
- // This get all blessings
- phmap::flat_hash_map getBlessingNames() const;
-
// Gets the equipped items with augment by type
std::vector> getEquippedAugmentItemsByType(Augment_t augmentType) const;
@@ -2631,6 +2631,10 @@ class Player final : public Creature, public Cylinder, public Bankable {
std::unique_ptr &title();
const std::unique_ptr &title() const;
+ // Player summary interface
+ std::unique_ptr &cyclopedia();
+ const std::unique_ptr &cyclopedia() const;
+
// Player vip interface
std::unique_ptr &vip();
const std::unique_ptr &vip() const;
@@ -2952,6 +2956,8 @@ class Player final : public Creature, public Cylinder, public Bankable {
int32_t magicShieldCapacityFlat = 0;
int32_t magicShieldCapacityPercent = 0;
+ int32_t marriageSpouse = -1;
+
void updateItemsLight(bool internal = false);
uint16_t getStepSpeed() const override {
return std::max(PLAYER_MIN_SPEED, std::min(PLAYER_MAX_SPEED, getSpeed()));
@@ -3028,12 +3034,14 @@ class Player final : public Creature, public Cylinder, public Bankable {
friend class IOLoginDataSave;
friend class PlayerAchievement;
friend class PlayerBadge;
+ friend class PlayerCyclopedia;
friend class PlayerTitle;
friend class PlayerVIP;
std::unique_ptr m_wheelPlayer;
std::unique_ptr m_playerAchievement;
std::unique_ptr m_playerBadge;
+ std::unique_ptr m_playerCyclopedia;
std::unique_ptr m_playerTitle;
std::unique_ptr m_playerVIP;
@@ -3059,4 +3067,11 @@ class Player final : public Creature, public Cylinder, public Bankable {
bool hasOtherRewardContainerOpen(const std::shared_ptr container) const;
void checkAndShowBlessingMessage();
+
+ void setMarriageSpouse(const int32_t spouseId) {
+ marriageSpouse = spouseId;
+ }
+ int32_t getMarriageSpouse() const {
+ return marriageSpouse;
+ }
};
diff --git a/src/enums/player_cyclopedia.hpp b/src/enums/player_cyclopedia.hpp
index c6e1b7032c0..295e573984f 100644
--- a/src/enums/player_cyclopedia.hpp
+++ b/src/enums/player_cyclopedia.hpp
@@ -37,6 +37,16 @@ enum CyclopediaTitle_t : uint8_t {
OTHERS,
};
+enum Summary_t : uint8_t {
+ HOUSE_ITEMS = 9,
+ BOOSTS = 10,
+ PREY_CARDS = 12,
+ BLESSINGS = 14,
+ ALL_BLESSINGS = 17,
+ INSTANT_REWARDS = 18,
+ HIRELINGS = 20,
+};
+
enum class CyclopediaMapData_t : uint8_t {
MinimapMarker = 0,
DiscoveryData = 1,
diff --git a/src/game/game.cpp b/src/game/game.cpp
index 819fdaec19a..bf12bb77bf3 100644
--- a/src/game/game.cpp
+++ b/src/game/game.cpp
@@ -38,6 +38,7 @@
#include "creatures/players/wheel/player_wheel.hpp"
#include "creatures/players/achievement/player_achievement.hpp"
#include "creatures/players/cyclopedia/player_badge.hpp"
+#include "creatures/players/cyclopedia/player_cyclopedia.hpp"
#include "creatures/players/cyclopedia/player_title.hpp"
#include "creatures/npcs/npc.hpp"
#include "server/network/webhook/webhook.hpp"
@@ -362,6 +363,45 @@ Game::Game() {
HighscoreCategory("Fishing", static_cast(HighscoreCategories_t::FISHING)),
HighscoreCategory("Magic Level", static_cast(HighscoreCategories_t::MAGIC_LEVEL))
};
+
+ m_blessingNames = {
+ { static_cast(TWIST_OF_FATE), "Twist of Fate" },
+ { static_cast(WISDOM_OF_SOLITUDE), "The Wisdom of Solitude" },
+ { static_cast(SPARK_OF_THE_PHOENIX), "The Spark of the Phoenix" },
+ { static_cast(FIRE_OF_THE_SUNS), "The Fire of the Suns" },
+ { static_cast(SPIRITUAL_SHIELDING), "The Spiritual Shielding" },
+ { static_cast(EMBRACE_OF_TIBIA), "The Embrace of Tibia" },
+ { static_cast(BLOOD_OF_THE_MOUNTAIN), "Blood of the Mountain" },
+ { static_cast(HEARTH_OF_THE_MOUNTAIN), "Heart of the Mountain" },
+ };
+
+ m_summaryCategories = {
+ { static_cast(Summary_t::HOUSE_ITEMS), "house-items" },
+ { static_cast(Summary_t::BOOSTS), "xp-boosts" },
+ { static_cast(Summary_t::PREY_CARDS), "prey-cards" },
+ { static_cast(Summary_t::BLESSINGS), "blessings" },
+ { static_cast(Summary_t::INSTANT_REWARDS), "instant-rewards" },
+ { static_cast(Summary_t::HIRELINGS), "hirelings" },
+ };
+
+ m_hirelingSkills = {
+ { 1001, "banker" },
+ { 1002, "cooker" },
+ { 1003, "steward" },
+ { 1004, "trader" }
+ };
+
+ m_hirelingOutfits = {
+ { 2001, "banker" },
+ { 2002, "cooker" },
+ { 2003, "steward" },
+ { 2004, "trader" },
+ { 2005, "servant" },
+ { 2006, "hydra" },
+ { 2007, "ferumbras" },
+ { 2008, "bonelord" },
+ { 2009, "dragon" },
+ };
}
Game::~Game() = default;
@@ -8305,121 +8345,12 @@ void Game::playerCyclopediaCharacterInfo(std::shared_ptr player, uint32_
case CYCLOPEDIA_CHARACTERINFO_COMBATSTATS:
player->sendCyclopediaCharacterCombatStats();
break;
- case CYCLOPEDIA_CHARACTERINFO_RECENTDEATHS: {
- std::ostringstream query;
- uint32_t offset = static_cast(page - 1) * entriesPerPage;
- query << "SELECT `time`, `level`, `killed_by`, `mostdamage_by`, (select count(*) FROM `player_deaths` WHERE `player_id` = " << playerGUID << ") as `entries` FROM `player_deaths` WHERE `player_id` = " << playerGUID << " ORDER BY `time` DESC LIMIT " << offset << ", " << entriesPerPage;
-
- uint32_t playerID = player->getID();
- std::function callback = [playerID, page, entriesPerPage](const DBResult_ptr &result, bool) {
- std::shared_ptr player = g_game().getPlayerByID(playerID);
- if (!player) {
- return;
- }
-
- player->resetAsyncOngoingTask(PlayerAsyncTask_RecentDeaths);
- if (!result) {
- player->sendCyclopediaCharacterRecentDeaths(0, 0, {});
- return;
- }
-
- uint32_t pages = result->getNumber("entries");
- pages += entriesPerPage - 1;
- pages /= entriesPerPage;
-
- std::vector entries;
- entries.reserve(result->countResults());
- do {
- std::string cause1 = result->getString("killed_by");
- std::string cause2 = result->getString("mostdamage_by");
-
- std::ostringstream cause;
- cause << "Died at Level " << result->getNumber("level") << " by";
- if (!cause1.empty()) {
- const char &character = cause1.front();
- if (character == 'a' || character == 'e' || character == 'i' || character == 'o' || character == 'u') {
- cause << " an ";
- } else {
- cause << " a ";
- }
- cause << cause1;
- }
-
- if (!cause2.empty()) {
- if (!cause1.empty()) {
- cause << " and ";
- }
-
- const char &character = cause2.front();
- if (character == 'a' || character == 'e' || character == 'i' || character == 'o' || character == 'u') {
- cause << " an ";
- } else {
- cause << " a ";
- }
- cause << cause2;
- }
- cause << '.';
- entries.emplace_back(std::move(cause.str()), result->getNumber("time"));
- } while (result->next());
- player->sendCyclopediaCharacterRecentDeaths(page, static_cast(pages), entries);
- };
- g_databaseTasks().store(query.str(), callback);
- player->addAsyncOngoingTask(PlayerAsyncTask_RecentDeaths);
+ case CYCLOPEDIA_CHARACTERINFO_RECENTDEATHS:
+ player->cyclopedia()->loadDeathHistory(page, entriesPerPage);
break;
- }
- case CYCLOPEDIA_CHARACTERINFO_RECENTPVPKILLS: {
- // TODO: add guildwar, assists and arena kills
- Database &db = Database::getInstance();
- const std::string &escapedName = db.escapeString(player->getName());
- std::ostringstream query;
- uint32_t offset = static_cast(page - 1) * entriesPerPage;
- query << "SELECT `d`.`time`, `d`.`killed_by`, `d`.`mostdamage_by`, `d`.`unjustified`, `d`.`mostdamage_unjustified`, `p`.`name`, (select count(*) FROM `player_deaths` WHERE ((`killed_by` = " << escapedName << " AND `is_player` = 1) OR (`mostdamage_by` = " << escapedName << " AND `mostdamage_is_player` = 1))) as `entries` FROM `player_deaths` AS `d` INNER JOIN `players` AS `p` ON `d`.`player_id` = `p`.`id` WHERE ((`d`.`killed_by` = " << escapedName << " AND `d`.`is_player` = 1) OR (`d`.`mostdamage_by` = " << escapedName << " AND `d`.`mostdamage_is_player` = 1)) ORDER BY `time` DESC LIMIT " << offset << ", " << entriesPerPage;
-
- uint32_t playerID = player->getID();
- std::function callback = [playerID, page, entriesPerPage](const DBResult_ptr &result, bool) {
- std::shared_ptr player = g_game().getPlayerByID(playerID);
- if (!player) {
- return;
- }
-
- player->resetAsyncOngoingTask(PlayerAsyncTask_RecentPvPKills);
- if (!result) {
- player->sendCyclopediaCharacterRecentPvPKills(0, 0, {});
- return;
- }
-
- uint32_t pages = result->getNumber("entries");
- pages += entriesPerPage - 1;
- pages /= entriesPerPage;
-
- std::vector entries;
- entries.reserve(result->countResults());
- do {
- std::string cause1 = result->getString("killed_by");
- std::string cause2 = result->getString("mostdamage_by");
- std::string name = result->getString("name");
-
- uint8_t status = CYCLOPEDIA_CHARACTERINFO_RECENTKILLSTATUS_JUSTIFIED;
- if (player->getName() == cause1) {
- if (result->getNumber("unjustified") == 1) {
- status = CYCLOPEDIA_CHARACTERINFO_RECENTKILLSTATUS_UNJUSTIFIED;
- }
- } else if (player->getName() == cause2) {
- if (result->getNumber("mostdamage_unjustified") == 1) {
- status = CYCLOPEDIA_CHARACTERINFO_RECENTKILLSTATUS_UNJUSTIFIED;
- }
- }
-
- std::ostringstream description;
- description << "Killed " << name << '.';
- entries.emplace_back(std::move(description.str()), result->getNumber("time"), status);
- } while (result->next());
- player->sendCyclopediaCharacterRecentPvPKills(page, static_cast(pages), entries);
- };
- g_databaseTasks().store(query.str(), callback);
- player->addAsyncOngoingTask(PlayerAsyncTask_RecentPvPKills);
+ case CYCLOPEDIA_CHARACTERINFO_RECENTPVPKILLS:
+ player->cyclopedia()->loadRecentKills(page, entriesPerPage);
break;
- }
case CYCLOPEDIA_CHARACTERINFO_ACHIEVEMENTS:
player->achiev()->sendUnlockedSecretAchievements();
break;
@@ -10740,3 +10671,19 @@ Title Game::getTitleByName(const std::string &name) {
}
return {};
}
+
+const std::string &Game::getSummaryKeyByType(uint8_t type) {
+ return m_summaryCategories[type];
+}
+
+const std::map &Game::getBlessingNames() {
+ return m_blessingNames;
+}
+
+const std::unordered_map &Game::getHirelingSkills() {
+ return m_hirelingSkills;
+}
+
+const std::unordered_map &Game::getHirelingOutfits() {
+ return m_hirelingOutfits;
+}
diff --git a/src/game/game.hpp b/src/game/game.hpp
index 2de00410e5e..0537ee030a7 100644
--- a/src/game/game.hpp
+++ b/src/game/game.hpp
@@ -732,6 +732,12 @@ class Game {
Title getTitleById(uint8_t id);
Title getTitleByName(const std::string &name);
+ const std::string &getSummaryKeyByType(uint8_t type);
+
+ const std::map &getBlessingNames();
+ const std::unordered_map &getHirelingSkills();
+ const std::unordered_map &getHirelingOutfits();
+
private:
std::map m_achievements;
std::map m_achievementsNameToId;
@@ -742,6 +748,12 @@ class Game {
std::vector m_highscoreCategories;
std::unordered_map m_highscoreCategoriesNames;
+ std::map m_blessingNames;
+
+ std::unordered_map m_summaryCategories;
+ std::unordered_map m_hirelingSkills;
+ std::unordered_map m_hirelingOutfits;
+
std::map forgeMonsterEventIds;
std::unordered_set fiendishMonsters;
std::unordered_set influencedMonsters;
diff --git a/src/game/game_definitions.hpp b/src/game/game_definitions.hpp
index a6ce6e7eaa8..8b165bc725d 100644
--- a/src/game/game_definitions.hpp
+++ b/src/game/game_definitions.hpp
@@ -102,6 +102,17 @@ enum class HighscoreCategories_t : uint8_t {
BOSS_POINTS = 14,
};
+enum Blessings_t : uint8_t {
+ TWIST_OF_FATE = 1,
+ WISDOM_OF_SOLITUDE = 2,
+ SPARK_OF_THE_PHOENIX = 3,
+ FIRE_OF_THE_SUNS = 4,
+ SPIRITUAL_SHIELDING = 5,
+ EMBRACE_OF_TIBIA = 6,
+ BLOOD_OF_THE_MOUNTAIN = 7,
+ HEARTH_OF_THE_MOUNTAIN = 8,
+};
+
enum HighscoreType_t : uint8_t {
HIGHSCORE_GETENTRIES = 0,
HIGHSCORE_OURRANK = 1
diff --git a/src/io/functions/iologindata_load_player.cpp b/src/io/functions/iologindata_load_player.cpp
index 3ef1e8b1f16..08b3b6c04ab 100644
--- a/src/io/functions/iologindata_load_player.cpp
+++ b/src/io/functions/iologindata_load_player.cpp
@@ -182,6 +182,8 @@ bool IOLoginDataLoad::loadPlayerFirst(std::shared_ptr player, DBResult_p
player->setManaShield(result->getNumber("manashield"));
player->setMaxManaShield(result->getNumber("max_manashield"));
+
+ player->setMarriageSpouse(result->getNumber("marriage_spouse"));
return true;
}
@@ -215,9 +217,7 @@ void IOLoginDataLoad::loadPlayerBlessings(std::shared_ptr player, DBResu
}
for (int i = 1; i <= 8; i++) {
- std::ostringstream ss;
- ss << "blessings" << i;
- player->addBlessing(static_cast(i), static_cast(result->getNumber(ss.str())));
+ player->addBlessing(static_cast(i), static_cast(result->getNumber(fmt::format("blessings{}", i))));
}
}
@@ -918,6 +918,7 @@ void IOLoginDataLoad::loadPlayerInitializeSystem(std::shared_ptr player)
player->achiev()->loadUnlockedAchievements();
player->badge()->checkAndUpdateNewBadges();
player->title()->checkAndUpdateNewTitles();
+ player->cyclopedia()->loadSummaryData();
player->initializePrey();
player->initializeTaskHunting();
diff --git a/src/lua/functions/core/game/game_functions.cpp b/src/lua/functions/core/game/game_functions.cpp
index 9df65da8514..10a6379781a 100644
--- a/src/lua/functions/core/game/game_functions.cpp
+++ b/src/lua/functions/core/game/game_functions.cpp
@@ -28,6 +28,7 @@
#include "lua/callbacks/events_callbacks.hpp"
#include "creatures/players/achievement/player_achievement.hpp"
#include "creatures/players/cyclopedia/player_badge.hpp"
+#include "creatures/players/cyclopedia/player_cyclopedia.hpp"
#include "creatures/players/cyclopedia/player_title.hpp"
#include "map/spectators.hpp"
diff --git a/src/server/network/protocol/protocolgame.cpp b/src/server/network/protocol/protocolgame.cpp
index 1476f255135..18c23fbb1aa 100644
--- a/src/server/network/protocol/protocolgame.cpp
+++ b/src/server/network/protocol/protocolgame.cpp
@@ -28,6 +28,7 @@
#include "creatures/players/wheel/player_wheel.hpp"
#include "creatures/players/achievement/player_achievement.hpp"
#include "creatures/players/cyclopedia/player_badge.hpp"
+#include "creatures/players/cyclopedia/player_cyclopedia.hpp"
#include "creatures/players/cyclopedia/player_title.hpp"
#include "creatures/players/grouping/familiars.hpp"
#include "server/network/protocol/protocolgame.hpp"
@@ -3456,7 +3457,7 @@ void ProtocolGame::sendCyclopediaCharacterGeneralStats() {
msg.add(player->getOfflineTrainingTime() / 60 / 1000);
msg.add(player->getSpeed());
msg.add(player->getBaseSpeed());
- msg.add(player->getBonusCapacity());
+ msg.add(player->getCapacity());
msg.add(player->getBaseCapacity());
msg.add(player->hasFlag(PlayerFlags_t::HasInfiniteCapacity) ? 1000000 : player->getFreeCapacity());
msg.addByte(8);
@@ -3660,21 +3661,15 @@ void ProtocolGame::sendCyclopediaCharacterRecentDeaths(uint16_t page, uint16_t p
NetworkMessage msg;
msg.addByte(0xDA);
msg.addByte(CYCLOPEDIA_CHARACTERINFO_RECENTDEATHS);
- msg.addByte(0x00);
-
- uint16_t totalPages = static_cast(std::ceil(static_cast(entries.size()) / pages));
- uint16_t currentPage = std::min(page, totalPages);
- uint16_t firstObject = (currentPage - 1) * pages;
- uint16_t finalObject = firstObject + pages;
-
- msg.add(currentPage);
- msg.add(totalPages);
+ msg.addByte(0x00); // 0x00 Here means 'no error'
+ msg.add(page);
msg.add(pages);
- for (uint16_t i = firstObject; i < finalObject; i++) {
- RecentDeathEntry entry = entries[i];
+ msg.add(entries.size());
+ for (const RecentDeathEntry &entry : entries) {
msg.add(entry.timestamp);
msg.addString(entry.cause, "ProtocolGame::sendCyclopediaCharacterRecentDeaths - entry.cause");
}
+
writeToOutputBuffer(msg);
}
@@ -3686,22 +3681,16 @@ void ProtocolGame::sendCyclopediaCharacterRecentPvPKills(uint16_t page, uint16_t
NetworkMessage msg;
msg.addByte(0xDA);
msg.addByte(CYCLOPEDIA_CHARACTERINFO_RECENTPVPKILLS);
- msg.addByte(0x00);
-
- uint16_t totalPages = static_cast(std::ceil(static_cast(entries.size()) / pages));
- uint16_t currentPage = std::min(page, totalPages);
- uint16_t firstObject = (currentPage - 1) * pages;
- uint16_t finalObject = firstObject + pages;
-
- msg.add(currentPage);
- msg.add(totalPages);
+ msg.addByte(0x00); // 0x00 Here means 'no error'
+ msg.add(page);
msg.add(pages);
- for (uint16_t i = firstObject; i < finalObject; i++) {
- RecentPvPKillEntry entry = entries[i];
+ msg.add(entries.size());
+ for (const RecentPvPKillEntry &entry : entries) {
msg.add(entry.timestamp);
msg.addString(entry.description, "ProtocolGame::sendCyclopediaCharacterRecentPvPKills - entry.description");
msg.addByte(entry.status);
}
+
writeToOutputBuffer(msg);
}
@@ -3959,17 +3948,72 @@ void ProtocolGame::sendCyclopediaCharacterStoreSummary() {
msg.addByte(CYCLOPEDIA_CHARACTERINFO_STORESUMMARY);
msg.addByte(0x00); // 0x00 Here means 'no error'
msg.add(player->getXpBoostTime()); // Remaining Store Xp Boost Time
- msg.add(0); // RemainingDailyRewardXpBoostTime
+ auto remaining = player->kv()->get("daily-reward-xp-boost");
+ msg.add(remaining ? static_cast(remaining->getNumber()) : 0); // Remaining Daily Reward Xp Boost Time
+
+ auto cyclopediaSummary = player->cyclopedia()->getSummary();
+
+ // getBlessingsObtained
+ auto blessingNames = g_game().getBlessingNames();
+ msg.addByte(static_cast(blessingNames.size()));
+ for (const auto &bless : blessingNames) {
+ msg.addString(bless.second, "ProtocolGame::sendCyclopediaCharacterStoreSummary - blessing.name");
+ uint8_t blessingIndex = bless.first - 1;
+ msg.addByte((blessingIndex < player->blessings.size()) ? static_cast(player->blessings[blessingIndex]) : 0);
+ }
+
+ uint8_t preySlotsUnlocked = 0;
+ // Prey third slot unlocked
+ if (const auto &slotP = player->getPreySlotById(PreySlot_Three);
+ slotP && slotP->state != PreyDataState_Locked) {
+ preySlotsUnlocked++;
+ }
+ // Task hunting third slot unlocked
+ if (const auto &slotH = player->getTaskHuntingSlotById(PreySlot_Three);
+ slotH && slotH->state != PreyTaskDataState_Locked) {
+ preySlotsUnlocked++;
+ }
+ msg.addByte(preySlotsUnlocked); // getPreySlotById + getTaskHuntingSlotById
+
+ msg.addByte(cyclopediaSummary.m_preyWildcards); // getPreyCardsObtained
+ msg.addByte(cyclopediaSummary.m_instantRewards); // getRewardCollectionObtained
+ msg.addByte(player->hasCharmExpansion() ? 0x01 : 0x00);
+ msg.addByte(cyclopediaSummary.m_hirelings); // getHirelingsObtained
+
+ std::vector m_hSkills;
+ for (const auto &it : g_game().getHirelingSkills()) {
+ if (player->kv()->scoped("hireling-skills")->get(it.second)) {
+ m_hSkills.emplace_back(it.first);
+ g_logger().debug("skill id: {}, name: {}", it.first, it.second);
+ }
+ }
+ msg.addByte(m_hSkills.size());
+ for (const auto &id : m_hSkills) {
+ msg.addByte(id - 1000);
+ }
+
+ /*std::vector m_hOutfits;
+ for (const auto &it : g_game().getHirelingOutfits()) {
+ if (player->kv()->scoped("hireling-outfits")->get(it.second)) {
+ m_hOutfits.emplace_back(it.first);
+ g_logger().debug("outfit id: {}, name: {}", it.first, it.second);
+ }
+ }
+ msg.addByte(m_hOutfits.size());
+ for (const auto &id : m_hOutfits) {
+ msg.addByte(0x01); // TODO need to get the correct id from hireling outfit
+ }*/
+ msg.addByte(0x00); // hireling outfit size
+
+ auto houseItems = player->cyclopedia()->getResult(static_cast(Summary_t::HOUSE_ITEMS));
+ msg.add(houseItems.size());
+ for (const auto &hItem_it : houseItems) {
+ const ItemType &it = Item::items[hItem_it.first];
+ msg.add(it.id); // Item ID
+ msg.addString(it.name, "ProtocolGame::sendCyclopediaCharacterStoreSummary - houseItem.name");
+ msg.addByte(hItem_it.second);
+ }
- msg.addByte(0x00); // getBlessingsObtained
- msg.addByte(0x00); // getTaskHuntingSlotById
- msg.addByte(0x00); // getPreyCardsObtained
- msg.addByte(0x00); // getRewardCollectionObtained
- msg.addByte(0x00); // player->hasCharmExpansion() ? 0x01 : 0x00
- msg.addByte(0x00); // getHirelingsObtained
- msg.addByte(0x00); // getHirelinsJobsObtained
- msg.addByte(0x00); // getHirelinsOutfitsObtained
- msg.add(0); // getHouseItemsObtained
writeToOutputBuffer(msg);
}
@@ -4028,23 +4072,23 @@ void ProtocolGame::sendCyclopediaCharacterInspection() {
auto playerDescriptionPosition = msg.getBufferPosition();
msg.skipBytes(1);
+ // Player title
+ if (player->title()->getCurrentTitle() != 0) {
+ playerDescriptionSize++;
+ msg.addString("Character Title", "ProtocolGame::sendCyclopediaCharacterInspection - Title");
+ msg.addString(player->title()->getCurrentTitleName(), "ProtocolGame::sendCyclopediaCharacterInspection - player->title()->getCurrentTitleName()");
+ }
+
// Level description
playerDescriptionSize++;
msg.addString("Level", "ProtocolGame::sendCyclopediaCharacterInspection - Level");
+ msg.addString(std::to_string(player->getLevel()), "ProtocolGame::sendCyclopediaCharacterInspection - std::to_string(player->getLevel())");
// Vocation description
playerDescriptionSize++;
- msg.addString(std::to_string(player->getLevel()), "ProtocolGame::sendCyclopediaCharacterInspection - std::to_string(player->getLevel())");
msg.addString("Vocation", "ProtocolGame::sendCyclopediaCharacterInspection - Vocation");
msg.addString(player->getVocation()->getVocName(), "ProtocolGame::sendCyclopediaCharacterInspection - player->getVocation()->getVocName()");
- // Player title
- if (player->title()->getCurrentTitle() != 0) {
- playerDescriptionSize++;
- msg.addString("Title", "ProtocolGame::sendCyclopediaCharacterInspection - Title");
- msg.addString(player->title()->getCurrentTitleName(), "ProtocolGame::sendCyclopediaCharacterInspection - player->title()->getCurrentTitleName()");
- }
-
// Loyalty title
if (!player->getLoyaltyTitle().empty()) {
playerDescriptionSize++;
@@ -4052,6 +4096,47 @@ void ProtocolGame::sendCyclopediaCharacterInspection() {
msg.addString(player->getLoyaltyTitle(), "ProtocolGame::sendCyclopediaCharacterInspection - player->getLoyaltyTitle()");
}
+ // Marriage description
+ if (const auto spouseId = player->getMarriageSpouse(); spouseId > 0) {
+ if (const auto &spouse = g_game().getPlayerByID(spouseId, true); spouse) {
+ playerDescriptionSize++;
+ msg.addString("Married to", "ProtocolGame::sendCyclopediaCharacterInspection - Married to");
+ msg.addString(spouse->getName(), "ProtocolGame::sendCyclopediaCharacterInspection - spouse->getName()");
+ }
+ }
+
+ // Prey description
+ for (uint8_t slotId = PreySlot_First; slotId <= PreySlot_Last; slotId++) {
+ if (const auto &slot = player->getPreySlotById(static_cast(slotId));
+ slot && slot->isOccupied()) {
+ playerDescriptionSize++;
+ std::string activePrey = fmt::format("Active Prey {}", slotId + 1);
+ msg.addString(activePrey, "ProtocolGame::sendCyclopediaCharacterInspection - active prey");
+
+ std::string desc;
+ if (auto mtype = g_monsters().getMonsterTypeByRaceId(slot->selectedRaceId)) {
+ desc.append(mtype->name);
+ } else {
+ desc.append("Unknown creature");
+ }
+
+ if (slot->bonus == PreyBonus_Damage) {
+ desc.append(" (Improved Damage +");
+ } else if (slot->bonus == PreyBonus_Defense) {
+ desc.append(" (Improved Defense +");
+ } else if (slot->bonus == PreyBonus_Experience) {
+ desc.append(" (Improved Experience +");
+ } else if (slot->bonus == PreyBonus_Loot) {
+ desc.append(" (Improved Loot +");
+ }
+ desc.append(fmt::format("{}%, remaining", slot->bonusPercentage));
+ uint8_t hours = slot->bonusTimeLeft / 3600;
+ uint8_t minutes = (slot->bonusTimeLeft - (hours * 3600)) / 60;
+ desc.append(fmt::format("{}:{}{}h", hours, (minutes < 10 ? "0" : ""), minutes));
+ msg.addString(desc, "ProtocolGame::sendCyclopediaCharacterInspection - prey description");
+ }
+ }
+
// Outfit description
playerDescriptionSize++;
msg.addString("Outfit", "ProtocolGame::sendCyclopediaCharacterInspection - Outfit");
diff --git a/src/server/network/protocol/protocolgame.hpp b/src/server/network/protocol/protocolgame.hpp
index 24341f15e1e..55e9cbfbaea 100644
--- a/src/server/network/protocol/protocolgame.hpp
+++ b/src/server/network/protocol/protocolgame.hpp
@@ -14,6 +14,7 @@
#include "creatures/creature.hpp"
#include "enums/forge_conversion.hpp"
#include "creatures/players/cyclopedia/player_badge.hpp"
+#include "creatures/players/cyclopedia/player_cyclopedia.hpp"
#include "creatures/players/cyclopedia/player_title.hpp"
class NetworkMessage;
diff --git a/src/utils/tools.cpp b/src/utils/tools.cpp
index 584d4a6b5b6..6504fda859d 100644
--- a/src/utils/tools.cpp
+++ b/src/utils/tools.cpp
@@ -1834,6 +1834,31 @@ std::string getVerbForPronoun(PlayerPronoun_t pronoun, bool pastTense) {
return pastTense ? "was" : "is";
}
+std::string formatWithArticle(const std::string &value, bool withSpace) {
+ if (value.empty()) {
+ return "";
+ }
+
+ auto removeArticle = [](const std::string &str) -> std::string {
+ const std::string articles[] = { "a ", "an " };
+ for (const auto &article : articles) {
+ if (str.size() > article.size() && std::equal(article.begin(), article.end(), str.begin(), [](char a, char b) { return std::tolower(a) == std::tolower(b); })) {
+ return str.substr(article.size());
+ }
+ }
+ return str;
+ };
+
+ std::string modifiedValue = removeArticle(value);
+ if (modifiedValue.empty()) {
+ return "";
+ }
+
+ const char &character = std::tolower(modifiedValue.front());
+ auto article = character == 'a' || character == 'e' || character == 'i' || character == 'o' || character == 'u' ? "an" : "a";
+ return fmt::format("{}{} {}.", withSpace ? " " : "", article, modifiedValue);
+}
+
std::vector split(const std::string &str, char delimiter /* = ','*/) {
std::vector tokens;
std::string token;
diff --git a/src/utils/tools.hpp b/src/utils/tools.hpp
index 25683bf22bb..1f8ed382244 100644
--- a/src/utils/tools.hpp
+++ b/src/utils/tools.hpp
@@ -201,6 +201,8 @@ std::string getPlayerPossessivePronoun(PlayerPronoun_t pronoun, PlayerSex_t sex,
std::string getPlayerReflexivePronoun(PlayerPronoun_t pronoun, PlayerSex_t sex, const std::string &name);
std::string getVerbForPronoun(PlayerPronoun_t pronoun, bool pastTense = false);
+std::string formatWithArticle(const std::string &value, bool withSpace = true);
+
std::string toKey(const std::string &str);
static inline double quadraticPoly(double a, double b, double c, double x) {
diff --git a/src/utils/utils_definitions.hpp b/src/utils/utils_definitions.hpp
index e2992379f0c..b294045be04 100644
--- a/src/utils/utils_definitions.hpp
+++ b/src/utils/utils_definitions.hpp
@@ -709,17 +709,6 @@ enum class PlayerFlags_t : uint8_t {
FlagLast
};
-enum Blessings_t : uint8_t {
- TWIST_OF_FATE = 1,
- WISDOM_OF_SOLITUDE = 2,
- SPARK_OF_THE_PHOENIX = 3,
- FIRE_OF_THE_SUNS = 4,
- SPIRITUAL_SHIELDING = 5,
- EMBRACE_OF_TIBIA = 6,
- BLOOD_OF_THE_MOUNTAIN = 7,
- HEARTH_OF_THE_MOUNTAIN = 8,
-};
-
enum BedItemPart_t : uint8_t {
BED_NONE_PART,
BED_PILLOW_PART,
diff --git a/vcproj/canary.vcxproj b/vcproj/canary.vcxproj
index 4cb91d1cb94..77ece957935 100644
--- a/vcproj/canary.vcxproj
+++ b/vcproj/canary.vcxproj
@@ -46,6 +46,7 @@
+
@@ -261,6 +262,7 @@
+