- Lobbies: a name (default "Lobby <code>"), Unlisted or Public, and an optional password. The Multiplayer page scrolls: a Browser of public lobbies (name, count, padlock, code, little player previews, Join with a password field when locked), Join by code, Create, then Settings. Errors show under whatever caused them. Links take ?pass= (and --pass), and Copy Link includes it. Protocol 11. - Queue areas center on their host along the floor's flat stretch, squeezing their slots to fit, and move to a nearby surface when the host's is too short. - An airblast steals an enemy's flail ball onto its own chain for a while. Flail balls reel themselves back after 15 seconds. Doubler echoes of the Grimoire teleport move you too. - The Good Stuff: a new trinket, the inverse of Diazepam (more spread). Spread is now a multiplier, and it widens or narrows the Vacuum's cone. - Balance: Sword basic slash 25% smaller and charged slash half the size, Whip basic lash half the damage and reach, Recoil Coil recoil halved, Speedometer no longer speeds you up. - Fixes: fashions survive coming back from a game, Quit shows only in the sandbox menu, and the chat log keeps above the menu's tip.
931 lines
37 KiB
C++
931 lines
37 KiB
C++
// 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, then open a WebRTC connection to the server through it (signaling over the socket), and play
|
|
// over that: 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.
|
|
//
|
|
// The WebRTC connection has two data channels: a reliable one for everything that mustn't go missing, and a lossy one
|
|
// for frames, which are sent again until confirmed. Until both are open (or if they never open, where UDP is blocked),
|
|
// the binary messages go over the WebSocket instead.
|
|
//
|
|
// smack-a-brother-server [port] [first UDP port] [last UDP port]
|
|
// (listens on 127.0.0.1, port 47800 by default, and takes WebRTC on UDP 47810 to 47899, which the firewall must let
|
|
// through)
|
|
|
|
#include "protocol.h"
|
|
#include "sim.h"
|
|
#include "state.h"
|
|
#include <rtc/rtc.hpp>
|
|
#include <simdjson.h>
|
|
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <csignal>
|
|
#include <cstdio>
|
|
#include <map>
|
|
#include <mutex>
|
|
#include <random>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
constexpr uint16_t PORT_DEFAULT = 47800;
|
|
constexpr uint16_t UDP_FIRST_DEFAULT = 47810;
|
|
constexpr uint16_t UDP_LAST_DEFAULT = 47899;
|
|
constexpr const char STUN_SERVER[] = "stun:stun.l.google.com:19302";
|
|
constexpr const char INPUTS_LABEL[] = "inputs";
|
|
constexpr size_t MESSAGE_MAX = 65536;
|
|
constexpr int CODE_TRIES = 100;
|
|
constexpr int FRAME_RING = 256;
|
|
// Frames go out a packet of at most this many bytes at a time, so a lossy one fits one datagram and is never split.
|
|
constexpr size_t FRAME_BYTES_MAX = 1100;
|
|
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<double>(TICK);
|
|
|
|
// The controls every fighter played on one tick of a room, and how many of its events were in by then.
|
|
struct Frame
|
|
{
|
|
Controls controls[MAX_FIGHTERS];
|
|
int tick = -1;
|
|
int events = 0;
|
|
int count = 0;
|
|
};
|
|
|
|
// 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.
|
|
struct Room
|
|
{
|
|
Sim sim;
|
|
std::vector<RoomEvent> events;
|
|
std::map<int, std::map<int, Controls>> inputs;
|
|
std::map<int, Controls> held;
|
|
Frame frames[FRAME_RING];
|
|
std::string code;
|
|
int tick = 0;
|
|
};
|
|
|
|
// Someone connected: their name, socket and WebRTC connection (with its reliable channel and lossy one), 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<rtc::WebSocket> socket;
|
|
std::shared_ptr<rtc::PeerConnection> connection;
|
|
std::shared_ptr<rtc::DataChannel> channel;
|
|
std::shared_ptr<rtc::DataChannel> inputs;
|
|
int ack = -1;
|
|
int lead = 0;
|
|
int room = 0;
|
|
};
|
|
|
|
// How a member comes into a room: their fighter joining it from their profile, or already seated (a game set up with
|
|
// them in it) or spectating, with nothing to add.
|
|
enum RoomEntry
|
|
{
|
|
ENTRY_SEATED,
|
|
ENTRY_JOIN
|
|
};
|
|
|
|
// Why a member leaves a lobby: of their own accord (or dropping out), or kicked by the host.
|
|
enum LeaveReason
|
|
{
|
|
LEAVE_QUIT,
|
|
LEAVE_KICKED
|
|
};
|
|
|
|
// A lobby: its name, the password to join it (empty for none), whether the lobby list shows it, its members in the
|
|
// order they joined (the first hosting), and its rooms, the sandbox first.
|
|
struct Lobby
|
|
{
|
|
std::string name;
|
|
std::string password;
|
|
std::vector<int> members;
|
|
std::vector<int> rooms;
|
|
bool isPublic = false;
|
|
};
|
|
|
|
// What the network's threads hand the game loop: a member's connection opening or closing, one of their data channels
|
|
// arriving, or a message from them.
|
|
struct Arrival
|
|
{
|
|
std::string bytes;
|
|
std::shared_ptr<rtc::WebSocket> socket;
|
|
std::shared_ptr<rtc::DataChannel> channel;
|
|
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<Arrival> arrivals;
|
|
static std::map<int, Member> members;
|
|
static std::map<std::string, Lobby> lobbies;
|
|
static std::map<int, Room> rooms;
|
|
static simdjson::dom::parser parser;
|
|
static std::mt19937 rng(std::random_device{}());
|
|
static rtc::Configuration peerConfig;
|
|
static int nextRoom = 1;
|
|
|
|
// Sends a member a lobby message (text) or a game one (binary): binary goes over their data channels once both are
|
|
// open (the lossy one for frames), and the socket until then. 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, Delivery delivery = DELIVERY_TEXT)
|
|
{
|
|
auto found = members.find(id);
|
|
if (found == members.end())
|
|
return;
|
|
Member& member = found->second;
|
|
auto data = reinterpret_cast<const std::byte*>(message.data());
|
|
bool isLinked = member.channel && member.channel->isOpen() && member.inputs && member.inputs->isOpen();
|
|
bool isBinary = delivery != DELIVERY_TEXT;
|
|
try
|
|
{
|
|
if (isBinary && isLinked)
|
|
(delivery == DELIVERY_LOSSY ? member.inputs : member.channel)->send(data, message.size());
|
|
else if (isBinary && member.socket->isOpen())
|
|
member.socket->send(rtc::binary(data, data + message.size()));
|
|
else if (member.socket->isOpen())
|
|
member.socket->send(message);
|
|
}
|
|
catch (const std::exception&)
|
|
{}
|
|
}
|
|
|
|
// Answers a member's WebRTC offer with a connection of the server's own, passing its description and candidates back
|
|
// over the socket. Their data channels come to the game loop as they arrive, and their messages like the socket's.
|
|
static void peer_open(int id)
|
|
{
|
|
Member& member = members[id];
|
|
if (member.connection)
|
|
member.connection->close();
|
|
member.connection = std::make_shared<rtc::PeerConnection>(peerConfig);
|
|
std::weak_ptr<rtc::WebSocket> socket = member.socket;
|
|
auto signal_send = [socket](const std::string& payload) {
|
|
auto open = socket.lock();
|
|
if (open && open->isOpen())
|
|
open->send("{\"type\":\"signal\",\"payload\":" + payload + "}");
|
|
};
|
|
member.connection->onLocalDescription([signal_send](rtc::Description description) {
|
|
signal_send("{\"sdp\":" + json_quote(std::string(description)) +
|
|
",\"kind\":" + json_quote(description.typeString()) + "}");
|
|
});
|
|
member.connection->onLocalCandidate([signal_send](rtc::Candidate candidate) {
|
|
signal_send("{\"candidate\":" + json_quote(candidate.candidate()) + ",\"mid\":" + json_quote(candidate.mid()) +
|
|
"}");
|
|
});
|
|
member.connection->onDataChannel([id](std::shared_ptr<rtc::DataChannel> channel) {
|
|
std::weak_ptr<rtc::DataChannel> weak = channel;
|
|
channel->onMessage([id, weak](rtc::message_variant data) {
|
|
// Pings are answered at once, so they time the connection alone.
|
|
if (std::holds_alternative<std::string>(data))
|
|
{
|
|
std::string& text = std::get<std::string>(data);
|
|
auto open = weak.lock();
|
|
if (open && text.starts_with("ping:"))
|
|
open->send("pong:" + text.substr(5));
|
|
return;
|
|
}
|
|
rtc::binary& binary = std::get<rtc::binary>(data);
|
|
std::lock_guard guard(lock);
|
|
arrivals.push_back({.bytes = std::string(reinterpret_cast<const char*>(binary.data()), binary.size()),
|
|
.id = id,
|
|
.isBinary = true});
|
|
});
|
|
std::lock_guard guard(lock);
|
|
arrivals.push_back({.channel = channel, .id = id});
|
|
});
|
|
}
|
|
|
|
// A description or candidate for a member's connection, from their game.
|
|
static void signal_handle(int id, simdjson::dom::element payload)
|
|
{
|
|
std::string_view sdp;
|
|
std::string_view kind;
|
|
std::string_view candidate;
|
|
std::string_view mid;
|
|
Member& member = members[id];
|
|
if (!payload["sdp"].get(sdp) && !payload["kind"].get(kind))
|
|
{
|
|
if (kind == "offer")
|
|
peer_open(id);
|
|
if (member.connection)
|
|
member.connection->setRemoteDescription(rtc::Description(std::string(sdp), std::string(kind)));
|
|
return;
|
|
}
|
|
if (!payload["candidate"].get(candidate) && !payload["mid"].get(mid) && member.connection)
|
|
member.connection->addRemoteCandidate(rtc::Candidate(std::string(candidate), std::string(mid)));
|
|
}
|
|
|
|
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, Delivery delivery = DELIVERY_TEXT)
|
|
{
|
|
for (int id : lobbies[code].members)
|
|
send(id, message, delivery);
|
|
}
|
|
|
|
// 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 = text_clean(raw, PLAYER_NAME_MAX);
|
|
return name.empty() ? "Player" : name;
|
|
}
|
|
|
|
static std::string code_make()
|
|
{
|
|
std::uniform_int_distribution<int> letter(0, (int)sizeof(LOBBY_CODE_LETTERS) - 2);
|
|
for (int attempt = 0; attempt < CODE_TRIES; attempt++)
|
|
{
|
|
std::string code;
|
|
for (int i = 0; i < LOBBY_CODE_LENGTH; i++)
|
|
code += LOBBY_CODE_LETTERS[letter(rng)];
|
|
if (!lobbies.contains(code))
|
|
return code;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
// 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, DELIVERY_RELIABLE);
|
|
}
|
|
|
|
// 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, RoomEntry entry)
|
|
{
|
|
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, DELIVERY_RELIABLE);
|
|
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, DELIVERY_RELIABLE);
|
|
if (entry == ENTRY_JOIN)
|
|
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)
|
|
{
|
|
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.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) +
|
|
",\"name\":" + json_quote(lobby.name) + ",\"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, with its rooms.
|
|
static void lobby_exit(int id, LeaveReason leave = LEAVE_QUIT, const std::string& kick = "")
|
|
{
|
|
std::string code = members[id].code;
|
|
if (code.empty())
|
|
return;
|
|
room_exit(id);
|
|
members[id].code.clear();
|
|
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 = leave == LEAVE_KICKED ? ",\"kicked\":true,\"reason\":" + json_quote(kick) : "";
|
|
lobby_send(code, "{\"type\":\"member_leave\",\"id\":" + std::to_string(id) +
|
|
",\"host\":" + std::to_string(lobby.members.front()) + reason + "}");
|
|
}
|
|
|
|
// Tells a member about every public lobby: its code and name, how many are in it, whether it needs a password, and each
|
|
// player's color and fashion (as their fighter in its sandbox has them, or as they brought it while in a game), for
|
|
// little previews of them.
|
|
static void lobbies_send(int id)
|
|
{
|
|
std::string list;
|
|
for (auto& [code, lobby] : lobbies)
|
|
{
|
|
if (!lobby.isPublic || lobby.rooms.empty())
|
|
continue;
|
|
Sim& sandbox = rooms[lobby.rooms.front()].sim;
|
|
std::string players;
|
|
for (int member : lobby.members)
|
|
{
|
|
int fighter = sandbox.match.fighter_find(sandbox, member);
|
|
Profile& profile = members[member].profile;
|
|
int color = fighter >= 0 ? sandbox.match.colors[fighter] : profile.color;
|
|
int fashion = fighter >= 0 ? sandbox.match.fashions[fighter] : profile.fashion;
|
|
players += (players.empty() ? "" : ",") + std::string("{\"color\":") + std::to_string(color) +
|
|
",\"fashion\":" + std::to_string(fashion) + "}";
|
|
}
|
|
list += (list.empty() ? "" : ",") + std::string("{\"code\":") + json_quote(code) +
|
|
",\"name\":" + json_quote(lobby.name) + ",\"locked\":" + (lobby.password.empty() ? "false" : "true") +
|
|
",\"players\":[" + players + "]}";
|
|
}
|
|
send(id, "{\"type\":\"lobbies\",\"lobbies\":[" + list + "]}");
|
|
}
|
|
|
|
// The lobby messages, in JSON: listing, 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;
|
|
if (parser.parse(text).get(message) || message["type"].get(type))
|
|
return error_send(id, "Bad message");
|
|
|
|
// Answered, so proxies see the connection busy both ways.
|
|
if (type == "ping")
|
|
return send(id, "{\"type\":\"pong\"}");
|
|
if (type == "list" || type == "create" || type == "join")
|
|
{
|
|
int64_t version = 0;
|
|
std::string_view name;
|
|
if (message["version"].get(version) || version != PROTOCOL_VERSION)
|
|
return error_send(id, "Different game version");
|
|
if (type == "list")
|
|
return lobbies_send(id);
|
|
if (!members[id].code.empty())
|
|
return error_send(id, "Already in a lobby");
|
|
members[id].name = name_clean(message["name"].get(name) ? "" : name);
|
|
if (type == "create")
|
|
{
|
|
std::string code = code_make();
|
|
if (code.empty())
|
|
return error_send(id, "No free lobby codes");
|
|
// A lobby's name defaults to its code, and it starts unlisted unless asked otherwise.
|
|
std::string_view field;
|
|
bool isPublic = false;
|
|
Lobby& lobby = lobbies[code];
|
|
lobby.name = text_clean(message["lobby"].get(field) ? "" : field, LOBBY_NAME_MAX);
|
|
lobby.name = lobby.name.empty() ? "Lobby " + code : lobby.name;
|
|
lobby.password = text_clean(message["password"].get(field) ? "" : field, LOBBY_PASSWORD_MAX);
|
|
lobby.isPublic = !message["public"].get(isPublic) && isPublic;
|
|
std::printf("Lobby %s opened (%zu open)\n", code.c_str(), lobbies.size());
|
|
return lobby_enter(id, code);
|
|
}
|
|
std::string_view raw;
|
|
if (message["code"].get(raw))
|
|
return error_send(id, "No code");
|
|
std::string code;
|
|
for (char c : raw)
|
|
code += (char)std::toupper((unsigned char)c);
|
|
auto lobby = lobbies.find(code);
|
|
if (lobby == lobbies.end())
|
|
return error_send(id, "No lobby with that code");
|
|
if ((int)lobby->second.members.size() >= LOBBY_MEMBERS_MAX)
|
|
return error_send(id, "That lobby is full");
|
|
std::string_view password;
|
|
if (message["password"].get(password))
|
|
password = "";
|
|
if (!lobby->second.password.empty() && password != lobby->second.password)
|
|
return error_send(id, password.empty() ? LOBBY_PASSWORD_NEEDED : "Wrong password");
|
|
return lobby_enter(id, code);
|
|
}
|
|
if (type == "leave")
|
|
return lobby_exit(id);
|
|
if (type == "signal")
|
|
{
|
|
simdjson::dom::element payload;
|
|
if (message["payload"].get(payload) || members[id].code.empty())
|
|
return error_send(id, "Bad signal");
|
|
return signal_handle(id, payload);
|
|
}
|
|
if (type == "kick")
|
|
{
|
|
// Only a lobby's host can kick, and only someone else in their lobby. The kicked member is told why, then is
|
|
// out (their connection to the server stays; they can join a lobby again).
|
|
int64_t target = 0;
|
|
std::string_view raw;
|
|
std::string& code = members[id].code;
|
|
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)
|
|
return error_send(id, "No such player in the lobby");
|
|
std::string reason = text_clean(message["reason"].get(raw) ? "" : raw, KICK_REASON_MAX);
|
|
send((int)target, "{\"type\":\"kicked\",\"reason\":" + json_quote(reason) + "}");
|
|
std::printf("Lobby %s: %d kicked %d\n", code.c_str(), id, (int)target);
|
|
return lobby_exit((int)target, LEAVE_KICKED, reason);
|
|
}
|
|
if (type == "host")
|
|
{
|
|
// Only a lobby's host can hand hosting over, and only to someone else in their lobby, who moves to the front
|
|
// (the first member hosts).
|
|
int64_t target = 0;
|
|
std::string& code = members[id].code;
|
|
if (message["id"].get(target) || code.empty() || lobbies[code].members.front() != id || target == id)
|
|
return error_send(id, "Only the host can hand over hosting");
|
|
std::vector<int>& order = lobbies[code].members;
|
|
auto found = std::find(order.begin(), order.end(), (int)target);
|
|
if (found == order.end())
|
|
return error_send(id, "No such player in the lobby");
|
|
std::rotate(order.begin(), found, found + 1);
|
|
std::printf("Lobby %s: %d made %d host\n", code.c_str(), id, (int)target);
|
|
return lobby_send(code, "{\"type\":\"host_change\",\"host\":" + std::to_string(target) + "}");
|
|
}
|
|
error_send(id, "Unknown message");
|
|
}
|
|
|
|
// Sends to whoever a member's words reach: their room (everyone playing or watching where they are), or with
|
|
// CHAT_LOBBY (or no room yet), the whole lobby.
|
|
static void scope_send(int id, ChatScope scope, const std::string& bytes)
|
|
{
|
|
Member& member = members[id];
|
|
for (int other : lobbies[member.code].members)
|
|
if (scope == CHAT_LOBBY || member.room == 0 || members[other].room == member.room)
|
|
send(other, bytes, DELIVERY_RELIABLE);
|
|
}
|
|
|
|
// Text a member sent (a chat line or their new name), cleaned, passed on with who it's from: a new name to the whole
|
|
// lobby, and a chat line, with the room its sender is in, as far as its scope reaches.
|
|
static void text_relay(int id, PacketType type, PacketReader& reader)
|
|
{
|
|
Uint8 scope = CHAT_LOBBY;
|
|
Uint16 length = 0;
|
|
if ((type == PACKET_CHAT && !reader.get(scope)) || !reader.get(length) || reader.at + length > reader.bytes.size())
|
|
return;
|
|
std::string text = text_clean(std::string_view(reader.bytes.data() + reader.at, length),
|
|
type == PACKET_CHAT ? CHAT_MESSAGE_MAX : PLAYER_NAME_MAX);
|
|
if (text.empty())
|
|
return;
|
|
PacketWriter packet;
|
|
packet.put(type).put(id);
|
|
if (type == PACKET_CHAT)
|
|
packet.put(scope).put(members[id].room);
|
|
else
|
|
members[id].name = text;
|
|
packet.put((Uint16)text.size());
|
|
packet.bytes += text;
|
|
scope_send(id, scope == CHAT_ROOM ? CHAT_ROOM : CHAT_LOBBY, packet.bytes);
|
|
}
|
|
|
|
// 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)
|
|
{
|
|
// A member's first profile brings their fighter into the sandbox.
|
|
if (!reader.get(member.profile))
|
|
return;
|
|
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(), ENTRY_JOIN);
|
|
return;
|
|
}
|
|
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;
|
|
// A spectator's controls play nothing, so aren't kept.
|
|
if (room.sim.match.fighter_find(room.sim, id) < 0)
|
|
return;
|
|
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);
|
|
if (type == PACKET_TYPING)
|
|
{
|
|
Uint8 isTyping = 0;
|
|
// Only the room sees the "..." (a line said to all shows up elsewhere without one).
|
|
if (reader.get(isTyping))
|
|
scope_send(id, CHAT_ROOM, PacketWriter().put(PACKET_TYPING).put(id).put(isTyping).bytes);
|
|
}
|
|
if (type == PACKET_WATCH)
|
|
{
|
|
// From the sandbox, spectating one of the lobby's games. Spectating, back to the sandbox. Someone playing in a
|
|
// game leaves it by its own means.
|
|
int serial = 0;
|
|
std::vector<int>& lobbyRooms = lobbies[member.code].rooms;
|
|
int sandbox = lobbyRooms.front();
|
|
bool isInSandbox = member.room == sandbox;
|
|
auto room = rooms.find(member.room);
|
|
bool isPlaying = room != rooms.end() && room->second.sim.match.fighter_find(room->second.sim, id) >= 0;
|
|
if (!reader.get(serial))
|
|
return;
|
|
bool isGame = serial != sandbox && std::find(lobbyRooms.begin(), lobbyRooms.end(), serial) != lobbyRooms.end();
|
|
if (isGame && isInSandbox)
|
|
{
|
|
room_exit(id);
|
|
room_enter(id, serial, ENTRY_SEATED);
|
|
}
|
|
else if (serial == 0 && !isInSandbox && !isPlaying && member.room != 0)
|
|
{
|
|
room_exit(id);
|
|
room_enter(id, sandbox, ENTRY_JOIN);
|
|
}
|
|
return;
|
|
}
|
|
if (type == PACKET_PING)
|
|
{
|
|
Uint16 ping = 0;
|
|
if (reader.get(ping))
|
|
lobby_send(member.code, PacketWriter().put(PACKET_PING).put(id).put(ping).bytes, DELIVERY_RELIABLE);
|
|
}
|
|
}
|
|
|
|
// 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<int, Controls>& 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 color) 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]];
|
|
members[players[k]].profile.fashion = lobby.fashions[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, ENTRY_SEATED);
|
|
}
|
|
}
|
|
|
|
// 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 (see Match::againTicks), 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;
|
|
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, ENTRY_JOIN);
|
|
continue;
|
|
}
|
|
seats[humans++] = k;
|
|
}
|
|
if (humans > 0 && match.againTicks < 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, ENTRY_SEATED);
|
|
}
|
|
}
|
|
// Anyone still here was spectating, and goes back to the sandbox.
|
|
for (auto& [id, member] : members)
|
|
{
|
|
if (member.room != serial)
|
|
continue;
|
|
room_exit(id);
|
|
room_enter(id, sandbox, ENTRY_JOIN);
|
|
}
|
|
std::erase(lobbies[game.code].rooms, serial);
|
|
rooms.erase(serial);
|
|
}
|
|
|
|
// Tells a lobby's members who's in each of its rooms (the sandbox, then its games): each room's mode and the players
|
|
// still in it with their colors, for the lobby page's lists.
|
|
static void rooms_send(const std::string& code)
|
|
{
|
|
std::vector<int>& lobbyRooms = lobbies[code].rooms;
|
|
PacketWriter packet;
|
|
packet.put(PACKET_ROOMS).put((Uint8)lobbyRooms.size());
|
|
for (int serial : lobbyRooms)
|
|
{
|
|
Match& match = rooms[serial].sim.match;
|
|
int fighters = rooms[serial].sim.fighterCount;
|
|
Uint8 count = 0;
|
|
for (int k = 0; k < fighters; k++)
|
|
count += match.owners[k] >= 0 && !match.isGone[k];
|
|
packet.put(serial).put((Uint8)match.rules.mode).put(count);
|
|
for (int k = 0; k < fighters; k++)
|
|
if (match.owners[k] >= 0 && !match.isGone[k])
|
|
packet.put(match.owners[k]).put((Uint8)match.colors[k]);
|
|
}
|
|
lobby_send(code, packet.bytes, DELIVERY_RELIABLE);
|
|
}
|
|
|
|
// Sends everyone in a room the frames they haven't confirmed (as many as fit a packet), 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 = 0;
|
|
for (size_t bytes = 0; first + count < room.tick; count++)
|
|
{
|
|
bytes +=
|
|
sizeof(Uint16) + sizeof(Uint8) + room.frames[(first + count) % FRAME_RING].count * sizeof(Controls);
|
|
if (bytes > FRAME_BYTES_MAX && count > 0)
|
|
break;
|
|
}
|
|
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, DELIVERY_LOSSY);
|
|
}
|
|
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,
|
|
DELIVERY_RELIABLE);
|
|
}
|
|
|
|
// One tick of every room, in every lobby: the sandbox, then its games, then the frames out.
|
|
static void rooms_step()
|
|
{
|
|
std::vector<int> 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);
|
|
if (room.tick % CHECKSUM_INTERVAL == 0)
|
|
rooms_send(room.code);
|
|
}
|
|
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<Arrival> 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.channel && members.contains(arrival.id))
|
|
(arrival.channel->label() == INPUTS_LABEL ? members[arrival.id].inputs : members[arrival.id].channel) =
|
|
arrival.channel;
|
|
else if (arrival.isClosed)
|
|
{
|
|
lobby_exit(arrival.id);
|
|
if (members[arrival.id].connection)
|
|
members[arrival.id].connection->close();
|
|
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;
|
|
|
|
int main(int argc, char** argv)
|
|
{
|
|
std::setvbuf(stdout, nullptr, _IOLBF, 0);
|
|
rtc::InitLogger(rtc::LogLevel::Warning);
|
|
rtc::WebSocketServerConfiguration config;
|
|
config.port = argc > 1 ? (uint16_t)std::atoi(argv[1]) : PORT_DEFAULT;
|
|
config.bindAddress = "127.0.0.1";
|
|
config.maxMessageSize = MESSAGE_MAX;
|
|
rtc::WebSocketServer server(config);
|
|
peerConfig.iceServers.emplace_back(STUN_SERVER);
|
|
peerConfig.portRangeBegin = argc > 2 ? (uint16_t)std::atoi(argv[2]) : UDP_FIRST_DEFAULT;
|
|
peerConfig.portRangeEnd = argc > 3 ? (uint16_t)std::atoi(argv[3]) : UDP_LAST_DEFAULT;
|
|
peerConfig.maxMessageSize = MESSAGE_MAX;
|
|
|
|
int nextId = 1;
|
|
server.onClient([&nextId](std::shared_ptr<rtc::WebSocket> socket) {
|
|
std::lock_guard guard(lock);
|
|
int id = nextId++;
|
|
arrivals.push_back({.socket = socket, .id = id});
|
|
socket->onMessage([id](rtc::message_variant data) {
|
|
std::lock_guard guard(lock);
|
|
if (std::holds_alternative<std::string>(data))
|
|
arrivals.push_back({.bytes = std::get<std::string>(data), .id = id});
|
|
else
|
|
{
|
|
rtc::binary& binary = std::get<rtc::binary>(data);
|
|
arrivals.push_back({.bytes = std::string(reinterpret_cast<const char*>(binary.data()), binary.size()),
|
|
.id = id,
|
|
.isBinary = true});
|
|
}
|
|
});
|
|
socket->onClosed([id]() {
|
|
std::lock_guard guard(lock);
|
|
arrivals.push_back({.id = id, .isClosed = true});
|
|
});
|
|
});
|
|
std::printf("Server listening on 127.0.0.1:%d, WebRTC on UDP %d to %d\n", server.port(), peerConfig.portRangeBegin,
|
|
peerConfig.portRangeEnd);
|
|
|
|
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)
|
|
{
|
|
arrivals_take();
|
|
for (int i = 0; i < CATCH_UP_TICKS && std::chrono::steady_clock::now() >= next; i++)
|
|
{
|
|
rooms_step();
|
|
next += std::chrono::duration_cast<std::chrono::steady_clock::duration>(TICK_TIME);
|
|
}
|
|
if (std::chrono::steady_clock::now() > next)
|
|
next = std::chrono::steady_clock::now();
|
|
std::this_thread::sleep_until(next);
|
|
}
|
|
std::printf("Server stopping\n");
|
|
for (auto& [id, member] : members)
|
|
{
|
|
if (member.connection)
|
|
member.connection->close();
|
|
member.socket->close();
|
|
}
|
|
return 0;
|
|
}
|