diff --git a/src/ControlServer/Loadouts/ClassLoadouts.cpp b/src/ControlServer/Loadouts/ClassLoadouts.cpp index 78aa365b..83e86cfb 100644 --- a/src/ControlServer/Loadouts/ClassLoadouts.cpp +++ b/src/ControlServer/Loadouts/ClassLoadouts.cpp @@ -66,6 +66,8 @@ static const std::vector kMedic = { { 2061, 3, SVID_SPECIALTY, Q_EPIC, Mods::Letters("ppp", "ppp", false) }, // nanite restore { 7032, 5, SVID_JETPACK, Q_EPIC, Mods::Letters("ppp", "ppp", false) }, // Medic Crescent Jetpack + { 6077, 5, SVID_JETPACK, Q_EPIC, Mods::Letters("ppp", "ppp", false) }, // Medic Combat Jetpack + { 3500, 5, SVID_JETPACK, Q_EPIC, Mods::Letters("ppp", "ppp", false) }, // Medic Jetpack III { 2531, 7, SVID_OFFHAND1, Q_EPIC, Mods::Letters("hhh", "hhh", true) }, // Healing Grenade { 2531, 7, SVID_OFFHAND1, Q_EPIC, Mods::Letters("xxx", "hhh", false) }, // Healing Grenade @@ -138,7 +140,9 @@ static const std::vector kRobotics = { { 5810, 3, SVID_SPECIALTY, Q_EPIC, Mods::Letters("hhh", "hhh", true )}, // Nanite repair { 5811, 3, SVID_SPECIALTY, Q_EPIC, Mods::Letters("ddp", "ppp", false )}, // Force target - { 7034, 5, SVID_JETPACK, Q_EPIC, Mods::Letters("ppp", "ppp", false)}, + { 7034, 5, SVID_JETPACK, Q_EPIC, Mods::Letters("ppp", "ppp", false)}, // Robotics Crescent Jetpack + { 6079, 5, SVID_JETPACK, Q_EPIC, Mods::Letters("ppp", "ppp", false) }, // Robotics Combat Jetpack + { 3502, 5, SVID_JETPACK, Q_EPIC, Mods::Letters("ppp", "ppp", false) }, // Robotics Jetpack III { 2095, 7, SVID_OFFHAND1, Q_EPIC, Mods::Letters("ddd", "ddd", false )}, // rocket turret { 2095, 7, SVID_OFFHAND1, Q_EPIC, Mods::Letters("rrr", "ddd", false )}, // rocket turret @@ -169,8 +173,8 @@ static const std::vector kRobotics = { // 680 — Assault — Impact Hammer + Assault Crescent Jetpack static const std::vector kAssault = { { 5801, 1, SVID_MELEE, Q_EPIC, Mods::Letters("ddd", "ddd", false) }, // Impact Hammer - { 6806, 1, SVID_MELEE, Q_RARE, Mods::Letters("dd", "ppp", false) }, // beatstick - { 6806, 1, SVID_MELEE, Q_RARE, Mods::Letters("dd", "ddd", false) }, // beatstick + // { 6806, 1, SVID_MELEE, Q_RARE, Mods::Letters("dd", "ppp", false) }, // beatstick + // { 6806, 1, SVID_MELEE, Q_RARE, Mods::Letters("dd", "ddd", false) }, // beatstick { 3973, 1, SVID_MELEE, Q_EPIC, Mods::Letters("ddd", "ddd", false) }, // axe { 5788, 2, SVID_RANGED, Q_EPIC, Mods::Letters("ddd", "ddd", true) }, // Rhino SMG @@ -183,7 +187,7 @@ static const std::vector kAssault = { { 2790, 3, SVID_SPECIALTY, Q_EPIC, Mods::Letters("xxx", "ppp", false)}, // gammaburst { 2790, 3, SVID_SPECIALTY, Q_EPIC, Mods::Letters("ddd", "ddd", true)}, // gammaburst { 1991, 3, SVID_SPECIALTY, Q_EPIC, Mods::Letters("ddd", "ddd", true)}, // headhunter - { 6896, 3, SVID_SPECIALTY, Q_EPIC, Mods::Letters("ddd", "ddd", false)}, // helot mini + // { 6896, 3, SVID_SPECIALTY, Q_EPIC, Mods::Letters("ddd", "ddd", false)}, // helot mini { 2914, 3, SVID_SPECIALTY, Q_EPIC, Mods::Letters("ddd", "ddd", true)}, // inferno { 5789, 3, SVID_SPECIALTY, Q_EPIC, Mods::Letters("ddd", "ddd", true)}, // longbow { 5789, 3, SVID_SPECIALTY, Q_EPIC, Mods::Letters("ddx", "ddd", false)}, // longbow @@ -196,6 +200,8 @@ static const std::vector kAssault = { // { 2202, 3, SVID_SPECIALTY, Q_EPIC, Mods::Letters("ddd", "ddd", true)}, // aftershock { 7031, 5, SVID_JETPACK, Q_EPIC, Mods::Letters("ppp", "ppp", false) }, // Assault Crescent Jetpack + { 6076, 5, SVID_JETPACK, Q_EPIC, Mods::Letters("ppp", "ppp", false) }, // Assault Combat Jetpack + { 3499, 5, SVID_JETPACK, Q_EPIC, Mods::Letters("ppp", "ppp", false) }, // Assault Jetpack III { 3699, 7, SVID_OFFHAND1, Q_EPIC, Mods::Letters("ccc", "ccc", false) }, // Power Stim { 3699, 7, SVID_OFFHAND1, Q_EPIC, Mods::Letters("ttt", "ccc", false) }, // Power Stim @@ -247,6 +253,8 @@ static const std::vector kRecon = { { 2209, 3, SVID_SPECIALTY, Q_EPIC, Mods::Letters("ppp", "ppp", false) }, // sprint stealth { 7033, 5, SVID_JETPACK, Q_EPIC, Mods::Letters("ppp", "ppp", false) }, // Recon Crescent Jetpack + { 6078, 5, SVID_JETPACK, Q_EPIC, Mods::Letters("ppp", "ppp", false) }, // Recon Combat Jetpack + { 3501, 5, SVID_JETPACK, Q_EPIC, Mods::Letters("ppp", "ppp", false) }, // Recon Jetpack III { 4708, 7, SVID_OFFHAND1, Q_EPIC, Mods::Letters("xxx", "ccc", false) }, // Venom bomb { 4708, 7, SVID_OFFHAND1, Q_EPIC, Mods::Letters("xxx", "ddd", false) }, // Venom bomb diff --git a/src/ControlServer/PlayerSessionStore/PlayerSessionStore.cpp b/src/ControlServer/PlayerSessionStore/PlayerSessionStore.cpp index daf523b9..bb00b396 100644 --- a/src/ControlServer/PlayerSessionStore/PlayerSessionStore.cpp +++ b/src/ControlServer/PlayerSessionStore/PlayerSessionStore.cpp @@ -318,34 +318,52 @@ std::optional PlayerSessionStore::GetByPlayerName(const std::string return std::nullopt; } -int64_t PlayerSessionStore::UpsertUser(const std::string& username) { +int64_t PlayerSessionStore::UpsertUser(const std::string& username, + std::string* out_display_name) { std::lock_guard lock(mutex_); sqlite3* db = Database::GetConnection(); - // Insert if not present (ignore on conflict keeps existing row). - sqlite3_stmt* stmt = nullptr; - int rc = sqlite3_prepare_v2(db, "INSERT OR IGNORE INTO ga_users (username) VALUES (?)", -1, &stmt, nullptr); - if (rc == SQLITE_OK && stmt) { - sqlite3_bind_text(stmt, 1, username.c_str(), -1, SQLITE_TRANSIENT); - sqlite3_step(stmt); - sqlite3_finalize(stmt); + // Case-insensitive resolve. An existing account in any capitalization owns + // the name; COLLATE NOCASE folds the same ASCII set the login verifier does + // (LoginAuth::LowerAscii). ORDER BY id ASC makes the choice deterministic + // while a legacy case-duplicate may still exist, so this and GetUserAuth + // always agree on which row is canonical. + int64_t id = 0; + std::string display = username; + sqlite3_stmt* sel = nullptr; + if (sqlite3_prepare_v2(db, + "SELECT id, username FROM ga_users WHERE username = ? COLLATE NOCASE " + "ORDER BY id ASC LIMIT 1", + -1, &sel, nullptr) == SQLITE_OK) { + sqlite3_bind_text(sel, 1, username.c_str(), -1, SQLITE_TRANSIENT); + if (sqlite3_step(sel) == SQLITE_ROW) { + id = sqlite3_column_int64(sel, 0); + if (const unsigned char* nm = sqlite3_column_text(sel, 1)) + display = reinterpret_cast(nm); + } + sqlite3_finalize(sel); } else { - Logger::Log("db", "[PlayerSessionStore] UpsertUser insert prepare failed: %s\n", sqlite3_errmsg(db)); + Logger::Log("db", "[PlayerSessionStore] UpsertUser select prepare failed: %s\n", sqlite3_errmsg(db)); return 0; } - // Fetch id. - int64_t id = 0; - rc = sqlite3_prepare_v2(db, "SELECT id FROM ga_users WHERE username = ?", -1, &stmt, nullptr); - if (rc == SQLITE_OK && stmt) { - sqlite3_bind_text(stmt, 1, username.c_str(), -1, SQLITE_TRANSIENT); - if (sqlite3_step(stmt) == SQLITE_ROW) - id = sqlite3_column_int64(stmt, 0); - sqlite3_finalize(stmt); - } else { - Logger::Log("db", "[PlayerSessionStore] UpsertUser select prepare failed: %s\n", sqlite3_errmsg(db)); + // First time we've seen this name (in any case): create it with the typed + // capitalization, which becomes the permanent display name. The NOCASE + // unique index (migration v124) is the storage-level race backstop. + if (id == 0) { + sqlite3_stmt* ins = nullptr; + if (sqlite3_prepare_v2(db, "INSERT INTO ga_users (username) VALUES (?)", -1, &ins, nullptr) == SQLITE_OK) { + sqlite3_bind_text(ins, 1, username.c_str(), -1, SQLITE_TRANSIENT); + if (sqlite3_step(ins) == SQLITE_DONE) + id = sqlite3_last_insert_rowid(db); + sqlite3_finalize(ins); + } + if (id == 0) + Logger::Log("db", "[PlayerSessionStore] UpsertUser insert failed: %s\n", sqlite3_errmsg(db)); + display = username; } + if (out_display_name) *out_display_name = display; return id; } @@ -357,7 +375,11 @@ PlayerSessionStore::UserAuth PlayerSessionStore::GetUserAuth(const std::string& sqlite3_stmt* stmt = nullptr; int rc = sqlite3_prepare_v2(db, - "SELECT id, password_verifier, registered_at FROM ga_users WHERE username = ? LIMIT 1", + // Case-insensitive + deterministic: must resolve to the same canonical + // row UpsertUser picks (ORDER BY id ASC), or the verifier could be + // checked against a different row than the session is opened under. + "SELECT id, password_verifier, registered_at FROM ga_users " + "WHERE username = ? COLLATE NOCASE ORDER BY id ASC LIMIT 1", -1, &stmt, nullptr); if (rc != SQLITE_OK || !stmt) { Logger::Log("db", "[PlayerSessionStore] GetUserAuth prepare failed: %s\n", sqlite3_errmsg(db)); diff --git a/src/ControlServer/PlayerSessionStore/PlayerSessionStore.hpp b/src/ControlServer/PlayerSessionStore/PlayerSessionStore.hpp index f64c0ac2..b8cab2d0 100644 --- a/src/ControlServer/PlayerSessionStore/PlayerSessionStore.hpp +++ b/src/ControlServer/PlayerSessionStore/PlayerSessionStore.hpp @@ -94,7 +94,14 @@ class PlayerSessionStore { static std::optional GetByPlayerName(const std::string& player_name); static SessionInfo* GetByGuidPtr(const std::string& guid); - static int64_t UpsertUser(const std::string& username); + // Case-insensitive resolve-or-create. An account already registered in ANY + // capitalization owns the name; this returns that account's id and never + // rewrites the stored username, so the first registration's casing stays the + // display name. `out_display_name` (optional) receives that stored casing — + // callers should use it for display rather than the typed string. A fresh + // name is created with the typed casing as its permanent display name. + static int64_t UpsertUser(const std::string& username, + std::string* out_display_name = nullptr); // Account auth state for the login password check. `exists` is false when // the username has no ga_users row yet (first login). `verifier` is empty diff --git a/src/ControlServer/TcpSession/TcpSession.cpp b/src/ControlServer/TcpSession/TcpSession.cpp index fdff88b0..b6ecba8a 100644 --- a/src/ControlServer/TcpSession/TcpSession.cpp +++ b/src/ControlServer/TcpSession/TcpSession.cpp @@ -1347,8 +1347,13 @@ void TcpSession::handle_packet(const uint8_t* data, size_t length) { // login path) — allow through; there is nothing to verify yet. } - player_name = requested_user_name; - const int64_t resolved_user_id = PlayerSessionStore::UpsertUser(player_name); + // Resolve the account case-insensitively. player_name takes the + // account's stored (first-registered) capitalization so the display + // name is stable no matter how the player typed it this time. + std::string canonical_name; + const int64_t resolved_user_id = + PlayerSessionStore::UpsertUser(requested_user_name, &canonical_name); + player_name = canonical_name.empty() ? requested_user_name : canonical_name; if (store_new_verifier) { PlayerSessionStore::SetUserVerifier(resolved_user_id, new_verifier, diff --git a/src/Database/Database.cpp b/src/Database/Database.cpp index 5850cc48..85658bb5 100644 --- a/src/Database/Database.cpp +++ b/src/Database/Database.cpp @@ -7756,7 +7756,36 @@ void Database::Init() { Logger::Log("db", "v123: DomeCityDefense bot factory spawn table 102 -> 99\n"); } - result = sqlite3_exec(db, "UPDATE version_info SET version = 123", nullptr, nullptr, &err); + if (version < 124) { + // v124: case-insensitive usernames. The login verifier already folds the + // username to lowercase (LoginAuth::LowerAscii, ASCII A-Z only), and + // SQLite COLLATE NOCASE folds the same set — but the account lookups used + // the default BINARY collation, allowing a second login under a differently- + // cased spelling of an existing name to silently create a duplicate row. + // + // Resolution: for any NOCASE duplicate group, the lowest id keeps its + // name (first registered = the original account). Every higher-id + // duplicate (created by the bug) is renamed to <4-digit-number> + // so no account data is lost and the NOCASE unique index can be created + // cleanly. Idempotent: if no duplicates exist every UPDATE matches zero rows. + result = sqlite3_exec(db, + "UPDATE ga_users " + "SET username = username || CAST((ABS(RANDOM()) % 9000 + 1000) AS TEXT) " + "WHERE id NOT IN (SELECT MIN(id) FROM ga_users GROUP BY LOWER(username));", + nullptr, nullptr, &err); + if (result != SQLITE_OK) { Logger::Log("db", "Failed v124 (rename duplicate usernames): %s\n", err); return; } + + // Storage-level backstop: no two usernames may fold to the same string. + // IF NOT EXISTS makes this safe on a fresh DB that never had duplicates. + result = sqlite3_exec(db, + "CREATE UNIQUE INDEX IF NOT EXISTS idx_ga_users_username_nocase " + "ON ga_users(username COLLATE NOCASE);", nullptr, nullptr, &err); + if (result != SQLITE_OK) { Logger::Log("db", "Failed v124 (nocase unique index): %s\n", err); return; } + + Logger::Log("db", "v124: renamed case-duplicate usernames to <####>, added NOCASE username index\n"); + } + + result = sqlite3_exec(db, "UPDATE version_info SET version = 124", nullptr, nullptr, &err); if (result != SQLITE_OK) { Logger::Log("db", "Failed to update version_info: %s\n", err); return;