Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions src/ControlServer/Loadouts/ClassLoadouts.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ static const std::vector<GearSlot> 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
Expand Down Expand Up @@ -138,7 +140,9 @@ static const std::vector<GearSlot> 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
Expand Down Expand Up @@ -169,8 +173,8 @@ static const std::vector<GearSlot> kRobotics = {
// 680 — Assault — Impact Hammer + Assault Crescent Jetpack
static const std::vector<GearSlot> 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
Expand All @@ -183,7 +187,7 @@ static const std::vector<GearSlot> 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
Expand All @@ -196,6 +200,8 @@ static const std::vector<GearSlot> 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
Expand Down Expand Up @@ -247,6 +253,8 @@ static const std::vector<GearSlot> 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
Expand Down
62 changes: 42 additions & 20 deletions src/ControlServer/PlayerSessionStore/PlayerSessionStore.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -318,34 +318,52 @@ std::optional<SessionInfo> 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<std::mutex> 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<const char*>(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;
}

Expand All @@ -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));
Expand Down
9 changes: 8 additions & 1 deletion src/ControlServer/PlayerSessionStore/PlayerSessionStore.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,14 @@ class PlayerSessionStore {
static std::optional<SessionInfo> 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
Expand Down
9 changes: 7 additions & 2 deletions src/ControlServer/TcpSession/TcpSession.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
31 changes: 30 additions & 1 deletion src/Database/Database.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 <username><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 <name><####>, 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;
Expand Down