Skip to content

Commit

Permalink
feat: cyclopedia improvements (#2629)
Browse files Browse the repository at this point in the history
  • Loading branch information
elsongabriel authored Jul 5, 2024
1 parent 926a5f3 commit 3aeb3e7
Show file tree
Hide file tree
Showing 26 changed files with 597 additions and 227 deletions.
4 changes: 2 additions & 2 deletions data/modules/scripts/blessings/blessings.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions data/modules/scripts/gamestore/gamestore.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<i>Reduces your character's chance to lose any items as well as the amount of your character's experience and skill loss upon death:</i>\n\n&#8226; 1 blessing = 8.00% less Skill / XP loss, 30% equipment protection\n&#8226; 2 blessing = 16.00% less Skill / XP loss, 55% equipment protection\n&#8226; 3 blessing = 24.00% less Skill / XP loss, 75% equipment protection\n&#8226; 4 blessing = 32.00% less Skill / XP loss, 90% equipment protection\n&#8226; 5 blessing = 40.00% less Skill / XP loss, 100% equipment protection\n&#8226; 6 blessing = 48.00% less Skill / XP loss, 100% equipment protection\n&#8226; 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",
Expand All @@ -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 = "<i>Reduces your character's chance to lose any items as well as the amount of your character's experience and skill loss upon death:</i>\n\n&#8226; 1 blessing = 8.00% less Skill / XP loss, 30% equipment protection\n&#8226; 2 blessing = 16.00% less Skill / XP loss, 55% equipment protection\n&#8226; 3 blessing = 24.00% less Skill / XP loss, 75% equipment protection\n&#8226; 4 blessing = 32.00% less Skill / XP loss, 90% equipment protection\n&#8226; 5 blessing = 40.00% less Skill / XP loss, 100% equipment protection\n&#8226; 6 blessing = 48.00% less Skill / XP loss, 100% equipment protection\n&#8226; 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",
Expand Down
31 changes: 20 additions & 11 deletions data/modules/scripts/gamestore/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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]" })
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions data/scripts/eventcallbacks/player/on_look.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/creatures/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 7 additions & 6 deletions src/creatures/players/achievement/player_achievement.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<uint16_t>(kvScoped->getNumber()) : 0;
}

void PlayerAchievement::addPoints(uint16_t toAddPoints) {
Expand Down Expand Up @@ -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<std::pair<Achievement, uint32_t>> m_achievementsUnlocked;
std::vector<std::pair<Achievement, uint32_t>> achievementsUnlocked;
uint16_t unlockedSecret = 0;
for (const auto &[achievId, achievCreatedTime] : getUnlockedAchievements()) {
Achievement achievement = g_game().getAchievementById(achievId);
Expand All @@ -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<KV> &PlayerAchievement::getUnlockedKV() {
Expand Down
6 changes: 3 additions & 3 deletions src/creatures/players/achievement/player_achievement.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::pair<uint16_t, uint32_t>> getUnlockedAchievements() const;
[[nodiscard]] std::vector<std::pair<uint16_t, uint32_t>> getUnlockedAchievements() const;
void loadUnlockedAchievements();
void sendUnlockedSecretAchievements();
const std::shared_ptr<KV> &getUnlockedKV();
Expand Down
185 changes: 185 additions & 0 deletions src/creatures/players/cyclopedia/player_cyclopedia.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* Canary - A free and open-source MMORPG server emulator
* Copyright (©) 2019-2024 OpenTibiaBR <[email protected]>
* 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<uint8_t>(Summary_t::HIRELINGS)));
if (result && !kvScoped->get("amount").has_value()) {
kvScoped->set("amount", result->getNumber<int16_t>("count"));
}
}

void PlayerCyclopedia::loadDeathHistory(uint16_t page, uint16_t entriesPerPage) {
Benchmark bm_check;
uint32_t offset = static_cast<uint32_t>(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<void(DBResult_ptr, bool)> callback = [playerID, page, entriesPerPage](const DBResult_ptr &result, bool) {
std::shared_ptr<Player> player = g_game().getPlayerByID(playerID);
if (!player) {
return;
}

player->resetAsyncOngoingTask(PlayerAsyncTask_RecentDeaths);
if (!result) {
player->sendCyclopediaCharacterRecentDeaths(0, 0, {});
return;
}

auto pages = result->getNumber<uint32_t>("entries");
pages += entriesPerPage - 1;
pages /= entriesPerPage;

std::vector<RecentDeathEntry> 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<uint32_t>("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<uint32_t>("time"));
} while (result->next());
player->sendCyclopediaCharacterRecentDeaths(page, static_cast<uint16_t>(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<uint32_t>(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<void(DBResult_ptr, bool)> callback = [playerID, page, entriesPerPage](const DBResult_ptr &result, bool) {
std::shared_ptr<Player> player = g_game().getPlayerByID(playerID);
if (!player) {
return;
}

player->resetAsyncOngoingTask(PlayerAsyncTask_RecentPvPKills);
if (!result) {
player->sendCyclopediaCharacterRecentPvPKills(0, 0, {});
return;
}

auto pages = result->getNumber<uint32_t>("entries");
pages += entriesPerPage - 1;
pages /= entriesPerPage;

std::vector<RecentPvPKillEntry> 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<uint32_t>("unjustified") == 1) {
status = CYCLOPEDIA_CHARACTERINFO_RECENTKILLSTATUS_UNJUSTIFIED;
}
} else if (player->getName() == cause2) {
if (result->getNumber<uint32_t>("mostdamage_unjustified") == 1) {
status = CYCLOPEDIA_CHARACTERINFO_RECENTKILLSTATUS_UNJUSTIFIED;
}
}

entries.emplace_back(fmt::format("Killed {}.", name), result->getNumber<uint32_t>("time"), status);
} while (result->next());
player->sendCyclopediaCharacterRecentPvPKills(page, static_cast<uint16_t>(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<uint8_t>(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<uint16_t>(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<uint16_t, uint16_t> PlayerCyclopedia::getResult(uint8_t type) const {
auto kvScope = m_player.kv()->scoped("summary")->scoped(g_game().getSummaryKeyByType(type));
std::map<uint16_t, uint16_t> 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<uint16_t>(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);
}
Loading

0 comments on commit 3aeb3e7

Please sign in to comment.