From acb0e03a746edb99afe5f69492be895b4aeee182 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 00:35:46 -0500 Subject: [PATCH] feat(mail): keep mail a player deletes, marked deleted, for staff Deleting mail in game now sets mail.deleted_at (migrations mysql 95, sqlite 78) instead of removing the row. Every read for the game skips deleted mail: the mailbox, a single mail (claim, read, delete), the unread count, the economy scan of waiting attachments and the UGC lookup of mailed models. Deleting a character still removes its mail. Check: delete a mail in game; it leaves the mailbox, the unread count drops, and the row is still in the mail table with deleted_at set. Parity test: ParitySeeded.Mail (DeleteMail). Co-Authored-By: Claude Opus 5.5 --- dDatabase/GameDatabase/ITables/IMail.h | 4 ++-- dDatabase/GameDatabase/ITables/UgcLookupSql.h | 2 +- dDatabase/GameDatabase/MySQL/Tables/Economy.cpp | 2 +- dDatabase/GameDatabase/MySQL/Tables/Mail.cpp | 9 +++++---- dDatabase/GameDatabase/SQLite/Tables/Economy.cpp | 2 +- dDatabase/GameDatabase/SQLite/Tables/Mail.cpp | 9 +++++---- migrations/dlu/mysql/95_mail_deleted_at.sql | 7 +++++++ migrations/dlu/sqlite/78_mail_deleted_at.sql | 2 ++ tests/dDatabaseTests/DatabaseParityTests.cpp | 13 +++++++++++++ 9 files changed, 37 insertions(+), 13 deletions(-) create mode 100644 migrations/dlu/mysql/95_mail_deleted_at.sql create mode 100644 migrations/dlu/sqlite/78_mail_deleted_at.sql diff --git a/dDatabase/GameDatabase/ITables/IMail.h b/dDatabase/GameDatabase/ITables/IMail.h index 6fb657c93..396ad82e3 100644 --- a/dDatabase/GameDatabase/ITables/IMail.h +++ b/dDatabase/GameDatabase/ITables/IMail.h @@ -15,7 +15,7 @@ public: // Insert a new mail into the database. virtual void InsertNewMail(const MailInfo& mail) = 0; - // Get the mail for the given character id. + // Get the mail for the given character id. Mail the player deleted is left out (as in every read below). virtual std::vector GetMailForPlayer(const LWOOBJID characterId, const uint32_t numberOfMail) = 0; // Get the mail for the given mail id. @@ -30,7 +30,7 @@ public: // Claim the item from the given mail. virtual void ClaimMailItem(const uint64_t mailId) = 0; - // Delete the given mail. + // A player deleted the mail: it is marked deleted (deleted_at) and kept for staff, and the game never shows it again. virtual void DeleteMail(const uint64_t mailId) = 0; }; diff --git a/dDatabase/GameDatabase/ITables/UgcLookupSql.h b/dDatabase/GameDatabase/ITables/UgcLookupSql.h index 949d1a914..de4e8d30f 100644 --- a/dDatabase/GameDatabase/ITables/UgcLookupSql.h +++ b/dDatabase/GameDatabase/ITables/UgcLookupSql.h @@ -112,7 +112,7 @@ namespace UgcLookupSql { } inline std::string Mail(const std::vector& subkeys, LOT modelItemLot) { - return "SELECT id, receiver_id, receiver_name, attachment_lot, attachment_subkey, attachment_config FROM mail WHERE attachment_count > 0 AND " + return "SELECT id, receiver_id, receiver_name, attachment_lot, attachment_subkey, attachment_config FROM mail WHERE attachment_count > 0 AND deleted_at = 0 AND " "(attachment_lot = " + std::to_string(modelItemLot) + (subkeys.empty() ? "" : " OR attachment_subkey IN (" + IdList(subkeys) + ")") + ") ORDER BY id DESC LIMIT 500;"; } diff --git a/dDatabase/GameDatabase/MySQL/Tables/Economy.cpp b/dDatabase/GameDatabase/MySQL/Tables/Economy.cpp index 9965e9d19..4daca7d05 100644 --- a/dDatabase/GameDatabase/MySQL/Tables/Economy.cpp +++ b/dDatabase/GameDatabase/MySQL/Tables/Economy.cpp @@ -179,7 +179,7 @@ nlohmann::json MySQLDatabase::GetTransfersForItems(const std::vector& } void MySQLDatabase::ForEachMailAttachment(const std::function& visit) { - auto result = ExecuteSelect("SELECT id, receiver_id, attachment_id, attachment_lot, attachment_count FROM mail WHERE attachment_lot > 0 AND attachment_count > 0;"); + auto result = ExecuteSelect("SELECT id, receiver_id, attachment_id, attachment_lot, attachment_count FROM mail WHERE attachment_lot > 0 AND attachment_count > 0 AND deleted_at = 0;"); while (result->next()) { visit({ result->getUInt64("id"), result->getInt64("receiver_id"), result->getInt64("attachment_id"), result->getInt("attachment_lot"), static_cast(result->getInt("attachment_count")) }); diff --git a/dDatabase/GameDatabase/MySQL/Tables/Mail.cpp b/dDatabase/GameDatabase/MySQL/Tables/Mail.cpp index dd10feee8..a2745a2f3 100644 --- a/dDatabase/GameDatabase/MySQL/Tables/Mail.cpp +++ b/dDatabase/GameDatabase/MySQL/Tables/Mail.cpp @@ -23,7 +23,7 @@ void MySQLDatabase::InsertNewMail(const MailInfo& mail) { std::vector MySQLDatabase::GetMailForPlayer(const LWOOBJID characterId, const uint32_t numberOfMail) { auto res = ExecuteSelect( "SELECT id, subject, body, sender_name, attachment_id, attachment_lot, attachment_subkey, attachment_count, was_read, time_sent" - " FROM mail WHERE receiver_id=? limit ?;", + " FROM mail WHERE receiver_id=? AND deleted_at=0 limit ?;", characterId, numberOfMail); std::vector toReturn; @@ -49,7 +49,7 @@ std::vector MySQLDatabase::GetMailForPlayer(const LWOOBJID characterId } std::optional MySQLDatabase::GetMail(const uint64_t mailId) { - auto res = ExecuteSelect("SELECT sender_id, attachment_id, attachment_lot, attachment_subkey, attachment_count, attachment_config, receiver_id FROM mail WHERE id=? LIMIT 1;", mailId); + auto res = ExecuteSelect("SELECT sender_id, attachment_id, attachment_lot, attachment_subkey, attachment_count, attachment_config, receiver_id FROM mail WHERE id=? AND deleted_at=0 LIMIT 1;", mailId); if (!res->next()) { return std::nullopt; @@ -69,7 +69,7 @@ std::optional MySQLDatabase::GetMail(const uint64_t mailId) { } uint32_t MySQLDatabase::GetUnreadMailCount(const LWOOBJID characterId) { - auto res = ExecuteSelect("SELECT COUNT(*) AS number_unread FROM mail WHERE receiver_id=? AND was_read=0;", characterId); + auto res = ExecuteSelect("SELECT COUNT(*) AS number_unread FROM mail WHERE receiver_id=? AND was_read=0 AND deleted_at=0;", characterId); if (!res->next()) { return 0; @@ -87,5 +87,6 @@ void MySQLDatabase::ClaimMailItem(const uint64_t mailId) { } void MySQLDatabase::DeleteMail(const uint64_t mailId) { - ExecuteDelete("DELETE FROM mail WHERE id=? LIMIT 1;", mailId); + // Kept for staff (the dashboard shows it as deleted); every read for the game skips it + ExecuteUpdate("UPDATE mail SET deleted_at=? WHERE id=? AND deleted_at=0 LIMIT 1;", static_cast(time(NULL)), mailId); } diff --git a/dDatabase/GameDatabase/SQLite/Tables/Economy.cpp b/dDatabase/GameDatabase/SQLite/Tables/Economy.cpp index 5346fd9e0..d60b08506 100644 --- a/dDatabase/GameDatabase/SQLite/Tables/Economy.cpp +++ b/dDatabase/GameDatabase/SQLite/Tables/Economy.cpp @@ -183,7 +183,7 @@ nlohmann::json SQLiteDatabase::GetTransfersForItems(const std::vector& } void SQLiteDatabase::ForEachMailAttachment(const std::function& visit) { - auto [_, result] = ExecuteSelect("SELECT id, receiver_id, attachment_id, attachment_lot, attachment_count FROM mail WHERE attachment_lot > 0 AND attachment_count > 0;"); + auto [_, result] = ExecuteSelect("SELECT id, receiver_id, attachment_id, attachment_lot, attachment_count FROM mail WHERE attachment_lot > 0 AND attachment_count > 0 AND deleted_at = 0;"); while (!result.eof()) { visit({ static_cast(result.getInt64Field("id")), result.getInt64Field("receiver_id"), result.getInt64Field("attachment_id"), result.getIntField("attachment_lot"), static_cast(result.getIntField("attachment_count")) }); diff --git a/dDatabase/GameDatabase/SQLite/Tables/Mail.cpp b/dDatabase/GameDatabase/SQLite/Tables/Mail.cpp index f5aa7206a..a8e24ef8e 100644 --- a/dDatabase/GameDatabase/SQLite/Tables/Mail.cpp +++ b/dDatabase/GameDatabase/SQLite/Tables/Mail.cpp @@ -22,7 +22,7 @@ void SQLiteDatabase::InsertNewMail(const MailInfo& mail) { std::vector SQLiteDatabase::GetMailForPlayer(const LWOOBJID characterId, const uint32_t numberOfMail) { auto [_, res] = ExecuteSelect( "SELECT id, subject, body, sender_name, attachment_id, attachment_lot, attachment_subkey, attachment_count, was_read, time_sent" - " FROM mail WHERE receiver_id=? limit ?;", + " FROM mail WHERE receiver_id=? AND deleted_at=0 limit ?;", characterId, numberOfMail); std::vector toReturn; @@ -48,7 +48,7 @@ std::vector SQLiteDatabase::GetMailForPlayer(const LWOOBJID characterI } std::optional SQLiteDatabase::GetMail(const uint64_t mailId) { - auto [_, res] = ExecuteSelect("SELECT sender_id, attachment_id, attachment_lot, attachment_subkey, attachment_count, attachment_config, receiver_id FROM mail WHERE id=? LIMIT 1;", mailId); + auto [_, res] = ExecuteSelect("SELECT sender_id, attachment_id, attachment_lot, attachment_subkey, attachment_count, attachment_config, receiver_id FROM mail WHERE id=? AND deleted_at=0 LIMIT 1;", mailId); if (res.eof()) { return std::nullopt; @@ -68,7 +68,7 @@ std::optional SQLiteDatabase::GetMail(const uint64_t mailId) { } uint32_t SQLiteDatabase::GetUnreadMailCount(const LWOOBJID characterId) { - auto [_, res] = ExecuteSelect("SELECT COUNT(*) AS number_unread FROM mail WHERE receiver_id=? AND was_read=0;", characterId); + auto [_, res] = ExecuteSelect("SELECT COUNT(*) AS number_unread FROM mail WHERE receiver_id=? AND was_read=0 AND deleted_at=0;", characterId); if (res.eof()) { return 0; @@ -86,5 +86,6 @@ void SQLiteDatabase::ClaimMailItem(const uint64_t mailId) { } void SQLiteDatabase::DeleteMail(const uint64_t mailId) { - ExecuteDelete("DELETE FROM mail WHERE id=?;", mailId); + // Kept for staff (the dashboard shows it as deleted); every read for the game skips it + ExecuteUpdate("UPDATE mail SET deleted_at=? WHERE id=? AND deleted_at=0;", static_cast(time(NULL)), mailId); } diff --git a/migrations/dlu/mysql/95_mail_deleted_at.sql b/migrations/dlu/mysql/95_mail_deleted_at.sql new file mode 100644 index 000000000..5df0d2616 --- /dev/null +++ b/migrations/dlu/mysql/95_mail_deleted_at.sql @@ -0,0 +1,7 @@ +/* mail.deleted_at: when the player deleted the mail in game (unix time; 0 = not deleted). Deleted mail stays for staff + to read on the dashboard; the game never shows it again. */ +SET @dlu_column = (SELECT IF(COUNT(*) = 0, 'ALTER TABLE mail ADD COLUMN deleted_at BIGINT NOT NULL DEFAULT 0', 'DO 0') FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'mail' AND column_name = 'deleted_at'); +PREPARE dlu_column_stmt FROM @dlu_column; +EXECUTE dlu_column_stmt; +DEALLOCATE PREPARE dlu_column_stmt; diff --git a/migrations/dlu/sqlite/78_mail_deleted_at.sql b/migrations/dlu/sqlite/78_mail_deleted_at.sql new file mode 100644 index 000000000..46ae6fe34 --- /dev/null +++ b/migrations/dlu/sqlite/78_mail_deleted_at.sql @@ -0,0 +1,2 @@ +/* mail.deleted_at: when the player deleted the mail in game. See the MySQL migration. */ +ALTER TABLE mail ADD COLUMN deleted_at BIGINT NOT NULL DEFAULT 0; diff --git a/tests/dDatabaseTests/DatabaseParityTests.cpp b/tests/dDatabaseTests/DatabaseParityTests.cpp index 3aa20fab2..c9ab3168c 100644 --- a/tests/dDatabaseTests/DatabaseParityTests.cpp +++ b/tests/dDatabaseTests/DatabaseParityTests.cpp @@ -919,6 +919,19 @@ TEST_F(ParitySeeded, Mail) { return rows; }); Both("GetDashboardSnapshot", [](GameDatabase& db) { return db.GetDashboardSnapshot(); }); + // A player deleting mail keeps the row for staff; the game's reads skip it + Both("DeleteMail", [](GameDatabase& db) { + db.DeleteMail(2); + db.DeleteMail(1); // still has its attachment: the game refuses this, but the database hides it all the same + json out{ db.GetMail(1), db.GetMail(2), db.GetMailForPlayer(CHAR_BOB, 20), db.GetUnreadMailCount(CHAR_BOB) }; + json rows = json::array(); + db.ForEachMailAttachment([&](const IEconomyLedger::MailAttachment& attachment) { rows.push_back(attachment); }); + out.push_back(rows); + EXPECT_EQ(out[2], json::array()); + EXPECT_EQ(out[3], 0); + EXPECT_EQ(rows, json::array()); + return out; + }); } TEST_F(ParitySeeded, Friends) {