feat: store each named pet's LOT in pet_names

pet_names gets a pet_lot column (mysql 80, sqlite 63). The world writes
it whenever it saves a pet name, from the pet entity's LOT, and fills it
in for older rows when the owner loads into a world (from the pets the
game loads for that character, only where it is still missing).

The dashboard's pet name tables read pet_lot instead of scanning the
owner's character XML; pets without it yet show as Unknown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 03:51:39 -05:00
parent 330f02de30
commit 9682556128
14 changed files with 72 additions and 36 deletions

View File

@@ -90,24 +90,13 @@ namespace {
}
/**
* Adds each pet's LOT and kind (its CDClient name) to pet_names rows. pet_names doesn't hold the LOT; the owner's
* save does (<pet><p id l/>), so each owner on the page is read once. Pets whose owner is unknown, or who have
* left the owner's save, get lot 0.
* Adds each pet's kind (its CDClient name) to pet_names rows, from the pet_lot the game writes (lot 0: not known yet).
*/
std::string WithPetKinds(const std::string& raw) {
auto json = nlohmann::json::parse(raw, nullptr, false);
if (json.is_discarded() || !json.contains("data") || !json["data"].is_array()) return raw;
std::map<LWOOBJID, std::map<LWOOBJID, LOT>> owners;
for (auto& row : json["data"]) {
const auto owner = GeneralUtils::TryParse<LWOOBJID>(row.value("owner_id", std::string{})).value_or(0);
const auto pet = GeneralUtils::TryParse<LWOOBJID>(row.value("id", std::string{})).value_or(0);
LOT lot = 0;
if (owner && pet) {
auto [it, added] = owners.try_emplace(owner);
if (added) for (const auto& held : CharacterXml::Pets(Database::Get()->GetCharacterXml(owner))) it->second[held.id] = held.lot;
if (const auto found = it->second.find(pet); found != it->second.end()) lot = found->second;
}
row["lot"] = lot;
const LOT lot = row.value("lot", 0);
row["kind"] = lot > 0 ? ClientAssets::ItemName(lot) : "";
}
return json.dump();

View File

@@ -137,7 +137,7 @@
},
// A pet's kind: its icon and CDClient name (rows from the pet name tables carry lot and kind)
pet: function (lot, kind) {
if (!lot) return '<span class="text-body-secondary small" title="The owner\'s save doesn\'t hold this pet">Unknown</span>';
if (!lot) return '<span class="text-body-secondary small" title="Not recorded yet: filled in when the owner next loads into a world">Unknown</span>';
return '<span class="text-nowrap"><img src="/api/icon/' + esc(lot) + '" width="28" height="28" class="me-1 align-middle" alt="" loading="lazy">' +
esc(kind || ('LOT ' + lot)) + ' <span class="small text-body-secondary">(' + esc(lot) + ')</span></span>';
},

View File

@@ -16,6 +16,8 @@ public:
int32_t approvalStatus{};
// The character that owns the pet (0 when not known). Saving with 0 keeps the owner already stored.
LWOOBJID ownerId{};
// The pet's LOT (0 when not known). Saving with 0 keeps the LOT already stored.
LOT petLot{};
};
// Set the pet name moderation status for the given pet id.
@@ -36,6 +38,9 @@ public:
// Pets whose owner was never recorded (named before owners were saved), and recording one (0: nobody has it)
virtual std::vector<LWOOBJID> GetPetsWithUnknownOwner() = 0;
virtual void SetPetOwner(const LWOOBJID petId, const LWOOBJID ownerId) = 0;
// Record the pet's LOT on its pet_names row, only when the row has none yet (NULL or 0). Does nothing without a row.
virtual void SetPetLotIfMissing(const LWOOBJID petId, const LOT petLot) = 0;
};
#endif //!__IPETNAMES__H__

View File

@@ -406,6 +406,7 @@ public:
void RejectPetName(const int64_t id) override;
std::vector<LWOOBJID> GetPetsWithUnknownOwner() override;
void SetPetOwner(const LWOOBJID petId, const LWOOBJID ownerId) override;
void SetPetLotIfMissing(const LWOOBJID petId, const LOT petLot) override;
nlohmann::json GetAccountCharacters(uint32_t accountId) override;
void DeleteAccount(const uint32_t accountId) override;
void InsertAuditLog(uint32_t accountId, const std::string_view accountName, const std::string_view action, const std::string_view description, uint32_t targetAccountId, LWOOBJID targetCharacterId) override;

View File

@@ -4,20 +4,23 @@
void MySQLDatabase::SetPetNameModerationStatus(const LWOOBJID& petId, const IPetNames::Info& info) {
const auto owner = info.ownerId == 0 ? std::optional<LWOOBJID>{} : std::optional<LWOOBJID>{ info.ownerId };
const auto lot = info.petLot <= 0 ? std::optional<uint32_t>{} : std::optional<uint32_t>{ static_cast<uint32_t>(info.petLot) };
ExecuteInsert(
"INSERT INTO `pet_names` (`id`, `pet_name`, `approved`, `owner_id`) VALUES (?, ?, ?, ?) "
"ON DUPLICATE KEY UPDATE pet_name = ?, approved = ?, owner_id = COALESCE(?, owner_id);",
"INSERT INTO `pet_names` (`id`, `pet_name`, `approved`, `owner_id`, `pet_lot`) VALUES (?, ?, ?, ?, ?) "
"ON DUPLICATE KEY UPDATE pet_name = ?, approved = ?, owner_id = COALESCE(?, owner_id), pet_lot = COALESCE(?, pet_lot);",
petId,
info.petName,
info.approvalStatus,
owner,
lot,
info.petName,
info.approvalStatus,
owner);
owner,
lot);
}
std::optional<IPetNames::Info> MySQLDatabase::GetPetNameInfo(const LWOOBJID& petId) {
auto result = ExecuteSelect("SELECT pet_name, approved, owner_id FROM pet_names WHERE id = ? LIMIT 1;", petId);
auto result = ExecuteSelect("SELECT pet_name, approved, owner_id, pet_lot FROM pet_names WHERE id = ? LIMIT 1;", petId);
if (!result->next()) {
return std::nullopt;
@@ -27,6 +30,7 @@ std::optional<IPetNames::Info> MySQLDatabase::GetPetNameInfo(const LWOOBJID& pet
toReturn.petName = result->getString("pet_name").c_str();
toReturn.approvalStatus = result->getInt("approved");
toReturn.ownerId = result->isNull("owner_id") ? 0 : result->getInt64("owner_id");
toReturn.petLot = result->isNull("pet_lot") ? 0 : result->getInt("pet_lot");
return toReturn;
}
@@ -51,7 +55,7 @@ std::string MySQLDatabase::GetPetNamesTable(uint32_t start, uint32_t length, con
case 3: orderColumnName = "c.name"; break;
}
std::string orderClause = " ORDER BY " + orderColumnName + (orderAsc ? " ASC" : " DESC");
std::string mainQuery = "SELECT p.id, p.pet_name, p.approved, p.owner_id, c.name AS owner_name" + from + whereClause + orderClause + " LIMIT ?, ?;";
std::string mainQuery = "SELECT p.id, p.pet_name, p.approved, p.owner_id, p.pet_lot, c.name AS owner_name" + from + whereClause + orderClause + " LIMIT ?, ?;";
auto totalCountResult = ExecuteSelect("SELECT COUNT(*) as count FROM pet_names p" + (pendingOnly ? " WHERE " + pendingFilter : std::string{}) + ";");
uint32_t totalRecords = totalCountResult->next() ? totalCountResult->getUInt("count") : 0;
@@ -74,6 +78,7 @@ std::string MySQLDatabase::GetPetNamesTable(uint32_t start, uint32_t length, con
{"pet_name", result->getString("pet_name")},
{"approved", result->getInt("approved")},
{"owner_id", hasOwner ? std::to_string(result->getInt64("owner_id")) : ""},
{"lot", result->isNull("pet_lot") ? 0 : result->getInt("pet_lot")},
{"owner_name", std::string(result->isNull("owner_name") ? "" : result->getString("owner_name").c_str())}
});
}
@@ -98,3 +103,8 @@ std::vector<LWOOBJID> MySQLDatabase::GetPetsWithUnknownOwner() {
void MySQLDatabase::SetPetOwner(const LWOOBJID petId, const LWOOBJID ownerId) {
ExecuteUpdate("UPDATE pet_names SET owner_id = ? WHERE id = ?;", ownerId, petId);
}
void MySQLDatabase::SetPetLotIfMissing(const LWOOBJID petId, const LOT petLot) {
if (petLot <= 0) return;
ExecuteUpdate("UPDATE pet_names SET pet_lot = ? WHERE id = ? AND (pet_lot IS NULL OR pet_lot = 0);", petLot, petId);
}

View File

@@ -390,6 +390,7 @@ public:
void RejectPetName(const int64_t id) override;
std::vector<LWOOBJID> GetPetsWithUnknownOwner() override;
void SetPetOwner(const LWOOBJID petId, const LWOOBJID ownerId) override;
void SetPetLotIfMissing(const LWOOBJID petId, const LOT petLot) override;
nlohmann::json GetAccountCharacters(uint32_t accountId) override;
void DeleteAccount(const uint32_t accountId) override;
void InsertAuditLog(uint32_t accountId, const std::string_view accountName, const std::string_view action, const std::string_view description, uint32_t targetAccountId, LWOOBJID targetCharacterId) override;

View File

@@ -4,20 +4,23 @@
void SQLiteDatabase::SetPetNameModerationStatus(const LWOOBJID& petId, const IPetNames::Info& info) {
const auto owner = info.ownerId == 0 ? std::optional<LWOOBJID>{} : std::optional<LWOOBJID>{ info.ownerId };
const auto lot = info.petLot <= 0 ? std::optional<uint32_t>{} : std::optional<uint32_t>{ static_cast<uint32_t>(info.petLot) };
ExecuteInsert(
"INSERT INTO `pet_names` (`id`, `pet_name`, `approved`, `owner_id`) VALUES (?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET pet_name = ?, approved = ?, owner_id = COALESCE(?, owner_id);",
"INSERT INTO `pet_names` (`id`, `pet_name`, `approved`, `owner_id`, `pet_lot`) VALUES (?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET pet_name = ?, approved = ?, owner_id = COALESCE(?, owner_id), pet_lot = COALESCE(?, pet_lot);",
petId,
info.petName,
info.approvalStatus,
owner,
lot,
info.petName,
info.approvalStatus,
owner);
owner,
lot);
}
std::optional<IPetNames::Info> SQLiteDatabase::GetPetNameInfo(const LWOOBJID& petId) {
auto [_, result] = ExecuteSelect("SELECT pet_name, approved, owner_id FROM pet_names WHERE id = ? LIMIT 1;", petId);
auto [_, result] = ExecuteSelect("SELECT pet_name, approved, owner_id, pet_lot FROM pet_names WHERE id = ? LIMIT 1;", petId);
if (result.eof()) {
return std::nullopt;
@@ -27,6 +30,7 @@ std::optional<IPetNames::Info> SQLiteDatabase::GetPetNameInfo(const LWOOBJID& pe
toReturn.petName = result.getStringField("pet_name");
toReturn.approvalStatus = result.getIntField("approved");
toReturn.ownerId = result.fieldIsNull("owner_id") ? 0 : result.getInt64Field("owner_id");
toReturn.petLot = result.fieldIsNull("pet_lot") ? 0 : result.getIntField("pet_lot");
return toReturn;
}
@@ -51,7 +55,7 @@ std::string SQLiteDatabase::GetPetNamesTable(uint32_t start, uint32_t length, co
case 3: orderColumnName = "c.name"; break;
}
std::string orderClause = " ORDER BY " + orderColumnName + (orderAsc ? " ASC" : " DESC");
std::string mainQuery = "SELECT p.id, p.pet_name, p.approved, p.owner_id, c.name AS owner_name" + from + whereClause + orderClause + " LIMIT ? OFFSET ?;";
std::string mainQuery = "SELECT p.id, p.pet_name, p.approved, p.owner_id, p.pet_lot, c.name AS owner_name" + from + whereClause + orderClause + " LIMIT ? OFFSET ?;";
auto [__, totalCountResult] = ExecuteSelect("SELECT COUNT(*) as count FROM pet_names p" + (pendingOnly ? " WHERE " + pendingFilter : std::string{}) + ";");
uint32_t totalRecords = totalCountResult.eof() ? 0 : totalCountResult.getIntField("count");
@@ -74,6 +78,7 @@ std::string SQLiteDatabase::GetPetNamesTable(uint32_t start, uint32_t length, co
{"pet_name", result.getStringField("pet_name")},
{"approved", result.getIntField("approved")},
{"owner_id", hasOwner ? std::to_string(result.getInt64Field("owner_id")) : ""},
{"lot", result.fieldIsNull("pet_lot") ? 0 : result.getIntField("pet_lot")},
{"owner_name", std::string(result.fieldIsNull("owner_name") ? "" : result.getStringField("owner_name"))}
});
result.nextRow();
@@ -99,3 +104,8 @@ std::vector<LWOOBJID> SQLiteDatabase::GetPetsWithUnknownOwner() {
void SQLiteDatabase::SetPetOwner(const LWOOBJID petId, const LWOOBJID ownerId) {
ExecuteUpdate("UPDATE pet_names SET owner_id = ? WHERE id = ?;", ownerId, petId);
}
void SQLiteDatabase::SetPetLotIfMissing(const LWOOBJID petId, const LOT petLot) {
if (petLot <= 0) return;
ExecuteUpdate("UPDATE pet_names SET pet_lot = ? WHERE id = ? AND (pet_lot IS NULL OR pet_lot = 0);", petLot, petId);
}

View File

@@ -364,6 +364,7 @@ class TestSQLDatabase : public GameDatabase {
void RejectPetName(const int64_t id) override {};
std::vector<LWOOBJID> GetPetsWithUnknownOwner() override { return {}; }
void SetPetOwner(const LWOOBJID petId, const LWOOBJID ownerId) override {}
void SetPetLotIfMissing(const LWOOBJID petId, const LOT petLot) override {}
nlohmann::json GetAccountCharacters(uint32_t accountId) override { return nlohmann::json::array(); };
void ApproveProperty(const LWOOBJID propertyId) override {};
bool IsNameInUse(const std::string_view name) override { return false; };

View File

@@ -1731,9 +1731,9 @@ void InventoryComponent::LoadPetXml(const tinyxml2::XMLDocument& document) {
auto* petElement = petInventoryElement->FirstChildElement();
while (petElement != nullptr) {
LWOOBJID id;
LOT lot;
int32_t moderationStatus;
LWOOBJID id{};
LOT lot{};
int32_t moderationStatus{};
petElement->QueryAttribute("id", &id);
petElement->QueryAttribute("l", &lot);
@@ -1747,6 +1747,9 @@ void InventoryComponent::LoadPetXml(const tinyxml2::XMLDocument& document) {
SetDatabasePet(id, databasePet);
// Pets named before pet_names recorded their LOT get it now that their owner is loaded
if (id != LWOOBJID_EMPTY && lot > 0) Database::Get()->SetPetLotIfMissing(id, lot);
petElement = petElement->NextSiblingElement();
}
}

View File

@@ -613,7 +613,7 @@ void PetComponent::RequestSetPetName(std::u16string name) {
if (autoRejectNames && owner && owner->GetCharacter() && owner->GetCharacter()->GetParentUser()->GetIsMuted()) {
m_ModerationStatus = 2; // Approved
std::string forcedName = "Pet";
Database::Get()->SetPetNameModerationStatus(m_DatabaseId, IPetNames::Info{ forcedName, static_cast<int32_t>(m_ModerationStatus), m_Owner });
Database::Get()->SetPetNameModerationStatus(m_DatabaseId, IPetNames::Info{ forcedName, static_cast<int32_t>(m_ModerationStatus), m_Owner, m_Parent->GetLOT() });
DashboardNotify::Changed("pet_names", m_DatabaseId);
{
GameMessages::SetPetName msg;
@@ -678,7 +678,7 @@ void PetComponent::RequestSetPetName(std::u16string name) {
m_Name = "";
std::string forcedName = "Pet";
Database::Get()->SetPetNameModerationStatus(m_DatabaseId, IPetNames::Info{ forcedName, static_cast<int32_t>(m_ModerationStatus), m_Tamer });
Database::Get()->SetPetNameModerationStatus(m_DatabaseId, IPetNames::Info{ forcedName, static_cast<int32_t>(m_ModerationStatus), m_Tamer, m_Parent->GetLOT() });
DashboardNotify::Changed("pet_names", m_DatabaseId);
LOG("AccountID: %i is muted, forcing use of predefined pet name", tamer->GetCharacter()->GetParentUser()->GetAccountID());
} else {
@@ -1235,7 +1235,7 @@ void PetComponent::SetPetNameForModeration(const std::string& petName, const LWO
}
//Save to db:
Database::Get()->SetPetNameModerationStatus(m_DatabaseId, IPetNames::Info{ petName, approved, owner });
Database::Get()->SetPetNameModerationStatus(m_DatabaseId, IPetNames::Info{ petName, approved, owner, m_Parent->GetLOT() });
DashboardNotify::Changed("pet_names", m_DatabaseId);
}

View File

@@ -725,8 +725,9 @@ tells the player in game if they're online. Rejecting asks for an optional reaso
account page next to what they asked for. The queues show only what the game itself would still review: rejected
properties and names whose player must pick a new one no longer appear.
Pet names show what kind of pet each one is (its icon and name from the CDClient), read from the pet's entry in its
owner's save. A pet whose owner is unknown or no longer has it shows as Unknown.
Pet names show what kind of pet each one is (its icon and name from the CDClient), from the pet's LOT that the game
stores with the name (`pet_names.pet_lot`). Names set before that column existed get their LOT the next time the pet's
owner loads into a world; until then they show as Unknown.
### Moderation history, warnings and bans

View File

@@ -0,0 +1,7 @@
/* Which kind of pet (its LOT) each named pet is, written by the game when the name is set and filled in for older
rows when the owner next loads into a world. NULL or 0: not known yet. Only added when it is not there yet. */
SET @dlu_column = (SELECT IF(COUNT(*) = 0, 'ALTER TABLE pet_names ADD COLUMN pet_lot INT NULL DEFAULT NULL', 'DO 0') FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'pet_names' AND column_name = 'pet_lot');
PREPARE dlu_column_stmt FROM @dlu_column;
EXECUTE dlu_column_stmt;
DEALLOCATE PREPARE dlu_column_stmt;

View File

@@ -0,0 +1,2 @@
/* Which kind of pet (its LOT) each named pet is. See the MySQL migration. */
ALTER TABLE pet_names ADD COLUMN pet_lot INTEGER NULL DEFAULT NULL;

View File

@@ -89,7 +89,7 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(ICharInfo::Info, name, pendingName, id, accou
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IProperty::Info, name, description, rejectionReason, id, ownerId, cloneId, privacyOption, modApproved, lastUpdatedTime, claimedTime, reputation, performanceCost, zoneId);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IProperty::ShowcaseEntry, info, ownerName, modelCount);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IProperty::ShowcaseResult, total, entries);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IPetNames::Info, petName, approvalStatus, ownerId);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IPetNames::Info, petName, approvalStatus, ownerId, petLot);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(ILeaderboard::Entry, charId, lastPlayedTimestamp, primaryScore, secondaryScore, tertiaryScore, numWins, numTimesPlayed, ranking, name);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(ILeaderboard::Score, primaryScore, secondaryScore, tertiaryScore);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(ILiveOps::LiveEvent, id, type, title, message, zones, instanceId, config, startsAt, endsAt, state, endedAt, endReason, createdBy, endedBy);
@@ -604,11 +604,17 @@ TEST_F(ParitySeeded, BugReports) {
TEST_F(ParitySeeded, PetNames) {
Both("SetPetNameModerationStatus", [](GameDatabase& db) {
db.SetPetNameModerationStatus(PET1, { "Sparky", 1, CHAR_BOB });
db.SetPetNameModerationStatus(PET2, { "Sparky", 2, CHAR_ALICE });
db.SetPetNameModerationStatus(PET1, { "Sparky", 1, CHAR_BOB, 3520 });
db.SetPetNameModerationStatus(PET2, { "Sparky", 2, CHAR_ALICE, 12432 });
db.SetPetNameModerationStatus(PET3, { "Rex", 1, 0 });
});
Both("SetPetNameModerationStatus keeps owner", [](GameDatabase& db) { db.SetPetNameModerationStatus(PET1, { "Sparky", 1, 0 }); return db.GetPetNameInfo(PET1); });
Both("SetPetNameModerationStatus keeps owner and lot", [](GameDatabase& db) { db.SetPetNameModerationStatus(PET1, { "Sparky", 1, 0 }); return db.GetPetNameInfo(PET1); });
Both("SetPetLotIfMissing", [](GameDatabase& db) {
db.SetPetLotIfMissing(PET2, 3520); // PET2 already has its LOT: kept
db.SetPetLotIfMissing(PET3, 12434); // PET3 has none yet: written
db.SetPetLotIfMissing(5, 3520); // no row: nothing
return json{ db.GetPetNameInfo(PET2), db.GetPetNameInfo(PET3), db.GetPetNameInfo(5) };
});
Both("GetPetNameInfo", [](GameDatabase& db) { return json{ db.GetPetNameInfo(PET2), db.GetPetNameInfo(PET3), db.GetPetNameInfo(5) }; });
Both("GetPetNamesTable", [](GameDatabase& db) { return db.GetPetNamesTable(0, 10); });
Both("GetPetNamesTable pending", [](GameDatabase& db) { return db.GetPetNamesTable(0, 10, "", 0, true, true); });