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();