From b028532db38fd19bc237d41b9c59a36b34c2dd5d Mon Sep 17 00:00:00 2001 From: Martin Michelsen Date: Sat, 11 Jan 2025 22:16:26 -0800 Subject: [PATCH] add /y/shell-exec in HTTP server --- CMakeLists.txt | 1 + README.md | 27 +- src/Account.cc | 36 +- src/Account.hh | 2 +- src/Episode3/Tournament.cc | 59 +- src/Episode3/Tournament.hh | 2 +- src/EventUtils.cc | 41 +- src/EventUtils.hh | 3 + src/HTTPServer.cc | 49 ++ src/HTTPServer.hh | 3 + src/ReceiveCommands.cc | 7 +- src/ServerShell.cc | 1173 +---------------------------------- src/ServerShell.hh | 8 - src/ShellCommands.cc | 1184 ++++++++++++++++++++++++++++++++++++ src/ShellCommands.hh | 38 ++ 15 files changed, 1385 insertions(+), 1248 deletions(-) create mode 100644 src/ShellCommands.cc create mode 100644 src/ShellCommands.hh diff --git a/CMakeLists.txt b/CMakeLists.txt index a98f6e5c..67ba0eb7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -123,6 +123,7 @@ set(SOURCES src/Server.cc src/ServerShell.cc src/ServerState.cc + src/ShellCommands.cc src/SignalWatcher.cc src/StaticGameData.cc src/TeamIndex.cc diff --git a/README.md b/README.md index c0ee8e11..082de650 100644 --- a/README.md +++ b/README.md @@ -678,19 +678,20 @@ The HTTP server is disabled by default, and you have to explicitly enable it in To enable the HTTP server, add a port number in the HTTPListen list in config.json. The HTTP server will listen on that port. Currently, all endpoints only provide data (and hence are GET requests); there are no methods to make changes to the server state or take actions. All returned data is JSON-encoded. The HTTP server has the following endpoints: -* `/y/data/ep3-cards`: Returns the Episode 3 card definitions. -* `/y/data/ep3-cards-trial`: Returns the Episode 3 Trial Edition card definitions. -* `/y/data/common-tables`: Returns the parameters for generating common items (ItemPT files). This endpoint returns a lot of data and can be slow! -* `/y/data/rare-tables`: Returns a list of rare table names. -* `/y/data/rare-tables/` (for example, `/y/data/rare-tables/rare-table-v4`): Returns the contents of a rare item table. -* `/y/data/config`: Returns the server's configuration file. -* `/y/clients`: Returns information about all connected clients on the game server. -* `/y/proxy-clients`: Returns information about all connected clients on the proxy server. -* `/y/lobbies`: Returns information about all lobbies and games. -* `/y/server`: Returns information about the server. -* `/y/rare-drops/stream`: WebSocket endpoint that sends messages whenever an announceable rare item is dropped in any game. Announceable rare items are items for which an in-game or server-wide text message is sent announcing the find. -* `/y/summary`: Returns a summary of the server's state, connected clients, active games, and proxy sessions. -* `/y/all`: Returns everything. This endpoint can be slow! +* `GET /y/data/ep3-cards`: Returns the Episode 3 card definitions. +* `GET /y/data/ep3-cards-trial`: Returns the Episode 3 Trial Edition card definitions. +* `GET /y/data/common-tables`: Returns the parameters for generating common items (ItemPT files). This endpoint returns a lot of data and can be slow! +* `GET /y/data/rare-tables`: Returns a list of rare table names. +* `GET /y/data/rare-tables/` (for example, `/y/data/rare-tables/rare-table-v4`): Returns the contents of a rare item table. +* `GET /y/data/config`: Returns the server's configuration file. +* `GET /y/clients`: Returns information about all connected clients on the game server. +* `GET /y/proxy-clients`: Returns information about all connected clients on the proxy server. +* `GET /y/lobbies`: Returns information about all lobbies and games. +* `GET /y/server`: Returns information about the server. +* `GET /y/rare-drops/stream`: WebSocket endpoint that sends messages whenever an announceable rare item is dropped in any game. Announceable rare items are items for which an in-game or server-wide text message is sent announcing the find. +* `GET /y/summary`: Returns a summary of the server's state, connected clients, active games, and proxy sessions. +* `GET /y/all`: Returns everything. This endpoint can be slow! +* `POST /y/shell-exec`: Runs a server shell command. Input should be a JSON dict of e.g. `{"command": "announce hello"}`; response will be a JSON dict of `{"result": ""}` or an HTTP error. # Non-server features diff --git a/src/Account.cc b/src/Account.cc index b02a47d8..02ecdba1 100644 --- a/src/Account.cc +++ b/src/Account.cc @@ -291,8 +291,8 @@ phosg::JSON Account::json() const { }); } -void Account::print(FILE* stream) const { - fprintf(stream, "Account: %010" PRIu32 "/%08" PRIX32 "\n", this->account_id, this->account_id); +string Account::str() const { + std::string ret = phosg::string_printf("Account: %010" PRIu32 "/%08" PRIX32 "\n", this->account_id, this->account_id); if (this->flags) { string flags_str = ""; @@ -339,7 +339,7 @@ void Account::print(FILE* stream) const { } else if (phosg::ends_with(flags_str, ",")) { flags_str.pop_back(); } - fprintf(stream, " Flags: %08" PRIX32 " (%s)\n", this->flags, flags_str.c_str()); + ret += phosg::string_printf(" Flags: %08" PRIX32 " (%s)\n", this->flags, flags_str.c_str()); } if (this->user_flags) { @@ -352,53 +352,57 @@ void Account::print(FILE* stream) const { } else if (phosg::ends_with(user_flags_str, ",")) { user_flags_str.pop_back(); } - fprintf(stream, " User flags: %08" PRIX32 " (%s)\n", this->user_flags, user_flags_str.c_str()); + ret += phosg::string_printf(" User flags: %08" PRIX32 " (%s)\n", this->user_flags, user_flags_str.c_str()); } if (this->ban_end_time) { string time_str = phosg::format_time(this->ban_end_time); - fprintf(stream, " Banned until: %" PRIu64 " (%s)\n", this->ban_end_time, time_str.c_str()); + ret += phosg::string_printf(" Banned until: %" PRIu64 " (%s)\n", this->ban_end_time, time_str.c_str()); } if (this->ep3_current_meseta || this->ep3_total_meseta_earned) { - fprintf(stream, " Episode 3 meseta: %" PRIu32 " (total earned: %" PRIu32 ")\n", this->ep3_current_meseta, this->ep3_total_meseta_earned); + ret += phosg::string_printf(" Episode 3 meseta: %" PRIu32 " (total earned: %" PRIu32 ")\n", + this->ep3_current_meseta, this->ep3_total_meseta_earned); } if (!this->last_player_name.empty()) { - fprintf(stream, " Last player name: \"%s\"\n", this->last_player_name.c_str()); + ret += phosg::string_printf(" Last player name: \"%s\"\n", this->last_player_name.c_str()); } if (!this->auto_reply_message.empty()) { - fprintf(stream, " Auto reply message: \"%s\"\n", this->auto_reply_message.c_str()); + ret += phosg::string_printf(" Auto reply message: \"%s\"\n", this->auto_reply_message.c_str()); } if (this->bb_team_id) { - fprintf(stream, " BB team ID: %08" PRIX32 "\n", this->bb_team_id); + ret += phosg::string_printf(" BB team ID: %08" PRIX32 "\n", this->bb_team_id); } if (this->is_temporary) { - fprintf(stream, " Is temporary license: true\n"); + ret += phosg::string_printf(" Is temporary license: true\n"); } for (const auto& it : this->dc_nte_licenses) { - fprintf(stream, " DC NTE license: serial_number=%s access_key=%s\n", + ret += phosg::string_printf(" DC NTE license: serial_number=%s access_key=%s\n", it.second->serial_number.c_str(), it.second->access_key.c_str()); } for (const auto& it : this->dc_licenses) { - fprintf(stream, " DC license: serial_number=%" PRIX32 " access_key=%s\n", + ret += phosg::string_printf(" DC license: serial_number=%" PRIX32 " access_key=%s\n", it.second->serial_number, it.second->access_key.c_str()); } for (const auto& it : this->pc_licenses) { - fprintf(stream, " PC license: serial_number=%" PRIX32 " access_key=%s\n", + ret += phosg::string_printf(" PC license: serial_number=%" PRIX32 " access_key=%s\n", it.second->serial_number, it.second->access_key.c_str()); } for (const auto& it : this->gc_licenses) { - fprintf(stream, " GC license: serial_number=%010" PRIu32 " access_key=%s password=%s\n", + ret += phosg::string_printf(" GC license: serial_number=%010" PRIu32 " access_key=%s password=%s\n", it.second->serial_number, it.second->access_key.c_str(), it.second->password.c_str()); } for (const auto& it : this->xb_licenses) { - fprintf(stream, " XB license: gamertag=%s user_id=%016" PRIX64 " account_id=%016" PRIX64 "\n", + ret += phosg::string_printf(" XB license: gamertag=%s user_id=%016" PRIX64 " account_id=%016" PRIX64 "\n", it.second->gamertag.c_str(), it.second->user_id, it.second->account_id); } for (const auto& it : this->bb_licenses) { - fprintf(stream, " BB license: username=%s password=%s\n", + ret += phosg::string_printf(" BB license: username=%s password=%s\n", it.second->username.c_str(), it.second->password.c_str()); } + + phosg::strip_trailing_whitespace(ret); + return ret; } void Account::save() const { diff --git a/src/Account.hh b/src/Account.hh index 5e193952..a2864440 100644 --- a/src/Account.hh +++ b/src/Account.hh @@ -162,7 +162,7 @@ struct Account { this->user_flags ^= static_cast(flag); } - void print(FILE* stream) const; + std::string str() const; }; struct Login { diff --git a/src/Episode3/Tournament.cc b/src/Episode3/Tournament.cc index 0cefe159..c140b856 100644 --- a/src/Episode3/Tournament.cc +++ b/src/Episode3/Tournament.cc @@ -717,66 +717,71 @@ void Tournament::send_all_state_updates_on_deletion() const { } } -void Tournament::print_bracket(FILE* stream) const { - function, size_t)> print_match = [&](shared_ptr m, size_t indent_level) -> void { - for (size_t z = 0; z < indent_level; z++) { - fputc(' ', stream); - fputc(' ', stream); +string Tournament::bracket_str() const { + string ret = phosg::string_printf("Tournament \"%s\"\n", this->name.c_str()); + + function, size_t)> add_match = [&](shared_ptr m, size_t indent_level) -> void { + ret.append(2 * indent_level, ' '); + ret += m->str(); + if (this->pending_matches.count(m)) { + ret += " (PENDING)"; } - string match_str = m->str(); - fprintf(stream, "%s%s\n", match_str.c_str(), this->pending_matches.count(m) ? " (PENDING)" : ""); + ret.push_back('\n'); if (m->preceding_a) { - print_match(m->preceding_a, indent_level + 1); + add_match(m->preceding_a, indent_level + 1); } if (m->preceding_b) { - print_match(m->preceding_b, indent_level + 1); + add_match(m->preceding_b, indent_level + 1); } }; - fprintf(stream, "Tournament \"%s\"\n", this->name.c_str()); + auto en_vm = this->map->version(1); if (en_vm) { string map_name = en_vm->map->name.decode(en_vm->language); - fprintf(stream, " Map: %08" PRIX32 " (%s)\n", this->map->map_number, map_name.c_str()); + ret += phosg::string_printf(" Map: %08" PRIX32 " (%s)\n", this->map->map_number, map_name.c_str()); } else { - fprintf(stream, " Map: %08" PRIX32 "\n", this->map->map_number); + ret += phosg::string_printf(" Map: %08" PRIX32 "\n", this->map->map_number); } string rules_str = this->rules.str(); - fprintf(stream, " Rules: %s\n", rules_str.c_str()); - fprintf(stream, " Structure: %s, %zu entries\n", (this->flags & Flag::IS_2V2) ? "2v2" : "1v1", this->num_teams); - fprintf(stream, " COM teams: %s\n", (this->flags & Flag::HAS_COM_TEAMS) ? "allowed" : "forbidden"); - fprintf(stream, " Shuffle entries: %s\n", (this->flags & Flag::SHUFFLE_ENTRIES) ? "yes" : "no"); - fprintf(stream, " Resize on start: %s\n", (this->flags & Flag::RESIZE_ON_START) ? "yes" : "no"); + ret += phosg::string_printf(" Rules: %s\n", rules_str.c_str()); + ret += phosg::string_printf(" Structure: %s, %zu entries\n", (this->flags & Flag::IS_2V2) ? "2v2" : "1v1", this->num_teams); + ret += phosg::string_printf(" COM teams: %s\n", (this->flags & Flag::HAS_COM_TEAMS) ? "allowed" : "forbidden"); + ret += phosg::string_printf(" Shuffle entries: %s\n", (this->flags & Flag::SHUFFLE_ENTRIES) ? "yes" : "no"); + ret += phosg::string_printf(" Resize on start: %s\n", (this->flags & Flag::RESIZE_ON_START) ? "yes" : "no"); switch (this->current_state) { case State::REGISTRATION: - fprintf(stream, " State: REGISTRATION\n"); + ret += " State: REGISTRATION\n"; break; case State::IN_PROGRESS: - fprintf(stream, " State: IN_PROGRESS\n"); + ret += " State: IN_PROGRESS\n"; break; case State::COMPLETE: - fprintf(stream, " State: COMPLETE\n"); + ret += " State: COMPLETE\n"; break; default: - fprintf(stream, " State: UNKNOWN\n"); + ret += " State: UNKNOWN\n"; break; } if (this->final_match) { - fprintf(stream, " Standings:\n"); - print_match(this->final_match, 2); + ret += " Standings:\n"; + add_match(this->final_match, 2); } if (this->current_state == State::REGISTRATION) { - fprintf(stream, " Teams:\n"); + ret += " Teams:\n"; for (const auto& team : this->teams) { string team_str = team->str(); - fprintf(stream, " %s\n", team_str.c_str()); + ret += phosg::string_printf(" %s\n", team_str.c_str()); } } else { - fprintf(stream, " Pending matches:\n"); + ret += " Pending matches:\n"; for (const auto& match : this->pending_matches) { string match_str = match->str(); - fprintf(stream, " %s\n", match_str.c_str()); + ret += phosg::string_printf(" %s\n", match_str.c_str()); } } + + phosg::strip_trailing_whitespace(ret); + return ret; } TournamentIndex::TournamentIndex( diff --git a/src/Episode3/Tournament.hh b/src/Episode3/Tournament.hh index adefe6d0..603f4fbb 100644 --- a/src/Episode3/Tournament.hh +++ b/src/Episode3/Tournament.hh @@ -160,7 +160,7 @@ public: void send_all_state_updates() const; void send_all_state_updates_on_deletion() const; - void print_bracket(FILE* stream) const; + std::string bracket_str() const; private: void create_bracket_matches(); diff --git a/src/EventUtils.cc b/src/EventUtils.cc index c6e8444f..802a56de 100644 --- a/src/EventUtils.cc +++ b/src/EventUtils.cc @@ -1,5 +1,6 @@ #include "EventUtils.hh" +#include #include #include @@ -7,37 +8,55 @@ #include #include +using namespace std; + static void dispatch_forward_to_event_thread(evutil_socket_t, short, void* ctx) { - auto* fn = reinterpret_cast*>(ctx); + auto* fn = reinterpret_cast*>(ctx); (*fn)(); delete fn; } -void forward_to_event_thread(std::shared_ptr base, std::function&& fn) { +void forward_to_event_thread(shared_ptr base, function&& fn) { struct timeval tv = {0, 0}; - std::function* new_fn = new std::function(std::move(fn)); + function* new_fn = new function(std::move(fn)); event_base_once(base.get(), -1, EV_TIMEOUT, dispatch_forward_to_event_thread, new_fn, &tv); } template <> -void call_on_event_thread(std::shared_ptr base, std::function&& compute) { +void call_on_event_thread(shared_ptr base, function&& compute) { bool succeeded = false; - std::string exc_what; - std::mutex ret_lock; - std::condition_variable ret_cv; - std::unique_lock g(ret_lock); + string exc_what; + mutex ret_lock; + condition_variable ret_cv; + unique_lock g(ret_lock); forward_to_event_thread(base, [&]() -> void { - std::lock_guard g(ret_lock); + lock_guard g(ret_lock); try { compute(); succeeded = true; - } catch (const std::exception& e) { + } catch (const exception& e) { exc_what = e.what(); } ret_cv.notify_one(); }); ret_cv.wait(g); if (!succeeded) { - throw std::runtime_error(exc_what); + throw runtime_error(exc_what); } } + +string evbuffer_remove_str(struct evbuffer* buf, ssize_t size) { + if (!buf) { + return ""; + } + if (size < 0) { + size = static_cast(evbuffer_get_length(buf)); + } + string ret(size, '\0'); + ssize_t bytes_removed = evbuffer_remove(buf, ret.data(), ret.size()); + if (bytes_removed < 0) { + throw std::runtime_error("can\'t remove data from buffer"); + } + ret.resize(bytes_removed); + return ret; +} diff --git a/src/EventUtils.hh b/src/EventUtils.hh index 43ae359c..ddbaf977 100644 --- a/src/EventUtils.hh +++ b/src/EventUtils.hh @@ -8,6 +8,7 @@ #include #include #include +#include // Calls a function on the given base's event thread. This function returns // when the call has been enqueued, not necessarily after it returns. @@ -40,3 +41,5 @@ T call_on_event_thread(std::shared_ptr base, std::function void call_on_event_thread(std::shared_ptr base, std::function&& compute); + +std::string evbuffer_remove_str(struct evbuffer* buf, ssize_t size = -1); diff --git a/src/HTTPServer.cc b/src/HTTPServer.cc index b50ab1ba..942ccecb 100644 --- a/src/HTTPServer.cc +++ b/src/HTTPServer.cc @@ -14,6 +14,7 @@ #include "Loggers.hh" #include "ProxyServer.hh" #include "Server.hh" +#include "ShellCommands.hh" using namespace std; @@ -1154,6 +1155,25 @@ phosg::JSON HTTPServer::generate_rare_table_json(const std::string& table_name) } } +void HTTPServer::require_GET(struct evhttp_request* req) { + if (evhttp_request_get_command(req) != EVHTTP_REQ_GET) { + throw HTTPServer::http_error(405, "GET method required for this endpoint"); + } +} + +phosg::JSON HTTPServer::require_POST(struct evhttp_request* req) { + if (evhttp_request_get_command(req) != EVHTTP_REQ_POST) { + throw HTTPServer::http_error(405, "POST method required for this endpoint"); + } + const evkeyvalq* headers = evhttp_request_get_input_headers(req); + const char* content_type = evhttp_find_header(headers, "Content-Type"); + if (!content_type || strcmp(content_type, "application/json")) { + throw HTTPServer::http_error(400, "POST requests must use the application/json content type"); + } + struct evbuffer* in_buf = evhttp_request_get_input_buffer(req); + return phosg::JSON::parse(evbuffer_remove_str(in_buf)); +} + void HTTPServer::handle_request(struct evhttp_request* req) { shared_ptr ret; uint32_t serialize_options = 0; @@ -1177,6 +1197,7 @@ void HTTPServer::handle_request(struct evhttp_request* req) { } if (uri == "/") { + this->require_GET(req); auto endpoints_json = phosg::JSON::list({ "/y/data/ep3-cards", "/y/data/ep3-cards-trial", @@ -1191,10 +1212,22 @@ void HTTPServer::handle_request(struct evhttp_request* req) { "/y/rare-drops/stream", "/y/summary", "/y/all", + "/y/shell-exec", }); ret = make_shared(phosg::JSON::dict({{"endpoints", std::move(endpoints_json)}})); + } else if (uri == "/y/shell-exec") { + auto json = this->require_POST(req); + auto command = json.get_string("command"); + try { + ret = make_shared(phosg::JSON::dict( + {{"result", phosg::join(ShellCommand::dispatch_str(this->state, command), "\n")}})); + } catch (const exception& e) { + throw http_error(400, e.what()); + } + } else if (uri == "/y/rare-drops/stream") { + this->require_GET(req); auto c = this->enable_websockets(req); if (!c) { throw http_error(400, "this path requires a websocket connection"); @@ -1206,28 +1239,40 @@ void HTTPServer::handle_request(struct evhttp_request* req) { } } else if (uri == "/y/data/ep3-cards") { + this->require_GET(req); ret = make_shared(this->generate_ep3_cards_json(false)); } else if (uri == "/y/data/ep3-cards-trial") { + this->require_GET(req); ret = make_shared(this->generate_ep3_cards_json(true)); } else if (uri == "/y/data/common-tables") { + this->require_GET(req); ret = make_shared(this->generate_common_tables_json()); } else if (uri == "/y/data/rare-tables") { + this->require_GET(req); ret = make_shared(this->generate_rare_tables_json()); } else if (!strncmp(uri.c_str(), "/y/data/rare-tables/", 20)) { + this->require_GET(req); ret = make_shared(this->generate_rare_table_json(uri.substr(20))); } else if (uri == "/y/data/config") { + this->require_GET(req); ret = call_on_event_thread>(this->state->base, [this]() { return this->state->config_json; }); } else if (uri == "/y/clients") { + this->require_GET(req); ret = make_shared(this->generate_game_server_clients_json()); } else if (uri == "/y/proxy-clients") { + this->require_GET(req); ret = make_shared(this->generate_proxy_server_clients_json()); } else if (uri == "/y/lobbies") { + this->require_GET(req); ret = make_shared(this->generate_lobbies_json()); } else if (uri == "/y/server") { + this->require_GET(req); ret = make_shared(this->generate_server_info_json()); } else if (uri == "/y/summary") { + this->require_GET(req); ret = make_shared(this->generate_summary_json()); } else if (uri == "/y/all") { + this->require_GET(req); ret = make_shared(this->generate_all_json()); } else { @@ -1248,6 +1293,10 @@ void HTTPServer::handle_request(struct evhttp_request* req) { return; } + if (!ret) { + throw logic_error("ret was not set after HTTP handler completed"); + } + uint64_t handler_end = phosg::now(); unique_ptr out_buffer(evbuffer_new(), evbuffer_free); string* serialized = new string(ret->serialize(phosg::JSON::SerializeOption::ESCAPE_CONTROLS_ONLY | serialize_options)); diff --git a/src/HTTPServer.hh b/src/HTTPServer.hh index 382afdc4..1cfe8cae 100644 --- a/src/HTTPServer.hh +++ b/src/HTTPServer.hh @@ -66,6 +66,9 @@ protected: std::unordered_map> bev_to_websocket_client; + static void require_GET(struct evhttp_request* req); + static phosg::JSON require_POST(struct evhttp_request* req); + std::shared_ptr enable_websockets(struct evhttp_request* req); static void dispatch_on_websocket_read(struct bufferevent* bev, void* ctx); diff --git a/src/ReceiveCommands.cc b/src/ReceiveCommands.cc index 065b0a3d..4da2d0c9 100644 --- a/src/ReceiveCommands.cc +++ b/src/ReceiveCommands.cc @@ -1763,7 +1763,6 @@ static void on_CA_Ep3(shared_ptr c, uint16_t, uint32_t, string& data) { } auto tourn = l->tournament_match->tournament.lock(); - tourn->print_bracket(stderr); shared_ptr winner_team; shared_ptr loser_team; @@ -1785,7 +1784,7 @@ static void on_CA_Ep3(shared_ptr c, uint16_t, uint32_t, string& data) { meseta_reward = (l->tournament_match->round_num - 1 < round_rewards.size()) ? round_rewards[l->tournament_match->round_num - 1] : round_rewards.back(); - if (l->tournament_match == tourn->get_final_match()) { + if (tourn && (l->tournament_match == tourn->get_final_match())) { meseta_reward += s->ep3_final_round_meseta_bonus; } for (const auto& player : winner_team->players) { @@ -1803,7 +1802,9 @@ static void on_CA_Ep3(shared_ptr c, uint16_t, uint32_t, string& data) { } send_ep3_tournament_match_result(l, meseta_reward); - on_tournament_bracket_updated(s, tourn); + if (tourn) { + on_tournament_bracket_updated(s, tourn); + } l->ep3_server->tournament_match_result_sent = true; } } diff --git a/src/ServerShell.cc b/src/ServerShell.cc index 804b8618..7f1a5a2c 100644 --- a/src/ServerShell.cc +++ b/src/ServerShell.cc @@ -11,64 +11,11 @@ #include "ReceiveCommands.hh" #include "SendCommands.hh" #include "ServerState.hh" +#include "ShellCommands.hh" #include "StaticGameData.hh" using namespace std; -struct CommandArgs { - shared_ptr s; - ServerShell* shell; - string command; - string args; - string session_name; -}; - -struct CommandDefinition; - -static std::vector commands_by_order; -static std::unordered_map commands_by_name; - -struct CommandDefinition { - - const char* name; - const char* help_text; - bool run_on_event_thread; - void (*run)(CommandArgs&); - - CommandDefinition(const char* name, const char* help_text, bool run_on_event_thread, void (*run)(CommandArgs&)) - : name(name), - help_text(help_text), - run_on_event_thread(run_on_event_thread), - run(run) { - commands_by_order.emplace_back(this); - commands_by_name.emplace(this->name, this); - } - - static void dispatch(CommandArgs& args) { - const CommandDefinition* def = nullptr; - try { - def = commands_by_name.at(args.command); - } catch (const out_of_range&) { - } - if (!def) { - fprintf(stderr, "FAILED: no such command; try 'help'\n"); - } else if (def->run_on_event_thread) { - args.s->call_on_event_thread([def, args]() { - CommandArgs local_args = args; - try { - def->run(local_args); - } catch (const exception& e) { - fprintf(stderr, "FAILED: %s\n", e.what()); - } - }); - } else { - def->run(args); - } - } -}; - -ServerShell::exit_shell::exit_shell() : runtime_error("shell exited") {} - ServerShell::ServerShell(shared_ptr state) : state(state), th(&ServerShell::thread_fn, this) {} @@ -110,7 +57,10 @@ void ServerShell::thread_fn() { phosg::strip_leading_whitespace(command); try { - this->execute_command(command); + auto lines = ShellCommand::dispatch_str(this->state, command); + for (const auto& line : lines) { + fprintf(stdout, "%s\n", line.c_str()); + } } catch (const exit_shell&) { event_base_loopexit(this->state->base.get(), nullptr); return; @@ -119,1116 +69,3 @@ void ServerShell::thread_fn() { } } } - -void ServerShell::execute_command(const string& command) { - // Find the entry in the command table and run the command - size_t command_end = phosg::skip_non_whitespace(command, 0); - size_t args_begin = phosg::skip_whitespace(command, command_end); - CommandArgs args; - args.s = this->state; - args.shell = this; - args.command = command.substr(0, command_end); - args.args = command.substr(args_begin); - CommandDefinition::dispatch(args); -} - -shared_ptr ServerShell::get_proxy_session(const string& name) { - if (!this->state->proxy_server.get()) { - throw runtime_error("the proxy server is disabled"); - } - return name.empty() - ? this->state->proxy_server->get_session() - : this->state->proxy_server->get_session_by_name(name); -} - -static string get_quoted_string(string& s) { - string ret; - char end_char = (s.at(0) == '\"') ? '\"' : ' '; - size_t z = (s.at(0) == '\"') ? 1 : 0; - for (; (z < s.size()) && (s[z] != end_char); z++) { - if (s[z] == '\\') { - if (z + 1 < s.size()) { - ret.push_back(s[z + 1]); - } else { - throw runtime_error("incomplete escape sequence"); - } - } else { - ret.push_back(s[z]); - } - } - if (end_char != ' ') { - if (z >= s.size()) { - throw runtime_error("unterminated quoted string"); - } - s = s.substr(phosg::skip_whitespace(s, z + 1)); - } else { - s = s.substr(phosg::skip_whitespace(s, z)); - } - return ret; -} - -CommandDefinition c_nop1("", nullptr, false, +[](CommandArgs&) {}); -CommandDefinition c_nop2("//", nullptr, false, +[](CommandArgs&) {}); -CommandDefinition c_nop3("#", nullptr, false, +[](CommandArgs&) {}); - -CommandDefinition c_help( - "help", "help\n\ - You\'re reading it now.", - false, - +[](CommandArgs&) { - fputs("Commands:\n", stderr); - for (const auto& def : commands_by_order) { - if (def->help_text) { - fprintf(stderr, " %s\n", def->help_text); - } - } - }); -CommandDefinition c_exit( - "exit", "exit (or ctrl+d)\n\ - Shut down the server.", - false, - +[](CommandArgs&) { - throw ServerShell::exit_shell(); - }); -CommandDefinition c_on( - "on", "on SESSION COMMAND [ARGS...]\n\ - Run a command on a specific game server client or proxy server session.\n\ - Without this prefix, commands that affect a single client or session will\n\ - work only if there's exactly one connected client or open session. SESSION\n\ - may be a client ID (e.g. C-3), a player name, an account ID, an Xbox\n\ - gamertag, or a BB account username. For proxy commands, SESSION should be\n\ - the session ID, which generally is the same as the player\'s account ID\n\ - and appears after \"LinkedSession:\" in the log output.", - false, - +[](CommandArgs& args) { - size_t session_name_end = phosg::skip_non_whitespace(args.args, 0); - size_t command_begin = phosg::skip_whitespace(args.args, session_name_end); - size_t command_end = phosg::skip_non_whitespace(args.args, command_begin); - size_t args_begin = phosg::skip_whitespace(args.args, command_end); - args.session_name = args.args.substr(0, session_name_end); - args.command = args.args.substr(command_begin, command_end - command_begin); - args.args = args.args.substr(args_begin); - CommandDefinition::dispatch(args); - }); - -CommandDefinition c_reload( - "reload", "reload ITEM [ITEM...]\n\ - Reload various parts of the server configuration. The items are:\n\ - accounts - reindex user accounts\n\ - battle-params - reload the BB enemy stats files\n\ - bb-keys - reload BB private keys\n\ - caches - clear all cached files\n\ - config - reload most fields from config.json\n\ - dol-files - reindex all DOL files\n\ - drop-tables - reload drop tables\n\ - ep3-cards - reload Episode 3 card definitions\n\ - ep3-maps - reload Episode 3 maps (not download quests)\n\ - ep3-tournaments - reload Episode 3 tournament state\n\ - functions - recompile all client-side patches and functions\n\ - item-definitions - reload item definitions files\n\ - item-name-index - regenerate item name list\n\ - level-tables - reload the player stats tables\n\ - patch-files - reindex the PC and BB patch directories\n\ - quests - reindex all quests (including Episode3 download quests)\n\ - set-tables - reload set data tables\n\ - teams - reindex all BB teams\n\ - text-index - reload in-game text\n\ - word-select - regenerate the Word Select translation table\n\ - all - do all of the above\n\ - Reloading will not affect items that are in use; for example, if an Episode\n\ - 3 battle is in progress, it will continue to use the previous map and card\n\ - definitions. Similarly, BB clients are not forced to disconnect or reload\n\ - the battle parameters, so if these are changed without restarting, clients\n\ - may see (for example) EXP messages inconsistent with the amounts of EXP\n\ - actually received.", - false, - +[](CommandArgs& args) { - auto types = phosg::split(args.args, ' '); - for (const auto& type : types) { - if (type == "all") { - args.s->forward_to_event_thread([s = args.s]() { - try { - s->load_all(); - } catch (const exception& e) { - fprintf(stderr, "FAILED: %s\n", e.what()); - fprintf(stderr, "Some configuration may have been reloaded. Fix the underlying issue and try again.\n"); - } - }); - } else if (type == "bb-keys") { - args.s->load_bb_private_keys(true); - } else if (type == "accounts") { - args.s->load_accounts(true); - } else if (type == "maps") { - args.s->load_maps(true); - } else if (type == "caches") { - args.s->clear_file_caches(true); - } else if (type == "patch-files") { - args.s->load_patch_indexes(true); - } else if (type == "ep3-cards") { - args.s->load_ep3_cards(true); - } else if (type == "ep3-maps") { - args.s->load_ep3_maps(true); - } else if (type == "ep3-tournaments") { - args.s->load_ep3_tournament_state(true); - } else if (type == "functions") { - args.s->compile_functions(true); - } else if (type == "dol-files") { - args.s->load_dol_files(true); - } else if (type == "set-tables") { - args.s->load_set_data_tables(true); - } else if (type == "battle-params") { - args.s->load_battle_params(true); - } else if (type == "level-tables") { - args.s->load_level_tables(true); - } else if (type == "text-index") { - args.s->load_text_index(true); - } else if (type == "word-select") { - args.s->load_word_select_table(true); - } else if (type == "item-definitions") { - args.s->load_item_definitions(true); - } else if (type == "item-name-index") { - args.s->load_item_name_indexes(true); - } else if (type == "drop-tables") { - args.s->load_drop_tables(true); - } else if (type == "config") { - args.s->forward_to_event_thread([s = args.s]() { - try { - s->load_config_early(); - s->load_config_late(); - } catch (const exception& e) { - fprintf(stderr, "FAILED: %s\n", e.what()); - fprintf(stderr, "Some configuration may have been reloaded. Fix the underlying issue and try again.\n"); - } - }); - } else if (type == "teams") { - args.s->load_teams(true); - } else if (type == "quests") { - args.s->load_quest_index(true); - } else { - throw runtime_error("invalid data type: " + type); - } - } - }); - -CommandDefinition c_list_accounts( - "list-accounts", "list-accounts\n\ - List all accounts registered on the server.", - true, - +[](CommandArgs& args) { - auto accounts = args.s->account_index->all(); - if (accounts.empty()) { - fprintf(stderr, "No accounts registered\n"); - } else { - for (const auto& a : accounts) { - a->print(stderr); - } - } - }); - -uint32_t parse_account_flags(const string& flags_str) { - try { - size_t end_pos = 0; - uint32_t ret = stoul(flags_str, &end_pos, 16); - if (end_pos == flags_str.size()) { - return ret; - } - } catch (const exception&) { - } - - uint32_t ret = 0; - auto tokens = phosg::split(flags_str, ','); - for (const auto& token : tokens) { - string token_upper = phosg::toupper(token); - if (token_upper == "NONE") { - // Nothing to do - } else if (token_upper == "KICK_USER") { - ret |= static_cast(Account::Flag::KICK_USER); - } else if (token_upper == "BAN_USER") { - ret |= static_cast(Account::Flag::BAN_USER); - } else if (token_upper == "SILENCE_USER") { - ret |= static_cast(Account::Flag::SILENCE_USER); - } else if (token_upper == "MODERATOR") { - ret |= static_cast(Account::Flag::MODERATOR); - } else if (token_upper == "CHANGE_EVENT") { - ret |= static_cast(Account::Flag::CHANGE_EVENT); - } else if (token_upper == "ANNOUNCE") { - ret |= static_cast(Account::Flag::ANNOUNCE); - } else if (token_upper == "FREE_JOIN_GAMES") { - ret |= static_cast(Account::Flag::FREE_JOIN_GAMES); - } else if (token_upper == "ADMINISTRATOR") { - ret |= static_cast(Account::Flag::ADMINISTRATOR); - } else if (token_upper == "DEBUG") { - ret |= static_cast(Account::Flag::DEBUG); - } else if (token_upper == "CHEAT_ANYWHERE") { - ret |= static_cast(Account::Flag::CHEAT_ANYWHERE); - } else if (token_upper == "DISABLE_QUEST_REQUIREMENTS") { - ret |= static_cast(Account::Flag::DISABLE_QUEST_REQUIREMENTS); - } else if (token_upper == "ALWAYS_ENABLE_CHAT_COMMANDS") { - ret |= static_cast(Account::Flag::ALWAYS_ENABLE_CHAT_COMMANDS); - } else if (token_upper == "ROOT") { - ret |= static_cast(Account::Flag::ROOT); - } else if (token_upper == "IS_SHARED_ACCOUNT") { - ret |= static_cast(Account::Flag::IS_SHARED_ACCOUNT); - } else { - throw runtime_error("invalid flag name: " + token_upper); - } - } - return ret; -} - -uint32_t parse_account_user_flags(const string& user_flags_str) { - try { - size_t end_pos = 0; - uint32_t ret = stoul(user_flags_str, &end_pos, 16); - if (end_pos == user_flags_str.size()) { - return ret; - } - } catch (const exception&) { - } - - uint32_t ret = 0; - auto tokens = phosg::split(user_flags_str, ','); - for (const auto& token : tokens) { - string token_upper = phosg::toupper(token); - if (token_upper == "NONE") { - // Nothing to do - } else if (token_upper == "DISABLE_DROP_NOTIFICATION_BROADCAST") { - ret |= static_cast(Account::UserFlag::DISABLE_DROP_NOTIFICATION_BROADCAST); - } else { - throw runtime_error("invalid user flag name: " + token_upper); - } - } - return ret; -} - -CommandDefinition c_add_account( - "add-account", "add-account [PARAMETERS...]\n\ - Add an account to the server. is some subset of:\n\ - id=ACCOUNT-ID: preferred account ID in hex (optional)\n\ - flags=FLAGS: behaviors and permissions for the account (see below)\n\ - user-flags=FLAGS: user-set behaviors for the account\n\ - ep3-current-meseta=MESETA: Episode 3 Meseta value\n\ - ep3-total-meseta=MESETA: Episode 3 total Meseta ever earned\n\ - temporary: marks the account as temporary; it is not saved to disk and\n\ - therefore will be deleted when the server shuts down\n\ - If given, FLAGS is a comma-separated list of zero or more the following:\n\ - NONE: Placeholder if no other flags are specified\n\ - KICK_USER: Can kick other users offline\n\ - BAN_USER: Can ban other users\n\ - SILENCE_USER: Can silence other users\n\ - MODERATOR: Alias for all of the above flags\n\ - CHANGE_EVENT: Can change lobby events\n\ - ANNOUNCE: Can make server-wide announcements\n\ - FREE_JOIN_GAMES: Ignores game restrictions (level/quest requirements)\n\ - ADMINISTRATOR: Alias for all of the above flags (including MODERATOR)\n\ - DEBUG: Can use debugging commands\n\ - CHEAT_ANYWHERE: Can use cheat commands even if cheat mode is disabled\n\ - DISABLE_QUEST_REQUIREMENTS: Can play any quest without progression\n\ - restrictions\n\ - ALWAYS_ENABLE_CHAT_COMMANDS: Can use chat commands even if they are\n\ - disabled in config.json\n\ - ROOT: Alias for all of the above flags (including ADMINISTRATOR)\n\ - IS_SHARED_ACCOUNT: Account is a shared serial (disables Access Key and\n\ - password checks; players will get Guild Cards based on their player\n\ - names)", - true, - +[](CommandArgs& args) { - auto account = make_shared(); - for (const string& token : phosg::split(args.args, ' ')) { - if (phosg::starts_with(token, "id=")) { - account->account_id = stoul(token.substr(3), nullptr, 16); - } else if (phosg::starts_with(token, "ep3-current-meseta=")) { - account->ep3_current_meseta = stoul(token.substr(19), nullptr, 0); - } else if (phosg::starts_with(token, "ep3-total-meseta=")) { - account->ep3_total_meseta_earned = stoul(token.substr(17), nullptr, 0); - } else if (token == "temporary") { - account->is_temporary = true; - } else if (phosg::starts_with(token, "flags=")) { - account->flags = parse_account_flags(token.substr(6)); - } else if (phosg::starts_with(token, "user-flags=")) { - account->user_flags = parse_account_user_flags(token.substr(11)); - } else { - throw invalid_argument("invalid account field: " + token); - } - } - args.s->account_index->add(account); - account->save(); - fprintf(stderr, "Account %08" PRIX32 " added\n", account->account_id); - }); -CommandDefinition c_update_account( - "update-account", "update-account ACCOUNT-ID PARAMETERS...\n\ - Update an existing license. ACCOUNT-ID (8 hex digits) specifies which\n\ - account to update. The options are similar to the add-account command:\n\ - flags=FLAGS: Sets behaviors and permissions for the account (same as\n\ - add-account).\n\ - user-flags=FLAGS: Sets behaviors for the account (same as add-account).\n\ - ban-duration=DURATION: bans this account for the specified duration; the\n\ - duration should be of the form 3d, 2w, 1mo, or 1y. If any clients\n\ - are connected with this account when this command is run, they will\n\ - be disconnected.\n\ - unban: Clears any existing ban from this account.\n\ - ep3-current-meseta=MESETA: Sets Episode 3 Meseta value.\n\ - ep3-total-meseta=MESETA: Sets Episode 3 total Meseta ever earned.\n\ - temporary: Marks the account as temporary; it is not saved to disk and\n\ - therefore will be deleted when the server shuts down.\n\ - permanent: If the account was temporary, makes it non-temporary.", - true, - +[](CommandArgs& args) { - auto tokens = phosg::split(args.args, ' '); - if (tokens.size() < 2) { - throw runtime_error("not enough arguments"); - } - auto account = args.s->account_index->from_account_id(stoul(tokens[0], nullptr, 16)); - tokens.erase(tokens.begin()); - - // Do all the parsing first, then the updates afterward, so we won't - // partially update the account if parsing a later option fails - int64_t new_ep3_current_meseta = -1; - int64_t new_ep3_total_meseta = -1; - int64_t new_flags = -1; - int64_t new_user_flags = -1; - uint8_t new_is_temporary = 0xFF; - int64_t new_ban_duration = -1; - for (const string& token : tokens) { - if (phosg::starts_with(token, "ep3-current-meseta=")) { - new_ep3_current_meseta = stoul(token.substr(19), nullptr, 0); - } else if (phosg::starts_with(token, "ep3-total-meseta=")) { - new_ep3_total_meseta = stoul(token.substr(17), nullptr, 0); - } else if (token == "temporary") { - new_is_temporary = 1; - } else if (token == "permanent") { - new_is_temporary = 0; - } else if (phosg::starts_with(token, "flags=")) { - new_flags = parse_account_flags(token.substr(6)); - } else if (phosg::starts_with(token, "user-flags=")) { - new_user_flags = parse_account_user_flags(token.substr(11)); - } else if (token == "unban") { - new_ban_duration = 0; - } else if (phosg::starts_with(token, "ban-duration=")) { - auto duration_str = token.substr(13); - if (phosg::ends_with(duration_str, "s")) { - new_ban_duration = stoull(duration_str.substr(0, duration_str.size() - 1)) * 1000000LL; - } else if (phosg::ends_with(duration_str, "m")) { - new_ban_duration = stoull(duration_str.substr(0, duration_str.size() - 1)) * 60000000LL; - } else if (phosg::ends_with(duration_str, "h")) { - new_ban_duration = stoull(duration_str.substr(0, duration_str.size() - 1)) * 3600000000LL; - } else if (phosg::ends_with(duration_str, "d")) { - new_ban_duration = stoull(duration_str.substr(0, duration_str.size() - 1)) * 86400000000LL; - } else if (phosg::ends_with(duration_str, "w")) { - new_ban_duration = stoull(duration_str.substr(0, duration_str.size() - 1)) * 604800000000LL; - } else if (phosg::ends_with(duration_str, "mo")) { - new_ban_duration = stoull(duration_str.substr(0, duration_str.size() - 2)) * 2952000000000LL; - } else if (phosg::ends_with(duration_str, "y")) { - new_ban_duration = stoull(duration_str.substr(0, duration_str.size() - 1)) * 31536000000000LL; - } else { - throw runtime_error("invalid time unit"); - } - } else { - throw invalid_argument("invalid account field: " + token); - } - } - - if (new_ban_duration >= 0) { - account->ban_end_time = phosg::now() + new_ban_duration; - } - if (new_ep3_current_meseta >= 0) { - account->ep3_current_meseta = new_ep3_current_meseta; - } - if (new_ep3_total_meseta >= 0) { - account->ep3_total_meseta_earned = new_ep3_total_meseta; - } - if (new_flags >= 0) { - account->flags = new_flags; - } - if (new_user_flags >= 0) { - account->user_flags = new_user_flags; - } - if (new_is_temporary != 0xFF) { - account->is_temporary = new_is_temporary; - } - - account->save(); - fprintf(stderr, "Account %08" PRIX32 " updated\n", account->account_id); - - if (new_ban_duration > 0) { - args.s->disconnect_all_banned_clients(); - } - }); -CommandDefinition c_delete_account( - "delete-account", "delete-account ACCOUNT-ID\n\ - Delete an account from the server. If a player is online with the deleted\n\ - account, they will not be automatically disconnected.", - true, - +[](CommandArgs& args) { - auto account = args.s->account_index->from_account_id(stoul(args.args, nullptr, 16)); - args.s->account_index->remove(account->account_id); - account->is_temporary = true; - account->delete_file(); - fprintf(stderr, "Account deleted\n"); - }); - -CommandDefinition c_add_license( - "add-license", "add-license ACCOUNT-ID TYPE CREDENTIALS...\n\ - Add a license to an account. Each account may have multiple licenses of\n\ - each type. The types are:\n\ - DC-NTE: CREDENTIALS is serial number and access key (16 characters each)\n\ - DC: CREDENTIALS is serial number and access key (8 characters each)\n\ - PC: CREDENTIALS is serial number and access key (8 characters each)\n\ - GC: CREDENTIALS is serial number (10 digits), access key (12 digits), and\n\ - password (up to 8 characters)\n\ - XB: CREDENTIALS is gamertag (up to 16 characters), user ID (16 hex\n\ - digits), and account ID (16 hex digits)\n\ - BB: CREDENTIALS is username and password (up to 16 characters each)\n\ - Examples (adding licenses to account 385A92C4):\n\ - add-license 385A92C4 DC 107862F9 d38XTu2p\n\ - add-license 385A92C4 GC 0418572923 282949185033 hunter2\n\ - add-license 385A92C4 BB user1 trustno1", - true, - +[](CommandArgs& args) { - auto tokens = phosg::split(args.args, ' '); - if (tokens.size() < 3) { - throw runtime_error("not enough arguments"); - } - - auto account = args.s->account_index->from_account_id(stoul(tokens[0], nullptr, 16)); - - string type_str = phosg::toupper(tokens[1]); - if (type_str == "DC-NTE") { - if (tokens.size() != 4) { - throw runtime_error("incorrect number of parameters"); - } - auto license = make_shared(); - license->serial_number = std::move(tokens[2]); - license->access_key = std::move(tokens[3]); - args.s->account_index->add_dc_nte_license(account, license); - - } else if (type_str == "DC") { - if (tokens.size() != 4) { - throw runtime_error("incorrect number of parameters"); - } - auto license = make_shared(); - license->serial_number = stoul(tokens[2], nullptr, 16); - license->access_key = std::move(tokens[3]); - args.s->account_index->add_dc_license(account, license); - - } else if (type_str == "PC") { - if (tokens.size() != 4) { - throw runtime_error("incorrect number of parameters"); - } - auto license = make_shared(); - license->serial_number = stoul(tokens[2], nullptr, 16); - license->access_key = std::move(tokens[3]); - args.s->account_index->add_pc_license(account, license); - - } else if (type_str == "GC") { - if (tokens.size() != 5) { - throw runtime_error("incorrect number of parameters"); - } - auto license = make_shared(); - license->serial_number = stoul(tokens[2], nullptr, 10); - license->access_key = std::move(tokens[3]); - license->password = std::move(tokens[4]); - args.s->account_index->add_gc_license(account, license); - - } else if (type_str == "XB") { - if (tokens.size() != 5) { - throw runtime_error("incorrect number of parameters"); - } - auto license = make_shared(); - license->gamertag = std::move(tokens[2]); - license->user_id = stoull(tokens[3], nullptr, 16); - license->account_id = stoull(tokens[4], nullptr, 16); - args.s->account_index->add_xb_license(account, license); - - } else if (type_str == "BB") { - if (tokens.size() != 4) { - throw runtime_error("incorrect number of parameters"); - } - auto license = make_shared(); - license->username = std::move(tokens[2]); - license->password = std::move(tokens[3]); - args.s->account_index->add_bb_license(account, license); - - } else { - throw runtime_error("invalid license type"); - } - - account->save(); - fprintf(stderr, "Account %08" PRIX32 " updated\n", account->account_id); - }); -CommandDefinition c_delete_license( - "delete-license", "delete-license ACCOUNT-ID TYPE PRIMARY-CREDENTIAL\n\ - Delete a license from an account. ACCOUNT-ID and TYPE have the same\n\ - meanings as for add-license. PRIMARY-CREDENTIAL is the first credential\n\ - for the license type; specifically:\n\ - DC-NTE: PRIMARY-CREDENTIAL is the serial number\n\ - DC: PRIMARY-CREDENTIAL is the serial number (8 hex digits)\n\ - PC: PRIMARY-CREDENTIAL is the serial number (8 hex digits)\n\ - GC: PRIMARY-CREDENTIAL is the serial number (decimal)\n\ - XB: PRIMARY-CREDENTIAL is the user ID (16 hex digits)\n\ - BB: PRIMARY-CREDENTIAL is the username\n\ - Examples (deleting licenses from account 385A92C4):\n\ - delete-license 385A92C4 DC 107862F9\n\ - delete-license 385A92C4 PC 2F94C303\n\ - delete-license 385A92C4 GC 0418572923\n\ - delete-license 385A92C4 XB 7E29A2950019EB20\n\ - delete-license 385A92C4 BB user1", - true, - +[](CommandArgs& args) { - auto tokens = phosg::split(args.args, ' '); - if (tokens.size() != 3) { - throw runtime_error("incorrect argument count"); - } - - auto account = args.s->account_index->from_account_id(stoul(tokens[0], nullptr, 16)); - - string type_str = phosg::toupper(tokens[1]); - if (type_str == "DC-NTE") { - args.s->account_index->remove_dc_nte_license(account, tokens[2]); - } else if (type_str == "DC") { - args.s->account_index->remove_dc_license(account, stoul(tokens[2], nullptr, 16)); - } else if (type_str == "PC") { - args.s->account_index->remove_pc_license(account, stoul(tokens[2], nullptr, 16)); - } else if (type_str == "GC") { - args.s->account_index->remove_gc_license(account, stoul(tokens[2], nullptr, 0)); - } else if (type_str == "XB") { - args.s->account_index->remove_xb_license(account, stoull(tokens[2], nullptr, 16)); - } else if (type_str == "BB") { - args.s->account_index->remove_bb_license(account, tokens[2]); - } else { - throw runtime_error("invalid license type"); - } - - account->save(); - fprintf(stderr, "Account %08" PRIX32 " updated\n", account->account_id); - }); - -CommandDefinition c_lookup( - "lookup", "lookup USER\n\ - Find the account for a logged-in user.", - true, - +[](CommandArgs& args) { - auto target = args.s->find_client(&args.args); - if (target->login) { - fprintf(stderr, "Found client %s with account ID %08" PRIX32 "\n", - target->channel.name.c_str(), target->login->account->account_id); - } else { - // This should be impossible - throw std::logic_error("find_client found user who is not logged in"); - } - }); -CommandDefinition c_kick( - "kick", "kick USER\n\ - Disconnect a user from the server. USER may be an account ID, player name,\n\ - or client ID (beginning with \"C-\"). This does not ban the user; they are\n\ - free to reconnect after doing this.", - true, - +[](CommandArgs& args) { - auto target = args.s->find_client(&args.args); - send_message_box(target, "$C6You have been kicked off the server."); - target->should_disconnect = true; - }); - -CommandDefinition c_announce( - "announce", "announce MESSAGE\n\ - Send an announcement message to all players.", - true, - +[](CommandArgs& args) { - send_text_message(args.s, args.args); - }); -CommandDefinition c_announce_mail( - "announce-mail", "announce-mail MESSAGE\n\ - Send an announcement message via Simple Mail to all players.", - true, - +[](CommandArgs& args) { - send_simple_mail(args.s, 0, args.s->name, args.args); - }); - -CommandDefinition c_create_tournament( - "create-tournament", "create-tournament TOURNAMENT-NAME MAP-NAME NUM-TEAMS [OPTIONS...]\n\ - Create an Episode 3 tournament. Quotes are required around the tournament\n\ - and map names, unless the names contain no spaces.\n\ - OPTIONS may include:\n\ - 2v2: Set team size to 2 players (default is 1 without this option)\n\ - no-coms: Don\'t add any COM teams to the tournament bracket\n\ - shuffle: Shuffle entries when starting the tournament\n\ - resize: If the tournament is less than half full when it starts, reduce\n\ - the number of rounds to fit the existing entries\n\ - dice=A-B: Set minimum and maximum dice rolls\n\ - dice=A-B:C-D: Set minimum and maximum dice rolls for ATK dice (A-B) and\n\ - DEF dice (C-D) separately\n\ - dice=A-B:C-D:E-F: Set minimum and maximum dice rolls for ATK dice, DEF\n\ - dice, and solo vs. 2P ATK and DEF dice (E-F) separately\n\ - dice=A-B:C-D:E-F:G-H: Set minimum and maximum dice rolls for ATK dice,\n\ - DEF dice, solo vs. 2P ATK (E-F) and DEF (G-H) dice separately\n\ - overall-time-limit=N: Set battle time limit (in multiples of 5 minutes)\n\ - phase-time-limit=N: Set phase time limit (in seconds)\n\ - allowed-cards=ALL/N/NR/NRS: Set ranks of allowed cards\n\ - deck-shuffle=ON/OFF: Enable/disable deck shuffle\n\ - deck-loop=ON/OFF: Enable/disable deck loop\n\ - hp=N: Set Story Character initial HP\n\ - hp-type=TEAM/PLAYER/COMMON: Set team HP type\n\ - allow-assists=ON/OFF: Enable/disable assist cards\n\ - dialogue=ON/OFF: Enable/disable dialogue\n\ - dice-exchange=ATK/DEF/NONE: Set dice exchange mode\n\ - dice-boost=ON/OFF: Enable/disable dice boost", - true, - +[](CommandArgs& args) { - string name = get_quoted_string(args.args); - string map_name = get_quoted_string(args.args); - auto map = args.s->ep3_map_index->for_name(map_name); - uint32_t num_teams = stoul(get_quoted_string(args.args), nullptr, 0); - Episode3::Rules rules; - rules.set_defaults(); - uint8_t flags = Episode3::Tournament::Flag::HAS_COM_TEAMS; - if (!args.args.empty()) { - auto tokens = phosg::split(args.args, ' '); - for (auto& token : tokens) { - token = phosg::tolower(token); - if (token == "2v2") { - flags |= Episode3::Tournament::Flag::IS_2V2; - } else if (token == "no-coms") { - flags &= (~Episode3::Tournament::Flag::HAS_COM_TEAMS); - } else if (token == "shuffle") { - flags |= Episode3::Tournament::Flag::SHUFFLE_ENTRIES; - } else if (token == "resize") { - flags |= Episode3::Tournament::Flag::RESIZE_ON_START; - } else if (phosg::starts_with(token, "dice=")) { - auto parse_range_c = +[](const string& s) -> uint8_t { - auto tokens = phosg::split(s, '-'); - if (tokens.size() != 2) { - throw runtime_error("dice spec must be of the form MIN-MAX"); - } - return (stoul(tokens[0]) << 4) | (stoul(tokens[1]) & 0x0F); - }; - auto parse_range_p = +[](const string& s) -> pair { - auto tokens = phosg::split(s, '-'); - if (tokens.size() != 2) { - throw runtime_error("dice spec must be of the form MIN-MAX"); - } - return make_pair(stoul(tokens[0]), stoul(tokens[1])); - }; - - auto subtokens = phosg::split(token.substr(5), ':'); - if (subtokens.size() < 1) { - throw runtime_error("no dice ranges specified in dice= option"); - } - auto atk_range = parse_range_p(tokens[0]); - rules.min_dice_value = atk_range.first; - rules.max_dice_value = atk_range.second; - if (subtokens.size() >= 2) { - rules.def_dice_value_range = parse_range_c(tokens[1]); - if (subtokens.size() >= 3) { - rules.atk_dice_value_range_2v1 = parse_range_c(tokens[2]); - if (subtokens.size() == 3) { - rules.def_dice_value_range_2v1 = rules.atk_dice_value_range_2v1; - } else if (subtokens.size() == 4) { - rules.def_dice_value_range_2v1 = parse_range_c(tokens[3]); - } else { - throw runtime_error("too many range specs given"); - } - } else { - rules.atk_dice_value_range_2v1 = 0; - rules.def_dice_value_range_2v1 = 0; - } - } else { - rules.def_dice_value_range = 0; - rules.atk_dice_value_range_2v1 = 0; - rules.def_dice_value_range_2v1 = 0; - } - } else if (phosg::starts_with(token, "overall-time-limit=")) { - uint32_t limit = stoul(token.substr(19)); - if (limit > 600) { - throw runtime_error("overall-time-limit must be 600 or fewer minutes"); - } - if (limit % 5) { - throw runtime_error("overall-time-limit must be a multiple of 5 minutes"); - } - rules.overall_time_limit = limit; - } else if (phosg::starts_with(token, "phase-time-limit=")) { - rules.phase_time_limit = stoul(token.substr(17)); - } else if (phosg::starts_with(token, "hp=")) { - rules.char_hp = stoul(token.substr(3)); - } else if (token == "allowed-cards=all") { - rules.allowed_cards = Episode3::AllowedCards::ALL; - } else if (token == "allowed-cards=n") { - rules.allowed_cards = Episode3::AllowedCards::N_ONLY; - } else if (token == "allowed-cards=nr") { - rules.allowed_cards = Episode3::AllowedCards::N_R_ONLY; - } else if (token == "allowed-cards=nrs") { - rules.allowed_cards = Episode3::AllowedCards::N_R_S_ONLY; - } else if (token == "deck-shuffle=on") { - rules.disable_deck_shuffle = 0; - } else if (token == "deck-shuffle=off") { - rules.disable_deck_shuffle = 1; - } else if (token == "deck-loop=on") { - rules.disable_deck_loop = 0; - } else if (token == "deck-loop=off") { - rules.disable_deck_loop = 1; - } else if (token == "allow-assists=on") { - rules.no_assist_cards = 0; - } else if (token == "allow-assists=off") { - rules.no_assist_cards = 1; - } else if (token == "dialogue=on") { - rules.disable_dialogue = 0; - } else if (token == "dialogue=off") { - rules.disable_dialogue = 1; - } else if (token == "dice-boost=on") { - rules.disable_dice_boost = 0; - } else if (token == "dice-boost=off") { - rules.disable_dice_boost = 1; - } else if (token == "hp-type=player") { - rules.hp_type = Episode3::HPType::DEFEAT_PLAYER; - } else if (token == "hp-type=team") { - rules.hp_type = Episode3::HPType::DEFEAT_TEAM; - } else if (token == "hp-type=common") { - rules.hp_type = Episode3::HPType::COMMON_HP; - } else if (token == "dice-exchange=atk") { - rules.dice_exchange_mode = Episode3::DiceExchangeMode::HIGH_ATK; - } else if (token == "dice-exchange=def") { - rules.dice_exchange_mode = Episode3::DiceExchangeMode::HIGH_DEF; - } else if (token == "dice-exchange=none") { - rules.dice_exchange_mode = Episode3::DiceExchangeMode::NONE; - } else { - throw runtime_error("invalid rules option: " + token); - } - } - } - if (rules.check_and_reset_invalid_fields()) { - fprintf(stderr, "Warning: Some rules were invalid and reset to defaults\n"); - } - auto tourn = args.s->ep3_tournament_index->create_tournament(name, map, rules, num_teams, flags); - fprintf(stderr, "Created tournament \"%s\"\n", tourn->get_name().c_str()); - }); - -CommandDefinition c_delete_tournament( - "delete-tournament", "delete-tournament TOURNAMENT-NAME\n\ - Delete a tournament. Quotes are required around the tournament name unless\n\ - the name contains no spaces.", - true, - +[](CommandArgs& args) { - string name = get_quoted_string(args.args); - if (args.s->ep3_tournament_index->delete_tournament(name)) { - fprintf(stderr, "Deleted tournament\n"); - } else { - fprintf(stderr, "No such tournament exists\n"); - } - }); -CommandDefinition c_list_tournaments( - "list-tournaments", "list-tournaments\n\ - List the names and numbers of all existing tournaments.", - true, - +[](CommandArgs& args) { - for (const auto& it : args.s->ep3_tournament_index->all_tournaments()) { - fprintf(stderr, " %s\n", it.second->get_name().c_str()); - } - }); -CommandDefinition c_start_tournament( - "start-tournament", "start-tournament TOURNAMENT-NAME\n\ - End registration for a tournament and allow matches to begin. Quotes are\n\ - required around the tournament name unless the name contains no spaces.", - true, - +[](CommandArgs& args) { - string name = get_quoted_string(args.args); - auto tourn = args.s->ep3_tournament_index->get_tournament(name); - if (tourn) { - tourn->start(); - args.s->ep3_tournament_index->save(); - tourn->send_all_state_updates(); - send_ep3_text_message_printf(args.s, "$C7The tournament\n$C6%s$C7\nhas begun", tourn->get_name().c_str()); - fprintf(stderr, "Tournament started\n"); - } else { - fprintf(stderr, "No such tournament exists\n"); - } - }); -CommandDefinition c_describe_tournament( - "describe-tournament", "describe-tournament TOURNAMENT-NAME\n\ - Show the current state of a tournament. Quotes are required around the\n\ - tournament name unless the name contains no spaces.", - true, - +[](CommandArgs& args) { - string name = get_quoted_string(args.args); - auto tourn = args.s->ep3_tournament_index->get_tournament(name); - if (tourn) { - tourn->print_bracket(stderr); - } else { - fprintf(stderr, "No such tournament exists\n"); - } - }); - -CommandDefinition c_cc( - "cc", "cc COMMAND\n\ - Execute a chat command as if a client had sent it in-game. The command\n\ - should be specified exactly as it would be typed in-game; for example:\n\ - cc $itemnotifs on\n\ - This command cannot send chat messages to other players or to the server\n\ - (in proxy sessions); it can only execute chat commands.", - true, - +[](CommandArgs& args) { - shared_ptr ses; - try { - ses = args.shell->get_proxy_session(args.session_name); - } catch (const exception&) { - } - - if (ses.get()) { - on_chat_command(ses, args.args); - } else { - shared_ptr c; - if (args.session_name.empty()) { - c = args.s->game_server->get_client(); - } else { - auto clients = args.s->game_server->get_clients_by_identifier(args.session_name); - if (clients.empty()) { - throw runtime_error("no such client"); - } - if (clients.size() > 1) { - throw runtime_error("multiple clients found"); - } - c = std::move(clients[0]); - } - - if (c) { - on_chat_command(c, args.args); - } else { - throw runtime_error("no client available"); - } - } - return; - }); - -void f_sc_ss(CommandArgs& args) { - string data = phosg::parse_data_string(args.args, nullptr, phosg::ParseDataFlags::ALLOW_FILES); - if (data.size() == 0) { - throw invalid_argument("no data given"); - } - data.resize((data.size() + 3) & (~3)); - - shared_ptr ses; - try { - ses = args.shell->get_proxy_session(args.session_name); - } catch (const exception&) { - } - - if (ses.get()) { - if (args.command[1] == 's') { - ses->server_channel.send(data); - } else { - ses->client_channel.send(data); - } - - } else { - shared_ptr c; - if (args.session_name.empty()) { - c = args.s->game_server->get_client(); - } else { - auto clients = args.s->game_server->get_clients_by_identifier(args.session_name); - if (clients.empty()) { - throw runtime_error("no such client"); - } - if (clients.size() > 1) { - throw runtime_error("multiple clients found"); - } - c = std::move(clients[0]); - } - - if (c) { - if (args.command[1] == 's') { - on_command_with_header(c, data); - } else { - send_command_with_header(c->channel, data.data(), data.size()); - } - } else { - throw runtime_error("no client available"); - } - } -} - -CommandDefinition c_sc("sc", "sc DATA\n\ - Send a command to the client. This command also can be used to send data to\n\ - a client on the game server.", - true, f_sc_ss); -CommandDefinition c_ss("ss", "ss DATA\n\ - Send a command to the remote server.", - true, f_sc_ss); - -CommandDefinition c_show_slots( - "show-slots", "show-slots\n\ - Show the player names, Guild Card numbers, and client IDs of all players in\n\ - the current lobby or game.", - true, - +[](CommandArgs& args) { - auto ses = args.shell->get_proxy_session(args.session_name); - for (size_t z = 0; z < ses->lobby_players.size(); z++) { - const auto& player = ses->lobby_players[z]; - if (player.guild_card_number) { - fprintf(stderr, " %zu: %" PRIu32 " => %s (%c, %s, %s)\n", - z, player.guild_card_number, player.name.c_str(), - char_for_language_code(player.language), - name_for_char_class(player.char_class), - name_for_section_id(player.section_id)); - } else { - fprintf(stderr, " %zu: (no player)\n", z); - } - } - }); - -void fn_chat(CommandArgs& args) { - auto ses = args.shell->get_proxy_session(args.session_name); - bool is_dchat = (args.command == "dchat"); - - if (!is_dchat && uses_utf16(ses->version())) { - send_chat_message_from_client(ses->server_channel, args.args, 0); - } else { - string data(8, '\0'); - data.push_back('\x09'); - data.push_back('E'); - if (is_dchat) { - data += phosg::parse_data_string(args.args, nullptr, phosg::ParseDataFlags::ALLOW_FILES); - } else { - data += args.args; - data.push_back('\0'); - } - data.resize((data.size() + 3) & (~3)); - ses->server_channel.send(0x06, 0x00, data); - } -} -CommandDefinition c_c("c", "c TEXT", true, fn_chat); -CommandDefinition c_chat("chat", "chat TEXT\n\ - Send a chat message to the server.", - true, fn_chat); -CommandDefinition c_dchat("dchat", "dchat DATA\n\ - Send a chat message to the server with arbitrary data in it.", - true, fn_chat); - -void fn_wchat(CommandArgs& args) { - auto ses = args.shell->get_proxy_session(args.session_name); - if (!is_ep3(ses->version())) { - throw runtime_error("wchat can only be used on Episode 3"); - } - string data(8, '\0'); - data.push_back('\x40'); // private_flags: visible to all - data.push_back('\x09'); - data.push_back('E'); - data += args.args; - data.push_back('\0'); - data.resize((data.size() + 3) & (~3)); - ses->server_channel.send(0x06, 0x00, data); -} -CommandDefinition c_wc("wc", "wc TEXT", true, fn_wchat); -CommandDefinition c_wchat("wchat", "wchat TEXT\n\ - Send a chat message with private_flags on Episode 3.", - true, fn_wchat); - -CommandDefinition c_marker( - "marker", "marker COLOR-ID\n\ - Change your lobby marker color.", - true, +[](CommandArgs& args) { - auto ses = args.shell->get_proxy_session(args.session_name); - ses->server_channel.send(0x89, stoul(args.args)); - }); - -void fn_warp(CommandArgs& args) { - auto ses = args.shell->get_proxy_session(args.session_name); - - uint8_t floor = stoul(args.args); - send_warp(ses->client_channel, ses->lobby_client_id, floor, true); - if (args.command == "warpall") { - send_warp(ses->server_channel, ses->lobby_client_id, floor, false); - } -} -CommandDefinition c_warp("warp", "warp FLOOR-ID", true, fn_warp); -CommandDefinition c_warpme("warpme", "warpme FLOOR-ID\n\ - Send yourself to a specific floor.", - true, fn_warp); -CommandDefinition c_warpall("warpall", "warpall FLOOR-ID\n\ - Send everyone to a specific floor.", - true, fn_warp); - -void fn_info_board(CommandArgs& args) { - auto ses = args.shell->get_proxy_session(args.session_name); - - string data; - if (args.command == "info-board-data") { - data += phosg::parse_data_string(args.args, nullptr, phosg::ParseDataFlags::ALLOW_FILES); - } else { - data += args.args; - } - data.push_back('\0'); - data.resize((data.size() + 3) & (~3)); - - ses->server_channel.send(0xD9, 0x00, data); -} -CommandDefinition c_info_board("info-board", "info-board TEXT\n\ - Set your info board contents. This will affect the current session only,\n\ - and will not be saved for future sessions.", - true, fn_info_board); -CommandDefinition c_info_board_data("info-board-data", "info-board-data DATA\n\ - Set your info board contents with arbitrary data. Like the above, affects\n\ - the current session only.", - true, fn_info_board); - -CommandDefinition c_set_challenge_rank_title( - "set-challenge-rank-title", "set-challenge-rank-title TEXT\n\ - Set the player\'s override Challenge rank text.", - true, +[](CommandArgs& args) { - auto ses = args.shell->get_proxy_session(args.session_name); - ses->challenge_rank_title_override = args.args; - }); -CommandDefinition c_set_challenge_rank_color( - "set-challenge-rank-color", "set-challenge-rank-color RRGGBBAA\n\ - Set the player\'s override Challenge rank color.", - true, +[](CommandArgs& args) { - auto ses = args.shell->get_proxy_session(args.session_name); - ses->challenge_rank_color_override = stoul(args.args, nullptr, 16); - }); - -void fn_create_item(CommandArgs& args) { - auto ses = args.shell->get_proxy_session(args.session_name); - - if (ses->version() == Version::BB_V4) { - throw runtime_error("proxy session is BB"); - } - if (!ses->is_in_game) { - throw runtime_error("proxy session is not in a game"); - } - if (ses->lobby_client_id != ses->leader_client_id) { - throw runtime_error("proxy session is not game leader"); - } - - auto s = ses->require_server_state(); - ItemData item = s->parse_item_description(ses->version(), args.args); - item.id = phosg::random_object() | 0x80000000; - - if (args.command == "set-next-item") { - ses->next_drop_item = item; - - string name = s->describe_item(ses->version(), ses->next_drop_item, true); - send_text_message(ses->client_channel, "$C7Next drop:\n" + name); - - } else { - send_drop_stacked_item_to_channel(s, ses->client_channel, item, ses->floor, ses->pos); - send_drop_stacked_item_to_channel(s, ses->server_channel, item, ses->floor, ses->pos); - - string name = s->describe_item(ses->version(), ses->next_drop_item, true); - send_text_message(ses->client_channel, "$C7Item created:\n" + name); - } -} -CommandDefinition c_create_item("create-item", "create-item DATA\n\ - Create an item as if the client had run the $item command.", - true, fn_create_item); -CommandDefinition c_set_next_item("set-next-item", "set-next-item DATA\n\ - Set the next item to be dropped.", - true, fn_create_item); - -CommandDefinition c_close_idle_sessions( - "close-idle-sessions", "close-idle-sessions\n\ - Close all proxy sessions that don\'t have a client and server connected.", - true, +[](CommandArgs& args) { - if (args.s->proxy_server) { - size_t count = args.s->proxy_server->delete_disconnected_sessions(); - fprintf(stderr, "%zu sessions closed\n", count); - } else { - throw runtime_error("the proxy server is disabled"); - } - }); diff --git a/src/ServerShell.hh b/src/ServerShell.hh index dd57d045..545761a1 100644 --- a/src/ServerShell.hh +++ b/src/ServerShell.hh @@ -10,12 +10,6 @@ class ServerShell : public std::enable_shared_from_this { public: - class exit_shell : public std::runtime_error { - public: - exit_shell(); - ~exit_shell() = default; - }; - explicit ServerShell(std::shared_ptr state); ServerShell(const ServerShell&) = delete; ServerShell(ServerShell&&) = delete; @@ -25,8 +19,6 @@ public: std::shared_ptr get_proxy_session(const std::string& name); - void execute_command(const std::string& command); - protected: std::shared_ptr state; std::thread th; diff --git a/src/ShellCommands.cc b/src/ShellCommands.cc new file mode 100644 index 00000000..21a9e66d --- /dev/null +++ b/src/ShellCommands.cc @@ -0,0 +1,1184 @@ +#include "ShellCommands.hh" + +#include +#include +#include + +#include +#include + +#include "ChatCommands.hh" +#include "ReceiveCommands.hh" +#include "SendCommands.hh" +#include "ServerState.hh" +#include "StaticGameData.hh" + +using namespace std; + +std::vector ShellCommand::commands_by_order; +std::unordered_map ShellCommand::commands_by_name; + +exit_shell::exit_shell() : runtime_error("shell exited") {} + +ShellCommand::ShellCommand( + const char* name, + const char* help_text, + bool run_on_event_thread, + std::deque (*run)(Args&)) + : name(name), + help_text(help_text), + run_on_event_thread(run_on_event_thread), + run(run) { + ShellCommand::commands_by_order.emplace_back(this); + ShellCommand::commands_by_name.emplace(this->name, this); +} + +std::deque ShellCommand::dispatch_str(std::shared_ptr s, const std::string& command) { + size_t command_end = phosg::skip_non_whitespace(command, 0); + size_t args_begin = phosg::skip_whitespace(command, command_end); + Args args; + args.s = s; + args.command = command.substr(0, command_end); + args.args = command.substr(args_begin); + return ShellCommand::dispatch(args); +} + +std::deque ShellCommand::dispatch(Args& args) { + const ShellCommand* def = nullptr; + try { + def = commands_by_name.at(args.command); + } catch (const out_of_range&) { + } + if (!def) { + throw runtime_error("no such command; try 'help'"); + } else if (def->run_on_event_thread) { + return args.s->call_on_event_thread>([def, &args]() -> std::deque { + return def->run(args); + }); + } else { + return def->run(args); + } +} + +static shared_ptr get_proxy_session(std::shared_ptr s, const string& name) { + if (!s->proxy_server.get()) { + throw runtime_error("the proxy server is disabled"); + } + return name.empty() + ? s->proxy_server->get_session() + : s->proxy_server->get_session_by_name(name); +} + +static string get_quoted_string(string& s) { + string ret; + char end_char = (s.at(0) == '\"') ? '\"' : ' '; + size_t z = (s.at(0) == '\"') ? 1 : 0; + for (; (z < s.size()) && (s[z] != end_char); z++) { + if (s[z] == '\\') { + if (z + 1 < s.size()) { + ret.push_back(s[z + 1]); + } else { + throw runtime_error("incomplete escape sequence"); + } + } else { + ret.push_back(s[z]); + } + } + if (end_char != ' ') { + if (z >= s.size()) { + throw runtime_error("unterminated quoted string"); + } + s = s.substr(phosg::skip_whitespace(s, z + 1)); + } else { + s = s.substr(phosg::skip_whitespace(s, z)); + } + return ret; +} + +static auto empty_handler = +[](ShellCommand::Args&) -> std::deque { + return {}; +}; + +ShellCommand c_nop1("", nullptr, false, empty_handler); +ShellCommand c_nop2("//", nullptr, false, empty_handler); +ShellCommand c_nop3("#", nullptr, false, empty_handler); + +ShellCommand c_help( + "help", "help\n\ + You\'re reading it now.", + false, + +[](ShellCommand::Args&) -> std::deque { + std::deque ret({"Commands:"}); + for (const auto& def : ShellCommand::commands_by_order) { + if (def->help_text) { + // TODO: It's not great that we copy the text here. + auto& s = ret.emplace_back(" "); + s += def->help_text; + } + } + return ret; + }); +ShellCommand c_exit( + "exit", "exit (or ctrl+d)\n\ + Shut down the server.", + false, + +[](ShellCommand::Args&) -> std::deque { + throw exit_shell(); + }); +ShellCommand c_on( + "on", "on SESSION COMMAND [ARGS...]\n\ + Run a command on a specific game server client or proxy server session.\n\ + Without this prefix, commands that affect a single client or session will\n\ + work only if there's exactly one connected client or open session. SESSION\n\ + may be a client ID (e.g. C-3), a player name, an account ID, an Xbox\n\ + gamertag, or a BB account username. For proxy commands, SESSION should be\n\ + the session ID, which generally is the same as the player\'s account ID\n\ + and appears after \"LinkedSession:\" in the log output.", + false, + +[](ShellCommand::Args& args) -> std::deque { + size_t session_name_end = phosg::skip_non_whitespace(args.args, 0); + size_t command_begin = phosg::skip_whitespace(args.args, session_name_end); + size_t command_end = phosg::skip_non_whitespace(args.args, command_begin); + size_t args_begin = phosg::skip_whitespace(args.args, command_end); + args.session_name = args.args.substr(0, session_name_end); + args.command = args.args.substr(command_begin, command_end - command_begin); + args.args = args.args.substr(args_begin); + return ShellCommand::dispatch(args); + }); + +ShellCommand c_reload( + "reload", "reload ITEM [ITEM...]\n\ + Reload various parts of the server configuration. The items are:\n\ + accounts - reindex user accounts\n\ + battle-params - reload the BB enemy stats files\n\ + bb-keys - reload BB private keys\n\ + caches - clear all cached files\n\ + config - reload most fields from config.json\n\ + dol-files - reindex all DOL files\n\ + drop-tables - reload drop tables\n\ + ep3-cards - reload Episode 3 card definitions\n\ + ep3-maps - reload Episode 3 maps (not download quests)\n\ + ep3-tournaments - reload Episode 3 tournament state\n\ + functions - recompile all client-side patches and functions\n\ + item-definitions - reload item definitions files\n\ + item-name-index - regenerate item name list\n\ + level-tables - reload the player stats tables\n\ + patch-files - reindex the PC and BB patch directories\n\ + quests - reindex all quests (including Episode3 download quests)\n\ + set-tables - reload set data tables\n\ + teams - reindex all BB teams\n\ + text-index - reload in-game text\n\ + word-select - regenerate the Word Select translation table\n\ + all - do all of the above\n\ + Reloading will not affect items that are in use; for example, if an Episode\n\ + 3 battle is in progress, it will continue to use the previous map and card\n\ + definitions. Similarly, BB clients are not forced to disconnect or reload\n\ + the battle parameters, so if these are changed without restarting, clients\n\ + may see (for example) EXP messages inconsistent with the amounts of EXP\n\ + actually received.", + false, + +[](ShellCommand::Args& args) -> std::deque { + auto types = phosg::split(args.args, ' '); + for (const auto& type : types) { + if (type == "all") { + args.s->call_on_event_thread([s = args.s]() { + s->load_all(); + }); + } else if (type == "bb-keys") { + args.s->load_bb_private_keys(true); + } else if (type == "accounts") { + args.s->load_accounts(true); + } else if (type == "maps") { + args.s->load_maps(true); + } else if (type == "caches") { + args.s->clear_file_caches(true); + } else if (type == "patch-files") { + args.s->load_patch_indexes(true); + } else if (type == "ep3-cards") { + args.s->load_ep3_cards(true); + } else if (type == "ep3-maps") { + args.s->load_ep3_maps(true); + } else if (type == "ep3-tournaments") { + args.s->load_ep3_tournament_state(true); + } else if (type == "functions") { + args.s->compile_functions(true); + } else if (type == "dol-files") { + args.s->load_dol_files(true); + } else if (type == "set-tables") { + args.s->load_set_data_tables(true); + } else if (type == "battle-params") { + args.s->load_battle_params(true); + } else if (type == "level-tables") { + args.s->load_level_tables(true); + } else if (type == "text-index") { + args.s->load_text_index(true); + } else if (type == "word-select") { + args.s->load_word_select_table(true); + } else if (type == "item-definitions") { + args.s->load_item_definitions(true); + } else if (type == "item-name-index") { + args.s->load_item_name_indexes(true); + } else if (type == "drop-tables") { + args.s->load_drop_tables(true); + } else if (type == "config") { + args.s->forward_to_event_thread([s = args.s]() { + s->load_config_early(); + s->load_config_late(); + }); + } else if (type == "teams") { + args.s->load_teams(true); + } else if (type == "quests") { + args.s->load_quest_index(true); + } else { + throw runtime_error("invalid data type: " + type); + } + } + + return {}; + }); + +ShellCommand c_list_accounts( + "list-accounts", "list-accounts\n\ + List all accounts registered on the server.", + true, + +[](ShellCommand::Args& args) -> std::deque { + auto accounts = args.s->account_index->all(); + if (accounts.empty()) { + return {"No accounts registered"}; + } else { + std::deque ret; + for (const auto& a : accounts) { + ret.emplace_back(a->str()); + } + return ret; + } + }); + +uint32_t parse_account_flags(const string& flags_str) { + try { + size_t end_pos = 0; + uint32_t ret = stoul(flags_str, &end_pos, 16); + if (end_pos == flags_str.size()) { + return ret; + } + } catch (const exception&) { + } + + uint32_t ret = 0; + auto tokens = phosg::split(flags_str, ','); + for (const auto& token : tokens) { + string token_upper = phosg::toupper(token); + if (token_upper == "NONE") { + // Nothing to do + } else if (token_upper == "KICK_USER") { + ret |= static_cast(Account::Flag::KICK_USER); + } else if (token_upper == "BAN_USER") { + ret |= static_cast(Account::Flag::BAN_USER); + } else if (token_upper == "SILENCE_USER") { + ret |= static_cast(Account::Flag::SILENCE_USER); + } else if (token_upper == "MODERATOR") { + ret |= static_cast(Account::Flag::MODERATOR); + } else if (token_upper == "CHANGE_EVENT") { + ret |= static_cast(Account::Flag::CHANGE_EVENT); + } else if (token_upper == "ANNOUNCE") { + ret |= static_cast(Account::Flag::ANNOUNCE); + } else if (token_upper == "FREE_JOIN_GAMES") { + ret |= static_cast(Account::Flag::FREE_JOIN_GAMES); + } else if (token_upper == "ADMINISTRATOR") { + ret |= static_cast(Account::Flag::ADMINISTRATOR); + } else if (token_upper == "DEBUG") { + ret |= static_cast(Account::Flag::DEBUG); + } else if (token_upper == "CHEAT_ANYWHERE") { + ret |= static_cast(Account::Flag::CHEAT_ANYWHERE); + } else if (token_upper == "DISABLE_QUEST_REQUIREMENTS") { + ret |= static_cast(Account::Flag::DISABLE_QUEST_REQUIREMENTS); + } else if (token_upper == "ALWAYS_ENABLE_CHAT_COMMANDS") { + ret |= static_cast(Account::Flag::ALWAYS_ENABLE_CHAT_COMMANDS); + } else if (token_upper == "ROOT") { + ret |= static_cast(Account::Flag::ROOT); + } else if (token_upper == "IS_SHARED_ACCOUNT") { + ret |= static_cast(Account::Flag::IS_SHARED_ACCOUNT); + } else { + throw runtime_error("invalid flag name: " + token_upper); + } + } + return ret; +} + +uint32_t parse_account_user_flags(const string& user_flags_str) { + try { + size_t end_pos = 0; + uint32_t ret = stoul(user_flags_str, &end_pos, 16); + if (end_pos == user_flags_str.size()) { + return ret; + } + } catch (const exception&) { + } + + uint32_t ret = 0; + auto tokens = phosg::split(user_flags_str, ','); + for (const auto& token : tokens) { + string token_upper = phosg::toupper(token); + if (token_upper == "NONE") { + // Nothing to do + } else if (token_upper == "DISABLE_DROP_NOTIFICATION_BROADCAST") { + ret |= static_cast(Account::UserFlag::DISABLE_DROP_NOTIFICATION_BROADCAST); + } else { + throw runtime_error("invalid user flag name: " + token_upper); + } + } + return ret; +} + +ShellCommand c_add_account( + "add-account", "add-account [PARAMETERS...]\n\ + Add an account to the server. is some subset of:\n\ + id=ACCOUNT-ID: preferred account ID in hex (optional)\n\ + flags=FLAGS: behaviors and permissions for the account (see below)\n\ + user-flags=FLAGS: user-set behaviors for the account\n\ + ep3-current-meseta=MESETA: Episode 3 Meseta value\n\ + ep3-total-meseta=MESETA: Episode 3 total Meseta ever earned\n\ + temporary: marks the account as temporary; it is not saved to disk and\n\ + therefore will be deleted when the server shuts down\n\ + If given, FLAGS is a comma-separated list of zero or more the following:\n\ + NONE: Placeholder if no other flags are specified\n\ + KICK_USER: Can kick other users offline\n\ + BAN_USER: Can ban other users\n\ + SILENCE_USER: Can silence other users\n\ + MODERATOR: Alias for all of the above flags\n\ + CHANGE_EVENT: Can change lobby events\n\ + ANNOUNCE: Can make server-wide announcements\n\ + FREE_JOIN_GAMES: Ignores game restrictions (level/quest requirements)\n\ + ADMINISTRATOR: Alias for all of the above flags (including MODERATOR)\n\ + DEBUG: Can use debugging commands\n\ + CHEAT_ANYWHERE: Can use cheat commands even if cheat mode is disabled\n\ + DISABLE_QUEST_REQUIREMENTS: Can play any quest without progression\n\ + restrictions\n\ + ALWAYS_ENABLE_CHAT_COMMANDS: Can use chat commands even if they are\n\ + disabled in config.json\n\ + ROOT: Alias for all of the above flags (including ADMINISTRATOR)\n\ + IS_SHARED_ACCOUNT: Account is a shared serial (disables Access Key and\n\ + password checks; players will get Guild Cards based on their player\n\ + names)", + true, + +[](ShellCommand::Args& args) -> std::deque { + auto account = make_shared(); + for (const string& token : phosg::split(args.args, ' ')) { + if (phosg::starts_with(token, "id=")) { + account->account_id = stoul(token.substr(3), nullptr, 16); + } else if (phosg::starts_with(token, "ep3-current-meseta=")) { + account->ep3_current_meseta = stoul(token.substr(19), nullptr, 0); + } else if (phosg::starts_with(token, "ep3-total-meseta=")) { + account->ep3_total_meseta_earned = stoul(token.substr(17), nullptr, 0); + } else if (token == "temporary") { + account->is_temporary = true; + } else if (phosg::starts_with(token, "flags=")) { + account->flags = parse_account_flags(token.substr(6)); + } else if (phosg::starts_with(token, "user-flags=")) { + account->user_flags = parse_account_user_flags(token.substr(11)); + } else { + throw invalid_argument("invalid account field: " + token); + } + } + args.s->account_index->add(account); + account->save(); + return {phosg::string_printf("Account %08" PRIX32 " added", account->account_id)}; + }); +ShellCommand c_update_account( + "update-account", "update-account ACCOUNT-ID PARAMETERS...\n\ + Update an existing license. ACCOUNT-ID (8 hex digits) specifies which\n\ + account to update. The options are similar to the add-account command:\n\ + flags=FLAGS: Sets behaviors and permissions for the account (same as\n\ + add-account).\n\ + user-flags=FLAGS: Sets behaviors for the account (same as add-account).\n\ + ban-duration=DURATION: bans this account for the specified duration; the\n\ + duration should be of the form 3d, 2w, 1mo, or 1y. If any clients\n\ + are connected with this account when this command is run, they will\n\ + be disconnected.\n\ + unban: Clears any existing ban from this account.\n\ + ep3-current-meseta=MESETA: Sets Episode 3 Meseta value.\n\ + ep3-total-meseta=MESETA: Sets Episode 3 total Meseta ever earned.\n\ + temporary: Marks the account as temporary; it is not saved to disk and\n\ + therefore will be deleted when the server shuts down.\n\ + permanent: If the account was temporary, makes it non-temporary.", + true, + +[](ShellCommand::Args& args) -> std::deque { + auto tokens = phosg::split(args.args, ' '); + if (tokens.size() < 2) { + throw runtime_error("not enough arguments"); + } + auto account = args.s->account_index->from_account_id(stoul(tokens[0], nullptr, 16)); + tokens.erase(tokens.begin()); + + // Do all the parsing first, then the updates afterward, so we won't + // partially update the account if parsing a later option fails + int64_t new_ep3_current_meseta = -1; + int64_t new_ep3_total_meseta = -1; + int64_t new_flags = -1; + int64_t new_user_flags = -1; + uint8_t new_is_temporary = 0xFF; + int64_t new_ban_duration = -1; + for (const string& token : tokens) { + if (phosg::starts_with(token, "ep3-current-meseta=")) { + new_ep3_current_meseta = stoul(token.substr(19), nullptr, 0); + } else if (phosg::starts_with(token, "ep3-total-meseta=")) { + new_ep3_total_meseta = stoul(token.substr(17), nullptr, 0); + } else if (token == "temporary") { + new_is_temporary = 1; + } else if (token == "permanent") { + new_is_temporary = 0; + } else if (phosg::starts_with(token, "flags=")) { + new_flags = parse_account_flags(token.substr(6)); + } else if (phosg::starts_with(token, "user-flags=")) { + new_user_flags = parse_account_user_flags(token.substr(11)); + } else if (token == "unban") { + new_ban_duration = 0; + } else if (phosg::starts_with(token, "ban-duration=")) { + auto duration_str = token.substr(13); + if (phosg::ends_with(duration_str, "s")) { + new_ban_duration = stoull(duration_str.substr(0, duration_str.size() - 1)) * 1000000LL; + } else if (phosg::ends_with(duration_str, "m")) { + new_ban_duration = stoull(duration_str.substr(0, duration_str.size() - 1)) * 60000000LL; + } else if (phosg::ends_with(duration_str, "h")) { + new_ban_duration = stoull(duration_str.substr(0, duration_str.size() - 1)) * 3600000000LL; + } else if (phosg::ends_with(duration_str, "d")) { + new_ban_duration = stoull(duration_str.substr(0, duration_str.size() - 1)) * 86400000000LL; + } else if (phosg::ends_with(duration_str, "w")) { + new_ban_duration = stoull(duration_str.substr(0, duration_str.size() - 1)) * 604800000000LL; + } else if (phosg::ends_with(duration_str, "mo")) { + new_ban_duration = stoull(duration_str.substr(0, duration_str.size() - 2)) * 2952000000000LL; + } else if (phosg::ends_with(duration_str, "y")) { + new_ban_duration = stoull(duration_str.substr(0, duration_str.size() - 1)) * 31536000000000LL; + } else { + throw runtime_error("invalid time unit"); + } + } else { + throw invalid_argument("invalid account field: " + token); + } + } + + if (new_ban_duration >= 0) { + account->ban_end_time = phosg::now() + new_ban_duration; + } + if (new_ep3_current_meseta >= 0) { + account->ep3_current_meseta = new_ep3_current_meseta; + } + if (new_ep3_total_meseta >= 0) { + account->ep3_total_meseta_earned = new_ep3_total_meseta; + } + if (new_flags >= 0) { + account->flags = new_flags; + } + if (new_user_flags >= 0) { + account->user_flags = new_user_flags; + } + if (new_is_temporary != 0xFF) { + account->is_temporary = new_is_temporary; + } + + account->save(); + if (new_ban_duration > 0) { + args.s->disconnect_all_banned_clients(); + } + + return {phosg::string_printf("Account %08" PRIX32 " updated", account->account_id)}; + }); +ShellCommand c_delete_account( + "delete-account", "delete-account ACCOUNT-ID\n\ + Delete an account from the server. If a player is online with the deleted\n\ + account, they will not be automatically disconnected.", + true, + +[](ShellCommand::Args& args) -> std::deque { + auto account = args.s->account_index->from_account_id(stoul(args.args, nullptr, 16)); + args.s->account_index->remove(account->account_id); + account->is_temporary = true; + account->delete_file(); + return {"Account deleted"}; + }); + +ShellCommand c_add_license( + "add-license", "add-license ACCOUNT-ID TYPE CREDENTIALS...\n\ + Add a license to an account. Each account may have multiple licenses of\n\ + each type. The types are:\n\ + DC-NTE: CREDENTIALS is serial number and access key (16 characters each)\n\ + DC: CREDENTIALS is serial number and access key (8 characters each)\n\ + PC: CREDENTIALS is serial number and access key (8 characters each)\n\ + GC: CREDENTIALS is serial number (10 digits), access key (12 digits), and\n\ + password (up to 8 characters)\n\ + XB: CREDENTIALS is gamertag (up to 16 characters), user ID (16 hex\n\ + digits), and account ID (16 hex digits)\n\ + BB: CREDENTIALS is username and password (up to 16 characters each)\n\ + Examples (adding licenses to account 385A92C4):\n\ + add-license 385A92C4 DC 107862F9 d38XTu2p\n\ + add-license 385A92C4 GC 0418572923 282949185033 hunter2\n\ + add-license 385A92C4 BB user1 trustno1", + true, + +[](ShellCommand::Args& args) -> std::deque { + auto tokens = phosg::split(args.args, ' '); + if (tokens.size() < 3) { + throw runtime_error("not enough arguments"); + } + + auto account = args.s->account_index->from_account_id(stoul(tokens[0], nullptr, 16)); + + string type_str = phosg::toupper(tokens[1]); + if (type_str == "DC-NTE") { + if (tokens.size() != 4) { + throw runtime_error("incorrect number of parameters"); + } + auto license = make_shared(); + license->serial_number = std::move(tokens[2]); + license->access_key = std::move(tokens[3]); + args.s->account_index->add_dc_nte_license(account, license); + + } else if (type_str == "DC") { + if (tokens.size() != 4) { + throw runtime_error("incorrect number of parameters"); + } + auto license = make_shared(); + license->serial_number = stoul(tokens[2], nullptr, 16); + license->access_key = std::move(tokens[3]); + args.s->account_index->add_dc_license(account, license); + + } else if (type_str == "PC") { + if (tokens.size() != 4) { + throw runtime_error("incorrect number of parameters"); + } + auto license = make_shared(); + license->serial_number = stoul(tokens[2], nullptr, 16); + license->access_key = std::move(tokens[3]); + args.s->account_index->add_pc_license(account, license); + + } else if (type_str == "GC") { + if (tokens.size() != 5) { + throw runtime_error("incorrect number of parameters"); + } + auto license = make_shared(); + license->serial_number = stoul(tokens[2], nullptr, 10); + license->access_key = std::move(tokens[3]); + license->password = std::move(tokens[4]); + args.s->account_index->add_gc_license(account, license); + + } else if (type_str == "XB") { + if (tokens.size() != 5) { + throw runtime_error("incorrect number of parameters"); + } + auto license = make_shared(); + license->gamertag = std::move(tokens[2]); + license->user_id = stoull(tokens[3], nullptr, 16); + license->account_id = stoull(tokens[4], nullptr, 16); + args.s->account_index->add_xb_license(account, license); + + } else if (type_str == "BB") { + if (tokens.size() != 4) { + throw runtime_error("incorrect number of parameters"); + } + auto license = make_shared(); + license->username = std::move(tokens[2]); + license->password = std::move(tokens[3]); + args.s->account_index->add_bb_license(account, license); + + } else { + throw runtime_error("invalid license type"); + } + + account->save(); + return {phosg::string_printf("Account %08" PRIX32 " updated", account->account_id)}; + }); +ShellCommand c_delete_license( + "delete-license", "delete-license ACCOUNT-ID TYPE PRIMARY-CREDENTIAL\n\ + Delete a license from an account. ACCOUNT-ID and TYPE have the same\n\ + meanings as for add-license. PRIMARY-CREDENTIAL is the first credential\n\ + for the license type; specifically:\n\ + DC-NTE: PRIMARY-CREDENTIAL is the serial number\n\ + DC: PRIMARY-CREDENTIAL is the serial number (8 hex digits)\n\ + PC: PRIMARY-CREDENTIAL is the serial number (8 hex digits)\n\ + GC: PRIMARY-CREDENTIAL is the serial number (decimal)\n\ + XB: PRIMARY-CREDENTIAL is the user ID (16 hex digits)\n\ + BB: PRIMARY-CREDENTIAL is the username\n\ + Examples (deleting licenses from account 385A92C4):\n\ + delete-license 385A92C4 DC 107862F9\n\ + delete-license 385A92C4 PC 2F94C303\n\ + delete-license 385A92C4 GC 0418572923\n\ + delete-license 385A92C4 XB 7E29A2950019EB20\n\ + delete-license 385A92C4 BB user1", + true, + +[](ShellCommand::Args& args) -> std::deque { + auto tokens = phosg::split(args.args, ' '); + if (tokens.size() != 3) { + throw runtime_error("incorrect argument count"); + } + + auto account = args.s->account_index->from_account_id(stoul(tokens[0], nullptr, 16)); + + string type_str = phosg::toupper(tokens[1]); + if (type_str == "DC-NTE") { + args.s->account_index->remove_dc_nte_license(account, tokens[2]); + } else if (type_str == "DC") { + args.s->account_index->remove_dc_license(account, stoul(tokens[2], nullptr, 16)); + } else if (type_str == "PC") { + args.s->account_index->remove_pc_license(account, stoul(tokens[2], nullptr, 16)); + } else if (type_str == "GC") { + args.s->account_index->remove_gc_license(account, stoul(tokens[2], nullptr, 0)); + } else if (type_str == "XB") { + args.s->account_index->remove_xb_license(account, stoull(tokens[2], nullptr, 16)); + } else if (type_str == "BB") { + args.s->account_index->remove_bb_license(account, tokens[2]); + } else { + throw runtime_error("invalid license type"); + } + + account->save(); + return {phosg::string_printf("Account %08" PRIX32 " updated", account->account_id)}; + }); + +ShellCommand c_lookup( + "lookup", "lookup USER\n\ + Find the account for a logged-in user.", + true, + +[](ShellCommand::Args& args) -> std::deque { + auto target = args.s->find_client(&args.args); + if (target->login) { + return {phosg::string_printf("Found client %s with account ID %08" PRIX32, + target->channel.name.c_str(), target->login->account->account_id)}; + } else { + // This should be impossible + throw std::logic_error("find_client found user who is not logged in"); + } + }); +ShellCommand c_kick( + "kick", "kick USER\n\ + Disconnect a user from the server. USER may be an account ID, player name,\n\ + or client ID (beginning with \"C-\"). This does not ban the user; they are\n\ + free to reconnect after doing this.", + true, + +[](ShellCommand::Args& args) -> std::deque { + auto target = args.s->find_client(&args.args); + send_message_box(target, "$C6You have been kicked off the server."); + target->should_disconnect = true; + return {phosg::string_printf("Client C-%" PRIX64 " disconnected from server", target->id)}; + }); + +ShellCommand c_announce( + "announce", "announce MESSAGE\n\ + Send an announcement message to all players.", + true, + +[](ShellCommand::Args& args) -> std::deque { + send_text_message(args.s, args.args); + return {}; + }); +ShellCommand c_announce_mail( + "announce-mail", "announce-mail MESSAGE\n\ + Send an announcement message via Simple Mail to all players.", + true, + +[](ShellCommand::Args& args) -> std::deque { + send_simple_mail(args.s, 0, args.s->name, args.args); + return {}; + }); + +ShellCommand c_create_tournament( + "create-tournament", "create-tournament TOURNAMENT-NAME MAP-NAME NUM-TEAMS [OPTIONS...]\n\ + Create an Episode 3 tournament. Quotes are required around the tournament\n\ + and map names, unless the names contain no spaces.\n\ + OPTIONS may include:\n\ + 2v2: Set team size to 2 players (default is 1 without this option)\n\ + no-coms: Don\'t add any COM teams to the tournament bracket\n\ + shuffle: Shuffle entries when starting the tournament\n\ + resize: If the tournament is less than half full when it starts, reduce\n\ + the number of rounds to fit the existing entries\n\ + dice=A-B: Set minimum and maximum dice rolls\n\ + dice=A-B:C-D: Set minimum and maximum dice rolls for ATK dice (A-B) and\n\ + DEF dice (C-D) separately\n\ + dice=A-B:C-D:E-F: Set minimum and maximum dice rolls for ATK dice, DEF\n\ + dice, and solo vs. 2P ATK and DEF dice (E-F) separately\n\ + dice=A-B:C-D:E-F:G-H: Set minimum and maximum dice rolls for ATK dice,\n\ + DEF dice, solo vs. 2P ATK (E-F) and DEF (G-H) dice separately\n\ + overall-time-limit=N: Set battle time limit (in multiples of 5 minutes)\n\ + phase-time-limit=N: Set phase time limit (in seconds)\n\ + allowed-cards=ALL/N/NR/NRS: Set ranks of allowed cards\n\ + deck-shuffle=ON/OFF: Enable/disable deck shuffle\n\ + deck-loop=ON/OFF: Enable/disable deck loop\n\ + hp=N: Set Story Character initial HP\n\ + hp-type=TEAM/PLAYER/COMMON: Set team HP type\n\ + allow-assists=ON/OFF: Enable/disable assist cards\n\ + dialogue=ON/OFF: Enable/disable dialogue\n\ + dice-exchange=ATK/DEF/NONE: Set dice exchange mode\n\ + dice-boost=ON/OFF: Enable/disable dice boost", + true, + +[](ShellCommand::Args& args) -> std::deque { + string name = get_quoted_string(args.args); + string map_name = get_quoted_string(args.args); + auto map = args.s->ep3_map_index->for_name(map_name); + uint32_t num_teams = stoul(get_quoted_string(args.args), nullptr, 0); + Episode3::Rules rules; + rules.set_defaults(); + uint8_t flags = Episode3::Tournament::Flag::HAS_COM_TEAMS; + if (!args.args.empty()) { + auto tokens = phosg::split(args.args, ' '); + for (auto& token : tokens) { + token = phosg::tolower(token); + if (token == "2v2") { + flags |= Episode3::Tournament::Flag::IS_2V2; + } else if (token == "no-coms") { + flags &= (~Episode3::Tournament::Flag::HAS_COM_TEAMS); + } else if (token == "shuffle") { + flags |= Episode3::Tournament::Flag::SHUFFLE_ENTRIES; + } else if (token == "resize") { + flags |= Episode3::Tournament::Flag::RESIZE_ON_START; + } else if (phosg::starts_with(token, "dice=")) { + auto parse_range_c = +[](const string& s) -> uint8_t { + auto tokens = phosg::split(s, '-'); + if (tokens.size() != 2) { + throw runtime_error("dice spec must be of the form MIN-MAX"); + } + return (stoul(tokens[0]) << 4) | (stoul(tokens[1]) & 0x0F); + }; + auto parse_range_p = +[](const string& s) -> pair { + auto tokens = phosg::split(s, '-'); + if (tokens.size() != 2) { + throw runtime_error("dice spec must be of the form MIN-MAX"); + } + return make_pair(stoul(tokens[0]), stoul(tokens[1])); + }; + + auto subtokens = phosg::split(token.substr(5), ':'); + if (subtokens.size() < 1) { + throw runtime_error("no dice ranges specified in dice= option"); + } + auto atk_range = parse_range_p(tokens[0]); + rules.min_dice_value = atk_range.first; + rules.max_dice_value = atk_range.second; + if (subtokens.size() >= 2) { + rules.def_dice_value_range = parse_range_c(tokens[1]); + if (subtokens.size() >= 3) { + rules.atk_dice_value_range_2v1 = parse_range_c(tokens[2]); + if (subtokens.size() == 3) { + rules.def_dice_value_range_2v1 = rules.atk_dice_value_range_2v1; + } else if (subtokens.size() == 4) { + rules.def_dice_value_range_2v1 = parse_range_c(tokens[3]); + } else { + throw runtime_error("too many range specs given"); + } + } else { + rules.atk_dice_value_range_2v1 = 0; + rules.def_dice_value_range_2v1 = 0; + } + } else { + rules.def_dice_value_range = 0; + rules.atk_dice_value_range_2v1 = 0; + rules.def_dice_value_range_2v1 = 0; + } + } else if (phosg::starts_with(token, "overall-time-limit=")) { + uint32_t limit = stoul(token.substr(19)); + if (limit > 600) { + throw runtime_error("overall-time-limit must be 600 or fewer minutes"); + } + if (limit % 5) { + throw runtime_error("overall-time-limit must be a multiple of 5 minutes"); + } + rules.overall_time_limit = limit; + } else if (phosg::starts_with(token, "phase-time-limit=")) { + rules.phase_time_limit = stoul(token.substr(17)); + } else if (phosg::starts_with(token, "hp=")) { + rules.char_hp = stoul(token.substr(3)); + } else if (token == "allowed-cards=all") { + rules.allowed_cards = Episode3::AllowedCards::ALL; + } else if (token == "allowed-cards=n") { + rules.allowed_cards = Episode3::AllowedCards::N_ONLY; + } else if (token == "allowed-cards=nr") { + rules.allowed_cards = Episode3::AllowedCards::N_R_ONLY; + } else if (token == "allowed-cards=nrs") { + rules.allowed_cards = Episode3::AllowedCards::N_R_S_ONLY; + } else if (token == "deck-shuffle=on") { + rules.disable_deck_shuffle = 0; + } else if (token == "deck-shuffle=off") { + rules.disable_deck_shuffle = 1; + } else if (token == "deck-loop=on") { + rules.disable_deck_loop = 0; + } else if (token == "deck-loop=off") { + rules.disable_deck_loop = 1; + } else if (token == "allow-assists=on") { + rules.no_assist_cards = 0; + } else if (token == "allow-assists=off") { + rules.no_assist_cards = 1; + } else if (token == "dialogue=on") { + rules.disable_dialogue = 0; + } else if (token == "dialogue=off") { + rules.disable_dialogue = 1; + } else if (token == "dice-boost=on") { + rules.disable_dice_boost = 0; + } else if (token == "dice-boost=off") { + rules.disable_dice_boost = 1; + } else if (token == "hp-type=player") { + rules.hp_type = Episode3::HPType::DEFEAT_PLAYER; + } else if (token == "hp-type=team") { + rules.hp_type = Episode3::HPType::DEFEAT_TEAM; + } else if (token == "hp-type=common") { + rules.hp_type = Episode3::HPType::COMMON_HP; + } else if (token == "dice-exchange=atk") { + rules.dice_exchange_mode = Episode3::DiceExchangeMode::HIGH_ATK; + } else if (token == "dice-exchange=def") { + rules.dice_exchange_mode = Episode3::DiceExchangeMode::HIGH_DEF; + } else if (token == "dice-exchange=none") { + rules.dice_exchange_mode = Episode3::DiceExchangeMode::NONE; + } else { + throw runtime_error("invalid rules option: " + token); + } + } + } + std::deque ret; + if (rules.check_and_reset_invalid_fields()) { + ret.emplace_back("Warning: Some rules were invalid and reset to defaults"); + } + auto tourn = args.s->ep3_tournament_index->create_tournament(name, map, rules, num_teams, flags); + ret.emplace_back(phosg::string_printf("Created tournament \"%s\"", tourn->get_name().c_str())); + return ret; + }); + +ShellCommand c_delete_tournament( + "delete-tournament", "delete-tournament TOURNAMENT-NAME\n\ + Delete a tournament. Quotes are required around the tournament name unless\n\ + the name contains no spaces.", + true, + +[](ShellCommand::Args& args) -> std::deque { + string name = get_quoted_string(args.args); + if (args.s->ep3_tournament_index->delete_tournament(name)) { + return {"Deleted tournament"}; + } else { + throw runtime_error("tournament does not exist"); + } + }); +ShellCommand c_list_tournaments( + "list-tournaments", "list-tournaments\n\ + List the names and numbers of all existing tournaments.", + true, + +[](ShellCommand::Args& args) -> std::deque { + std::deque ret; + for (const auto& it : args.s->ep3_tournament_index->all_tournaments()) { + ret.emplace_back(" " + it.second->get_name()); + } + return ret; + }); +ShellCommand c_start_tournament( + "start-tournament", "start-tournament TOURNAMENT-NAME\n\ + End registration for a tournament and allow matches to begin. Quotes are\n\ + required around the tournament name unless the name contains no spaces.", + true, + +[](ShellCommand::Args& args) -> std::deque { + string name = get_quoted_string(args.args); + auto tourn = args.s->ep3_tournament_index->get_tournament(name); + if (tourn) { + tourn->start(); + args.s->ep3_tournament_index->save(); + tourn->send_all_state_updates(); + send_ep3_text_message_printf(args.s, "$C7The tournament\n$C6%s$C7\nhas begun", tourn->get_name().c_str()); + return {"Tournament started"}; + } else { + throw runtime_error("tournament does not exist"); + } + }); +ShellCommand c_describe_tournament( + "describe-tournament", "describe-tournament TOURNAMENT-NAME\n\ + Show the current state of a tournament. Quotes are required around the\n\ + tournament name unless the name contains no spaces.", + true, + +[](ShellCommand::Args& args) -> std::deque { + string name = get_quoted_string(args.args); + auto tourn = args.s->ep3_tournament_index->get_tournament(name); + if (tourn) { + return {tourn->bracket_str()}; + } else { + throw runtime_error("tournament does not exist"); + } + }); + +ShellCommand c_cc( + "cc", "cc COMMAND\n\ + Execute a chat command as if a client had sent it in-game. The command\n\ + should be specified exactly as it would be typed in-game; for example:\n\ + cc $itemnotifs on\n\ + This command cannot send chat messages to other players or to the server\n\ + (in proxy sessions); it can only execute chat commands.", + true, + +[](ShellCommand::Args& args) -> std::deque { + shared_ptr ses; + try { + ses = get_proxy_session(args.s, args.session_name); + } catch (const exception&) { + } + + if (ses.get()) { + on_chat_command(ses, args.args); + } else { + shared_ptr c; + if (args.session_name.empty()) { + c = args.s->game_server->get_client(); + } else { + auto clients = args.s->game_server->get_clients_by_identifier(args.session_name); + if (clients.empty()) { + throw runtime_error("no such client"); + } + if (clients.size() > 1) { + throw runtime_error("multiple clients found"); + } + c = std::move(clients[0]); + } + + if (c) { + on_chat_command(c, args.args); + } else { + throw runtime_error("no client available"); + } + } + return {}; + }); + +std::deque f_sc_ss(ShellCommand::Args& args) { + string data = phosg::parse_data_string(args.args, nullptr, phosg::ParseDataFlags::ALLOW_FILES); + if (data.size() == 0) { + throw invalid_argument("no data given"); + } + data.resize((data.size() + 3) & (~3)); + + shared_ptr ses; + try { + ses = get_proxy_session(args.s, args.session_name); + } catch (const exception&) { + } + + if (ses.get()) { + if (args.command[1] == 's') { + ses->server_channel.send(data); + } else { + ses->client_channel.send(data); + } + + } else { + shared_ptr c; + if (args.session_name.empty()) { + c = args.s->game_server->get_client(); + } else { + auto clients = args.s->game_server->get_clients_by_identifier(args.session_name); + if (clients.empty()) { + throw runtime_error("no such client"); + } + if (clients.size() > 1) { + throw runtime_error("multiple clients found"); + } + c = std::move(clients[0]); + } + + if (c) { + if (args.command[1] == 's') { + on_command_with_header(c, data); + } else { + send_command_with_header(c->channel, data.data(), data.size()); + } + } else { + throw runtime_error("no client available"); + } + } + + return {}; +} + +ShellCommand c_sc("sc", "sc DATA\n\ + Send a command to the client. This command also can be used to send data to\n\ + a client on the game server.", + true, f_sc_ss); +ShellCommand c_ss("ss", "ss DATA\n\ + Send a command to the remote server.", + true, f_sc_ss); + +ShellCommand c_show_slots( + "show-slots", "show-slots\n\ + Show the player names, Guild Card numbers, and client IDs of all players in\n\ + the current lobby or game.", + true, + +[](ShellCommand::Args& args) -> std::deque { + std::deque ret; + auto ses = get_proxy_session(args.s, args.session_name); + for (size_t z = 0; z < ses->lobby_players.size(); z++) { + const auto& player = ses->lobby_players[z]; + if (player.guild_card_number) { + ret.emplace_back(phosg::string_printf(" %zu: %" PRIu32 " => %s (%c, %s, %s)", + z, player.guild_card_number, player.name.c_str(), + char_for_language_code(player.language), + name_for_char_class(player.char_class), + name_for_section_id(player.section_id))); + } else { + ret.emplace_back(phosg::string_printf(" %zu: (no player)", z)); + } + } + return ret; + }); + +std::deque fn_chat(ShellCommand::Args& args) { + auto ses = get_proxy_session(args.s, args.session_name); + bool is_dchat = (args.command == "dchat"); + + if (!is_dchat && uses_utf16(ses->version())) { + send_chat_message_from_client(ses->server_channel, args.args, 0); + } else { + string data(8, '\0'); + data.push_back('\x09'); + data.push_back('E'); + if (is_dchat) { + data += phosg::parse_data_string(args.args, nullptr, phosg::ParseDataFlags::ALLOW_FILES); + } else { + data += args.args; + data.push_back('\0'); + } + data.resize((data.size() + 3) & (~3)); + ses->server_channel.send(0x06, 0x00, data); + } + + return {}; +} +ShellCommand c_c("c", "c TEXT", true, fn_chat); +ShellCommand c_chat("chat", "chat TEXT\n\ + Send a chat message to the server.", + true, fn_chat); +ShellCommand c_dchat("dchat", "dchat DATA\n\ + Send a chat message to the server with arbitrary data in it.", + true, fn_chat); + +std::deque fn_wchat(ShellCommand::Args& args) { + auto ses = get_proxy_session(args.s, args.session_name); + if (!is_ep3(ses->version())) { + throw runtime_error("wchat can only be used on Episode 3"); + } + string data(8, '\0'); + data.push_back('\x40'); // private_flags: visible to all + data.push_back('\x09'); + data.push_back('E'); + data += args.args; + data.push_back('\0'); + data.resize((data.size() + 3) & (~3)); + ses->server_channel.send(0x06, 0x00, data); + return {}; +} +ShellCommand c_wc("wc", "wc TEXT", true, fn_wchat); +ShellCommand c_wchat("wchat", "wchat TEXT\n\ + Send a chat message with private_flags on Episode 3.", + true, fn_wchat); + +ShellCommand c_marker( + "marker", "marker COLOR-ID\n\ + Change your lobby marker color.", + true, +[](ShellCommand::Args& args) -> std::deque { + auto ses = get_proxy_session(args.s, args.session_name); + ses->server_channel.send(0x89, stoul(args.args)); + return {}; + }); + +std::deque fn_warp(ShellCommand::Args& args) { + auto ses = get_proxy_session(args.s, args.session_name); + + uint8_t floor = stoul(args.args); + send_warp(ses->client_channel, ses->lobby_client_id, floor, true); + if (args.command == "warpall") { + send_warp(ses->server_channel, ses->lobby_client_id, floor, false); + } + return {}; +} +ShellCommand c_warp("warp", "warp FLOOR-ID", true, fn_warp); +ShellCommand c_warpme("warpme", "warpme FLOOR-ID\n\ + Send yourself to a specific floor.", + true, fn_warp); +ShellCommand c_warpall("warpall", "warpall FLOOR-ID\n\ + Send everyone to a specific floor.", + true, fn_warp); + +std::deque fn_info_board(ShellCommand::Args& args) { + auto ses = get_proxy_session(args.s, args.session_name); + + string data; + if (args.command == "info-board-data") { + data += phosg::parse_data_string(args.args, nullptr, phosg::ParseDataFlags::ALLOW_FILES); + } else { + data += args.args; + } + data.push_back('\0'); + data.resize((data.size() + 3) & (~3)); + + ses->server_channel.send(0xD9, 0x00, data); + return {}; +} +ShellCommand c_info_board("info-board", "info-board TEXT\n\ + Set your info board contents. This will affect the current session only,\n\ + and will not be saved for future sessions.", + true, fn_info_board); +ShellCommand c_info_board_data("info-board-data", "info-board-data DATA\n\ + Set your info board contents with arbitrary data. Like the above, affects\n\ + the current session only.", + true, fn_info_board); + +ShellCommand c_set_challenge_rank_title( + "set-challenge-rank-title", "set-challenge-rank-title TEXT\n\ + Set the player\'s override Challenge rank text.", + true, +[](ShellCommand::Args& args) -> std::deque { + auto ses = get_proxy_session(args.s, args.session_name); + ses->challenge_rank_title_override = args.args; + return {}; + }); +ShellCommand c_set_challenge_rank_color( + "set-challenge-rank-color", "set-challenge-rank-color RRGGBBAA\n\ + Set the player\'s override Challenge rank color.", + true, +[](ShellCommand::Args& args) -> std::deque { + auto ses = get_proxy_session(args.s, args.session_name); + ses->challenge_rank_color_override = stoul(args.args, nullptr, 16); + return {}; + }); + +std::deque fn_create_item(ShellCommand::Args& args) { + auto ses = get_proxy_session(args.s, args.session_name); + + if (ses->version() == Version::BB_V4) { + throw runtime_error("proxy session is BB"); + } + if (!ses->is_in_game) { + throw runtime_error("proxy session is not in a game"); + } + if (ses->lobby_client_id != ses->leader_client_id) { + throw runtime_error("proxy session is not game leader"); + } + + auto s = ses->require_server_state(); + ItemData item = s->parse_item_description(ses->version(), args.args); + item.id = phosg::random_object() | 0x80000000; + + if (args.command == "set-next-item") { + ses->next_drop_item = item; + + string name = s->describe_item(ses->version(), ses->next_drop_item, true); + send_text_message(ses->client_channel, "$C7Next drop:\n" + name); + + } else { + send_drop_stacked_item_to_channel(s, ses->client_channel, item, ses->floor, ses->pos); + send_drop_stacked_item_to_channel(s, ses->server_channel, item, ses->floor, ses->pos); + + string name = s->describe_item(ses->version(), ses->next_drop_item, true); + send_text_message(ses->client_channel, "$C7Item created:\n" + name); + } + + return {}; +} +ShellCommand c_create_item("create-item", "create-item DATA\n\ + Create an item as if the client had run the $item command.", + true, fn_create_item); +ShellCommand c_set_next_item("set-next-item", "set-next-item DATA\n\ + Set the next item to be dropped.", + true, fn_create_item); + +ShellCommand c_close_idle_sessions( + "close-idle-sessions", "close-idle-sessions\n\ + Close all proxy sessions that don\'t have a client and server connected.", + true, +[](ShellCommand::Args& args) -> std::deque { + if (args.s->proxy_server) { + size_t count = args.s->proxy_server->delete_disconnected_sessions(); + return {phosg::string_printf("%zu sessions closed\n", count)}; + } else { + throw runtime_error("the proxy server is disabled"); + } + }); diff --git a/src/ShellCommands.hh b/src/ShellCommands.hh new file mode 100644 index 00000000..ce271ec8 --- /dev/null +++ b/src/ShellCommands.hh @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "ProxyServer.hh" +#include "ServerState.hh" + +class exit_shell : public std::runtime_error { +public: + exit_shell(); + ~exit_shell() = default; +}; + +struct ShellCommand { + struct Args { + std::shared_ptr s; + std::string command; + std::string args; + std::string session_name; + }; + + const char* name; + const char* help_text; + bool run_on_event_thread; + std::deque (*run)(Args&); + + static std::vector commands_by_order; + static std::unordered_map commands_by_name; + + ShellCommand(const char* name, const char* help_text, bool run_on_event_thread, std::deque (*run)(Args&)); + + static std::deque dispatch_str(std::shared_ptr s, const std::string& command); + static std::deque dispatch(Args& args); +};