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
213 changes: 211 additions & 2 deletions src/ControlServer/ChatSession/ChatCommand.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
#include <algorithm>
#include <cctype>
#include <climits>
#include <random>
#include <vector>

#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 {
Expand Down Expand Up @@ -89,14 +92,50 @@ std::optional<int> ParseInt(const std::string& s) {
return v;
}

// Resolve a <type> token (category number 1-6 OR name, case-insensitive) to an
// index into `cats`. nullopt if it matches neither.
std::optional<size_t> ResolveCategoryIndex(
const std::string& token,
const std::vector<MapGameInfo::ChallengeCategory>& 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<size_t> 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;
}

Expand Down Expand Up @@ -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 <person> <type> -> list that type's map pool
// -challenge <person> <type> 0 [side] -> random map from the pool
// -challenge <person> <type> <map#> [side]-> a specific map in the pool
// <type> is a category number (1-6) or name. <side> (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<std::string> tokens = SplitWs(rest);
if (tokens.size() < 2 ||
LowerAscii(tokens[0]) == "maps" || LowerAscii(tokens[0]) == "list") {
out.challenge_list = true; // bare / keyword / missing <type> -> 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] <bot_id>
Expand Down Expand Up @@ -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 <player> <type> <a|d>");

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 <type> 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 <map#> -> just show this type's pool (informational; no player needed).
if (args.map_token.empty()) {
SendCategoryPool(cat, reply);
return;
}

// Resolve <map#>: "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<size_t>(*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 <person> 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<std::string> challenger_team = TeamService::GetTeamMemberGuids(challenger_guid);
std::vector<std::string> 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
48 changes: 48 additions & 0 deletions src/ControlServer/ChatSession/ChatCommand.hpp
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
#pragma once

#include <cstdint>
#include <functional>
#include <optional>
#include <string>
#include <vector>

namespace ChatCommand {

Expand Down Expand Up @@ -47,6 +49,19 @@ struct TopDownArgs {
float lift_z = 0.0f;
};

// -challenge <person> <type> [map#] [side]: challenge another online player (and
// their whole team) to a private match. <type> is a category number (1-6) or
// name (Arena, Scramble, …). <map#> is empty (→ list that type's pool), "0"
// (→ random map from the pool), or the 1-based map number within the pool.
// <side> is optional and defaults to attackers. Everything is resolved against
// the cs_challenge_catalog at dispatch time.
struct ChallengeArgs {
std::string target_name; // <person>
std::string type_token; // <type> — category number (1-6) or name
std::string map_token; // <map#> — "" = 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",
Expand All @@ -63,6 +78,11 @@ struct ParseResult {
std::optional<SpawnTargetArgs> spawn_target;
std::optional<DeployTargetArgs> deploy_target;
std::optional<TopDownArgs> topdown;
std::optional<ChallengeArgs> challenge;

// -challenge with no usable <person> <map> (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;
Expand Down Expand Up @@ -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<bool(
const std::vector<std::string>& challenger_team, int challenger_tf,
const std::vector<std::string>& 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<void(const std::string& line)>;

// Handle -challenge: resolve <type> to a catalog category, then either list that
// category's pool (no <map#>), pick a random map ("0"), or select a specific map
// (N). For an actual launch, resolve <person> 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
16 changes: 16 additions & 0 deletions src/ControlServer/ChatSession/ChatSession.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ std::vector<uint8_t> 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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down
Loading