From 655332778ce88cdcd3c03f5b87c6c5f078c0559d Mon Sep 17 00:00:00 2001 From: shweet Date: Sun, 8 Nov 2026 02:04:36 -0500 Subject: [PATCH] Run lobbies' games on the server The server now runs every lobby's sandbox and queued games as rooms, stepped at the tick rate on one thread. Players send controls ahead of time, late ones repeat their last, and everyone in a room gets back the controls it played, its events and checksums. Games start from sandbox queues, players return when they leave, and play-again moves them to a fresh room. Protocol version 3. Co-Authored-By: Claude Opus 5.5 --- server/server.cpp | 544 ++++++++++++++++++++++++++++++++++++++++------ src/protocol.h | 37 +++- src/session.cpp | 62 +++--- src/sim.cpp | 18 ++ src/sim.h | 21 ++ 5 files changed, 585 insertions(+), 97 deletions(-) diff --git a/server/server.cpp b/server/server.cpp index 1bd61b8..7aaa09d 100644 --- a/server/server.cpp +++ b/server/server.cpp @@ -1,14 +1,19 @@ -// The server for Smack a Brother. Players connect over a WebSocket (behind nginx, which handles TLS), make a -// lobby or join one by its code, and the server passes WebRTC signaling between a lobby's members so they can connect -// to each other directly. Nothing of a match passes through here. See src/protocol.h for the messages. +// The server for Smack a Brother. Players connect over a WebSocket (behind nginx, which handles TLS), make a lobby or +// join one by its code, and play in it here: the server runs every lobby's sandbox and the games queued in it, each +// a room, stepping them all at the game's tick rate on one thread. Players send their controls ahead of time; each +// tick takes whatever has come (repeating a player's last controls when theirs are late) and sends every player in +// the room the controls it played, which their game replays to confirm its own. See src/protocol.h for the messages. // // smack-a-brother-server [port] (listens on 127.0.0.1, port 47800 by default) #include "protocol.h" +#include "sim.h" +#include "state.h" #include #include #include +#include #include #include #include @@ -18,31 +23,99 @@ #include constexpr uint16_t PORT_DEFAULT = 47800; -constexpr size_t MESSAGE_MAX = 16384; +constexpr size_t MESSAGE_MAX = 65536; constexpr int CODE_TRIES = 100; +constexpr int FRAME_RING = 256; +constexpr int FRAMES_PER_PACKET = 30; +constexpr int INPUT_REACH = 120; +constexpr int CHECKSUM_INTERVAL = 60; +constexpr int CATCH_UP_TICKS = 5; +constexpr Uint32 ROOM_SEED_MIX = 2654435761u; +constexpr auto TICK_TIME = std::chrono::duration(TICK); -// Someone connected: their id, name, socket, and the lobby they're in (empty until they make or join one). -struct Member +// The controls every fighter played on one tick of a room, and how many of its events were in by then. +struct Frame { - std::string name; - std::shared_ptr socket; - std::string code; + Controls controls[MAX_FIGHTERS]; + int tick = -1; + int events = 0; + int count = 0; }; -// Every member and lobby (codes to member ids, in the order they joined, the first hosting). The WebSocket callbacks -// run on the library's threads, so everything here is touched under the lock. +// One online game: a lobby's sandbox or a game queued in it. Inputs holds each member's controls by tick as they come +// (until played), and held what each played last, repeated when the next are late. Again counts the ticks a finished +// game's players have all been set to play again. +struct Room +{ + Sim sim; + std::vector events; + std::map> inputs; + std::map held; + Frame frames[FRAME_RING]; + std::string code; + int again = 0; + int tick = 0; +}; + +// Someone connected: their name and socket, their lobby (empty until they make or join one), the room they play in +// (0 for none), the last tick of it they've confirmed, how early their controls last came, and the fighter they +// brought, kept while they're away in a game so they come back to the sandbox as they left. +struct Member +{ + Profile profile; + std::string name; + std::string code; + std::shared_ptr socket; + int ack = -1; + int lead = 0; + int room = 0; +}; + +// A lobby: its members in the order they joined (the first hosting), and its rooms, the sandbox first. +struct Lobby +{ + std::vector members; + std::vector rooms; +}; + +// What the network's threads hand the game loop: a member's connection opening or closing, or a message from them. +struct Arrival +{ + std::string bytes; + std::shared_ptr socket; + int id = 0; + bool isBinary = false; + bool isClosed = false; +}; + +// Everything below belongs to the game loop's thread, except the arrivals, which the network's threads add to under +// the lock. static std::mutex lock; +static std::vector arrivals; static std::map members; -static std::map> lobbies; +static std::map lobbies; +static std::map rooms; static simdjson::dom::parser parser; static std::mt19937 rng(std::random_device{}()); -static int nextId = 1; +static int nextRoom = 1; -static void send(int id, const std::string& message) +// A connection can drop between checking it and sending, which throws. It's let go: its closing comes in later. +static void send(int id, const std::string& message, bool isBinary = false) { auto member = members.find(id); - if (member != members.end() && member->second.socket->isOpen()) - member->second.socket->send(message); + if (member == members.end() || !member->second.socket->isOpen()) + return; + auto data = reinterpret_cast(message.data()); + try + { + if (isBinary) + member->second.socket->send(rtc::binary(data, data + message.size())); + else + member->second.socket->send(message); + } + catch (const std::exception&) + { + } } static void error_send(int id, const char* message) @@ -50,13 +123,16 @@ static void error_send(int id, const char* message) send(id, "{\"type\":\"error\",\"message\":" + json_quote(message) + "}"); } +static void lobby_send(const std::string& code, const std::string& message, bool isBinary = false) +{ + for (int id : lobbies[code].members) + send(id, message, isBinary); +} + // A name as the lobby shows it: printable characters only, cut to length, or "Player" when nothing's left. static std::string name_clean(std::string_view raw) { - std::string name; - for (char c : raw) - if (c >= ' ' && c <= '~' && (int)name.size() < PLAYER_NAME_MAX) - name += c; + std::string name = text_clean(raw, PLAYER_NAME_MAX); return name.empty() ? "Player" : name; } @@ -74,47 +150,115 @@ static std::string code_make() return ""; } -// Puts a member into a lobby and tells everyone: the newcomer gets the whole lobby, the rest get the newcomer. +// A fresh room in a lobby, numbered. +static int room_open(const std::string& code) +{ + int serial = nextRoom++; + Room& room = rooms[serial]; + room.code = code; + lobbies[code].rooms.push_back(serial); + return serial; +} + +// Sets a change in a room for the tick it's about to play, and tells everyone in it. +static void event_add(int serial, RoomEvent event) +{ + Room& room = rooms[serial]; + event.tick = room.tick; + room.events.push_back(event); + PacketWriter packet; + packet.put(PACKET_EVENT).put(serial).put((int)room.events.size() - 1).put(event); + for (auto& [id, member] : members) + if (member.room == serial) + send(id, packet.bytes, true); +} + +// Puts a member in a room as its next tick starts: their fighter joins (from their profile, or their seat in a game +// already set up for them), and they get the room as it is then, with every event it's had. +static void room_enter(int id, int serial, bool isJoining) +{ + Member& member = members[id]; + Room& room = rooms[serial]; + member.room = serial; + member.ack = room.tick - 1; + room.held[id] = {}; + PacketWriter packet; + packet.put(PACKET_STATE).put(serial).put(room.tick); + packet.bytes += state_save(room.sim); + send(id, packet.bytes, true); + for (size_t i = 0; i < room.events.size(); i++) + send(id, PacketWriter().put(PACKET_EVENT).put(serial).put((int)i).put(room.events[i]).bytes, true); + if (isJoining) + event_add(serial, {.type = ROOM_JOIN, .member = id, .profile = member.profile}); +} + +// Takes a member out of their room: the sandbox drops their fighter, and a game counts them as having left it. +static void room_exit(int id) +{ + Member& member = members[id]; + auto room = rooms.find(member.room); + member.room = 0; + if (room == rooms.end()) + return; + room->second.inputs.erase(id); + room->second.held.erase(id); + if (lobbies[member.code].rooms.front() == room->first) + event_add(room->first, {.type = ROOM_LEAVE, .member = id}); +} + +// Puts a member into a lobby and tells everyone: the newcomer gets the whole lobby, the rest get the newcomer. Their +// fighter joins the sandbox once their profile comes. static void lobby_enter(int id, const std::string& code) { - std::vector& lobby = lobbies[code]; - lobby.push_back(id); + Lobby& lobby = lobbies[code]; + if (lobby.rooms.empty()) + { + Room& sandbox = rooms[room_open(code)]; + sandbox.sim.init(rng() | 1); + sandbox.sim.fighterCount = 0; + sandbox.sim.match.isOnline = true; + std::fill(std::begin(sandbox.sim.match.owners), std::end(sandbox.sim.match.owners), -1); + } + lobby.members.push_back(id); members[id].code = code; std::string list; - for (int other : lobby) + for (int other : lobby.members) list += (list.empty() ? "" : ",") + std::string("{\"id\":") + std::to_string(other) + ",\"name\":" + json_quote(members[other].name) + "}"; send(id, "{\"type\":\"welcome\",\"id\":" + std::to_string(id) + ",\"code\":" + json_quote(code) + - ",\"host\":" + std::to_string(lobby.front()) + ",\"members\":[" + list + "]}"); - for (int other : lobby) + ",\"host\":" + std::to_string(lobby.members.front()) + ",\"members\":[" + list + "]}"); + for (int other : lobby.members) if (other != id) send(other, "{\"type\":\"member_join\",\"id\":" + std::to_string(id) + ",\"name\":" + json_quote(members[id].name) + "}"); } // Takes a member out of its lobby, telling the rest (and who hosts now, and why if they were kicked). An empty lobby is -// gone. +// gone, with its rooms. static void lobby_exit(int id, const std::string& kick = "", bool isKicked = false) { std::string code = members[id].code; if (code.empty()) return; + room_exit(id); members[id].code.clear(); - std::vector& lobby = lobbies[code]; - std::erase(lobby, id); - if (lobby.empty()) + Lobby& lobby = lobbies[code]; + std::erase(lobby.members, id); + if (lobby.members.empty()) { + for (int serial : lobby.rooms) + rooms.erase(serial); lobbies.erase(code); std::printf("Lobby %s closed (%zu open)\n", code.c_str(), lobbies.size()); return; } std::string reason = isKicked ? ",\"kicked\":true,\"reason\":" + json_quote(kick) : ""; - for (int other : lobby) - send(other, "{\"type\":\"member_leave\",\"id\":" + std::to_string(id) + - ",\"host\":" + std::to_string(lobby.front()) + reason + "}"); + lobby_send(code, "{\"type\":\"member_leave\",\"id\":" + std::to_string(id) + + ",\"host\":" + std::to_string(lobby.members.front()) + reason + "}"); } -static void message_handle(int id, const std::string& text) +// The lobby messages, in JSON: making, joining and leaving lobbies, kicking, and keeping the connection busy. +static void text_handle(int id, const std::string& text) { simdjson::dom::element message; std::string_view type; @@ -150,7 +294,7 @@ static void message_handle(int id, const std::string& text) auto lobby = lobbies.find(code); if (lobby == lobbies.end()) return error_send(id, "No lobby with that code"); - if ((int)lobby->second.size() >= LOBBY_MEMBERS_MAX) + if ((int)lobby->second.members.size() >= LOBBY_MEMBERS_MAX) return error_send(id, "That lobby is full"); return lobby_enter(id, code); } @@ -163,7 +307,7 @@ static void message_handle(int id, const std::string& text) int64_t target = 0; std::string_view raw; std::string& code = members[id].code; - if (message["id"].get(target) || code.empty() || lobbies[code].front() != id || target == id) + if (message["id"].get(target) || code.empty() || lobbies[code].members.front() != id || target == id) return error_send(id, "Only the host can kick"); auto kicked = members.find((int)target); if (kicked == members.end() || kicked->second.code != code) @@ -173,21 +317,282 @@ static void message_handle(int id, const std::string& text) std::printf("Lobby %s: %d kicked %d\n", code.c_str(), id, (int)target); return lobby_exit((int)target, reason, true); } - if (type == "signal") + error_send(id, "Unknown message"); +} + +// Text a member sent for the lobby (a chat line or their new name), cleaned, passed to everyone with who it's from. +static void text_relay(int id, PacketType type, PacketReader& reader, size_t max) +{ + Uint16 length = 0; + if (!reader.get(length) || reader.at + length > reader.bytes.size()) + return; + std::string text = text_clean(std::string_view(reader.bytes.data() + reader.at, length), max); + if (text.empty()) + return; + if (type == PACKET_RENAME) + members[id].name = text; + PacketWriter packet; + packet.put(type).put(id).put((Uint16)text.size()); + packet.bytes += text; + lobby_send(members[id].code, packet.bytes, true); +} + +// The game messages, in binary (see protocol.h), from a member in a lobby. +static void packet_handle(int id, const std::string& bytes) +{ + Member& member = members[id]; + if (member.code.empty()) + return; + PacketReader reader = {bytes}; + PacketType type; + if (!reader.get(type)) + return; + + if (type == PACKET_PROFILE) { - // Passed on as it came, only between members of the same lobby. - int64_t to = 0; - simdjson::dom::element payload; - if (message["to"].get(to) || message["payload"].get(payload)) - return error_send(id, "Bad signal"); - auto target = members.find((int)to); - if (target == members.end() || members[id].code.empty() || target->second.code != members[id].code) + // A member's first profile brings their fighter into the sandbox. + if (!reader.get(member.profile)) return; - send((int)to, - "{\"type\":\"signal\",\"from\":" + std::to_string(id) + ",\"payload\":" + simdjson::minify(payload) + "}"); + member.profile.color = std::clamp(member.profile.color, -1, COLOR_COUNT - 1); + std::memcpy(member.profile.name, member.name.c_str(), member.name.size() + 1); + if (member.room == 0) + room_enter(id, lobbies[member.code].rooms.front(), true); return; } - error_send(id, "Unknown message"); + if (type == PACKET_INPUTS) + { + // Controls for ticks already played are too late, and dropped. How early the latest came steers the player's + // game to keep its lead. + int serial = 0; + int ack = 0; + int first = 0; + Uint16 count = 0; + if (!reader.get(serial) || !reader.get(ack) || !reader.get(first) || !reader.get(count) || + serial != member.room) + return; + Room& room = rooms[serial]; + member.ack = std::max(member.ack, std::min(ack, room.tick - 1)); + member.lead = first + count - 1 - room.tick; + for (int i = 0; i < count; i++) + { + Controls controls; + if (!reader.get(controls)) + return; + int at = first + i; + if (at >= room.tick && at < room.tick + INPUT_REACH) + room.inputs[id][at] = controls; + } + return; + } + if (type == PACKET_QUEUE) + { + Uint8 isOpen = 0; + Rules rules; + int sandbox = lobbies[member.code].rooms.front(); + if (reader.get(isOpen) && reader.get(rules) && member.room == sandbox) + event_add(sandbox, {.type = isOpen ? ROOM_QUEUE : ROOM_UNQUEUE, .member = id, .rules = rules_clean(rules)}); + return; + } + if (type == PACKET_CHAT || type == PACKET_RENAME) + return text_relay(id, type, reader, type == PACKET_CHAT ? CHAT_MESSAGE_MAX : PLAYER_NAME_MAX); + if (type == PACKET_TYPING) + { + Uint8 isTyping = 0; + if (reader.get(isTyping)) + lobby_send(member.code, PacketWriter().put(PACKET_TYPING).put(id).put(isTyping).bytes, true); + } +} + +// The controls a room's fighter plays this tick: its member's for the tick if they came, or their last again (without +// a one-off edit or weapon switch). A member gone from the lobby leaves a game, and bots and empty seats send nothing. +static Controls controls_take(Room& room, int owner) +{ + if (owner < 0) + return {}; + auto member = members.find(owner); + if (member == members.end() || member->second.code != room.code) + return {.edit = edit_encode({.type = EDIT_LEAVE})}; + std::map& inputs = room.inputs[owner]; + auto found = inputs.find(room.tick); + Controls& held = room.held[owner]; + if (found != inputs.end()) + held = found->second; + else + { + held.edit = -1; + held.slot = -1; + } + inputs.erase(inputs.begin(), inputs.upper_bound(room.tick)); + return held; +} + +// Plays one tick of a room: its events due, then every fighter's controls, kept as the tick's frame. +static void room_step(Room& room) +{ + for (RoomEvent& event : room.events) + if (event.tick == room.tick) + room.sim.event_apply(event); + Frame& frame = room.frames[room.tick % FRAME_RING]; + frame.tick = room.tick; + frame.events = room.events.size(); + frame.count = room.sim.fighterCount; + for (int i = 0; i < room.sim.fighterCount; i++) + frame.controls[i] = room.sim.fighters[i].controls = controls_take(room, room.sim.match.owners[i]); + room.sim.tick(); + room.tick++; +} + +// A sandbox's queued game that's ready starts in a room of its own: its players leave the sandbox (bringing nothing +// along but their name and colour) and go into it. +static void game_open(int sandboxSerial, int queuer) +{ + Room& sandbox = rooms[sandboxSerial]; + Match& lobby = sandbox.sim.match; + int seats[MAX_FIGHTERS]; + int count = lobby.queue_members_get(sandbox.sim, queuer, seats); + int players[MAX_FIGHTERS]; + for (int k = 0; k < count; k++) + { + players[k] = lobby.owners[seats[k]]; + members[players[k]].profile = profile_get(sandbox.sim.fighters[seats[k]]); + std::memcpy(members[players[k]].profile.name, lobby.names[seats[k]], sizeof(lobby.names[seats[k]])); + members[players[k]].profile.color = lobby.colors[seats[k]]; + } + int serial = room_open(sandbox.code); + Room& game = rooms[serial]; + game.sim.game_start(lobby, seats, count, lobby.queues[queuer].rules, + sandbox.sim.rng.state ^ (Uint32)sandbox.tick * ROOM_SEED_MIX); + for (int k = 0; k < count; k++) + { + room_exit(players[k]); + room_enter(players[k], serial, false); + } +} + +// After a game's tick: anyone who's left it goes back to the sandbox, and once a finished match's players have all +// been set to play again for a moment, they go on to a fresh game in a room of its own. A game no one plays in any more +// is over. +static void game_follow(int serial) +{ + Room& game = rooms[serial]; + Match& match = game.sim.match; + int sandbox = lobbies[game.code].rooms.front(); + int seats[MAX_FIGHTERS]; + int humans = 0; + bool isAgain = match.state == MATCH_OVER; + for (int k = 0; k < game.sim.fighterCount; k++) + { + int owner = match.owners[k]; + auto member = members.find(owner); + if (owner < 0 || member == members.end() || member->second.room != serial) + continue; + if (match.isGone[k]) + { + room_exit(owner); + room_enter(owner, sandbox, true); + continue; + } + isAgain &= match.isReady[k]; + seats[humans++] = k; + } + bool isSet = isAgain && (humans >= PLAYERS_MIN || match.rules.isBotFill); + game.again = isSet ? game.again + 1 : 0; + if (humans > 0 && game.again < QUEUE_START_TICKS) + return; + if (humans > 0) + { + Rules rules = match.rules; + if (!rules.isBotFill) + rules.players = humans; + int next = room_open(game.code); + rooms[next].sim.game_start(match, seats, humans, rules, game.sim.rng.state ^ (Uint32)game.tick * ROOM_SEED_MIX); + for (int k = 0; k < humans; k++) + { + int owner = match.owners[seats[k]]; + room_exit(owner); + room_enter(owner, next, false); + } + } + std::erase(lobbies[game.code].rooms, serial); + rooms.erase(serial); +} + +// Sends everyone in a room the frames they haven't confirmed (up to a packet's worth), and every so often the state's +// checksum. +static void frames_send(int serial) +{ + Room& room = rooms[serial]; + for (auto& [id, member] : members) + { + if (member.room != serial) + continue; + int first = std::max(member.ack + 1, room.tick - FRAME_RING + 1); + int count = std::min(room.tick - first, FRAMES_PER_PACKET); + if (count <= 0) + continue; + PacketWriter packet; + packet.put(PACKET_FRAMES).put(serial).put(member.lead).put(first).put((Uint16)count); + for (int at = first; at < first + count; at++) + { + Frame& frame = room.frames[at % FRAME_RING]; + packet.put((Uint16)frame.events).put((Uint8)frame.count); + for (int i = 0; i < frame.count; i++) + packet.put(frame.controls[i]); + } + send(id, packet.bytes, true); + } + if (room.tick % CHECKSUM_INTERVAL == 0) + for (auto& [id, member] : members) + if (member.room == serial) + send(id, PacketWriter().put(PACKET_CHECKSUM).put(serial).put(room.tick).put(room.sim.checksum_get()).bytes, + true); +} + +// One tick of every room, in every lobby: the sandbox, then its games, then the frames out. +static void rooms_step() +{ + std::vector serials; + for (auto& [serial, room] : rooms) + serials.push_back(serial); + for (int serial : serials) + { + if (!rooms.contains(serial)) + continue; + Room& room = rooms[serial]; + room_step(room); + if (lobbies[room.code].rooms.front() == serial) + { + int queuer = room.sim.match.queue_startable_get(room.sim); + if (queuer >= 0) + game_open(serial, queuer); + } + else + game_follow(serial); + } + for (auto& [serial, room] : rooms) + frames_send(serial); +} + +// Takes in what the network's threads have handed over since the last tick. +static void arrivals_take() +{ + std::vector taken; + { + std::lock_guard guard(lock); + taken.swap(arrivals); + } + for (Arrival& arrival : taken) + { + if (arrival.socket) + members[arrival.id] = {.socket = arrival.socket}; + else if (arrival.isClosed) + { + lobby_exit(arrival.id); + members.erase(arrival.id); + } + else if (members.contains(arrival.id)) + arrival.isBinary ? packet_handle(arrival.id, arrival.bytes) : text_handle(arrival.id, arrival.bytes); + } } static volatile std::sig_atomic_t isStopping = 0; @@ -202,39 +607,48 @@ int main(int argc, char** argv) config.maxMessageSize = MESSAGE_MAX; rtc::WebSocketServer server(config); - server.onClient([](std::shared_ptr socket) { + int nextId = 1; + server.onClient([&nextId](std::shared_ptr socket) { std::lock_guard guard(lock); int id = nextId++; - members[id] = {.socket = socket}; + arrivals.push_back({.socket = socket, .id = id}); socket->onMessage([id](rtc::message_variant data) { - if (!std::holds_alternative(data)) - return; std::lock_guard guard(lock); - message_handle(id, std::get(data)); + if (std::holds_alternative(data)) + arrivals.push_back({.bytes = std::get(data), .id = id}); + else + { + rtc::binary& binary = std::get(data); + arrivals.push_back({.bytes = std::string(reinterpret_cast(binary.data()), binary.size()), + .id = id, + .isBinary = true}); + } }); socket->onClosed([id]() { std::lock_guard guard(lock); - if (!members.contains(id)) - return; - lobby_exit(id); - members.erase(id); + arrivals.push_back({.id = id, .isClosed = true}); }); }); std::printf("Server listening on 127.0.0.1:%d\n", server.port()); std::signal(SIGINT, [](int) { isStopping = 1; }); std::signal(SIGTERM, [](int) { isStopping = 1; }); + // The game loop: a tick of every room each TICK, catching up a few at most when it falls behind. + auto next = std::chrono::steady_clock::now(); while (!isStopping) - std::this_thread::sleep_for(std::chrono::milliseconds(200)); - std::printf("Server stopping\n"); - // Closing calls back into onClosed, which takes the lock, so the sockets are gathered first. - std::vector> sockets; { - std::lock_guard guard(lock); - for (auto& [id, member] : members) - sockets.push_back(member.socket); + arrivals_take(); + for (int i = 0; i < CATCH_UP_TICKS && std::chrono::steady_clock::now() >= next; i++) + { + rooms_step(); + next += std::chrono::duration_cast(TICK_TIME); + } + if (std::chrono::steady_clock::now() > next) + next = std::chrono::steady_clock::now(); + std::this_thread::sleep_until(next); } - for (auto& socket : sockets) - socket->close(); + std::printf("Server stopping\n"); + for (auto& [id, member] : members) + member.socket->close(); return 0; } diff --git a/src/protocol.h b/src/protocol.h index b11bd90..6029c82 100644 --- a/src/protocol.h +++ b/src/protocol.h @@ -27,7 +27,7 @@ #include #include -constexpr int PROTOCOL_VERSION = 2; +constexpr int PROTOCOL_VERSION = 3; constexpr int LOBBY_MEMBERS_MAX = 4; constexpr int LOBBY_CODE_LENGTH = 4; // Letters only, without I and O, so codes are easy to read out and type. @@ -38,6 +38,41 @@ constexpr int KICK_REASON_MAX = 60; constexpr const char SERVER_URL_DEFAULT[] = "wss://shweetz.net/smack-a-brother/ws"; constexpr int SERVER_URL_LENGTH = 128; +// Binary messages between a game and the server once it's in a lobby, each starting with its type. A room is one +// online game (a lobby's sandbox, or a game queued in it), numbered by the server. +// +// Game to server: +// PACKET_PROFILE Profile the fighter this player brings into the lobby +// PACKET_INPUTS room, ack, first, Uint16 count, Controls* this player's controls from a tick on, and the last +// tick it has confirmed +// PACKET_QUEUE Uint8 isOpen, Rules queue a game in the sandbox, or stop queueing it +// PACKET_CHAT Uint16 length, text +// PACKET_TYPING Uint8 isTyping +// PACKET_RENAME Uint16 length, text +// +// Server to game: +// PACKET_STATE room, tick, state the game this player is in now, as a tick starts +// PACKET_EVENT room, serial, RoomEvent the room's events, numbered from 0, in order +// PACKET_FRAMES room, lead, first, Uint16 count, then for each tick: Uint16 events, Uint8 fighters, Controls* +// every fighter's controls as the server played them, +// once the events before them are in, and how early +// (in ticks) this player's last controls came +// PACKET_CHECKSUM room, tick, Uint32 the room's state as that tick starts +// PACKET_CHAT from, Uint16 length, text and the same for typing and renames +enum PacketType : unsigned char +{ + PACKET_PROFILE = 1, + PACKET_INPUTS, + PACKET_QUEUE, + PACKET_CHAT, + PACKET_TYPING, + PACKET_RENAME, + PACKET_STATE, + PACKET_EVENT, + PACKET_FRAMES, + PACKET_CHECKSUM +}; + // Packets are plain values one after another, as they sit in memory (every platform agrees on these layouts). struct PacketWriter { diff --git a/src/session.cpp b/src/session.cpp index c7b712a..9c673c2 100644 --- a/src/session.cpp +++ b/src/session.cpp @@ -22,17 +22,17 @@ constexpr Uint64 RENAME_SLACK = 2000; constexpr int QUEUE_LEAD = 15; constexpr Uint32 ARENA_SEED_MIX = 2654435761u; -enum PacketType : Uint8 +enum PeerPacket : Uint8 { - PACKET_JOIN_REQUEST = 1, - PACKET_EVENT, - PACKET_STATE, - PACKET_CHECKSUM, - PACKET_INPUTS, - PACKET_CHAT, - PACKET_TYPING, - PACKET_RENAME, - PACKET_QUEUE + PEER_JOIN_REQUEST = 1, + PEER_EVENT, + PEER_STATE, + PEER_CHECKSUM, + PEER_INPUTS, + PEER_CHAT, + PEER_TYPING, + PEER_RENAME, + PEER_QUEUE }; // Field by field, since the padding between them is whatever was in memory. @@ -440,7 +440,7 @@ void Session::members_watch(Game& game) if (!isJoined && !isRequested && net.is_connected_all() && net.hostId != self) { Profile profile = profile_get(game); - net.send(net.hostId, PacketWriter().put(PACKET_JOIN_REQUEST).put(profile).bytes, true); + net.send(net.hostId, PacketWriter().put(PEER_JOIN_REQUEST).put(profile).bytes, true); isRequested = true; } if (!isJoined || net.hostId != self) @@ -469,11 +469,11 @@ void Session::packets_take(Game& game) void Session::packet_handle(Game& game, int from, const std::string& bytes, bool isReliable) { PacketReader reader = {bytes}; - PacketType type; + PeerPacket type; if (!reader.get(type)) return; - if (type == PACKET_INPUTS && !isReliable) + if (type == PEER_INPUTS && !isReliable) { int senderTick = 0; int ack = 0; @@ -496,7 +496,7 @@ void Session::packet_handle(Game& game, int from, const std::string& bytes, bool } return; } - if (type == PACKET_QUEUE) + if (type == PEER_QUEUE) { Uint8 isOpen = 0; Rules rules; @@ -504,14 +504,14 @@ void Session::packet_handle(Game& game, int from, const std::string& bytes, bool queue_decide(game, from, isOpen != 0, rules); return; } - if (type == PACKET_JOIN_REQUEST) + if (type == PEER_JOIN_REQUEST) { Profile profile; if (reader.get(profile) && isJoined && game.net.hostId == game.net.selfId) join_grant(game, from, profile); return; } - if (type == PACKET_EVENT) + if (type == PEER_EVENT) { SessionEvent event; int first = 0; @@ -530,7 +530,7 @@ void Session::packet_handle(Game& game, int from, const std::string& bytes, bool event_add(game, event); return; } - if (type == PACKET_STATE && !isJoined) + if (type == PEER_STATE && !isJoined) { int at = 0; if (!reader.get(at)) @@ -559,15 +559,15 @@ void Session::packet_handle(Game& game, int from, const std::string& bytes, bool local_find(game); return; } - if (type == PACKET_CHAT || type == PACKET_RENAME) + if (type == PEER_CHAT || type == PEER_RENAME) { // Text from another player is cleaned before it's used (see text_clean), whatever their game sent. Uint8 isQuiet = 0; Uint16 length = 0; - if ((type == PACKET_RENAME && !reader.get(isQuiet)) || !reader.get(length) || reader.at + length > bytes.size()) + if ((type == PEER_RENAME && !reader.get(isQuiet)) || !reader.get(length) || reader.at + length > bytes.size()) return; std::string_view raw(bytes.data() + reader.at, length); - if (type == PACKET_CHAT) + if (type == PEER_CHAT) { std::string text = text_clean(raw, CHAT_MESSAGE_MAX); if (!text.empty()) @@ -592,14 +592,14 @@ void Session::packet_handle(Game& game, int from, const std::string& bytes, bool game.chat.system(before + " changed their name to " + name_get(game, from) + "."); return; } - if (type == PACKET_TYPING) + if (type == PEER_TYPING) { Uint8 isTyping = 0; if (reader.get(isTyping)) game.chat.typing[from] = isTyping != 0; return; } - if (type == PACKET_CHECKSUM) + if (type == PEER_CHECKSUM) { int at = 0; Uint32 value = 0; @@ -636,7 +636,7 @@ void Session::join_grant(Game& game, int member, const Profile& profile) if (pending.tick >= from && !(pending.member == member && pending.type == EVENT_JOIN)) event_send(game, member, pending, 0, -1); PacketWriter packet; - packet.put(PACKET_STATE).put(from); + packet.put(PEER_STATE).put(from); packet.bytes += state; game.net.send(member, packet.bytes, true); SDL_Log("Session: %d joins at tick %d, from tick %d (%zu bytes)", member, event.tick, from, state.size()); @@ -665,7 +665,7 @@ void Session::event_send(Game& game, int to, const SessionEvent& event, int inpu for (int at = inputsFrom; at <= inputsTo && log.has(at); at++) inputs.push_back(log.controls[at % INPUT_RING]); PacketWriter packet; - packet.put(PACKET_EVENT).put(event).put(inputsFrom).put((Uint16)inputs.size()); + packet.put(PEER_EVENT).put(event).put(inputsFrom).put((Uint16)inputs.size()); for (Controls& controls : inputs) packet.put(controls); game.net.send(to, packet.bytes, true); @@ -737,7 +737,7 @@ void Session::inputs_send(Game& game) int last = std::min(ownNext - 1, first + INPUT_PACKET_MAX - 1); Uint16 count = last >= first ? last - first + 1 : 0; PacketWriter packet; - packet.put(PACKET_INPUTS).put(tick).put(logs[member].contiguous).put(first).put(count); + packet.put(PEER_INPUTS).put(tick).put(logs[member].contiguous).put(first).put(count); for (int at = first; at < first + count; at++) packet.put(own.controls[at % INPUT_RING]); game.net.send(member, packet.bytes, false); @@ -755,7 +755,7 @@ void Session::checksums_exchange(Game& game) continue; checksumSent = at; PacketWriter packet; - packet.put(PACKET_CHECKSUM).put(at).put(checksums[at % SNAPSHOT_RING]); + packet.put(PEER_CHECKSUM).put(at).put(checksums[at % SNAPSHOT_RING]); for (auto& [member, peer] : peers) if (peer.isSending) game.net.send(member, packet.bytes, true); @@ -837,7 +837,7 @@ void Session::chat_send(Game& game, const std::string& text) if (!isActive) return game.chat.say(game, game.net.selfId, text); PacketWriter packet; - packet.put(PACKET_CHAT).put((Uint16)text.size()); + packet.put(PEER_CHAT).put((Uint16)text.size()); packet.bytes += text; broadcast(game, packet.bytes); game.chat.say(game, game.net.selfId, text); @@ -847,7 +847,7 @@ void Session::chat_send(Game& game, const std::string& text) void Session::typing_send(Game& game, bool isTyping) { if (isActive) - broadcast(game, PacketWriter().put(PACKET_TYPING).put((Uint8)isTyping).bytes); + broadcast(game, PacketWriter().put(PEER_TYPING).put((Uint8)isTyping).bytes); } // Sends this screen's name to one member (or everyone, for -1). A quiet one isn't announced in the chat. @@ -855,7 +855,7 @@ void Session::rename_send(Game& game, int to, bool isQuiet) { std::string& name = names[game.net.selfId]; PacketWriter packet; - packet.put(PACKET_RENAME).put((Uint8)isQuiet).put((Uint16)name.size()); + packet.put(PEER_RENAME).put((Uint8)isQuiet).put((Uint16)name.size()); packet.bytes += name; if (to < 0) broadcast(game, packet.bytes); @@ -941,7 +941,7 @@ void Session::queue_send(Game& game, const Rules& rules) return; if (game.net.hostId == game.net.selfId) return queue_decide(game, game.net.selfId, true, rules); - game.net.send(game.net.hostId, PacketWriter().put(PACKET_QUEUE).put((Uint8)1).put(rules).bytes, true); + game.net.send(game.net.hostId, PacketWriter().put(PEER_QUEUE).put((Uint8)1).put(rules).bytes, true); } void Session::unqueue_send(Game& game) @@ -950,7 +950,7 @@ void Session::unqueue_send(Game& game) return; if (game.net.hostId == game.net.selfId) return queue_decide(game, game.net.selfId, false, {}); - game.net.send(game.net.hostId, PacketWriter().put(PACKET_QUEUE).put((Uint8)0).put(Rules{}).bytes, true); + game.net.send(game.net.hostId, PacketWriter().put(PEER_QUEUE).put((Uint8)0).put(Rules{}).bytes, true); } // The host sets a queue change for a moment from now and tells everyone. diff --git a/src/sim.cpp b/src/sim.cpp index 5c9c121..873e39f 100644 --- a/src/sim.cpp +++ b/src/sim.cpp @@ -73,6 +73,24 @@ void Sim::game_start(Match& lobby, const int* members, int count, const Rules& r match.start(*this); } +void Sim::event_apply(const RoomEvent& event) +{ + if (event.type == ROOM_JOIN) + match.member_join(*this, event.member, event.profile); + if (event.type == ROOM_LEAVE) + match.member_leave(*this, event.member); + int index = match.fighter_find(*this, event.member); + if (index < 0) + return; + if (event.type == ROOM_QUEUE) + match.queue_open(*this, index, event.rules); + if (event.type == ROOM_UNQUEUE) + { + match.queues[index].isActive = false; + match.isQueueReady[index] = false; + } +} + // Moves a level by a step within the levels there are, cueing whether it went up or down. void level_step(Sim& sim, int& level, int step) { diff --git a/src/sim.h b/src/sim.h index dd6cfe9..0589ce8 100644 --- a/src/sim.h +++ b/src/sim.h @@ -30,6 +30,26 @@ public: inline Cues CUES_NONE; +enum RoomEventType +{ + ROOM_JOIN, + ROOM_LEAVE, + ROOM_QUEUE, + ROOM_UNQUEUE +}; + +// A change to who's in an online game, or to what they've queued in the sandbox, set for a tick so the server and every +// player make it at the same point: a member's fighter appearing (from their profile) or going, or a queued game +// opening (by its rules) or closing. +struct RoomEvent +{ + int tick = 0; + int type = ROOM_JOIN; + int member = -1; + Profile profile; + Rules rules; +}; + // Everything a tick reads and writes, so a game can be rewound to it. struct Snapshot { @@ -57,6 +77,7 @@ struct Sim void init(Uint32 seed); void tick(); void game_start(Match& lobby, const int* members, int count, const Rules& rules, Uint32 seed); + void event_apply(const RoomEvent& event); Snapshot snapshot_save(); void snapshot_load(Snapshot& snapshot); Uint32 checksum_get();