diff --git a/src/ControlServer/ChatSession/ChatCommand.cpp b/src/ControlServer/ChatSession/ChatCommand.cpp index 2e32679a..a9618a20 100644 --- a/src/ControlServer/ChatSession/ChatCommand.cpp +++ b/src/ControlServer/ChatSession/ChatCommand.cpp @@ -3,13 +3,16 @@ #include #include #include +#include #include #include "lib/nlohmann/json.hpp" #include "src/ControlServer/InstanceRegistry/InstanceRegistry.hpp" #include "src/ControlServer/Logger.hpp" +#include "src/ControlServer/MapGameInfo/MapGameInfo.hpp" #include "src/ControlServer/TcpSession/TcpSession.hpp" +#include "src/ControlServer/TeamService/TeamService.hpp" #include "src/Shared/IpcProtocol.hpp" namespace ChatCommand { @@ -89,14 +92,50 @@ std::optional ParseInt(const std::string& s) { return v; } +// Resolve a token (category number 1-6 OR name, case-insensitive) to an +// index into `cats`. nullopt if it matches neither. +std::optional ResolveCategoryIndex( + const std::string& token, + const std::vector& cats) { + if (auto n = ParseInt(token)) { // numeric -> 1-based type number + for (size_t i = 0; i < cats.size(); ++i) + if (cats[i].number == *n) return i; + return std::nullopt; + } + const std::string want = LowerAscii(token); // else name (case-insensitive) + for (size_t i = 0; i < cats.size(); ++i) + if (LowerAscii(cats[i].name) == want) return i; + return std::nullopt; +} + +// Uniform random index in [0, count). Caller guarantees count >= 1. +size_t PickRandomIndex(size_t count) { + static std::mt19937 rng(std::random_device{}()); + std::uniform_int_distribution dist(0, count - 1); + return dist(rng); +} + +// Send one category's map pool to the requester, one map per line, with a hint +// describing how to launch (a specific number, or 0 for random). +void SendCategoryPool(const MapGameInfo::ChallengeCategory& cat, const ChallengeReply& reply) { + reply(cat.name + " maps:"); + reply(" 0: Random"); + for (const auto& m : cat.maps) + reply(" " + std::to_string(m.number) + ". " + m.display_name); +} + } // namespace ParseResult TryParseChatCommand(const std::string& message_text) { ParseResult out; std::string trimmed = TrimAscii(message_text); - if (trimmed.empty() || trimmed[0] != '-') { - // Not a slash command at all. + // Commands use a leading '-' (the GA chat box appears to swallow '/'-prefixed + // text client-side, which is why the existing commands all use '-'). Accept a + // leading '/' too so /challenge works if the client does forward it; both + // sigils normalise to the same command name below. Unrecognised '-'/'/' text + // still falls through to broadcast as ordinary chat. + if (trimmed.empty() || (trimmed[0] != '-' && trimmed[0] != '/')) { return out; } @@ -134,6 +173,40 @@ ParseResult TryParseChatCommand(const std::string& message_text) { return out; } + if (cmd_name == "-challenge" || cmd_name == "/challenge") { + // -challenge -> usage + list of map TYPES + // -challenge maps | list -> same + // -challenge -> list that type's map pool + // -challenge 0 [side] -> random map from the pool + // -challenge [side]-> a specific map in the pool + // is a category number (1-6) or name. (optional, last) + // is attack[ers]/a or defend[ers]/d; default attackers. The map pool, + // categories and vanity names all come from cs_challenge_catalog. + out.recognized = true; + out.suppress_broadcast = true; + + std::vector tokens = SplitWs(rest); + if (tokens.size() < 2 || + LowerAscii(tokens[0]) == "maps" || LowerAscii(tokens[0]) == "list") { + out.challenge_list = true; // bare / keyword / missing -> show types + return out; + } + + ChallengeArgs args; + args.target_name = tokens[0]; + args.type_token = tokens[1]; + args.map_token = (tokens.size() >= 3) ? tokens[2] : ""; // "" -> list pool + args.challenger_tf = 1; // default = attackers + if (tokens.size() >= 4) { + const std::string side = LowerAscii(tokens[3]); + if (side == "defenders" || side == "defender" || side == "defend" || side == "d") + args.challenger_tf = 2; + // attackers/attacker/attack/a (or any other token) -> default tf 1 + } + out.challenge = args; + return out; + } + if (cmd_name == "-spawnfriend" || cmd_name == "-spawnenemy" || cmd_name == "-spawnhenchman") { // -spawnfriend [low|medium|high|max|umax] @@ -386,4 +459,140 @@ void DispatchTopDown(const TopDownArgs& args, const std::string& session_guid) { } } +namespace { ChallengeLauncher g_challenge_launcher; } + +void SetChallengeLauncher(ChallengeLauncher launcher) { + g_challenge_launcher = std::move(launcher); +} + +void SendChallengeMapList(const ChallengeReply& reply) { + reply("Usage: -challenge "); + + auto cats = MapGameInfo::GetChallengeCategories(); + if (cats.empty()) return; + reply("Types:"); + for (const auto& cat : cats) { + reply(" " + std::to_string(cat.number) + ". " + cat.name + + " (" + std::to_string(cat.maps.size()) + " maps)"); + } +} + +void DispatchChallenge(const ChallengeArgs& args, const std::string& challenger_guid, + const ChallengeReply& reply) { + Logger::Log("challenge", + "[Challenge] DispatchChallenge target='%s' type='%s' map='%s' challenger_tf=%d challenger_guid=%s\n", + args.target_name.c_str(), args.type_token.c_str(), args.map_token.c_str(), + args.challenger_tf, challenger_guid.c_str()); + + if (challenger_guid.empty()) { + Logger::Log("challenge", "[Challenge] dropped: empty challenger session_guid\n"); + return; + } + + // Resolve first so a bad type can hint the type list. + auto cats = MapGameInfo::GetChallengeCategories(); + auto ci = ResolveCategoryIndex(args.type_token, cats); + if (!ci) { + reply("Unknown type '" + args.type_token + "'. Type -challenge to list types."); + Logger::Log("challenge", + "[Challenge] guid=%s type='%s' outcome=ignored details=unknown_type\n", + challenger_guid.c_str(), args.type_token.c_str()); + return; + } + const MapGameInfo::ChallengeCategory& cat = cats[*ci]; + + // No -> just show this type's pool (informational; no player needed). + if (args.map_token.empty()) { + SendCategoryPool(cat, reply); + return; + } + + // Resolve : "0" -> random map from the pool, N -> the Nth map. + const MapGameInfo::ChallengeMapEntry* picked = nullptr; + if (auto n = ParseInt(args.map_token)) { + if (*n == 0) { + if (!cat.maps.empty()) + picked = &cat.maps[PickRandomIndex(cat.maps.size())]; + } else if (*n >= 1 && static_cast(*n) <= cat.maps.size()) { + picked = &cat.maps[*n - 1]; + } + } + if (!picked) { + reply("Pick a map number (0 = random):"); + SendCategoryPool(cat, reply); + Logger::Log("challenge", + "[Challenge] guid=%s type='%s' map='%s' outcome=ignored details=bad_map_number\n", + challenger_guid.c_str(), cat.name.c_str(), args.map_token.c_str()); + return; + } + + // Resolve to an online session (case-insensitive name match over + // players currently inside any running instance — incl. the home map). + const std::string want = LowerAscii(args.target_name); + std::string target_guid; + for (const auto& row : InstanceRegistry::GetActiveSearchablePlayers()) { + if (LowerAscii(row.player_name) == want) { target_guid = row.session_guid; break; } + } + if (target_guid.empty()) { + reply("Player '" + args.target_name + "' is not online."); + Logger::Log("challenge", + "[Challenge] guid=%s target='%s' outcome=ignored details=target_not_online\n", + challenger_guid.c_str(), args.target_name.c_str()); + return; + } + if (target_guid == challenger_guid) { + reply("You cannot challenge yourself."); + Logger::Log("challenge", + "[Challenge] guid=%s outcome=ignored details=self_challenge\n", challenger_guid.c_str()); + return; + } + + // Pull WHOLE TEAMS: the challenger's team takes their chosen side, the + // target's team the opposite. Solo players resolve to a single-guid list. + std::vector challenger_team = TeamService::GetTeamMemberGuids(challenger_guid); + std::vector target_team = TeamService::GetTeamMemberGuids(target_guid); + + // Same-team guard: can't challenge a teammate (would seat a player on both + // sides). self_challenge is handled above; a DIFFERENT teammate resolving + // into the challenger's roster is rejected here. + for (const auto& g : challenger_team) { + if (g == target_guid) { + reply("You cannot challenge a teammate."); + Logger::Log("challenge", + "[Challenge] guid=%s target='%s' outcome=ignored details=same_team\n", + challenger_guid.c_str(), args.target_name.c_str()); + return; + } + } + + if (!g_challenge_launcher) { + Logger::Log("challenge", + "[Challenge] guid=%s outcome=ignored details=no_launcher_registered\n", + challenger_guid.c_str()); + return; + } + + Logger::Log("challenge", + "[Challenge] resolved challenger_team=%zu target='%s' target_team=%zu type=%s map=%s vanity='%s' mode=%s -- invoking launcher\n", + challenger_team.size(), args.target_name.c_str(), target_team.size(), + cat.name.c_str(), picked->map_name.c_str(), picked->display_name.c_str(), + picked->game_class.c_str()); + + const bool ok = g_challenge_launcher( + challenger_team, args.challenger_tf, target_team, picked->map_name, picked->game_class); + + const char* side = (args.challenger_tf == 2) ? "defenders" : "attackers"; + if (ok) { + reply("Challenge sent to " + args.target_name + " on " + picked->display_name + + " (you: " + side + "). Waiting for them to accept..."); + } else { + reply("Could not start the challenge match. Try again."); + } + Logger::Log("challenge", + "[Challenge] guid=%s target='%s' map=%s challenger_tf=%d challengers=%zu targets=%zu outcome=%s\n", + challenger_guid.c_str(), args.target_name.c_str(), picked->map_name.c_str(), + args.challenger_tf, challenger_team.size(), target_team.size(), + ok ? "instance_spawning" : "spawn_failed"); +} + } // namespace ChatCommand diff --git a/src/ControlServer/ChatSession/ChatCommand.hpp b/src/ControlServer/ChatSession/ChatCommand.hpp index e35adc40..6f2dd047 100644 --- a/src/ControlServer/ChatSession/ChatCommand.hpp +++ b/src/ControlServer/ChatSession/ChatCommand.hpp @@ -1,8 +1,10 @@ #pragma once #include +#include #include #include +#include namespace ChatCommand { @@ -47,6 +49,19 @@ struct TopDownArgs { float lift_z = 0.0f; }; +// -challenge [map#] [side]: challenge another online player (and +// their whole team) to a private match. is a category number (1-6) or +// name (Arena, Scramble, …). is empty (→ list that type's pool), "0" +// (→ random map from the pool), or the 1-based map number within the pool. +// is optional and defaults to attackers. Everything is resolved against +// the cs_challenge_catalog at dispatch time. +struct ChallengeArgs { + std::string target_name; // + std::string type_token; // — category number (1-6) or name + std::string map_token; // — "" = list pool, "0" = random, N = specific + int challenger_tf = 1; // challenger's side; 1=attackers (default), 2=defenders +}; + struct ParseResult { // True if the message was a /-prefixed slash command attempt that we own // (currently: "-changeteam", "-spawnfriend", "-spawnenemy", "-possess", @@ -63,6 +78,11 @@ struct ParseResult { std::optional spawn_target; std::optional deploy_target; std::optional topdown; + std::optional challenge; + + // -challenge with no usable (bare, or "maps"/"list"): reply + // to the requester with the numbered map list instead of launching. + bool challenge_list = false; // No-arg toggles. Flag is set when recognized + parsed cleanly. bool possess = false; @@ -119,4 +139,32 @@ void DispatchCoords(const std::string& session_guid); // invocations alternate enter/restore. lift_z=0 means "use the DLL default". void DispatchTopDown(const TopDownArgs& args, const std::string& session_guid); +// Launcher injected by main.cpp (which owns ControlServerConfig). Spawns the +// private challenge instance and registers the PendingMatch so the existing +// INSTANCE_READY → MATCH_INVITATION flow invites every player. Returns true if +// the instance was spawned. Whole teams are pulled: the challenger's team takes +// `challenger_tf`, the target's team the opposite side. Each side is the set of +// online team-member guids (or a single guid for a solo player). +using ChallengeLauncher = std::function& challenger_team, int challenger_tf, + const std::vector& target_team, + const std::string& map_name, const std::string& game_mode)>; +void SetChallengeLauncher(ChallengeLauncher launcher); + +// Sink for sending a single line of private chat back to the requesting player. +// ChatSession supplies one that delivers to just that player's chat. +using ChallengeReply = std::function; + +// Handle -challenge: resolve to a catalog category, then either list that +// category's pool (no ), pick a random map ("0"), or select a specific map +// (N). For an actual launch, resolve to an online session and invoke +// the launcher for both whole teams. `reply` reports the pool / errors / +// confirmation to the requester. +void DispatchChallenge(const ChallengeArgs& args, const std::string& challenger_guid, + const ChallengeReply& reply); + +// Send the -challenge usage hint and the list of map TYPES (one per line) to the +// requester via `reply`. Backs bare `-challenge` (and `-challenge maps`/`list`). +void SendChallengeMapList(const ChallengeReply& reply); + } // namespace ChatCommand diff --git a/src/ControlServer/ChatSession/ChatSession.cpp b/src/ControlServer/ChatSession/ChatSession.cpp index 91b6ae01..d6a52833 100644 --- a/src/ControlServer/ChatSession/ChatSession.cpp +++ b/src/ControlServer/ChatSession/ChatSession.cpp @@ -114,6 +114,7 @@ std::vector BuildChatFrame(uint32_t channel, const std::string& message return chunk; } + // Broadcast a system message (e.g. join/leave announcement) to every active // chat session. Doesn't read or modify any per-session state besides // write_queue_ via deliver(). `exclude` skips one specific session (used for @@ -152,6 +153,8 @@ bool HasParsedCommandAction(const ChatCommand::ParseResult& parsed) { || parsed.spawn_target.has_value() || parsed.deploy_target.has_value() || parsed.topdown.has_value() + || parsed.challenge.has_value() + || parsed.challenge_list || parsed.possess || parsed.unpossess || parsed.reload_queues; @@ -378,6 +381,19 @@ void ChatSession::handle_packet(const uint8_t* data, size_t length) { if (parsed.recognized && parsed.topdown) { ChatCommand::DispatchTopDown(*parsed.topdown, session_guid_); } + if (parsed.recognized && (parsed.challenge || parsed.challenge_list)) { + // Reply sink: deliver a private chat line to just this requester. + // Echo the incoming channel so the response appears in the right tab. + const uint32_t reply_ch = pkt.Read4B(GA_T::CHAT_CH_TYPE).value_or(4); + auto reply = [this, reply_ch](const std::string& line) { + deliver(BuildChatFrame(reply_ch, line)); + }; + if (parsed.challenge_list) { + ChatCommand::SendChallengeMapList(reply); + } else { + ChatCommand::DispatchChallenge(*parsed.challenge, session_guid_, reply); + } + } if (parsed.recognized && parsed.reload_queues) { Logger::Log("chat-command", "[ChatCmd] -reload-queues player='%s' guid=%s outcome=activated details=MatchmakingService::ReloadQueues\n", diff --git a/src/ControlServer/Database/Database.cpp b/src/ControlServer/Database/Database.cpp index 09c3a2a6..acbe2e88 100644 --- a/src/ControlServer/Database/Database.cpp +++ b/src/ControlServer/Database/Database.cpp @@ -716,6 +716,86 @@ void Database::Init() { if (err) { sqlite3_free(err); err = nullptr; } } + // cs_challenge_catalog — curated -challenge map catalog. Lives entirely on + // the control server (the game DLL never reads it), so it's created here + // rather than in the game-DLL migration ladder. PvP maps are grouped into + // six player-facing categories (Arena, Scramble, Payload, Control, + // Acquisition, AvA) with a fixed in-category order and vanity names. + // game_class is NOT stored — it stays in map_game_info (source of truth) + // and is JOINed at read time by MapGameInfo::GetChallengeCategories. + // + // The seed uses INSERT OR IGNORE so it's safe to run every boot AND so + // live re-curation survives: renaming a vanity_name or reordering + // map_pos/category via SQL keeps the row (same map_name PK), and the + // boot-time seed never overwrites it. New baseline maps added to the seed + // below get inserted; existing rows are left untouched. + result = sqlite3_exec(db, + "CREATE TABLE IF NOT EXISTS cs_challenge_catalog (" + " map_name TEXT PRIMARY KEY," + " category TEXT NOT NULL," + " category_pos INTEGER NOT NULL," + " map_pos INTEGER NOT NULL," + " vanity_name TEXT NOT NULL);", + nullptr, nullptr, &err); + if (result != SQLITE_OK) { + Logger::Log("db", "Failed to create cs_challenge_catalog: %s\n", err); + sqlite3_free(err); err = nullptr; + } + result = sqlite3_exec(db, + "INSERT OR IGNORE INTO cs_challenge_catalog " + "(map_name, category, category_pos, map_pos, vanity_name) VALUES " + // Arena + "('Ticket_HimLab_4v4','Arena',1,1,'HM-44')," + "('Ticket_Silo_4v4_P','Arena',1,2,'Silo')," + "('Ticket_Osprey_4v4_P','Arena',1,3,'Osprey')," + // Scramble + "('Rot_Redistribution05','Scramble',2,1,'Tetra Pier')," + "('Rot_Redistribution03','Scramble',2,2,'Stockpile')," + "('Rot_Redistribution04','Scramble',2,3,'Redistribution')," + "('Rot_Trafalgar_P','Scramble',2,4,'CNS Trafalgar')," + "('Rot_BlackwaterLoch_P','Scramble',2,5,'Blackwater Loch')," + // Payload + "('Push_IceFloe_P','Payload',3,1,'Ice Floe')," + "('Push_IceFloe3_P','Payload',3,2,'Tundra')," + "('push_Ravine_P','Payload',3,3,'Ravine')," + "('Push_Toxicity','Payload',3,4,'Toxicity')," + "('Push_Dust_P','Payload',3,5,'Haulin'' Acid')," + // Control + "('Ticket_Datafarm_P','Control',4,1,'Data Farm')," + "('SeaSide_Ticket_P','Control',4,2,'Seaside')," + "('SeaSide_Ticket2_P','Control',4,3,'Azores Complex')," + "('SeaSide_Ticket3','Control',4,4,'Brine Complex')," + "('Ticket_Datafarm3','Control',4,5,'Harvest')," + "('Ticket_Datafarm2','Control',4,6,'Sun Spot')," + "('Ticket_Volcano_P','Control',4,7,'Magmarock')," + // Acquisition + "('CTR_DuelStrike_P','Acquisition',5,1,'Hart Station')," + "('CTR_DuelStrike3_P','Acquisition',5,2,'Kimerial Point')," + "('CTR_DuelStrike2_P','Acquisition',5,3,'The Crossroads')," + // AvA + "('HEX_AVA_Push_Factory1_P','AvA',6,1,'AvA 1')," + "('HEX_AVA_Push_Lab1_P','AvA',6,2,'AvA 2')," + "('HEX_AVA_Plant_P','AvA',6,3,'AvA 3')," + "('HEX_AVA_Plant2_P','AvA',6,4,'AvA 4')," + "('HEX_AVA_Plant3_P','AvA',6,5,'AvA 5')," + "('HEX_AVA_2pt_Theft_Lab1','AvA',6,6,'Theft Lab')," + "('HEX_AVA_2pt_Theft_Factory1_P','AvA',6,7,'Theft Factory')," + "('HEX_AVA_Factory1_P','AvA',6,8,'Factory 1')," + "('HEX_AVA_Factory2_P','AvA',6,9,'Factory 2')," + "('HEX_AVA_Factory3_P','AvA',6,10,'Factory 3')," + "('HEX_AVA_Lab1_P','AvA',6,11,'Lab 1')," + "('HEX_AVA_Lab2_P','AvA',6,12,'Lab 2')," + "('HEX_AVA_Lab3_P','AvA',6,13,'Lab 3')," + "('HEX_AVA_Missile1_P','AvA',6,14,'Missile')," + "('HEX_AVA_Ticket_Neutral','AvA',6,15,'Neutral')," + "('HEX_AVA_Defense1_P','AvA',6,16,'Tech Defence 1')," + "('HEX_AVA_Defense2_P','AvA',6,17,'Tech Defence 2');", + nullptr, nullptr, &err); + if (result != SQLITE_OK) { + Logger::Log("db", "Failed to seed cs_challenge_catalog: %s\n", err); + sqlite3_free(err); err = nullptr; + } + // Continuous-queue support: instances optionally carry the queue_id // they were spawned for, can point at a predecessor (DRAFTING state = // READY-but-waiting for predecessor's BeginEndMission), and stamp the @@ -1754,6 +1834,112 @@ void Database::Init() { if (result != SQLITE_OK) { sqlite3_free(err); err = nullptr; } } + // AvA challenge maps — seed HEX_AVA maps into map_game_info so they appear + // in the -challenge list. Guarded by marker; easy to revert by deleting the + // marker row + the 4 map_game_info rows (map_game_id 100010..100013). + { + sqlite3_stmt* probe = nullptr; + bool already = false; + if (sqlite3_prepare_v2(db, + "SELECT 1 FROM cs_migration_markers WHERE name='ava_maps_map_game_info_2026_06_25'", + -1, &probe, nullptr) == SQLITE_OK) { + already = sqlite3_step(probe) == SQLITE_ROW; + sqlite3_finalize(probe); + } + if (!already) { + // gameplay_type_value_id 1547 = Escort (same as all Push/Payload maps). + // friendly_name_msg_id: + // 31231 -> "10v10 HEX Lab" (push-lab) + // 31840 -> "Hex Factory" (push-factory) + // 43906 -> "* HEX LABS STEAL" (theft-lab) + // 43907 -> "* HEX FACTORY STEAL"(theft-factory) + result = sqlite3_exec(db, + "INSERT OR IGNORE INTO map_game_info" + " (map_game_id, map_name, game_class, gameplay_type_value_id, friendly_name_msg_id, entry_background_image_res_id)" + " VALUES" + " (100010, 'HEX_AVA_Push_Lab1_P', 'TgGame.TgGame_Escort', 1547, 31231, 0)," + " (100011, 'HEX_AVA_Push_Factory1_P', 'TgGame.TgGame_Escort', 1547, 31840, 0)," + " (100012, 'HEX_AVA_2pt_Theft_Lab1', 'TgGame.TgGame_Escort', 1547, 43906, 0)," + " (100013, 'HEX_AVA_2pt_Theft_Factory1_P','TgGame.TgGame_Escort', 1547, 43907, 0);", + nullptr, nullptr, &err); + if (result != SQLITE_OK) { + Logger::Log("db", "Failed ava_maps_map_game_info seed: %s\n", err); + sqlite3_free(err); err = nullptr; + } else { + sqlite3_exec(db, + "INSERT OR IGNORE INTO cs_migration_markers (name)" + " VALUES ('ava_maps_map_game_info_2026_06_25');", + nullptr, nullptr, &err); + sqlite3_free(err); err = nullptr; + Logger::Log("db", "ava_maps_map_game_info_2026_06_25: seeded 4 HEX_AVA rows into map_game_info\n"); + } + } + } + + // AvA challenge maps wave 2 — remaining HEX_AVA map packages visible on the + // client filesystem. map_game_ids 100020..100032. Easy rollback: + // DELETE FROM map_game_info WHERE map_game_id BETWEEN 100020 AND 100032; + // DELETE FROM cs_migration_markers WHERE name='ava_maps_wave2_2026_06_26'; + { + sqlite3_stmt* probe = nullptr; + bool already = false; + if (sqlite3_prepare_v2(db, + "SELECT 1 FROM cs_migration_markers WHERE name='ava_maps_wave2_2026_06_26'", + -1, &probe, nullptr) == SQLITE_OK) { + already = sqlite3_step(probe) == SQLITE_ROW; + sqlite3_finalize(probe); + } + if (!already) { + // gameplay_type_value_id: + // 1550 = TgGame_Defense (same as Raid_DomeCityDefense_P) + // 1548 = TgGame_PointRotation (Scramble / 3-point) + // 1547 = TgGame_Escort (Payload) + // 1545 = TgGame_Ticket (Control) + // friendly_name_msg_ids from asm_data_set_msg_translations: + // 43934=HEX_AVA_Defense1, 43933=HEX_AVA_Defense2 + // 38247=HEX_AVA_Factory1, 39033=HEX_AVA_Factory2, 39135=HEX_AVA_Factory3 + // 39936=HEX_AVA_Lab1, 37965=HEX_AVA_Lab2, 38116=HEX_AVA_Lab3 + // 41220=HEX_AVA_Missile1 + // 39935=HEX_AVA_Plant1, 39908=HEX_AVA_Plant2, 39929=HEX_AVA_Plant3 + // 42184=HEX_AVA_Neutral_Ticket + result = sqlite3_exec(db, + "INSERT OR IGNORE INTO map_game_info" + " (map_game_id, map_name, game_class, gameplay_type_value_id, friendly_name_msg_id, entry_background_image_res_id)" + " VALUES" + // Defense (base raid) + " (100020, 'HEX_AVA_Defense1_P', 'TgGame.TgGame_Defense', 1550, 43934, 0)," + " (100021, 'HEX_AVA_Defense2_P', 'TgGame.TgGame_Defense', 1550, 43933, 0)," + // Factory 3-point capture (Scramble) + " (100022, 'HEX_AVA_Factory1_P', 'TgGame.TgGame_PointRotation', 1548, 38247, 0)," + " (100023, 'HEX_AVA_Factory2_P', 'TgGame.TgGame_PointRotation', 1548, 39033, 0)," + " (100024, 'HEX_AVA_Factory3_P', 'TgGame.TgGame_PointRotation', 1548, 39135, 0)," + // Lab 3-point capture (Scramble) + " (100025, 'HEX_AVA_Lab1_P', 'TgGame.TgGame_PointRotation', 1548, 39936, 0)," + " (100026, 'HEX_AVA_Lab2_P', 'TgGame.TgGame_PointRotation', 1548, 37965, 0)," + " (100027, 'HEX_AVA_Lab3_P', 'TgGame.TgGame_PointRotation', 1548, 38116, 0)," + // Missile escort + " (100028, 'HEX_AVA_Missile1_P', 'TgGame.TgGame_Escort', 1547, 41220, 0)," + // Plant base defense + " (100029, 'HEX_AVA_Plant_P', 'TgGame.TgGame_Defense', 1550, 39935, 0)," + " (100030, 'HEX_AVA_Plant2_P', 'TgGame.TgGame_Defense', 1550, 39908, 0)," + " (100031, 'HEX_AVA_Plant3_P', 'TgGame.TgGame_Defense', 1550, 39929, 0)," + // Ticket/Control neutral + " (100032, 'HEX_AVA_Ticket_Neutral', 'TgGame.TgGame_Ticket', 1545, 42184, 0);", + nullptr, nullptr, &err); + if (result != SQLITE_OK) { + Logger::Log("db", "Failed ava_maps_wave2 seed: %s\n", err); + sqlite3_free(err); err = nullptr; + } else { + sqlite3_exec(db, + "INSERT OR IGNORE INTO cs_migration_markers (name)" + " VALUES ('ava_maps_wave2_2026_06_26');", + nullptr, nullptr, &err); + sqlite3_free(err); err = nullptr; + Logger::Log("db", "ava_maps_wave2_2026_06_26: seeded 13 HEX_AVA rows into map_game_info\n"); + } + } + } + // NOTE: PlayerSessionStore::Init() is called separately from main.cpp -- not here. Logger::Log("db", "[Database::Init] Schema at version >= 19, WAL mode enabled\n"); } @@ -2155,7 +2341,9 @@ int64_t Database::FindUserIdByUsername(const std::string& username) { sqlite3* db = GetConnection(); sqlite3_stmt* stmt = nullptr; int rc = sqlite3_prepare_v2(db, - "SELECT id FROM ga_users WHERE username = ? LIMIT 1", + // Case-insensitive: admin actions (ban/unban/pvp/reset) target the same + // canonical account regardless of the capitalization typed. + "SELECT id FROM ga_users WHERE username = ? COLLATE NOCASE ORDER BY id ASC LIMIT 1", -1, &stmt, nullptr); if (rc != SQLITE_OK || !stmt) { Logger::Log("db", "[User] FindUserIdByUsername prepare failed: %s\n", sqlite3_errmsg(db)); 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/MapGameInfo/MapGameInfo.cpp b/src/ControlServer/MapGameInfo/MapGameInfo.cpp index 4c151d0a..6bf664dd 100644 --- a/src/ControlServer/MapGameInfo/MapGameInfo.cpp +++ b/src/ControlServer/MapGameInfo/MapGameInfo.cpp @@ -44,10 +44,12 @@ void MapGameInfo::Init() { sqlite3_stmt* stmt = nullptr; const char* kSql = - "SELECT map_game_id, COALESCE(map_name, ''), COALESCE(game_class, ''), " - " COALESCE(gameplay_type_value_id, 0), friendly_name_msg_id, " - " COALESCE(entry_background_image_res_id, 0) " - "FROM map_game_info"; + "SELECT m.map_game_id, COALESCE(m.map_name, ''), COALESCE(m.game_class, ''), " + " COALESCE(m.gameplay_type_value_id, 0), m.friendly_name_msg_id, " + " COALESCE(m.entry_background_image_res_id, 0), " + " COALESCE(NULLIF(t.message, ''), m.map_name) " + "FROM map_game_info m " + "LEFT JOIN asm_data_set_msg_translations t ON t.msg_id = m.friendly_name_msg_id"; if (sqlite3_prepare_v2(db, kSql, -1, &stmt, nullptr) != SQLITE_OK) { // Table may not exist yet (game-server v51 hasn't run). Treat as // empty registry — every lookup falls back to caller defaults. @@ -66,6 +68,7 @@ void MapGameInfo::Init() { r.gameplay_type_value_id = static_cast(sqlite3_column_int(stmt, 3)); r.friendly_name_msg_id = static_cast(sqlite3_column_int(stmt, 4)); r.entry_background_image_res_id = static_cast(sqlite3_column_int(stmt, 5)); + r.friendly_name = SafeText(stmt, 6); if (r.map_name.empty()) { // Rows whose map_name is still NULL can't be reverse-resolved by @@ -91,3 +94,66 @@ std::optional MapGameInfo::LookupByName(const std::string& map_na if (it == g_byMapName.end()) return std::nullopt; return it->second; } + +std::vector MapGameInfo::GetChallengeCategories() { + std::vector out; + + sqlite3* db = Database::GetConnection(); + if (!db) return out; + + sqlite3_stmt* stmt = nullptr; + // Curated catalog. game_class is resolved from map_game_info (source of + // truth) via a correlated subquery — NOCASE because some map_names lowercase + // the leading word (e.g. push_Ravine_P). Catalog rows are already grouped by + // category and ordered, so we just walk them and start a new category each + // time category_pos changes. + const char* kSql = + "SELECT c.category, c.category_pos, c.vanity_name, c.map_name, " + " (SELECT m.game_class FROM map_game_info m " + " WHERE m.map_name = c.map_name COLLATE NOCASE " + " AND m.game_class IS NOT NULL AND m.game_class <> '' LIMIT 1) AS game_class " + "FROM cs_challenge_catalog c " + "ORDER BY c.category_pos, c.map_pos"; + if (sqlite3_prepare_v2(db, kSql, -1, &stmt, nullptr) != SQLITE_OK) { + // Table may not exist yet (game-server v125 hasn't run). Empty catalog — + // -challenge will just show no types until the migration runs. + Logger::Log("config", "MapGameInfo::GetChallengeCategories — prepare failed (%s)\n", + sqlite3_errmsg(db)); + return out; + } + + int cur_pos = -1; + while (sqlite3_step(stmt) == SQLITE_ROW) { + const std::string category = SafeText(stmt, 0); + const int categoryPos = sqlite3_column_int(stmt, 1); + const std::string vanity = SafeText(stmt, 2); + const std::string map_name = SafeText(stmt, 3); + const std::string game_class = SafeText(stmt, 4); + + if (game_class.empty()) { + // No map_game_info row → no game_mode to launch. Drop it rather than + // risk spawning a broken instance. + Logger::Log("config", + "MapGameInfo::GetChallengeCategories — '%s' has no map_game_info game_class; skipped\n", + map_name.c_str()); + continue; + } + + if (categoryPos != cur_pos) { + ChallengeCategory cat; + cat.number = categoryPos; + cat.name = category; + out.push_back(std::move(cat)); + cur_pos = categoryPos; + } + + ChallengeMapEntry e; + e.number = static_cast(out.back().maps.size()) + 1; + e.map_name = map_name; + e.game_class = game_class; + e.display_name = vanity; + out.back().maps.push_back(std::move(e)); + } + sqlite3_finalize(stmt); + return out; +} diff --git a/src/ControlServer/MapGameInfo/MapGameInfo.hpp b/src/ControlServer/MapGameInfo/MapGameInfo.hpp index b0fa5f23..6e0d47c6 100644 --- a/src/ControlServer/MapGameInfo/MapGameInfo.hpp +++ b/src/ControlServer/MapGameInfo/MapGameInfo.hpp @@ -2,6 +2,7 @@ #include #include #include +#include // Process-wide read-only registry of map_game_info rows, populated once at // startup. Each record carries the four fields GSC_GO_PLAY needs (plus the @@ -22,6 +23,10 @@ struct MapGameRecord { uint32_t gameplay_type_value_id; uint32_t friendly_name_msg_id; uint32_t entry_background_image_res_id; + // Loading-screen vanity name resolved from friendly_name_msg_id via + // asm_data_set_msg_translations (e.g. "CONTROL: Seaside"). Falls back to + // map_name when there's no translation. + std::string friendly_name; }; class MapGameInfo { @@ -33,4 +38,28 @@ class MapGameInfo { // Returns the record for the given .upk filename if one exists. // Case-insensitive match against map_name. static std::optional LookupByName(const std::string& map_name); + + // One challenge-playable map (PvP). `number` is the 1-based position WITHIN + // its category — exactly what a player types as `` in -challenge. + struct ChallengeMapEntry { + int number = 0; + std::string map_name; + std::string game_class; // = the instance game_mode + std::string display_name; // curated vanity name (cs_challenge_catalog) + }; + + // A player-facing -challenge category (Arena, Scramble, …). `number` is the + // 1-based type number (category_pos) the player types as ``. + struct ChallengeCategory { + int number = 0; + std::string name; // "Arena", "Scramble", … + std::vector maps; // ordered by map_pos; display = vanity + }; + + // The curated -challenge catalog from cs_challenge_catalog (seeded by + // game-server migration v125), joined to map_game_info for game_class (the + // source of truth). Categories are returned in category_pos order; maps + // within each in map_pos order. A catalog row whose map_name has no + // map_game_info match is skipped (logged) so it can never spawn a bad mode. + static std::vector GetChallengeCategories(); }; diff --git a/src/ControlServer/PlayerSessionStore/PlayerSessionStore.cpp b/src/ControlServer/PlayerSessionStore/PlayerSessionStore.cpp index daf523b9..4e8bc6d5 100644 --- a/src/ControlServer/PlayerSessionStore/PlayerSessionStore.cpp +++ b/src/ControlServer/PlayerSessionStore/PlayerSessionStore.cpp @@ -453,6 +453,74 @@ int64_t PlayerSessionStore::InsertCharacter(int64_t user_id, uint32_t profile_id return newId; } +bool PlayerSessionStore::DeleteCharacter(int64_t character_id, int64_t user_id) { + std::lock_guard lock(mutex_); + sqlite3* db = Database::GetConnection(); + + // Ownership gate: only delete a character that belongs to this user. + { + sqlite3_stmt* own = nullptr; + if (sqlite3_prepare_v2(db, + "SELECT 1 FROM ga_characters WHERE id = ? AND user_id = ?", + -1, &own, nullptr) != SQLITE_OK) { + Logger::Log("db", "[PlayerSessionStore] DeleteCharacter own-check prepare failed: %s\n", + sqlite3_errmsg(db)); + return false; + } + sqlite3_bind_int64(own, 1, character_id); + sqlite3_bind_int64(own, 2, user_id); + const bool owned = (sqlite3_step(own) == SQLITE_ROW); + sqlite3_finalize(own); + if (!owned) { + Logger::Log("db", + "[PlayerSessionStore] DeleteCharacter refused: char=%lld not owned by user=%lld\n", + (long long)character_id, (long long)user_id); + return false; + } + } + + // Delete children before parent (correct whether or not FK enforcement is + // on). Inventory pool is account-scoped (user_id) and intentionally left + // alone. Match/stats history rows are kept for historical integrity. + sqlite3_exec(db, "BEGIN IMMEDIATE", nullptr, nullptr, nullptr); + static const char* const kChildDeletes[] = { + "DELETE FROM ga_character_devices WHERE character_id = ?", + "DELETE FROM ga_character_skills WHERE character_id = ?", + "DELETE FROM ga_character_quests WHERE character_id = ?", + }; + for (const char* sql : kChildDeletes) { + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) == SQLITE_OK) { + sqlite3_bind_int64(st, 1, character_id); + sqlite3_step(st); + sqlite3_finalize(st); + } else { + // A missing optional table shouldn't abort the delete. + Logger::Log("db", "[PlayerSessionStore] DeleteCharacter child delete skipped (%s): %s\n", + sql, sqlite3_errmsg(db)); + } + } + + bool removed = false; + { + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, + "DELETE FROM ga_characters WHERE id = ? AND user_id = ?", + -1, &st, nullptr) == SQLITE_OK) { + sqlite3_bind_int64(st, 1, character_id); + sqlite3_bind_int64(st, 2, user_id); + sqlite3_step(st); + sqlite3_finalize(st); + removed = (sqlite3_changes(db) > 0); + } + } + sqlite3_exec(db, removed ? "COMMIT" : "ROLLBACK", nullptr, nullptr, nullptr); + + Logger::Log("db", "[PlayerSessionStore] DeleteCharacter char=%lld user=%lld removed=%d\n", + (long long)character_id, (long long)user_id, (int)removed); + return removed; +} + std::vector PlayerSessionStore::GetCharactersByUserId(int64_t user_id) { std::lock_guard lock(mutex_); sqlite3* db = Database::GetConnection(); diff --git a/src/ControlServer/PlayerSessionStore/PlayerSessionStore.hpp b/src/ControlServer/PlayerSessionStore/PlayerSessionStore.hpp index f64c0ac2..05e16969 100644 --- a/src/ControlServer/PlayerSessionStore/PlayerSessionStore.hpp +++ b/src/ControlServer/PlayerSessionStore/PlayerSessionStore.hpp @@ -122,6 +122,11 @@ class PlayerSessionStore { uint32_t eye_mat_param_id = 0); static std::vector GetCharactersByUserId(int64_t user_id); static std::optional GetCharacterById(int64_t id); + // Deletes a character and its per-character rows (devices, skills, quests). + // Guarded by user_id so a client can only delete its own characters; the + // account-scoped ga_players_inventory pool is NOT touched (shared across + // characters). Returns true if a ga_characters row was actually removed. + static bool DeleteCharacter(int64_t character_id, int64_t user_id); static void SetSelectedCharacter(const std::string& guid, int64_t char_id, uint32_t profile_id); // Walk every profile's ClassLoadouts entry and INSERT any (user_id, diff --git a/src/ControlServer/TcpSession/TcpSession.cpp b/src/ControlServer/TcpSession/TcpSession.cpp index fdff88b0..6d71ab66 100644 --- a/src/ControlServer/TcpSession/TcpSession.cpp +++ b/src/ControlServer/TcpSession/TcpSession.cpp @@ -1641,10 +1641,28 @@ void TcpSession::handle_packet(const uint8_t* data, size_t length) { wait_for_home_map_then_register(120); // 120 second timeout break; } - case GA_U::DELETE_CHARACTER: + case GA_U::DELETE_CHARACTER: { Logger::Log("tcp", "[%s] Received: DELETE_CHARACTER [0x%04X], item count: %d\n", Logger::GetTime(), packet_type, item_count); - // todo + PacketView pkt(data + 6, length - 6); + uint32_t charId = pkt.Read4B(GA_T::CHARACTER_ID).value_or(0); + + const bool ok = (charId != 0) && + PlayerSessionStore::DeleteCharacter(static_cast(charId), user_id_); + + // If the deleted character was the active selection, clear it so a + // later SELECT/register can't target a now-gone row. + if (ok && selected_character_id_ == static_cast(charId)) { + selected_character_id_ = 0; + selected_profile_id_ = 0; + } + + Logger::Log("tcp", "[%s] DELETE_CHARACTER: charId=%u user=%lld ok=%d\n", + Logger::GetTime(), charId, (long long)user_id_, (int)ok); + + send_delete_character_response(charId, ok ? 0 : 1); + send_character_list_response(); break; + } case GA_U::UPDATE_NEW_MAIL_COUNT: Logger::Log("tcp", "[%s] Received: UPDATE_NEW_MAIL_COUNT [0x%04X], item count: %d\n", Logger::GetTime(), packet_type, item_count); send_update_new_mail_count_response(); @@ -1781,6 +1799,20 @@ void TcpSession::handle_packet(const uint8_t* data, size_t length) { break; } default: + // Challenge-system probe: if the retail client has a NATIVE challenge + // send path (e.g. the stubbed TgPlayerController.TeamChallenge exec, or + // a UI button), challenging a player should produce one of these on the + // game-TCP connection. Seeing 0x01F3 here means we should HANDLE the + // client's request rather than parse `/challenge` chat text; the raw + // LogData below then hands us the on-wire payload format for free. + if (packet_type == GA_U::CHALLENGE_REQUEST || + packet_type == GA_U::CHALLENGE_INVITES || + packet_type == GA_U::CHALLENGE_EXPIRED_INVITE) { + Logger::Log("challenge", + "[Challenge] INBOUND challenge-family packet 0x%04X player='%s' guid=%s item_count=%d " + "-- client HAS a native challenge send path; raw bytes follow on 'tcp'\n", + packet_type, player_name.c_str(), session_guid_.c_str(), item_count); + } Logger::Log("tcp", "[%s] Received unknown packet type: %04X, raw data:\n", Logger::GetTime(), packet_type); LogData(data, length); break; @@ -3157,6 +3189,22 @@ void TcpSession::send_add_player_character_response() send_response(response); } +void TcpSession::send_delete_character_response(uint32_t character_id, uint32_t error_code) +{ + std::vector response; + + uint16_t packet_type = GA_U::DELETE_CHARACTER; + uint16_t item_count = 2; + + append(response, packet_type & 0xFF, packet_type >> 8); + append(response, item_count & 0xFF, item_count >> 8); + + Write4B(response, GA_T::ERROR_CODE, error_code); + Write4B(response, GA_T::CHARACTER_ID, character_id); + + send_response(response); +} + void TcpSession::send_select_character_response() { std::vector response; diff --git a/src/ControlServer/TcpSession/TcpSession.hpp b/src/ControlServer/TcpSession/TcpSession.hpp index 7279a009..35d79f4f 100644 --- a/src/ControlServer/TcpSession/TcpSession.hpp +++ b/src/ControlServer/TcpSession/TcpSession.hpp @@ -567,6 +567,8 @@ class TcpSession : public std::enable_shared_from_this { void send_add_player_character_response(); + void send_delete_character_response(uint32_t character_id, uint32_t error_code); + void send_select_character_response(); void send_change_map_prep_response(); diff --git a/src/ControlServer/TeamService/TeamService.cpp b/src/ControlServer/TeamService/TeamService.cpp index e629f8cf..dc2b351e 100644 --- a/src/ControlServer/TeamService/TeamService.cpp +++ b/src/ControlServer/TeamService/TeamService.cpp @@ -73,6 +73,17 @@ bool TeamService::IsLeader(const std::string& session_guid) { return team && team->leader_guid == session_guid; } +std::vector TeamService::GetTeamMemberGuids(const std::string& session_guid) { + std::lock_guard lock(mutex_); + Team* team = FindTeamByMemberLocked(session_guid); + if (!team) return { session_guid }; // solo / not in a team + std::vector guids; + for (const auto& m : team->members) + if (!m.offline) guids.push_back(m.session_guid); + if (guids.empty()) guids.push_back(session_guid); // defensive + return guids; +} + TeamRoster TeamService::BuildRosterLocked(const Team& team) { TeamRoster roster; roster.team_id = team.id; diff --git a/src/ControlServer/TeamService/TeamService.hpp b/src/ControlServer/TeamService/TeamService.hpp index b9f1eb69..2bae9e1c 100644 --- a/src/ControlServer/TeamService/TeamService.hpp +++ b/src/ControlServer/TeamService/TeamService.hpp @@ -86,6 +86,13 @@ class TeamService { static bool IsTeamed(const std::string& session_guid); static bool IsLeader(const std::string& session_guid); + // Session guids of all ONLINE members of the team containing `session_guid` + // (leader + members), or just { session_guid } if the player isn't in a + // team. Offline-marked members are skipped (they can't be routed into a + // match). Used by the challenge system to pull whole teams into a private + // challenge match rather than just the two individuals. + static std::vector GetTeamMemberGuids(const std::string& session_guid); + // --- Team queueing ----------------------------------------------------- // Build a matchmaking party from the team led by `leader_guid` (live diff --git a/src/ControlServer/main.cpp b/src/ControlServer/main.cpp index f3fafb66..0df6f429 100644 --- a/src/ControlServer/main.cpp +++ b/src/ControlServer/main.cpp @@ -12,6 +12,7 @@ #include "src/ControlServer/TcpSession/TcpSession.hpp" #include "src/ControlServer/MatchmakingService/MatchmakingService.hpp" #include "src/ControlServer/MatchmakingService/SidePlacement.hpp" +#include "src/ControlServer/ChatSession/ChatCommand.hpp" #include #include #include @@ -278,6 +279,62 @@ static bool SpawnAdminInstance(const ControlServerConfig& cfg, const std::string return true; } +// Spawn a fresh instance for an already-populated PendingMatch and register the +// pending match BEFORE Spawn — so the spawn-failure / IPC-disconnect-before-READY +// unwind can DiscardPendingMatchForDeadInstance and unstick the matched players. +// On INSTANCE_READY, IpcServer consumes the pending match and delivers a +// MATCH_INVITATION to each session_guid on its assigned task force. Returns the +// new instance_id, or 0 if no UDP port was free / the spawn failed. +// +// `pending` must already carry session_guids, task_force_assignments, +// profile_ids, cap, access_mode and owner_party_ids; instance_id/queue_id/ +// game_mode are stamped here. Shared by the matchmaker's match-pop path and the +// /challenge launcher. +static int64_t SpawnInstanceForPendingMatch( + const ControlServerConfig& cfg, + const std::string& map_name, const std::string& game_mode, + uint32_t queue_id, uint32_t difficulty, PendingMatch&& pending) { + auto port = InstanceRegistry::AllocatePort(cfg.udp_port_range.lo, cfg.udp_port_range.hi); + if (!port) { + Logger::Log("matchmaking", "[Matchmaking] No UDP ports available for match spawn\n"); + return 0; + } + + // Carry the access semantics onto the instance row so the provider (and the + // invite-into-locked-match path) can read them back. owner_party_ids -> CSV. + std::string owner_csv; + for (size_t i = 0; i < pending.owner_party_ids.size(); ++i) { + if (i) owner_csv += ","; + owner_csv += std::to_string(pending.owner_party_ids[i]); + } + const std::string access_str = mm::AccessModeToString(pending.access_mode); + + int64_t instance_id = InstanceRegistry::InsertStarting( + map_name, game_mode, *port, 0, /*is_home_map=*/false, + /*queue_id=*/queue_id, /*predecessor_instance_id=*/0, + access_str, owner_csv); + + pending.instance_id = instance_id; + pending.queue_id = queue_id; + pending.game_mode = game_mode; + MatchmakingService::AddPendingMatch(instance_id, std::move(pending)); + + pid_t pid = InstanceSpawner::Spawn( + cfg, map_name, game_mode, *port, instance_id, difficulty); + if (pid < 0) { + MatchmakingService::DiscardPendingMatchForDeadInstance( + instance_id, "InstanceSpawner::Spawn returned -1"); + InstanceRegistry::MarkStopped(instance_id); + return 0; + } + + InstanceRegistry::UpdatePid(instance_id, pid); + Logger::Log("matchmaking", + "[Matchmaking] Spawned instance %lld (pid=%d port=%d) for queue %u\n", + (long long)instance_id, (int)pid, (int)*port, queue_id); + return instance_id; +} + // --------------------------------------------------------------------------- // main // --------------------------------------------------------------------------- @@ -483,82 +540,60 @@ int main(int argc, char* argv[]) { return; } - // Spawn new instance - auto port = InstanceRegistry::AllocatePort( - cfg.udp_port_range.lo, cfg.udp_port_range.hi); - if (!port) { - Logger::Log("matchmaking", - "[Matchmaking] No UDP ports available for match spawn\n"); - return; - } - - // Carry the rule's access semantics onto the instance row so the - // provider (and the invite-into-locked-match path) can read them - // back. owner_party_ids -> CSV. - std::string owner_csv; - for (size_t i = 0; i < result.owner_party_ids.size(); ++i) { - if (i) owner_csv += ","; - owner_csv += std::to_string(result.owner_party_ids[i]); - } - const std::string access_str = mm::AccessModeToString(result.access_mode); - - int64_t instance_id = InstanceRegistry::InsertStarting( - result.map_name, result.game_mode, *port, 0, /*is_home_map=*/false, - /*queue_id=*/queue_id, /*predecessor_instance_id=*/0, - access_str, owner_csv); - - // Register pending match BEFORE Spawn so the unwind path on a - // spawn-failure (and the symmetric IPC-disconnect-before-READY - // path in IpcServer) can call DiscardPendingMatchForDeadInstance - // to unstick the matched players. Without this, a process that - // fails to fork leaves its session_guids out of queue.players - // with no way back. + // Spawn new instance. Build the PendingMatch from the popped result, + // then hand off to the shared spawn-and-register helper. PendingMatch pending; - pending.instance_id = instance_id; - pending.queue_id = queue_id; - pending.game_mode = result.game_mode; pending.access_mode = result.access_mode; pending.owner_party_ids = result.owner_party_ids; pending.session_guids = std::move(result.session_guids); pending.task_force_assignments = std::move(result.task_force_assignments); pending.profile_ids = std::move(result.profile_ids); - // Honour rule-supplied cap_override (e.g. DoubleAgentRule seals - // each match at its roster size). Falls back to the queue-wide - // cap when the rule didn't override. - if (auto qcfg = MatchmakingService::GetQueueConfig(queue_id)) { - pending.cap = result.cap_override.value_or( - MatchmakingService::GetQueueInstanceCap(*qcfg)); - } - MatchmakingService::AddPendingMatch(instance_id, std::move(pending)); - // Look up queue's configured difficulty so the instance runs - // at the queue's tier instead of the DLL's map-name heuristic. + // Honour rule-supplied cap_override (e.g. DoubleAgentRule seals each + // match at its roster size); fall back to the queue-wide cap. Look up + // queue difficulty so the instance runs at the queue's tier instead + // of the DLL's map-name heuristic. uint32_t queue_difficulty = 0; if (auto qcfg = MatchmakingService::GetQueueConfig(queue_id)) { + pending.cap = result.cap_override.value_or( + MatchmakingService::GetQueueInstanceCap(*qcfg)); queue_difficulty = qcfg->difficulty_value_id; } - pid_t pid = InstanceSpawner::Spawn( - cfg, result.map_name, result.game_mode, *port, instance_id, - queue_difficulty); - - if (pid < 0) { + if (SpawnInstanceForPendingMatch( + cfg, result.map_name, result.game_mode, queue_id, + queue_difficulty, std::move(pending)) == 0) { Logger::Log("matchmaking", "[Matchmaking] Failed to spawn instance for queue %u\n", queue_id); - MatchmakingService::DiscardPendingMatchForDeadInstance( - instance_id, "InstanceSpawner::Spawn returned -1"); - InstanceRegistry::MarkStopped(instance_id); - return; } - - InstanceRegistry::UpdatePid(instance_id, pid); - - Logger::Log("matchmaking", - "[Matchmaking] Spawned instance %lld (pid=%d port=%d) for queue %u\n", - (long long)instance_id, (int)pid, (int)*port, queue_id); } ); + // Challenge launcher — invoked from ChatCommand::DispatchChallenge when a + // player runs /challenge. Builds a sealed 2-player PendingMatch (challenger + // on their chosen side, target opposite) and reuses the same spawn-and-invite + // path as a matchmade pop (queue_id=0 = ad-hoc, difficulty=0 = DLL default). + ChatCommand::SetChallengeLauncher( + [&cfg](const std::vector& challenger_team, int challenger_tf, + const std::vector& target_team, const std::string& map_name, + const std::string& game_mode) -> bool { + PendingMatch pending; + pending.access_mode = AccessMode::Sealed; + const int target_tf = (challenger_tf == 1) ? 2 : 1; + for (const auto& g : challenger_team) { + pending.session_guids.push_back(g); + pending.task_force_assignments[g] = challenger_tf; + } + for (const auto& g : target_team) { + pending.session_guids.push_back(g); + pending.task_force_assignments[g] = target_tf; + } + pending.cap = (uint32_t)pending.session_guids.size(); + return SpawnInstanceForPendingMatch( + cfg, map_name, game_mode, /*queue_id=*/0, + /*difficulty=*/0, std::move(pending)) != 0; + }); + // Successor spawner — invoked from IpcServer's MSG_REQUEST_SUCCESSOR // handler after dedupe. Picks a fresh (map, game_mode) at random from // the parent's queue's map_pool, so successors don't necessarily diff --git a/src/GameServer/Cosmetics/CosmeticEquip.cpp b/src/GameServer/Cosmetics/CosmeticEquip.cpp index c7363933..f1e515b3 100644 --- a/src/GameServer/Cosmetics/CosmeticEquip.cpp +++ b/src/GameServer/Cosmetics/CosmeticEquip.cpp @@ -477,6 +477,64 @@ bool ClearSlot(ATgPawn* Pawn, int64_t character_id, int item_profile_id, int slo return true; } +struct CharacterBaseState { + int headMesh = 1605; + int genderId = GA_G::GENDER_TYPE_ID_MALE; + uint32_t profileId = 680; + int hairMesh = 1757; +}; + +// Reads the per-character creation fields from ga_characters and decodes the +// morph blob into the pawn's replicated morph arrays. Returns the base state +// (head mesh, gender, profile, hair mesh) with defaults if the row is missing. +static CharacterBaseState ReadCharacterBaseState(int64_t character_id, ATgPawn_Character* charPawn) { + CharacterBaseState state; + sqlite3* db = Database::GetConnection(); + if (!db) return state; + + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db, + "SELECT head_asm_id, gender_type_value_id, profile_id, morph_data, hair_asm_id " + "FROM ga_characters WHERE id = ?", + -1, &stmt, nullptr) != SQLITE_OK) return state; + + enum Col { head_asm_id = 0, gender_type_value_id, profile_id, morph_data, hair_asm_id }; + sqlite3_bind_int64(stmt, 1, character_id); + if (sqlite3_step(stmt) == SQLITE_ROW) { + const int h = sqlite3_column_int(stmt, Col::head_asm_id); + const int g = sqlite3_column_int(stmt, Col::gender_type_value_id); + const int p = sqlite3_column_int(stmt, Col::profile_id); + const int hr = sqlite3_column_int(stmt, Col::hair_asm_id); + if (h > 0) state.headMesh = h; + if (g > 0) state.genderId = g; + if (p > 0) state.profileId = (uint32_t)p; + if (hr > 0) state.hairMesh = hr; + + struct MorphPair { uint32_t index; uint32_t weight; }; + static_assert(sizeof(MorphPair) == 8, "morph blob stride"); + + const auto* blob = (const uint8_t*)sqlite3_column_blob(stmt, Col::morph_data); + const int pairCount = sqlite3_column_bytes(stmt, Col::morph_data) / (int)sizeof(MorphPair); + int maxIdx = -1; + for (int i = 0; i < pairCount; ++i) { + MorphPair pair; + memcpy(&pair, blob + i * sizeof(MorphPair), sizeof(MorphPair)); + if (pair.index < 255u) { + charPawn->r_nMorphSettings[pair.index] = (int)pair.weight; + if ((int)pair.index > maxIdx) maxIdx = (int)pair.index; + } + } + if (maxIdx >= 0) { + charPawn->r_nMaxMorphIndexSentFromServer = maxIdx + 1; + Logger::Log("spawn-asm", + "LoadFromDB morph: char=%lld decoded %d pairs (maxIdx=%d)\n", + (long long)character_id, pairCount, maxIdx); + } + } + sqlite3_finalize(stmt); + return state; +} + // Baseline assembly used as the starting point before overlaying DB cosmetics. // Mirrors what SpawnPlayerCharacter used to write inline, except SuitMeshId // starts at -1 instead of the 1225 Medic Acolyte placeholder — the cosmetic @@ -519,42 +577,13 @@ void LoadFromDB(ATgPawn* Pawn, int64_t character_id, int item_profile_id) { auto* PRI = (ATgRepInfo_Player*)Pawn->PlayerReplicationInfo; item_profile_id = NormalizeItemProfileId(Pawn, item_profile_id); - // (0) Pull head_asm_id + gender from ga_characters. These are character - // state (picked at character creation), not cosmetic-equip state. Engine - // class defaults (TgPawn_Character.uc:1097 male / line 907-908 female via - // GetDefaultAssembly) are used as the fallback when the row is missing. - int headMesh = 1605; // male class default - int genderId = GA_G::GENDER_TYPE_ID_MALE; // 853 - uint32_t profileId = 680; // Assault class default - { - sqlite3* db = Database::GetConnection(); - if (db) { - sqlite3_stmt* stmt = nullptr; - if (sqlite3_prepare_v2(db, - "SELECT head_asm_id, gender_type_value_id, profile_id " - "FROM ga_characters WHERE id = ?", - -1, &stmt, nullptr) == SQLITE_OK) { - sqlite3_bind_int64(stmt, 1, character_id); - if (sqlite3_step(stmt) == SQLITE_ROW) { - const int h = sqlite3_column_int(stmt, 0); - const int g = sqlite3_column_int(stmt, 1); - const int p = sqlite3_column_int(stmt, 2); - if (h > 0) headMesh = h; - if (g > 0) genderId = g; - if (p > 0) profileId = (uint32_t)p; - } - sqlite3_finalize(stmt); - } - } - } - // Hair pairs with gender. Both branches now use 1757 (NewHair1) — the - // male class default that was already in use. The original code wrote - // 0 for female, which crashes the client at 0x109d1f5b on the hair- - // component deref through 0xCCCCCCCC. This is the MINIMAL change off - // the pre-session baseline. Earlier attempts in this session widened - // the SELECT and added a SpawnPlayerCharacter pre-Fill — both regressed - // the inventory pipeline and have been reverted. - const int hairMesh = 1757; + // (0) Read per-character creation state (head, hair, gender, morph poses) + // from ga_characters and write morph arrays onto the pawn. + const CharacterBaseState base = ReadCharacterBaseState(character_id, charPawn); + const int headMesh = base.headMesh; + const int genderId = base.genderId; + const uint32_t profileId = base.profileId; + const int hairMesh = base.hairMesh; // (1) Initialize the baseline assembly. Owning this here (instead of in // SpawnPlayerCharacter) keeps the cosmetic state machine in one place and