mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
feat(dashboard): check uploaded character XML before storing it
The XML upload is the one place hand-written XML reaches the game, whose load path trusts the XML because the server writes it itself. Instead of making the game skip bad data at load, the upload is checked against what the load path assumes and refused (400, with the problems) when the game couldn't load it: required elements, attributes that must parse (flags read with std::stoul/stoull), required item and mission fields, known inventory types, mission states and character versions, items and missions that exist in the CDClient, unique item IDs and slots, and the acct attribute matching the owner. Suspicious but loadable content is returned as warnings that need confirm=true (409 otherwise): contraband (same matching as the world, now shared in ContrabandRules.h), stacks above the stack size, coins, level or u-score out of reach, a GM level above the account's. Contraband marked flag-and-remove is removed only if the uploader asks; once stored, findings are flagged (CONTRABAND) and audited. The XML editor shows the findings and offers "Save anyway". Related: issue 1332. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
#include "CharacterTools.h"
|
||||
#include "ClientAssets.h"
|
||||
#include "CharacterXml.h"
|
||||
#include "CharacterXmlCheck.h"
|
||||
#include "Scenery.h"
|
||||
#include "Workers.h"
|
||||
#include "LiveWorld.h"
|
||||
@@ -135,6 +136,45 @@ namespace {
|
||||
return info->accountId;
|
||||
}
|
||||
|
||||
// Checks an uploaded character XML (CharacterXmlCheck) with the CDClient, the contraband list and the owner's account
|
||||
CharacterXmlCheck::Result CheckUploadedCharacterXml(const std::string& xml, uint32_t ownerAccountId) {
|
||||
CharacterXmlCheck::Context context;
|
||||
context.ownerAccountId = ownerAccountId;
|
||||
const auto account = Database::Get()->GetAccountById(ownerAccountId);
|
||||
context.accountGmLevel = account.is_object() && account.contains("gm_level") && account["gm_level"].is_number() ? account["gm_level"].get<int32_t>() : 0;
|
||||
context.contrabandApplies = Contraband::Applies(static_cast<eGameMasterLevel>(context.accountGmLevel), ConfigFlag("contraband_ignore_staff", true));
|
||||
for (const auto& item : Database::Get()->GetContrabandItems()) context.contraband[item.lot] = { item.reason, item.action };
|
||||
|
||||
auto& lookups = context.lookups;
|
||||
lookups.isItem = [](LOT lot) {
|
||||
auto stmt = CDClientDatabase::CreatePreppedStmt("SELECT 1 FROM ComponentsRegistry WHERE id = ? AND component_type = 11 LIMIT 1;");
|
||||
stmt.bind(1, static_cast<int32_t>(lot));
|
||||
return !stmt.execQuery().eof();
|
||||
};
|
||||
lookups.stackSize = [](LOT lot) {
|
||||
auto stmt = CDClientDatabase::CreatePreppedStmt("SELECT ic.stackSize FROM ComponentsRegistry cr JOIN ItemComponent ic ON ic.id = cr.component_id "
|
||||
"WHERE cr.id = ? AND cr.component_type = 11 LIMIT 1;");
|
||||
stmt.bind(1, static_cast<int32_t>(lot));
|
||||
auto result = stmt.execQuery();
|
||||
return result.eof() ? 0 : result.getIntField(0, 0);
|
||||
};
|
||||
lookups.missionExists = [](int32_t id) {
|
||||
auto stmt = CDClientDatabase::CreatePreppedStmt("SELECT 1 FROM Missions WHERE id = ? LIMIT 1;");
|
||||
stmt.bind(1, id);
|
||||
return !stmt.execQuery().eof();
|
||||
};
|
||||
lookups.levelUScore = [](uint32_t level) -> std::optional<int64_t> {
|
||||
auto stmt = CDClientDatabase::CreatePreppedStmt("SELECT requiredUScore FROM LevelProgressionLookup WHERE id = ?;");
|
||||
stmt.bind(1, static_cast<int32_t>(level));
|
||||
auto result = stmt.execQuery();
|
||||
if (result.eof()) return std::nullopt;
|
||||
return result.getInt64Field(0, 0);
|
||||
};
|
||||
auto maxLevel = CDClientDatabase::ExecuteQuery("SELECT MAX(id) FROM LevelProgressionLookup;");
|
||||
if (!maxLevel.eof()) lookups.maxLevel = static_cast<uint32_t>(maxLevel.getIntField(0, 0));
|
||||
return CharacterXmlCheck::Check(xml, context);
|
||||
}
|
||||
|
||||
// Random play key in the XXXX-XXXX-XXXX-XXXX format used by NexusDashboard
|
||||
std::string GeneratePlayKey() {
|
||||
static constexpr std::string_view alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/O/1/I
|
||||
@@ -811,7 +851,9 @@ namespace {
|
||||
JsonSuccess(reply, { {"requestId", requestId}, {"message", "Rescue requested"} });
|
||||
});
|
||||
|
||||
Route(eHTTPMethod::POST, "/api/characters/:id/xml", Perm("characters_edit_xml"), "Replace a character's XML (enable_char_xml_upload=1). Disconnects the owner first. Body: {xml}",
|
||||
Route(eHTTPMethod::POST, "/api/characters/:id/xml", Perm("characters_edit_xml"), "Replace a character's XML (enable_char_xml_upload=1). Disconnects the owner first. "
|
||||
"Refused (400, errors) when the game couldn't load it; suspicious content (contraband, out of reach values) needs confirm=true (409, warnings). "
|
||||
"Body: {xml, confirm, remove_contraband}",
|
||||
[](HTTPReply& reply, const HTTPContext& context) {
|
||||
if (!ConfigFlag("enable_char_xml_upload", false)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Character XML upload is disabled (enable_char_xml_upload)");
|
||||
const auto charId = RequireId<LWOOBJID>(context, 2, reply);
|
||||
@@ -821,21 +863,59 @@ namespace {
|
||||
const auto owner = CharacterOwner(*charId, reply);
|
||||
if (!owner || !AuthorizeAccountAction(context, *owner, reply, eAccountAction::ITEMS)) return;
|
||||
|
||||
const std::string xml = body->value("xml", "");
|
||||
tinyxml2::XMLDocument doc;
|
||||
if (xml.empty() || doc.Parse(xml.c_str()) != tinyxml2::XML_SUCCESS) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "That is not valid XML");
|
||||
if (!doc.FirstChildElement("obj") || !doc.FirstChildElement("obj")->FirstChildElement("char")) {
|
||||
return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Character XML must have an <obj> root with a <char> element");
|
||||
const std::string xml = body->is_object() && (*body)["xml"].is_string() ? (*body)["xml"].get<std::string>() : std::string();
|
||||
const auto check = CheckUploadedCharacterXml(xml, *owner);
|
||||
if (!check.Ok()) {
|
||||
return JsonReply(reply, eHTTPStatusCode::BAD_REQUEST, { {"success", false},
|
||||
{"error", "The XML was not stored: " + std::to_string(check.errors.size()) + (check.errors.size() >= CharacterXmlCheck::MAX_REPORTED ? "+" : "") + " problem(s) the game can't load"},
|
||||
{"errors", check.errors}, {"warnings", check.warnings} });
|
||||
}
|
||||
nlohmann::json contraband = nlohmann::json::array();
|
||||
for (const auto& found : check.contraband) {
|
||||
contraband.push_back({ {"id", std::to_string(found.item.id)}, {"lot", found.item.lot}, {"name", ClientAssets::ItemName(found.item.lot)}, {"count", found.item.count},
|
||||
{"inventory", InventoryType::InventoryTypeToString(found.item.inventory)}, {"reason", found.entry.reason},
|
||||
{"action", found.entry.action == IContraband::eContrabandAction::REMOVE ? "remove" : "flag"} });
|
||||
}
|
||||
if (!check.warnings.empty() && !body->value("confirm", false)) {
|
||||
return JsonReply(reply, eHTTPStatusCode::CONFLICT, { {"success", false}, {"confirm_required", true},
|
||||
{"error", "Check the warnings and confirm to store this XML"}, {"warnings", check.warnings}, {"contraband", contraband} });
|
||||
}
|
||||
|
||||
// Contraband marked "flag and remove" only goes when the uploader chose so
|
||||
const bool removeContraband = body->value("remove_contraband", false);
|
||||
std::set<LWOOBJID> removeIds;
|
||||
if (removeContraband) for (const auto& found : check.contraband) if (found.entry.action == IContraband::eContrabandAction::REMOVE) removeIds.insert(found.item.id);
|
||||
// Store compactly like the game does
|
||||
tinyxml2::XMLDocument doc;
|
||||
doc.Parse(xml.c_str());
|
||||
tinyxml2::XMLPrinter printer(nullptr, true);
|
||||
doc.Print(&printer);
|
||||
const std::string compact = printer.CStr();
|
||||
const std::string compact = removeIds.empty() ? std::string(printer.CStr()) : CharacterXmlCheck::RemoveItems(printer.CStr(), removeIds);
|
||||
|
||||
// Through the safe path: the owner is disconnected first and the current version is kept as a snapshot
|
||||
// Through the safe path: the owner is disconnected first and the current version is kept as a snapshot.
|
||||
// Once stored, what the checks found is flagged and audited like the world's own contraband check.
|
||||
const auto target = *charId;
|
||||
const auto ownerId = *owner;
|
||||
const auto actor = context.authenticatedUser;
|
||||
const auto actorId = context.accountId;
|
||||
const auto findings = check.contraband;
|
||||
const auto warnings = check.warnings;
|
||||
const auto requestId = WriteCharacterXml(*charId, *owner, context.authenticatedUser, context.accountId, "XML upload",
|
||||
[compact](const std::string&, std::string&) { return std::optional<std::string>(compact); });
|
||||
JsonSuccess(reply, { {"requestId", requestId}, {"message", "Uploading"} });
|
||||
[compact](const std::string&, std::string&) { return std::optional<std::string>(compact); }, nullptr,
|
||||
[target, ownerId, actor, actorId, findings, warnings, removeIds](const PlayerActions::Outcome& outcome) {
|
||||
if (!outcome.success || warnings.empty()) return;
|
||||
const auto today = static_cast<uint32_t>(std::time(nullptr) / 86400);
|
||||
for (const auto& found : findings) {
|
||||
const bool removed = removeIds.contains(found.item.id);
|
||||
Database::Get()->InsertEconomyFlag({ today, IDashboardAdmin::eFlagKind::CONTRABAND, target, found.item.lot, found.item.id, found.item.count, removed ? 1 : 0,
|
||||
std::string(removed ? "Removed from" : "Kept in") + " an uploaded character XML (" + InventoryType::InventoryTypeToString(found.item.inventory) + ", by " + actor + "): " + found.entry.reason });
|
||||
}
|
||||
std::string text = "Character " + std::to_string(target) + " XML uploaded with " + std::to_string(warnings.size()) + " warning(s)" +
|
||||
(removeIds.empty() ? "" : ", " + std::to_string(removeIds.size()) + " contraband item stack(s) removed") + ":";
|
||||
for (const auto& warning : warnings) text += "\n- " + warning;
|
||||
Database::Get()->InsertAuditLog(actorId, actor, "character_xml_warnings", text, ownerId, target);
|
||||
});
|
||||
JsonSuccess(reply, { {"requestId", requestId}, {"message", "Uploading"}, {"warnings", check.warnings}, {"removed", removeIds.size()} });
|
||||
});
|
||||
|
||||
Route(eHTTPMethod::GET, "/api/characters/:id/mail", 0, "A character's mailbox (up to 100 most recent). With characters_mail, or the character's owner",
|
||||
|
||||
380
dDashboardServer/routes/CharacterXmlCheck.h
Normal file
380
dDashboardServer/routes/CharacterXmlCheck.h
Normal file
@@ -0,0 +1,380 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "tinyxml2.h"
|
||||
#include "ContrabandRules.h"
|
||||
#include "GeneralUtils.h"
|
||||
#include "dCommonVars.h"
|
||||
#include "eCharacterVersion.h"
|
||||
#include "eInventoryType.h"
|
||||
|
||||
/**
|
||||
* Checks a character XML uploaded on the dashboard before it is stored. That upload is the one place hand-written
|
||||
* XML reaches the game, and the game's load path (Character::DoQuickXMLDataParse and the LoadFromXml/LoadXml of the
|
||||
* Character, Inventory, Mission, Destroyable, Buff, LevelProgression, ControllablePhysics and Character components)
|
||||
* trusts the XML because the server writes it itself. So instead of the game skipping bad data at load (and saving
|
||||
* the loss back), bad uploads are refused here.
|
||||
*
|
||||
* - errors: things the load path would throw on, read uninitialized, stop loading at (dropping everything after it,
|
||||
* which is then saved back), or silently drop (an unknown mission or item, a duplicate item id or slot).
|
||||
* The upload is refused.
|
||||
* - warnings: valid but suspicious content (contraband, stacks above the item's stack size, coins, level or u-score
|
||||
* out of reach, GM level above the account's). Staff see them and must confirm to store the XML.
|
||||
*
|
||||
* Pure (the game data it needs comes in through Lookups) so it can be unit tested.
|
||||
*/
|
||||
namespace CharacterXmlCheck {
|
||||
constexpr int64_t MAX_COINS = 2000000000;
|
||||
constexpr int64_t MAX_COUNT = 999999;
|
||||
constexpr size_t MAX_REPORTED = 50;
|
||||
|
||||
// Game data from the CDClient; an unset lookup skips its check
|
||||
struct Lookups {
|
||||
std::function<bool(LOT)> isItem; // the LOT has an item component
|
||||
std::function<int32_t(LOT)> stackSize; // the item's stack size, 0 if unknown
|
||||
std::function<bool(int32_t)> missionExists; // the mission is in the Missions table
|
||||
std::function<std::optional<int64_t>(uint32_t)> levelUScore; // u-score needed to reach a level
|
||||
uint32_t maxLevel = 0; // highest level (0: unknown)
|
||||
};
|
||||
|
||||
struct Context {
|
||||
uint32_t ownerAccountId = 0; // the account of the character being replaced
|
||||
int32_t accountGmLevel = 0; // that account's GM level
|
||||
bool contrabandApplies = true; // Contraband::Applies for that account
|
||||
Contraband::List contraband;
|
||||
Lookups lookups;
|
||||
};
|
||||
|
||||
struct Result {
|
||||
std::vector<std::string> errors;
|
||||
std::vector<std::string> warnings;
|
||||
std::vector<Contraband::Finding> contraband;
|
||||
bool Ok() const { return errors.empty(); }
|
||||
};
|
||||
|
||||
namespace Detail {
|
||||
enum class eKind { INT, UINT, INT64, UINT64, FLOAT, BOOL };
|
||||
|
||||
inline const char* KindName(eKind kind) {
|
||||
switch (kind) {
|
||||
case eKind::INT: return "a whole number";
|
||||
case eKind::UINT: return "a non-negative whole number";
|
||||
case eKind::INT64: return "a whole number";
|
||||
case eKind::UINT64: return "a non-negative whole number";
|
||||
case eKind::FLOAT: return "a number";
|
||||
case eKind::BOOL: return "true/false or 1/0";
|
||||
}
|
||||
return "a number";
|
||||
}
|
||||
|
||||
// Parses like the game's tinyxml2 Query*Attribute calls
|
||||
inline bool Parses(const tinyxml2::XMLElement& e, const char* name, eKind kind) {
|
||||
int i; unsigned u; int64_t i64; uint64_t u64; float f; bool b;
|
||||
switch (kind) {
|
||||
case eKind::INT: return e.QueryIntAttribute(name, &i) == tinyxml2::XML_SUCCESS;
|
||||
case eKind::UINT: return e.QueryUnsignedAttribute(name, &u) == tinyxml2::XML_SUCCESS && e.Attribute(name)[0] != '-';
|
||||
case eKind::INT64: return e.QueryInt64Attribute(name, &i64) == tinyxml2::XML_SUCCESS;
|
||||
case eKind::UINT64: return e.QueryUnsigned64Attribute(name, &u64) == tinyxml2::XML_SUCCESS && e.Attribute(name)[0] != '-';
|
||||
case eKind::FLOAT: return e.QueryFloatAttribute(name, &f) == tinyxml2::XML_SUCCESS;
|
||||
case eKind::BOOL: return e.QueryBoolAttribute(name, &b) == tinyxml2::XML_SUCCESS;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
struct Checker {
|
||||
Result& result;
|
||||
const Context& context;
|
||||
|
||||
void Error(const std::string& text) { if (result.errors.size() < MAX_REPORTED) result.errors.push_back(text); }
|
||||
void Warn(const std::string& text) { if (result.warnings.size() < MAX_REPORTED) result.warnings.push_back(text); }
|
||||
|
||||
// Optional attributes: when present they must parse
|
||||
void Numbers(const tinyxml2::XMLElement& e, const std::string& where, eKind kind, std::initializer_list<const char*> names) {
|
||||
for (const auto* name : names) {
|
||||
const char* value = e.Attribute(name);
|
||||
if (value && !Parses(e, name, kind)) Error(where + ": " + name + "=\"" + value + "\" must be " + KindName(kind));
|
||||
}
|
||||
}
|
||||
|
||||
// Required attributes: the game reads them into uninitialized variables
|
||||
bool Required(const tinyxml2::XMLElement& e, const std::string& where, eKind kind, std::initializer_list<const char*> names) {
|
||||
bool ok = true;
|
||||
for (const auto* name : names) {
|
||||
if (!e.Attribute(name)) { Error(where + ": " + name + " is missing"); ok = false; }
|
||||
}
|
||||
Numbers(e, where, kind, names);
|
||||
for (const auto* name : names) if (e.Attribute(name) && !Parses(e, name, kind)) ok = false;
|
||||
return ok;
|
||||
}
|
||||
|
||||
static int64_t Int64(const tinyxml2::XMLElement& e, const char* name) { return e.Int64Attribute(name, 0); }
|
||||
static std::string Str(int64_t value) { return std::to_string(value); }
|
||||
|
||||
static bool KnownInventory(uint32_t type) { return type <= eInventoryType::ITEM_SETS; }
|
||||
static bool KnownMissionState(int64_t state) {
|
||||
switch (state) {
|
||||
case -1: case 0: case 1: case 2: case 4: case 8: case 9: case 10: case 12: case 16: return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
void Mf(const tinyxml2::XMLElement& mf) {
|
||||
Numbers(mf, "<mf>", eKind::UINT, { "hc", "hs", "hd", "t", "l", "hdc", "cd", "lh", "rh", "es", "ess", "ms" });
|
||||
}
|
||||
|
||||
void Char(const tinyxml2::XMLElement& c) {
|
||||
const std::string where = "<char>";
|
||||
Numbers(c, where, eKind::INT64, { "cc", "rpt", "ls", "lrid", "gid", "llog" });
|
||||
Numbers(c, where, eKind::INT, { "gm", "ft" });
|
||||
Numbers(c, where, eKind::UINT, { "lwid", "lnzid", "acct" });
|
||||
Numbers(c, where, eKind::UINT64, { "lzid", "co", "co1", "co2", "co3", "time" });
|
||||
Numbers(c, where, eKind::FLOAT, { "lzx", "lzy", "lzz", "lzrx", "lzry", "lzrz", "lzrw" });
|
||||
|
||||
if (c.Attribute("acct") && Parses(c, "acct", eKind::UINT) && c.UnsignedAttribute("acct") != context.ownerAccountId) {
|
||||
Error(where + ": acct=\"" + c.Attribute("acct") + "\" is not the account of this character (" + std::to_string(context.ownerAccountId) + ")");
|
||||
}
|
||||
if (c.Attribute("gm") && Parses(c, "gm", eKind::INT)) {
|
||||
const auto gm = c.IntAttribute("gm");
|
||||
if (gm < 0 || gm > 9) Error(where + ": gm=\"" + std::to_string(gm) + "\" is not a GM level (0 to 9)");
|
||||
else if (gm > context.accountGmLevel) Warn("GM level " + std::to_string(gm) + " in the XML is above the account's (" + std::to_string(context.accountGmLevel) + ")");
|
||||
}
|
||||
if (c.Attribute("cc") && Parses(c, "cc", eKind::INT64)) {
|
||||
const auto coins = Int64(c, "cc");
|
||||
if (coins < 0) Error(where + ": cc (coins) can't be negative");
|
||||
else if (coins > MAX_COINS) Warn("Coins " + Str(coins) + " are above " + Str(MAX_COINS));
|
||||
}
|
||||
if (c.Attribute("ls") && Parses(c, "ls", eKind::INT64) && Int64(c, "ls") < 0) Error(where + ": ls (u-score) can't be negative");
|
||||
|
||||
if (const auto* ue = c.FirstChildElement("ue")) {
|
||||
for (const auto* e = ue->FirstChildElement(); e; e = e->NextSiblingElement()) Required(*e, "<char><ue> emote", eKind::INT, { "id" });
|
||||
}
|
||||
if (const auto* vl = c.FirstChildElement("vl")) {
|
||||
for (const auto* l = vl->FirstChildElement("l"); l; l = l->NextSiblingElement("l")) Numbers(*l, "<char><vl><l>", eKind::INT, { "id", "cid" });
|
||||
}
|
||||
if (const auto* zs = c.FirstChildElement("zs")) {
|
||||
for (const auto* s = zs->FirstChildElement(); s; s = s->NextSiblingElement()) {
|
||||
Required(*s, "<char><zs> zone statistics", eKind::UINT, { "map" });
|
||||
Numbers(*s, "<char><zs> zone statistics", eKind::UINT64, { "ac", "cc", "es", "qbc" });
|
||||
Numbers(*s, "<char><zs> zone statistics", eKind::INT64, { "bc" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Flags(const tinyxml2::XMLElement& flags) {
|
||||
for (const auto* f = flags.FirstChildElement(); f; f = f->NextSiblingElement()) {
|
||||
const char* id = f->Attribute("id");
|
||||
const char* v = f->Attribute("v");
|
||||
// Character::DoQuickXMLDataParse reads both with std::stoul/std::stoull, which throw
|
||||
if (id && v) {
|
||||
if (!GeneralUtils::TryParse<uint32_t>(id)) Error(std::string("<flag>: id=\"") + id + "\" must be a non-negative whole number");
|
||||
if (!GeneralUtils::TryParse<uint64_t>(v)) Error(std::string("<flag>: v=\"") + v + "\" must be a non-negative whole number");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Dest(const tinyxml2::XMLElement& dest) {
|
||||
Numbers(dest, "<dest>", eKind::INT, { "hc", "ic", "ac" });
|
||||
Numbers(dest, "<dest>", eKind::FLOAT, { "hm", "im", "am" });
|
||||
if (const auto* buff = dest.FirstChildElement("buff")) {
|
||||
for (const auto* b = buff->FirstChildElement("b"); b; b = b->NextSiblingElement("b")) {
|
||||
Numbers(*b, "<dest><buff><b>", eKind::INT, { "id", "b", "refCount" });
|
||||
Numbers(*b, "<dest><buff><b>", eKind::FLOAT, { "t", "tk", "tt", "s" });
|
||||
Numbers(*b, "<dest><buff><b>", eKind::INT64, { "sr" });
|
||||
Numbers(*b, "<dest><buff><b>", eKind::BOOL, { "cancelOnDamaged", "cancelOnDeath", "cancelOnLogout", "cancelOnRemoveBuff", "cancelOnUi", "cancelOnUnequip", "cancelOnZone", "applyOnTeammates" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Level(const tinyxml2::XMLElement& lvl, int64_t uscore) {
|
||||
Numbers(lvl, "<lvl>", eKind::UINT, { "l", "cv" });
|
||||
Numbers(lvl, "<lvl>", eKind::FLOAT, { "sb" });
|
||||
if (lvl.Attribute("cv") && Parses(lvl, "cv", eKind::UINT) && lvl.UnsignedAttribute("cv") > GeneralUtils::ToUnderlying(eCharacterVersion::UP_TO_DATE)) {
|
||||
Error("<lvl>: cv=\"" + std::string(lvl.Attribute("cv")) + "\" is newer than this server's character version (" + std::to_string(GeneralUtils::ToUnderlying(eCharacterVersion::UP_TO_DATE)) + ")");
|
||||
}
|
||||
if (!lvl.Attribute("l") || !Parses(lvl, "l", eKind::UINT)) return;
|
||||
const auto level = lvl.UnsignedAttribute("l");
|
||||
const auto& lookups = context.lookups;
|
||||
if (lookups.maxLevel > 0 && level > lookups.maxLevel) {
|
||||
Warn("Level " + std::to_string(level) + " is above the highest level (" + std::to_string(lookups.maxLevel) + ")");
|
||||
return;
|
||||
}
|
||||
if (!lookups.levelUScore || uscore < 0) return;
|
||||
const auto needed = lookups.levelUScore(level);
|
||||
if (needed && uscore < *needed) Warn("Level " + std::to_string(level) + " needs " + Str(*needed) + " u-score but the character has " + Str(uscore));
|
||||
const auto next = level < lookups.maxLevel ? lookups.levelUScore(level + 1) : std::nullopt;
|
||||
if (next && uscore >= *next) Warn("U-score " + Str(uscore) + " is enough for level " + std::to_string(level + 1) + " but the character is level " + std::to_string(level));
|
||||
}
|
||||
|
||||
void Inventory(const tinyxml2::XMLElement& inv) {
|
||||
Numbers(inv, "<inv>", eKind::INT, { "csl" });
|
||||
if (const auto* grps = inv.FirstChildElement("grps")) {
|
||||
for (const auto* g = grps->FirstChildElement("grp"); g; g = g->NextSiblingElement("grp")) Numbers(*g, "<inv><grps><grp>", eKind::UINT, { "t" });
|
||||
}
|
||||
const auto* bag = inv.FirstChildElement("bag");
|
||||
for (const auto* b = bag->FirstChildElement(); b; b = b->NextSiblingElement()) {
|
||||
if (Required(*b, "<inv><bag><b>", eKind::UINT, { "t", "m" }) && !KnownInventory(b->UnsignedAttribute("t"))) {
|
||||
Error("<inv><bag><b>: t=\"" + std::string(b->Attribute("t")) + "\" is not an inventory type");
|
||||
}
|
||||
}
|
||||
|
||||
std::set<LWOOBJID> ids;
|
||||
std::map<uint32_t, std::set<uint32_t>> slots;
|
||||
std::map<LOT, int32_t> stackSizes;
|
||||
const auto& lookups = context.lookups;
|
||||
for (const auto* in = inv.FirstChildElement("items")->FirstChildElement(); in; in = in->NextSiblingElement()) {
|
||||
if (!Required(*in, "<inv><items><in>", eKind::UINT, { "t" })) continue;
|
||||
const auto type = in->UnsignedAttribute("t");
|
||||
if (!KnownInventory(type)) {
|
||||
Error("<inv><items><in>: t=\"" + std::to_string(type) + "\" is not an inventory type");
|
||||
continue;
|
||||
}
|
||||
const auto inventoryName = std::string(InventoryType::InventoryTypeToString(static_cast<eInventoryType>(type)));
|
||||
// Items in the build inventories are moved (and given new slots) when the character loads
|
||||
const bool reslotted = type == eInventoryType::MODELS_IN_BBB || type == eInventoryType::BRICKS_IN_BBB;
|
||||
for (const auto* i = in->FirstChildElement(); i; i = i->NextSiblingElement()) {
|
||||
const std::string where = "Item in " + inventoryName + (i->Attribute("l") ? std::string(" (LOT ") + i->Attribute("l") + ")" : std::string());
|
||||
bool ok = Required(*i, where, eKind::INT64, { "id" });
|
||||
ok = Required(*i, where, eKind::INT, { "l" }) && ok;
|
||||
ok = Required(*i, where, eKind::UINT, { "s", "c" }) && ok;
|
||||
ok = Required(*i, where, eKind::BOOL, { "eq", "b" }) && ok;
|
||||
Numbers(*i, where, eKind::INT64, { "sk", "parent" });
|
||||
if (!ok) continue;
|
||||
|
||||
const auto id = i->Int64Attribute("id");
|
||||
const LOT lot = i->IntAttribute("l");
|
||||
const auto slot = i->UnsignedAttribute("s");
|
||||
const auto count = i->UnsignedAttribute("c");
|
||||
if (id == LWOOBJID_EMPTY) Error(where + ": id can't be 0");
|
||||
else if (!ids.insert(id).second) Error(where + ": id " + std::to_string(id) + " is used by another item (the game would drop one)");
|
||||
if (!reslotted && !slots[type].insert(slot).second) Error(where + ": slot " + std::to_string(slot) + " is used by another item in " + inventoryName + " (the game would drop one)");
|
||||
if (lookups.isItem && !lookups.isItem(lot)) {
|
||||
Error(where + ": LOT " + std::to_string(lot) + " is not an item");
|
||||
continue;
|
||||
}
|
||||
if (count == 0) Warn(where + ": count is 0");
|
||||
else if (count > MAX_COUNT) Warn(where + ": count " + std::to_string(count) + " is above " + std::to_string(MAX_COUNT));
|
||||
else if (lookups.stackSize) {
|
||||
auto it = stackSizes.find(lot);
|
||||
if (it == stackSizes.end()) it = stackSizes.emplace(lot, lookups.stackSize(lot)).first;
|
||||
if (it->second > 0 && count > static_cast<uint32_t>(it->second)) Warn(where + ": count " + std::to_string(count) + " is above the stack size (" + std::to_string(it->second) + ")");
|
||||
}
|
||||
// Proxy items of item sets aren't what the player holds, like the world's check
|
||||
if (i->Int64Attribute("parent", LWOOBJID_EMPTY) == LWOOBJID_EMPTY) held.push_back({ id, lot, count, static_cast<eInventoryType>(type) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Pets(const tinyxml2::XMLElement& pet) {
|
||||
for (const auto* p = pet.FirstChildElement(); p; p = p->NextSiblingElement()) {
|
||||
Numbers(*p, "<pet> pet", eKind::INT64, { "id" });
|
||||
Numbers(*p, "<pet> pet", eKind::INT, { "l", "m", "t" });
|
||||
}
|
||||
}
|
||||
|
||||
void Missions(const tinyxml2::XMLElement& mis) {
|
||||
const auto& lookups = context.lookups;
|
||||
const auto mission = [&](const tinyxml2::XMLElement& m, const char* where, bool done) {
|
||||
if (!Required(m, where, eKind::INT, { "id" })) return;
|
||||
const auto id = m.IntAttribute("id");
|
||||
const std::string name = std::string(where) + " " + std::to_string(id);
|
||||
if (lookups.missionExists && !lookups.missionExists(id)) Error(name + ": no such mission (the game would lose it)");
|
||||
Numbers(m, name, eKind::INT, { "state" });
|
||||
Numbers(m, name, eKind::UINT, { "cct", "cts", "o" });
|
||||
if (m.Attribute("state") && Parses(m, "state", eKind::INT)) {
|
||||
const auto state = m.IntAttribute("state");
|
||||
if (!KnownMissionState(state)) Error(name + ": state " + std::to_string(state) + " is not a mission state");
|
||||
else if (done && state < 8) Warn(name + " is in the completed list but its state (" + std::to_string(state) + ") is not complete");
|
||||
}
|
||||
if (!done) for (const auto* t = m.FirstChildElement(); t; t = t->NextSiblingElement()) Numbers(*t, name + " task", eKind::UINT, { "v" });
|
||||
};
|
||||
if (const auto* done = mis.FirstChildElement("done")) for (const auto* m = done->FirstChildElement(); m; m = m->NextSiblingElement()) mission(*m, "Completed mission", true);
|
||||
if (const auto* cur = mis.FirstChildElement("cur")) for (const auto* m = cur->FirstChildElement(); m; m = m->NextSiblingElement()) mission(*m, "Current mission", false);
|
||||
}
|
||||
|
||||
void Respawns(const tinyxml2::XMLElement& res) {
|
||||
for (const auto* r = res.FirstChildElement("r"); r; r = r->NextSiblingElement("r")) {
|
||||
Numbers(*r, "<res><r>", eKind::INT, { "w" });
|
||||
Numbers(*r, "<res><r>", eKind::FLOAT, { "x", "y", "z" });
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<Contraband::HeldItem> held;
|
||||
};
|
||||
}
|
||||
|
||||
inline Result Check(const std::string& xml, const Context& context) {
|
||||
Result result;
|
||||
Detail::Checker check{ result, context };
|
||||
tinyxml2::XMLDocument doc;
|
||||
if (xml.empty() || doc.Parse(xml.c_str()) != tinyxml2::XML_SUCCESS) {
|
||||
check.Error(xml.empty() ? "The XML is empty" : std::string("That is not valid XML: ") + (doc.ErrorStr() ? doc.ErrorStr() : "parse error"));
|
||||
return result;
|
||||
}
|
||||
const auto* obj = doc.FirstChildElement("obj");
|
||||
if (!obj) {
|
||||
check.Error("The root element must be <obj>");
|
||||
return result;
|
||||
}
|
||||
|
||||
// Loading stops at the first of these that is missing; what would have been read after it is lost when the character is saved
|
||||
const auto* character = obj->FirstChildElement("char");
|
||||
const auto* mf = obj->FirstChildElement("mf");
|
||||
const auto* inv = obj->FirstChildElement("inv");
|
||||
const auto* items = inv ? inv->FirstChildElement("items") : nullptr;
|
||||
if (!character) check.Error("<obj> must have a <char> element");
|
||||
if (!mf) check.Error("<obj> must have an <mf> (appearance) element");
|
||||
if (!inv) check.Error("<obj> must have an <inv> element");
|
||||
if (inv && !inv->FirstChildElement("bag")) check.Error("<inv> must have a <bag> element");
|
||||
if (inv && (!items || !items->FirstChildElement("in"))) check.Error("<inv> must have an <items> element with at least one <in>");
|
||||
|
||||
if (mf) check.Mf(*mf);
|
||||
int64_t uscore = -1;
|
||||
if (character) {
|
||||
check.Char(*character);
|
||||
if (character->Attribute("ls") && Detail::Parses(*character, "ls", Detail::eKind::INT64)) uscore = character->Int64Attribute("ls");
|
||||
else uscore = 0;
|
||||
}
|
||||
if (const auto* flags = obj->FirstChildElement("flag")) check.Flags(*flags);
|
||||
if (const auto* dest = obj->FirstChildElement("dest")) check.Dest(*dest);
|
||||
if (const auto* lvl = obj->FirstChildElement("lvl")) check.Level(*lvl, uscore);
|
||||
if (inv && inv->FirstChildElement("bag") && items) check.Inventory(*inv);
|
||||
if (const auto* pet = obj->FirstChildElement("pet")) check.Pets(*pet);
|
||||
if (const auto* mis = obj->FirstChildElement("mis")) check.Missions(*mis);
|
||||
if (const auto* res = obj->FirstChildElement("res")) check.Respawns(*res);
|
||||
|
||||
if (context.contrabandApplies) {
|
||||
result.contraband = Contraband::Find(check.held, context.contraband);
|
||||
for (const auto& found : result.contraband) {
|
||||
const bool remove = found.entry.action == IContraband::eContrabandAction::REMOVE;
|
||||
check.Warn("Contraband: " + std::to_string(found.item.count) + " of item " + std::to_string(found.item.lot) + " (id " + std::to_string(found.item.id) + ") in " +
|
||||
InventoryType::InventoryTypeToString(found.item.inventory) + (remove ? " [flag and remove]" : " [flag]") + (found.entry.reason.empty() ? "" : ": " + found.entry.reason));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// The XML without the items with these ids (and without set proxy items whose parent is one of them), printed compactly
|
||||
inline std::string RemoveItems(const std::string& xml, const std::set<LWOOBJID>& ids) {
|
||||
tinyxml2::XMLDocument doc;
|
||||
if (doc.Parse(xml.c_str()) != tinyxml2::XML_SUCCESS) return xml;
|
||||
auto* inv = doc.FirstChildElement("obj") ? doc.FirstChildElement("obj")->FirstChildElement("inv") : nullptr;
|
||||
auto* items = inv ? inv->FirstChildElement("items") : nullptr;
|
||||
for (auto* in = items ? items->FirstChildElement() : nullptr; in; in = in->NextSiblingElement()) {
|
||||
for (auto* i = in->FirstChildElement(); i;) {
|
||||
auto* next = i->NextSiblingElement();
|
||||
if (ids.contains(i->Int64Attribute("id")) || ids.contains(i->Int64Attribute("parent", LWOOBJID_EMPTY))) in->DeleteChild(i);
|
||||
i = next;
|
||||
}
|
||||
}
|
||||
tinyxml2::XMLPrinter printer(nullptr, true);
|
||||
doc.Print(&printer);
|
||||
return printer.CStr();
|
||||
}
|
||||
}
|
||||
@@ -335,11 +335,13 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-danger small">Saving replaces all of this character's data. If the player is online they are disconnected first. Invalid data can make the character unplayable.</div>
|
||||
<div id="xmlFindings" class="d-none"></div>
|
||||
<textarea id="xmlEditor" class="form-control font-monospace" rows="24" spellcheck="false"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" onclick="saveXml()">Save</button>
|
||||
<button type="button" class="btn btn-warning d-none" id="xmlConfirm" onclick="saveXml(true)">Save anyway</button>
|
||||
<button type="button" class="btn btn-danger" onclick="saveXml(false)">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -575,16 +577,40 @@ function prettyXml(xml) {
|
||||
function openXmlEditor() {
|
||||
var editor = document.getElementById('xmlEditor');
|
||||
editor.value = 'Loading…';
|
||||
showXmlFindings({});
|
||||
editor.oninput = function () { document.getElementById('xmlConfirm').classList.add('d-none'); }; // changed XML is checked again
|
||||
new bootstrap.Modal(document.getElementById('xmlModal')).show();
|
||||
api.get('/api/characters/' + characterId + '/xml').then(function (r) { editor.value = prettyXml(r.text || ''); });
|
||||
}
|
||||
|
||||
function saveXml() {
|
||||
if (!confirm('Replace this character\'s data?')) return;
|
||||
api.action('/api/characters/' + characterId + '/xml', { xml: document.getElementById('xmlEditor').value }).then(function (d) {
|
||||
return d.result;
|
||||
}).then(function (r) {
|
||||
if (r && r.success) location.reload();
|
||||
// The server checks the XML first: problems the game can't load refuse it, suspicious content (contraband and values
|
||||
// out of reach) needs "Save anyway"
|
||||
function showXmlFindings(d) {
|
||||
var box = document.getElementById('xmlFindings');
|
||||
var list = function (items) { return '<ul class="mb-0">' + items.map(function (t) { return '<li>' + esc(t) + '</li>'; }).join('') + '</ul>'; };
|
||||
var html = '';
|
||||
if (d.errors && d.errors.length) html += '<div class="alert alert-danger small"><strong>' + esc(d.error || 'The XML was not stored') + '</strong>' + list(d.errors) + '</div>';
|
||||
if (d.warnings && d.warnings.length) html += '<div class="alert alert-warning small"><strong>Warnings</strong>' + list(d.warnings) +
|
||||
((d.contraband || []).some(function (c) { return c.action === 'remove'; })
|
||||
? '<div class="form-check mt-2"><input class="form-check-input" type="checkbox" id="xmlRemoveContraband" checked><label class="form-check-label" for="xmlRemoveContraband">Remove the contraband marked "flag and remove" before storing</label></div>'
|
||||
: '') + '<div class="mt-1">Contraband is flagged and the warnings are audited either way.</div></div>';
|
||||
box.innerHTML = html;
|
||||
box.classList.toggle('d-none', !html);
|
||||
document.getElementById('xmlConfirm').classList.toggle('d-none', !d.confirm_required);
|
||||
box.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function saveXml(confirmed) {
|
||||
if (!confirmed && !confirm('Replace this character\'s data?')) return;
|
||||
var remove = document.getElementById('xmlRemoveContraband');
|
||||
api.post('/api/characters/' + characterId + '/xml', { xml: document.getElementById('xmlEditor').value, confirm: !!confirmed, remove_contraband: !!(remove && remove.checked) }).then(function (d) {
|
||||
if (!d.success) { showXmlFindings(d); if (!d.errors && !d.confirm_required) toast(d.error || 'Request failed', 'danger'); return; }
|
||||
showXmlFindings({});
|
||||
if (!d.requestId) return;
|
||||
Live.waitForAction(d.requestId).then(function (r) {
|
||||
if (r.message) toast(r.message, r.success ? 'info' : 'danger');
|
||||
if (r.success) location.reload();
|
||||
});
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
|
||||
55
dDatabase/GameDatabase/ITables/ContrabandRules.h
Normal file
55
dDatabase/GameDatabase/ITables/ContrabandRules.h
Normal file
@@ -0,0 +1,55 @@
|
||||
#ifndef __CONTRABANDRULES__H__
|
||||
#define __CONTRABANDRULES__H__
|
||||
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "dCommonVars.h"
|
||||
#include "eGameMasterLevel.h"
|
||||
#include "eInventoryType.h"
|
||||
#include "IContraband.h"
|
||||
|
||||
/**
|
||||
* The pure contraband rules, shared by world servers (Contraband.h, checking characters as they load and receive
|
||||
* items) and the dashboard (checking an uploaded character XML), so both find the same items.
|
||||
*/
|
||||
namespace Contraband {
|
||||
struct Entry {
|
||||
std::string reason;
|
||||
IContraband::eContrabandAction action{};
|
||||
};
|
||||
|
||||
using List = std::map<LOT, Entry>;
|
||||
|
||||
struct HeldItem {
|
||||
LWOOBJID id{};
|
||||
LOT lot{};
|
||||
uint32_t count{};
|
||||
eInventoryType inventory{};
|
||||
};
|
||||
|
||||
struct Finding {
|
||||
HeldItem item;
|
||||
Entry entry;
|
||||
};
|
||||
|
||||
// The held items that are on the list, in the order given
|
||||
inline std::vector<Finding> Find(const std::vector<HeldItem>& items, const List& list) {
|
||||
std::vector<Finding> found;
|
||||
if (list.empty()) return found;
|
||||
for (const auto& item : items) {
|
||||
const auto it = list.find(item.lot);
|
||||
if (it != list.end() && item.count > 0) found.push_back({ item, it->second });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// Whether an account at this GM level is checked
|
||||
inline bool Applies(eGameMasterLevel accountLevel, bool ignoreStaff) {
|
||||
return !ignoreStaff || accountLevel <= eGameMasterLevel::CIVILIAN;
|
||||
}
|
||||
}
|
||||
|
||||
#endif //!__CONTRABANDRULES__H__
|
||||
@@ -85,20 +85,6 @@ namespace {
|
||||
}
|
||||
|
||||
namespace Contraband {
|
||||
std::vector<Finding> Find(const std::vector<HeldItem>& items, const List& list) {
|
||||
std::vector<Finding> found;
|
||||
if (list.empty()) return found;
|
||||
for (const auto& item : items) {
|
||||
const auto it = list.find(item.lot);
|
||||
if (it != list.end() && item.count > 0) found.push_back({ item, it->second });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
bool Applies(eGameMasterLevel accountLevel, bool ignoreStaff) {
|
||||
return !ignoreStaff || accountLevel <= eGameMasterLevel::CIVILIAN;
|
||||
}
|
||||
|
||||
bool CountsAsAdded(eLootSourceType source, eInventoryType sourceInventory) {
|
||||
return sourceInventory == eInventoryType::INVALID && source != eLootSourceType::RELOCATE && source != eLootSourceType::INVENTORY;
|
||||
}
|
||||
|
||||
@@ -9,10 +9,9 @@
|
||||
#include "dCommonVars.h"
|
||||
#include "eInventoryType.h"
|
||||
#include "eLootSourceType.h"
|
||||
#include "IContraband.h"
|
||||
#include "ContrabandRules.h"
|
||||
|
||||
class Entity;
|
||||
enum class eGameMasterLevel : uint8_t;
|
||||
|
||||
/**
|
||||
* Contraband (issue #1563): items staff don't want players to have, listed on the dashboard's Contraband page
|
||||
@@ -29,32 +28,7 @@ enum class eGameMasterLevel : uint8_t;
|
||||
* Staff accounts (GM level above civilian) are not checked unless contraband_ignore_staff is 0.
|
||||
*/
|
||||
namespace Contraband {
|
||||
struct Entry {
|
||||
std::string reason;
|
||||
IContraband::eContrabandAction action{};
|
||||
};
|
||||
|
||||
using List = std::map<LOT, Entry>;
|
||||
|
||||
struct HeldItem {
|
||||
LWOOBJID id{};
|
||||
LOT lot{};
|
||||
uint32_t count{};
|
||||
eInventoryType inventory{};
|
||||
};
|
||||
|
||||
struct Finding {
|
||||
HeldItem item;
|
||||
Entry entry;
|
||||
};
|
||||
|
||||
// ---- Pure rules, unit tested ----
|
||||
|
||||
// The held items that are on the list, in the order given
|
||||
std::vector<Finding> Find(const std::vector<HeldItem>& items, const List& list);
|
||||
|
||||
// Whether an account at this GM level is checked
|
||||
bool Applies(eGameMasterLevel accountLevel, bool ignoreStaff);
|
||||
// ---- Pure rules, unit tested (Entry, List, HeldItem, Finding, Find and Applies are in ContrabandRules.h) ----
|
||||
|
||||
// Whether an added item should be checked: moves between a player's own inventories are not new items
|
||||
bool CountsAsAdded(eLootSourceType source, eInventoryType sourceInventory);
|
||||
|
||||
@@ -680,6 +680,14 @@ shows under the account's related data); that world saves nothing more for the c
|
||||
connected to it is disconnected (the client's save failure message) so they load the newer data.
|
||||
|
||||
- **Edit** (GM 8+, `characters_edit`): coins, U-score, level, and adding or removing items.
|
||||
- **Edit XML** (GM 8+, `characters_edit_xml`, needs `enable_char_xml_upload=1`): replace the whole character XML. The
|
||||
upload is checked against everything the game assumes when it loads a character (required elements, numbers that
|
||||
must parse, known inventory types and mission states, items and missions that exist, unique item IDs and slots, the
|
||||
`acct` attribute matching the owner) and refused with the list of problems if the game couldn't load it. Suspicious
|
||||
content (contraband, stacks above the item's stack size, coins, level or U-score out of reach, a GM level above the
|
||||
account's) is shown as warnings and needs **Save anyway**; contraband marked *flag and remove* is taken out only if
|
||||
you tick the box, and either way it is flagged (kind *Contraband*) and the warnings are audited
|
||||
(`character_xml_warnings`).
|
||||
- **History** (GM 3+, `characters_history`): earlier versions of the character. Each one shows what differs from
|
||||
now, can be downloaded as XML, and (with `characters_edit`) restored. The `character_snapshots` task saves every
|
||||
character that changed once a day; old snapshots are removed after `snapshot_days` (90), but each character always
|
||||
|
||||
@@ -17,6 +17,7 @@ set(DWEBTESTS_SOURCES
|
||||
"SettingsCatalogTests.cpp"
|
||||
"BehaviorXmlTests.cpp"
|
||||
"CharacterXmlTests.cpp"
|
||||
"CharacterXmlCheckTests.cpp"
|
||||
"CharacterRestoreTests.cpp"
|
||||
"ZonePathsTests.cpp"
|
||||
"RawTerrainTests.cpp"
|
||||
|
||||
174
tests/dWebTests/CharacterXmlCheckTests.cpp
Normal file
174
tests/dWebTests/CharacterXmlCheckTests.cpp
Normal file
@@ -0,0 +1,174 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "CharacterXmlCheck.h"
|
||||
|
||||
namespace {
|
||||
// Shaped like what the server writes (UserManager's new character plus what the components save), made-up values
|
||||
const std::string VALID =
|
||||
"<obj v=\"1\">"
|
||||
"<mf hc=\"1\" hs=\"2\" hd=\"0\" t=\"3\" l=\"4\" hdc=\"0\" cd=\"5\" lh=\"6\" rh=\"7\" es=\"8\" ess=\"9\" ms=\"10\"/>"
|
||||
"<char acct=\"7\" cc=\"150\" gm=\"0\" ft=\"0\" llog=\"1700000000\" ls=\"150\" lzx=\"-626.5\" lzy=\"613.3\" lzz=\"-28.6\" "
|
||||
"lzrx=\"0.0\" lzry=\"0.7\" lzrz=\"0.0\" lzrw=\"0.7\" lzid=\"1000\" lnzid=\"1000\" rpt=\"0\" time=\"3600\" stt=\"0;0;0;\">"
|
||||
"<ue><e id=\"1\"/></ue><vl><l id=\"1000\" cid=\"0\"/></vl><zs><s map=\"1000\" ac=\"1\" bc=\"2\" cc=\"3\" es=\"4\" qbc=\"5\"/></zs></char>"
|
||||
"<dest hm=\"4\" hc=\"4\" im=\"0\" ic=\"0\" am=\"0\" ac=\"0\" d=\"0\"><buff><b id=\"5\" t=\"10\" tk=\"0\" tt=\"0\" s=\"1\" sr=\"0\" b=\"0\" refCount=\"1\" cancelOnDeath=\"1\"/></buff></dest>"
|
||||
"<inv csl=\"-1\"><bag><b t=\"0\" m=\"20\"/><b t=\"1\" m=\"40\"/><b t=\"2\" m=\"240\"/><b t=\"5\" m=\"240\"/></bag>"
|
||||
"<items><in t=\"0\"><i l=\"1001\" id=\"100\" s=\"0\" c=\"1\" eq=\"1\" b=\"1\" sk=\"0\"/><i l=\"1002\" id=\"101\" s=\"1\" c=\"1\" eq=\"1\" b=\"1\"/></in>"
|
||||
"<in t=\"2\"><i l=\"2000\" id=\"102\" s=\"0\" c=\"500\" eq=\"0\" b=\"0\"/></in></items></inv>"
|
||||
"<lvl l=\"2\" cv=\"1\" sb=\"500\"/>"
|
||||
"<flag><f id=\"1\" v=\"18446744073709551615\"/><s si=\"42\"/></flag>"
|
||||
"<mis><done><m id=\"1\" state=\"8\" cct=\"1\" cts=\"1700000000\"/></done><cur><m id=\"2\" state=\"2\" o=\"1\"><sv v=\"3\"/></m></cur></mis>"
|
||||
"<pet><p id=\"200\" l=\"3000\" m=\"0\" n=\"Rex\" t=\"0\"/></pet>"
|
||||
"<res><r w=\"1100\" x=\"1\" y=\"2\" z=\"3\"/></res>"
|
||||
"</obj>";
|
||||
|
||||
CharacterXmlCheck::Context MakeContext() {
|
||||
CharacterXmlCheck::Context context;
|
||||
context.ownerAccountId = 7;
|
||||
auto& l = context.lookups;
|
||||
l.isItem = [](LOT lot) { return lot == 1001 || lot == 1002 || lot == 2000 || lot == 6666; };
|
||||
l.stackSize = [](LOT lot) { return lot == 2000 ? 999 : 1; };
|
||||
l.missionExists = [](int32_t id) { return id == 1 || id == 2; };
|
||||
l.levelUScore = [](uint32_t level) -> std::optional<int64_t> {
|
||||
if (level == 1) return 0;
|
||||
if (level == 2) return 100;
|
||||
if (level == 3) return 300;
|
||||
return std::nullopt;
|
||||
};
|
||||
l.maxLevel = 3;
|
||||
return context;
|
||||
}
|
||||
|
||||
std::string Replace(std::string text, const std::string& from, const std::string& to) {
|
||||
const auto at = text.find(from);
|
||||
EXPECT_NE(at, std::string::npos) << from;
|
||||
if (at != std::string::npos) text.replace(at, from.size(), to);
|
||||
return text;
|
||||
}
|
||||
|
||||
bool Mentions(const std::vector<std::string>& list, const std::string& part) {
|
||||
return std::any_of(list.begin(), list.end(), [&](const std::string& s) { return s.find(part) != std::string::npos; });
|
||||
}
|
||||
|
||||
CharacterXmlCheck::Result Check(const std::string& xml) { return CharacterXmlCheck::Check(xml, MakeContext()); }
|
||||
|
||||
void ExpectError(const std::string& xml, const std::string& part) {
|
||||
const auto result = Check(xml);
|
||||
EXPECT_FALSE(result.Ok()) << part;
|
||||
EXPECT_TRUE(Mentions(result.errors, part)) << "expected an error mentioning: " << part << (result.errors.empty() ? "" : " got: " + result.errors.front());
|
||||
}
|
||||
|
||||
void ExpectWarning(const std::string& xml, const std::string& part) {
|
||||
const auto result = Check(xml);
|
||||
EXPECT_TRUE(result.Ok()) << (result.errors.empty() ? "" : result.errors.front());
|
||||
EXPECT_TRUE(Mentions(result.warnings, part)) << "expected a warning mentioning: " << part;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(CharacterXmlCheckTests, ValidCharacterPasses) {
|
||||
const auto result = Check(VALID);
|
||||
EXPECT_TRUE(result.errors.empty()) << (result.errors.empty() ? "" : result.errors.front());
|
||||
EXPECT_TRUE(result.warnings.empty()) << (result.warnings.empty() ? "" : result.warnings.front());
|
||||
}
|
||||
|
||||
TEST(CharacterXmlCheckTests, Structure) {
|
||||
ExpectError("", "empty");
|
||||
ExpectError("<obj><char>", "not valid XML");
|
||||
ExpectError("<root/>", "<obj>");
|
||||
ExpectError(Replace(Replace(VALID, "<char acct", "<chr acct"), "</char>", "</chr>"), "<char>");
|
||||
ExpectError(Replace(VALID, "<mf ", "<mx "), "<mf>");
|
||||
ExpectError(Replace(Replace(VALID, "<bag>", "<bog>"), "</bag>", "</bog>"), "<bag>");
|
||||
ExpectError(Replace(VALID, VALID.substr(VALID.find("<items>"), VALID.find("</items>") - VALID.find("<items>")), "<items>"), "at least one <in>");
|
||||
ExpectError(Replace(Replace(VALID, "<inv csl", "<inx csl"), "</inv>", "</inx>"), "<inv>");
|
||||
}
|
||||
|
||||
TEST(CharacterXmlCheckTests, Numbers) {
|
||||
ExpectError(Replace(VALID, "cc=\"150\"", "cc=\"lots\""), "cc=\"lots\"");
|
||||
ExpectError(Replace(VALID, "cc=\"150\"", "cc=\"-5\""), "negative");
|
||||
ExpectError(Replace(VALID, "ls=\"150\"", "ls=\"-1\""), "negative");
|
||||
ExpectError(Replace(VALID, "lzx=\"-626.5\"", "lzx=\"west\""), "lzx");
|
||||
ExpectError(Replace(VALID, "hc=\"1\"", "hc=\"red\""), "<mf>");
|
||||
ExpectError(Replace(VALID, "hm=\"4\"", "hm=\"full\""), "<dest>");
|
||||
ExpectError(Replace(VALID, "refCount=\"1\"", "refCount=\"x\""), "<buff>");
|
||||
ExpectError(Replace(VALID, "<l id=\"1000\"", "<l id=\"ag\""), "<vl>");
|
||||
ExpectError(Replace(VALID, "<e id=\"1\"/>", "<e/>"), "emote: id is missing");
|
||||
ExpectError(Replace(VALID, "map=\"1000\" ", ""), "map is missing");
|
||||
ExpectError(Replace(VALID, "<r w=\"1100\"", "<r w=\"x\""), "<res>");
|
||||
ExpectError(Replace(VALID, "l=\"2\" cv", "l=\"two\" cv"), "<lvl>");
|
||||
ExpectError(Replace(VALID, "csl=\"-1\"", "csl=\"none\""), "csl");
|
||||
ExpectError(Replace(VALID, "<p id=\"200\"", "<p id=\"pet\""), "<pet>");
|
||||
}
|
||||
|
||||
TEST(CharacterXmlCheckTests, Flags) {
|
||||
// The game reads these with std::stoul/std::stoull, which throw
|
||||
ExpectError(Replace(VALID, "<f id=\"1\"", "<f id=\"one\""), "<flag>: id");
|
||||
ExpectError(Replace(VALID, "v=\"18446744073709551615\"", "v=\"18446744073709551616\""), "<flag>: v");
|
||||
ExpectError(Replace(VALID, "<f id=\"1\"", "<f id=\"-1\""), "<flag>: id");
|
||||
}
|
||||
|
||||
TEST(CharacterXmlCheckTests, Enums) {
|
||||
ExpectError(Replace(VALID, "gm=\"0\"", "gm=\"12\""), "not a GM level");
|
||||
ExpectError(Replace(VALID, "cv=\"1\"", "cv=\"99\""), "newer than");
|
||||
ExpectError(Replace(VALID, "<b t=\"5\"", "<b t=\"40\""), "not an inventory type");
|
||||
ExpectError(Replace(VALID, "<in t=\"2\">", "<in t=\"17\">"), "not an inventory type");
|
||||
ExpectError(Replace(VALID, "state=\"2\"", "state=\"3\""), "not a mission state");
|
||||
}
|
||||
|
||||
TEST(CharacterXmlCheckTests, Items) {
|
||||
ExpectError(Replace(VALID, " s=\"1\" c=\"1\"", " c=\"1\""), "s is missing");
|
||||
ExpectError(Replace(VALID, "c=\"500\" eq=\"0\"", "c=\"500\""), "eq is missing");
|
||||
ExpectError(Replace(VALID, "id=\"101\"", "id=\"abc\""), "id=\"abc\"");
|
||||
ExpectError(Replace(VALID, "id=\"101\"", "id=\"0\""), "id can't be 0");
|
||||
ExpectError(Replace(VALID, "id=\"101\"", "id=\"100\""), "used by another item");
|
||||
ExpectError(Replace(VALID, "id=\"101\" s=\"1\"", "id=\"101\" s=\"0\""), "slot 0");
|
||||
ExpectError(Replace(VALID, "l=\"1002\"", "l=\"4242\""), "not an item");
|
||||
ExpectError(Replace(VALID, "c=\"500\"", "c=\"-3\""), "c=\"-3\"");
|
||||
// Build inventories are re-slotted when loading, so shared slots there are fine
|
||||
const auto bbb = Replace(VALID, "</items>", "<in t=\"3\"><i l=\"2000\" id=\"300\" s=\"0\" c=\"1\" eq=\"0\" b=\"0\"/><i l=\"2000\" id=\"301\" s=\"0\" c=\"1\" eq=\"0\" b=\"0\"/></in></items>");
|
||||
EXPECT_TRUE(Check(bbb).Ok());
|
||||
}
|
||||
|
||||
TEST(CharacterXmlCheckTests, MissionsAndOwnership) {
|
||||
ExpectError(Replace(VALID, "<m id=\"2\"", "<m id=\"99\""), "no such mission");
|
||||
ExpectError(Replace(VALID, "<m id=\"1\" ", "<m "), "id is missing");
|
||||
ExpectError(Replace(VALID, "acct=\"7\"", "acct=\"8\""), "not the account");
|
||||
}
|
||||
|
||||
TEST(CharacterXmlCheckTests, Warnings) {
|
||||
ExpectWarning(Replace(VALID, "c=\"500\"", "c=\"1000\""), "stack size");
|
||||
ExpectWarning(Replace(VALID, "c=\"500\"", "c=\"5000000\""), "is above 999999");
|
||||
ExpectWarning(Replace(VALID, "c=\"500\"", "c=\"0\""), "count is 0");
|
||||
ExpectWarning(Replace(VALID, "cc=\"150\"", "cc=\"3000000000\""), "Coins");
|
||||
ExpectWarning(Replace(VALID, "l=\"2\" cv", "l=\"9\" cv"), "above the highest level");
|
||||
ExpectWarning(Replace(VALID, "ls=\"150\"", "ls=\"50\""), "needs 100 u-score");
|
||||
ExpectWarning(Replace(VALID, "ls=\"150\"", "ls=\"400\""), "enough for level 3");
|
||||
ExpectWarning(Replace(VALID, "gm=\"0\"", "gm=\"8\""), "above the account");
|
||||
ExpectWarning(Replace(VALID, "state=\"8\" cct", "state=\"2\" cct"), "not complete");
|
||||
}
|
||||
|
||||
TEST(CharacterXmlCheckTests, Contraband) {
|
||||
auto context = MakeContext();
|
||||
context.contraband[2000] = { "duped", IContraband::eContrabandAction::REMOVE };
|
||||
context.contraband[1001] = { "", IContraband::eContrabandAction::FLAG };
|
||||
auto result = CharacterXmlCheck::Check(VALID, context);
|
||||
ASSERT_TRUE(result.Ok());
|
||||
ASSERT_EQ(result.contraband.size(), 2u);
|
||||
EXPECT_EQ(result.contraband[0].item.id, 100);
|
||||
EXPECT_EQ(result.contraband[1].item.lot, 2000);
|
||||
EXPECT_EQ(result.contraband[1].item.count, 500u);
|
||||
EXPECT_EQ(result.contraband[1].item.inventory, eInventoryType::BRICKS);
|
||||
EXPECT_TRUE(Mentions(result.warnings, "flag and remove"));
|
||||
|
||||
// Staff accounts aren't checked (contraband_ignore_staff), like in the world
|
||||
context.contrabandApplies = false;
|
||||
EXPECT_TRUE(CharacterXmlCheck::Check(VALID, context).contraband.empty());
|
||||
|
||||
// Removing drops the item and any set items that belong to it
|
||||
const auto withProxy = Replace(VALID, "</in></items>", "<i l=\"1002\" id=\"103\" s=\"1\" c=\"1\" eq=\"0\" b=\"0\" parent=\"102\"/></in></items>");
|
||||
const auto removed = CharacterXmlCheck::RemoveItems(withProxy, { 102 });
|
||||
EXPECT_EQ(removed.find("id=\"102\""), std::string::npos);
|
||||
EXPECT_EQ(removed.find("id=\"103\""), std::string::npos);
|
||||
EXPECT_NE(removed.find("id=\"101\""), std::string::npos);
|
||||
EXPECT_TRUE(Check(removed).Ok());
|
||||
}
|
||||
Reference in New Issue
Block a user