From 3b65cb575f1e6f4665f979d7d3c0f1328f23d853 Mon Sep 17 00:00:00 2001 From: shweet Date: Sun, 8 Nov 2026 02:18:53 -0500 Subject: [PATCH] Rewrite the online session to play the server's rooms Session now runs only the room this screen is in. It plays ahead of the server with its own controls, guesses everyone else's, confirms ticks against the server's frames and rewinds on a wrong guess. It keeps its lead by playing a little faster or slower as the server says how early its controls arrive, resends an edit the server dropped, and checks the server's checksums. Gone with the peer-to-peer design: the host's decisions, the second arena game, the view swapping and spectating (to come back through the server). SAB_NET_STATS logs how it's keeping up. Co-Authored-By: Claude Opus 5.5 --- src/main.cpp | 12 +- src/menu.cpp | 28 +- src/session.cpp | 1449 +++++++++++------------------------------------ src/session.h | 213 ++----- 4 files changed, 406 insertions(+), 1296 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index a194ae8..cccc85c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -137,10 +137,8 @@ static bool frame_run(bool isDrawn = true) Session& session = game.session; for (; frameClock.accumulator >= TICK; frameClock.accumulator -= TICK) { - // Online, this screen's own work happens in the game it shows (the arena while it plays or watches a queued - // game), and the session runs the shared games, taking this screen's controls; otherwise each fighter on this + // Online, the session runs the room this screen is in, taking its controls; otherwise each fighter on this // screen takes its device's. Watching, they do nothing. - session.view_enter(game); game.menu.update(game); if (session.is_wanted(game)) { @@ -148,20 +146,16 @@ static bool frame_run(bool isDrawn = true) bool isHeld = game.menu.isOpen || game.chat.isOpen || session.is_watching(); Controls controls = game.input.controls_get(DEVICE_KEYBOARD, game.camera, own.center_get(), own.slot, isHeld); - session.view_leave(game); session.update(game, controls); game.input.tick_end(); - session.view_enter(game); if (session.isViewChanged) { session.isViewChanged = false; game.camera = game.cameraPrevious = camera_target_get(game); } presentation_update(game); - session.view_leave(game); continue; } - session.view_leave(game); for (int i = 0; i < game.sim.fighterCount; i++) { Fighter& fighter = game.sim.fighters[i]; @@ -176,11 +170,7 @@ static bool frame_run(bool isDrawn = true) game.settings.update(game); game.alpha = frameClock.accumulator / TICK; if (isDrawn) - { - session.view_enter(game); game_draw(game); - session.view_leave(game); - } return !game.input.isQuit; } diff --git a/src/menu.cpp b/src/menu.cpp index 13f70c3..388c0ae 100644 --- a/src/menu.cpp +++ b/src/menu.cpp @@ -75,14 +75,6 @@ void Menu::update(Game& game) bool isEscapePressed = input.is_pressed(SDL_SCANCODE_ESCAPE); bool isEscape = isEscapePressed && (isOpen || match.state != MATCH_OVER); isEscapeTaken = isEscape; - if (session.is_watching() && (isEscapePressed || input.is_control_pressed(CONTROL_MENU))) - { - session.watch_stop(game); - isOpen = false; - isEscapeTaken = true; - game.audio.play(SOUND_SWAP); - return; - } if (isEscapePressed && !isOpen && match.state == MATCH_OVER && session.is_playing_arena(game)) { session.arena_leave(game); @@ -653,35 +645,21 @@ void Menu::online_draw(Game& game, Rect area) net.lobby_leave(); y += ROW_HEIGHT + PADDING; - // Queueing a game (or stopping), and watching the one being played. Someone playing one can do neither. + // Queueing a game (or stopping). Someone playing one can't. Session& session = game.session; bool isPlaying = session.is_playing_arena(game); bool isQueued = session.is_queued(game); ui.separator_draw(game, {area.x, y, area.w, WIDGET_HEIGHT}, "Queue"); y += ROW_HEIGHT; - bool isArena = session.is_arena_on(); - int buttons = isArena ? 3 : 2; - float width = std::floor((area.w - PADDING * (buttons - 1)) / buttons); + float width = std::floor((area.w - PADDING) / 2); ui.widgets_enable(session.isJoined && !isPlaying); if (ui.button_draw(game, {area.x, y, width, WIDGET_HEIGHT}, "Queue a Game")) page = PAGE_QUEUE; ui.widgets_enable(session.isJoined && isQueued); if (ui.button_draw(game, {area.x + width + PADDING, y, width, WIDGET_HEIGHT}, "Stop Queueing")) - session.unqueue_send(game); - ui.widgets_enable(!isPlaying); - if (isArena && ui.button_draw(game, {area.x + (width + PADDING) * 2, y, width, WIDGET_HEIGHT}, "Spectate")) - { - session.watch_start(game); - isOpen = false; - } + session.queue_send(game, {}, false); ui.widgets_enable(true); y += ROW_HEIGHT; - if (isArena) - { - std::string playing = "Playing: " + session.arena_names_get(game); - text.draw(game.renderer, playing.c_str(), {.color = ui.shade, .position = {area.x, y + TEXT_ROW_INSET}}); - y += ROW_HEIGHT; - } // Everyone in it, in the order they came (the first hosting), in their colours, with their connections. ui.separator_draw(game, {area.x, y, area.w, WIDGET_HEIGHT}, "Players"); diff --git a/src/session.cpp b/src/session.cpp index 597bcd7..01407aa 100644 --- a/src/session.cpp +++ b/src/session.cpp @@ -2,175 +2,55 @@ #include "game.h" #include "state.h" #include +#include #include #include -#include -constexpr int ROLLBACK_LIMIT = 8; -constexpr int JOIN_LEAD = 60; -constexpr int INPUT_PACKET_MAX = 64; -constexpr int LEAVE_INPUTS_MAX = 128; -constexpr int CATCH_UP_MAX = 240; -constexpr int AHEAD_LIMIT = 2; -constexpr int BEHIND_LIMIT = 3; -constexpr int GUESS_REACH = 64; -constexpr int CHECKSUM_INTERVAL = 60; -constexpr float MS_PER_TICK = TICK * 1000; +// How far past the last confirmed tick this screen plays at most, and how many of its own controls go again in each +// packet. Its controls should reach the server LEAD_LOW to LEAD_HIGH ticks early: outside that, it changes pace (at +// most every PACE_WAIT ticks), towards LEAD_TARGET, by up to PACE_BURST extra ticks at once. +constexpr int PREDICT_MAX = 30; +constexpr int RESEND_MAX = 30; +constexpr int LEAD_LOW = 1; +constexpr int LEAD_HIGH = 5; +constexpr int LEAD_TARGET = 3; +constexpr int PACE_BURST = 4; +constexpr int PACE_WAIT = 30; +// How long a newcomer waits for the WebRTC link before playing over the socket instead. +constexpr Uint64 LINK_WAIT = 3000; constexpr Uint64 RENAME_WAIT = 30000; -constexpr Uint64 RENAME_SLACK = 2000; -// How far ahead the host sets a queue change, so it rarely lands on a tick someone's already played. -constexpr int QUEUE_LEAD = 15; -constexpr Uint32 ARENA_SEED_MIX = 2654435761u; +constexpr int STATS_INTERVAL = 300; -enum PeerPacket : Uint8 -{ - 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. static bool controls_same(const Controls& a, const Controls& b) { - return std::memcmp(&a.cursor.x, &b.cursor.x, sizeof(float)) == 0 && - std::memcmp(&a.cursor.y, &b.cursor.y, sizeof(float)) == 0 && a.buttons == b.buttons && a.edit == b.edit && + return a.cursor.x == b.cursor.x && a.cursor.y == b.cursor.y && a.buttons == b.buttons && a.edit == b.edit && a.slot == b.slot; } -InputLog::InputLog() +// In a lobby: starts afresh. Once the link to the server is up (or has had long enough), this screen's sandbox fighter +// is brought in (see update). +void Session::start(Game& game) { - std::fill(std::begin(ticks), std::end(ticks), -1); - std::fill(std::begin(usedTicks), std::end(usedTicks), -1); -} - -// Needed from a new tick on: what's certain is counted again from there. -void InputLog::restart(int from) -{ - start = from; - contiguous = from - 1; - while (has(contiguous + 1)) - contiguous++; -} - -bool InputLog::has(int at) const -{ - return at >= 0 && ticks[at % INPUT_RING] == at; -} - -// Keeps a tick's controls (the first to arrive; later copies are the same). Returns whether that tick was already -// simulated with a guess that turned out wrong. -bool InputLog::put(int at, const Controls& value) -{ - if (at < 0 || has(at)) - return false; - int i = at % INPUT_RING; - controls[i] = value; - ticks[i] = at; - latest = std::max(latest, at); - while (has(contiguous + 1)) - contiguous++; - return usedTicks[i] == at && !controls_same(used[i], value); -} - -// A guess at a tick's controls: the last ones known before it, held the same, but without a one-off edit or weapon -// switch (which would otherwise happen again every guessed tick). -Controls InputLog::guess(int at) const -{ - for (int back = at - 1; back >= std::max(start, at - GUESS_REACH); back--) - if (has(back)) - { - Controls guessed = controls[back % INPUT_RING]; - guessed.edit = -1; - guessed.slot = -1; - return guessed; - } - return {}; -} - -// This screen's player as they join: name, colour, and the sandbox fighter they're playing now. -Profile Session::profile_get(Game& game) -{ - Profile profile = ::profile_get(game.local_get()); - SDL_strlcpy(profile.name, game.settings.name, sizeof(profile.name)); - profile.color = game.settings.color_get(); - return profile; -} - -// Starts afresh: no logs, peers, events or snapshots, and the input delay as set. -static void session_clear(Game& game, Session& session) -{ - session.tick = 0; - session.delay = game.settings.inputDelay; - session.rollbacks = 0; - session.stalls = 0; - session.isDesynced = false; -} - -// The lobby's maker plays on in their sandbox, now as the session's first fighter. -void Session::host_start(Game& game) -{ - session_clear(game, *this); - logs.clear(); - peers.clear(); - events.clear(); - departed.clear(); - remoteChecksums.clear(); - std::fill(std::begin(snapshotTicks), std::end(snapshotTicks), -1); - checksumSent = -1; - rollbackTo = -1; - isActive = true; - isJoined = true; - isRequested = true; - - Fighter own = game.local_get(); - for (Device& device : game.input.devices) - device.isClaimed = false; - game.sim.fighterCount = 1; - game.sim.fighters[0] = own; - game.sim.fighters[0].index = 0; - game.sim.weapons.attacks.clear(); - Match& match = game.sim.match; - std::fill(std::begin(match.owners), std::end(match.owners), -1); - match.owners[0] = game.net.selfId; - SDL_strlcpy(match.names[0], game.settings.name, sizeof(match.names[0])); - match.colors[0] = std::max(0, game.settings.color_get()); - int self = game.net.selfId; - logs[self].restart(0); - ownStart = ownNext = 0; - names.clear(); - names[self] = text_clean(game.settings.name, PLAYER_NAME_MAX); - sentColor = game.settings.color_get(); - game.chat.players_clear(); - game.chat.system("Created lobby " + game.net.code + ". Press T to chat, or type /help for commands."); - local_find(game); -} - -// A newcomer waits to be connected to everyone, then asks the host to be let in (see members_watch). -void Session::join_start(Game& game) -{ - session_clear(game, *this); - logs.clear(); - peers.clear(); - events.clear(); - departed.clear(); - remoteChecksums.clear(); - std::fill(std::begin(snapshotTicks), std::end(snapshotTicks), -1); - checksumSent = -1; - rollbackTo = -1; isActive = true; isJoined = false; - isRequested = false; + isDesynced = false; + room = 0; + tick = confirmed = 0; + rewindTo = -1; + frames.clear(); + events.clear(); + checksums.clear(); + held.clear(); names.clear(); names[game.net.selfId] = text_clean(game.settings.name, PLAYER_NAME_MAX); sentColor = game.settings.color_get(); + linkDeadline = SDL_GetTicks() + LINK_WAIT; + isProfileSent = false; + isStatsLogged = SDL_getenv("SAB_NET_STATS"); game.chat.players_clear(); - game.chat.system("Joining lobby " + game.net.code + "..."); + bool isMaker = game.net.members.size() <= 1; + game.chat.system((isMaker ? "Created lobby " : "Joined lobby ") + game.net.code + + ". Press T to chat, or type /help for commands."); } // Back to playing alone: this screen's fighter (if it had one) stays, in a sandbox of its own. @@ -179,283 +59,92 @@ void Session::stop(Game& game) if (!isActive) return; isActive = false; - int own = -1; - for (int i = 0; i < game.sim.fighterCount; i++) - if (isJoined && game.sim.match.owners[i] == game.net.selfId) - own = i; + Sim& sim = game.sim; + Match& match = sim.match; + int own = fighter_get(game, game.net.selfId); if (own >= 0) - game.sim.fighters[0] = game.sim.fighters[own]; - game.sim.fighters[0].index = 0; - game.sim.fighterCount = 1; - game.sim.weapons.attacks.clear(); - std::fill(std::begin(game.sim.match.owners), std::end(game.sim.match.owners), -1); + sim.fighters[0] = sim.fighters[own]; + sim.fighters[0].index = 0; + sim.fighters[0].isBot = false; + sim.fighterCount = 1; + sim.weapons.attacks.clear(); + if (match.state != MATCH_PRACTICE) + match.sandbox_return(sim); + match.isOnline = false; + std::fill(std::begin(match.owners), std::end(match.owners), -1); + std::fill(std::begin(match.queues), std::end(match.queues), Queue{}); + std::fill(std::begin(match.queuedIn), std::end(match.queuedIn), -1); + std::fill(std::begin(match.isQueueReady), std::end(match.isQueueReady), false); game.local = 0; std::fill(std::begin(game.devices), std::end(game.devices), DEVICE_NONE); game.devices[0] = DEVICE_KEYBOARD; - game.sim.fighters[0].isBot = false; - Match& match = game.sim.match; - std::fill(std::begin(match.queues), std::end(match.queues), Queue{}); - std::fill(std::begin(match.isAway), std::end(match.isAway), false); - std::fill(std::begin(match.queuedIn), std::end(match.queuedIn), -1); - std::fill(std::begin(match.isQueueReady), std::end(match.isQueueReady), false); - arena.isActive = false; - isWatching = false; - announced.clear(); isJoined = false; game.chat.players_clear(); } -// Whether this screen should run the shared game: it's in a lobby (or was, and is still to wind it down). +// Whether this screen should run the online game: it's in a lobby (or was, and is still to wind it down). bool Session::is_wanted(Game& game) { return isActive || game.net.state == NET_LOBBY; } -// Whether a member's controls count at a tick: their fighter is in the game then. -bool Session::is_needed(int member, int at) -{ - auto log = logs.find(member); - return log != logs.end() && at >= log->second.start && at < log->second.until; -} - -// The last tick whose state is certain: every member's controls before it are known. (A member whose fighter hasn't -// appeared yet doesn't hold it back.) Never older than the oldest snapshot kept. -int Session::confirmed_get(Game& game) -{ - int confirmed = tick; - for (auto& [member, log] : logs) - if (member != game.net.selfId && log.start < tick && log.until > log.contiguous + 1) - confirmed = std::min(confirmed, std::max(log.contiguous + 1, log.start)); - for (int oldest = confirmed; oldest < tick; oldest++) - if (snapshotTicks[oldest % SNAPSHOT_RING] == oldest) - return oldest; - return tick; -} - -// Once per real tick: takes in what's arrived, adds this screen's controls (delay ticks ahead), sends everyone what -// they're missing, rewinds over any wrong guess, then moves the game on: a tick, none if this machine is ahead of the -// others (or would be guessing too far), or several if it's behind (catching up after joining). +// Once per real tick: brings this screen's fighter in when it's time, takes in what's arrived, confirms what the +// server's frames settle (rewinding over any wrong guess), checks it agrees with the server, then plays on with this +// screen's controls: a tick, or a few to get further ahead of the server, or none to fall back (or when it's guessed +// too far past what's confirmed). void Session::update(Game& game, Controls local) { if (!isActive && game.net.state == NET_LOBBY) - { - if (game.net.members.size() <= 1) - host_start(game); - else - join_start(game); - } + start(game); if (!isActive) return; if (game.net.state != NET_LOBBY) + return stop(game); + if (!isProfileSent && (game.net.is_linked() || SDL_GetTicks() >= linkDeadline)) { - stop(game); - return; + Profile profile = profile_get(game.local_get()); + SDL_strlcpy(profile.name, game.settings.name, sizeof(profile.name)); + profile.color = game.settings.color_get(); + game.net.send(PacketWriter().put(PACKET_PROFILE).put(profile).bytes, true); + isProfileSent = true; } - members_watch(game); packets_take(game); if (!isJoined) return; profile_watch(game); - - int self = game.net.selfId; - InputLog& own = logs[self]; - ownNext = std::max(ownNext, ownStart); - bool isEdited = false; - auto own_fill = [&](int until) { - for (; ownNext <= until; ownNext++) - { - Controls held = local; - if (isEdited) - held.edit = held.slot = -1; - own.put(ownNext, held); - isEdited = true; - } - }; - own_fill(tick + delay); - inputs_send(game); - - if (rollbackTo >= 0 && rollbackTo < tick) + confirm(game); + rewind(game); + frames.erase(frames.begin(), frames.lower_bound(confirmed)); + for (auto check = checksums.begin(); check != checksums.end() && check->first < confirmed;) { - if (snapshotTicks[rollbackTo % SNAPSHOT_RING] != rollbackTo) - SDL_Log("Session: can't rewind to tick %d, it's gone", rollbackTo); - else + SessionTick& slot = ticks[check->first % SESSION_RING]; + if (slot.tick == check->first && slot.checksum != check->second && !isDesynced) { - int end = tick; - game.sim.snapshot_load(snapshots[rollbackTo % SNAPSHOT_RING]); - arena_restore(game, rollbackTo % SNAPSHOT_RING); - tick = rollbackTo; - while (tick < end) - step(game, false); - rollbacks++; + SDL_Log("Session: out of sync with the server at tick %d", check->first); + isDesynced = true; } + check = checksums.erase(check); } - rollbackTo = -1; - // How far ahead or behind the others this machine is, from the ticks they last reported (moved on by half the - // round trip). - int behind = 0; - bool isAhead = false; - for (auto& [member, peer] : peers) + int advance = 1; + if (tick - paced >= PACE_WAIT && (lead < LEAD_LOW || lead > LEAD_HIGH)) { - if (!peer.isSending || !is_needed(member, tick)) - continue; - float ping = 0; - for (NetMember& netMember : game.net.members) - if (netMember.id == member && netMember.ping > 0) - ping = netMember.ping; - int estimate = peer.remoteTick + (int)(ping / 2 / MS_PER_TICK); - isAhead |= tick > estimate + AHEAD_LIMIT; - behind = std::max(behind, estimate - tick); + advance = lead < LEAD_LOW ? 1 + std::min(PACE_BURST, LEAD_TARGET - lead) : 0; + paced = tick; } - int advance = isAhead ? 0 : behind > BEHIND_LIMIT ? std::min(behind, CATCH_UP_MAX) : 1; - stalls += advance == 0; + advance = std::min(advance, confirmed + PREDICT_MAX - tick); + stalls += advance <= 0; for (int i = 0; i < advance; i++) { - own_fill(tick + delay); - bool isReady = true; - for (auto& [member, log] : logs) - if (member != self && is_needed(member, tick) && tick - log.contiguous > ROLLBACK_LIMIT) - isReady = false; - if (!isReady) - { - stalls++; - break; - } + own[tick % SESSION_RING] = local; + local.edit = local.slot = -1; step(game, i == advance - 1); } - checksums_exchange(game); + inputs_send(game); local_find(game); -} - -// Simulates one tick: keeps the state from before it (to rewind to), makes any change of players due then, gives -// every fighter its member's controls (known, or guessed), and steps the sandbox, then the arena if a queued game is -// being played (and starts one that's become ready). Replays are silent, as is whichever of the two isn't shown. -void Session::step(Game& game, bool isLive) -{ - int slot = tick % SNAPSHOT_RING; - snapshots[slot] = game.sim.snapshot_save(); - arena_save(game, slot); - snapshotTicks[slot] = tick; - checksums[slot] = checksum_full(game); - events_apply(game, tick); - - bool isArenaShown = arena.isActive && (isWatching || is_playing_arena(game)); - controls_give(game, false); - game.scene.isMuted = !isLive || isArenaShown; - game_tick(game); - if (arena.isActive) - { - arena_swap(game); - arena_locals_find(game); - controls_give(game, true); - game.scene.isMuted = !isLive || !isArenaShown; - game_tick(game); - arena_locals_find(game); - arena_swap(game); - arena_after(game); - } - game.scene.isMuted = false; - if (!arena.isActive) - { - int queuer = game.sim.match.queue_startable_get(game.sim); - if (queuer >= 0) - arena_start(game, queuer); - } - tick++; -} - -// Gives every fighter of the game in place (the sandbox, or the arena swapped in) its member's controls for this tick, -// known or guessed. In the sandbox, anyone away in the arena does nothing; in the arena, anyone who's left it does -// nothing, and anyone gone from the lobby altogether leaves it. -void Session::controls_give(Game& game, bool isArena) -{ - // While the arena's swapped in, the sandbox is what's set aside. - Match& lobby = isArena ? arena.state.match : game.sim.match; - int lobbyCount = isArena ? arena.state.fighterCount : game.sim.fighterCount; - for (int i = 0; i < game.sim.fighterCount; i++) - { - Fighter& fighter = game.sim.fighters[i]; - int owner = game.sim.match.owners[i]; - bool isIdle = isArena ? owner >= 0 && game.sim.match.isGone[i] : game.sim.match.isAway[i]; - auto log = logs.find(owner); - if (isIdle || owner < 0 || log == logs.end()) - { - fighter.controls = {}; - continue; - } - if (isArena && std::find(lobby.owners, lobby.owners + lobbyCount, owner) == lobby.owners + lobbyCount) - { - fighter.controls = {.edit = edit_encode({.type = EDIT_LEAVE})}; - continue; - } - InputLog& inputs = log->second; - int ring = tick % INPUT_RING; - if (inputs.has(tick)) - { - fighter.controls = inputs.controls[ring]; - inputs.usedTicks[ring] = -1; - continue; - } - fighter.controls = inputs.guess(tick); - inputs.used[ring] = fighter.controls; - inputs.usedTicks[ring] = tick; - } -} - -// Adds and removes fighters as the events due at a tick say. A newcomer's fighter starts from their profile, in the -// colour they asked for if it's free; one leaving takes its attacks with it, and the fighters after it move down. -void Session::events_apply(Game& game, int at) -{ - Match& match = game.sim.match; - for (SessionEvent& event : events) - { - if (event.tick != at) - continue; - if (event.type == EVENT_JOIN) - match.member_join(game.sim, event.member, event.profile); - if (event.type == EVENT_LEAVE) - match.member_leave(game.sim, event.member); - if (event.type == EVENT_QUEUE || event.type == EVENT_UNQUEUE) - { - int index = std::find(match.owners, match.owners + game.sim.fighterCount, event.member) - match.owners; - if (index >= game.sim.fighterCount || match.isAway[index]) - continue; - if (event.type == EVENT_QUEUE) - match.queue_open(game.sim, index, event.rules); - else - { - match.queues[index].isActive = false; - match.isQueueReady[index] = false; - } - } - } -} - -// Joins and leaves the server reports. A newcomer, once connected to everyone, asks the host to come in. The host -// takes out anyone gone (a new host does the same for anyone who left with the old one). -void Session::members_watch(Game& game) -{ - Net& net = game.net; - int self = net.selfId; - if (!isJoined && !isRequested && net.is_linked() && net.hostId != self) - { - Profile profile = profile_get(game); - net.send(PacketWriter().put(PEER_JOIN_REQUEST).put(profile).bytes, true); - isRequested = true; - } - if (!isJoined || net.hostId != self) - return; - for (auto& [member, log] : logs) - { - bool isPresent = - std::any_of(net.members.begin(), net.members.end(), [&](NetMember& m) { return m.id == member; }); - bool isDone = std::find(departed.begin(), departed.end(), member) != departed.end(); - if (!isPresent && !isDone && member != self) - { - departed.push_back(member); - leave_decide(game, member); - } - } + if (isStatsLogged && tick % STATS_INTERVAL == 0 && advance > 0) + SDL_Log("Session: room %d tick %d, %d ahead of confirmed, lead %d, %d rollbacks, %d stalls%s", room, tick, + tick - confirmed, lead, rollbacks, stalls, isDesynced ? ", OUT OF SYNC" : ""); } void Session::packets_take(Game& game) @@ -463,338 +152,292 @@ void Session::packets_take(Game& game) std::vector packets; packets.swap(game.net.packets); for (std::string& packet : packets) - packet_handle(game, -1, packet, true); + packet_handle(game, packet); } -void Session::packet_handle(Game& game, int from, const std::string& bytes, bool isReliable) +// One message from the server (see protocol.h). Game messages for another room than this screen's are old and +// dropped, except events, kept in case their room's state is still on its way. +void Session::packet_handle(Game& game, const std::string& bytes) { PacketReader reader = {bytes}; - PeerPacket type; + PacketType type; + int serial = 0; if (!reader.get(type)) return; + bool isGame = type == PACKET_STATE || type == PACKET_EVENT || type == PACKET_FRAMES || type == PACKET_CHECKSUM; + if (isGame && !reader.get(serial)) + return; - if (type == PEER_INPUTS && !isReliable) - { - int senderTick = 0; - int ack = 0; - int first = 0; - Uint16 count = 0; - if (!reader.get(senderTick) || !reader.get(ack) || !reader.get(first) || !reader.get(count)) - return; - SessionPeer& peer = peers[from]; - peer.remoteTick = std::max(peer.remoteTick, senderTick); - peer.ack = std::max(peer.ack, ack); - InputLog& log = logs[from]; - for (int i = 0; i < count; i++) - { - Controls controls; - if (!reader.get(controls)) - return; - int at = first + i; - if (log.put(at, controls) && at < tick) - rollbackTo = rollbackTo < 0 ? at : std::min(rollbackTo, at); - } - return; - } - if (type == PEER_QUEUE) - { - Uint8 isOpen = 0; - Rules rules; - if (reader.get(isOpen) && reader.get(rules) && isJoined && game.net.hostId == game.net.selfId) - queue_decide(game, from, isOpen != 0, rules); - return; - } - 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 == PEER_EVENT) - { - SessionEvent event; - int first = 0; - Uint16 count = 0; - if (!reader.get(event) || !reader.get(first) || !reader.get(count)) - return; - InputLog& log = logs[event.member]; - for (int i = 0; i < count; i++) - { - Controls controls; - if (!reader.get(controls)) - return; - if (log.put(first + i, controls) && first + i < tick) - rollbackTo = rollbackTo < 0 ? first + i : std::min(rollbackTo, first + i); - } - event_add(game, event); - return; - } - if (type == PEER_STATE && !isJoined) + if (type == PACKET_STATE) { int at = 0; - if (!reader.get(at)) - return; - if (!state_full_load(game, bytes.substr(reader.at))) - { - SDL_Log("Session: the host's game state didn't load (a different version?)"); - game.net.error = "Couldn't load the lobby's game (different versions?)"; - return; - } - tick = at; - isJoined = true; - std::erase_if(events, [&](SessionEvent& event) { return event.tick < at; }); - int self = game.net.selfId; - for (int i = 0; i < game.sim.fighterCount; i++) - { - int owner = game.sim.match.owners[i]; - if (owner >= 0 && owner != self && !names.contains(owner)) - names[owner] = text_clean(game.sim.match.names[i], PLAYER_NAME_MAX); - if (owner < 0 || owner == self) - continue; - logs[owner].restart(at); - peers[owner].isSending = true; - peers[owner].sendFrom = std::max(peers[owner].sendFrom, ownStart); - } - local_find(game); + if (reader.get(at)) + state_load(game, reader, serial, at); return; } - if (type == PEER_CHAT || type == PEER_RENAME) + if (type == PACKET_EVENT) { - // 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 == PEER_RENAME && !reader.get(isQuiet)) || !reader.get(length) || reader.at + length > bytes.size()) + int number = 0; + RoomEvent event; + if (!reader.get(number) || !reader.get(event)) return; - std::string_view raw(bytes.data() + reader.at, length); - if (type == PEER_CHAT) - { - std::string text = text_clean(raw, CHAT_MESSAGE_MAX); - if (!text.empty()) - game.chat.say(game, from, text); - return; - } - std::string name = text_clean(raw, PLAYER_NAME_MAX); - if (name.empty() || name == names[from]) - return; - // A rename sooner than they're allowed (a game not keeping to the wait) is ignored. - Uint64 now = SDL_GetTicks(); - if (!isQuiet && renamedAt.contains(from) && now < renamedAt[from] + RENAME_WAIT - RENAME_SLACK) - return; - if (!isQuiet) - renamedAt[from] = now; - std::string before = name_get(game, from); - names[from] = name; - for (NetMember& member : game.net.members) - if (member.id == from) - member.name = name; - if (!isQuiet) - game.chat.system(before + " changed their name to " + name_get(game, from) + "."); + // One from before the confirmed state is in it already. + events[serial][number] = event; + if (serial == room && event.tick >= confirmed && event.tick < tick) + rewindTo = rewindTo < 0 ? event.tick : std::min(rewindTo, event.tick); return; } - if (type == PEER_TYPING) - { - Uint8 isTyping = 0; - if (reader.get(isTyping)) - game.chat.typing[from] = isTyping != 0; - return; - } - if (type == PEER_CHECKSUM) + if (type == PACKET_FRAMES && serial == room) + return frames_take(reader); + if (type == PACKET_CHECKSUM && serial == room) { int at = 0; Uint32 value = 0; if (reader.get(at) && reader.get(value)) - remoteChecksums[at] = value; + checksums[at] = value; + return; } -} + if (isGame) + return; -// The host lets a newcomer in: they get the game as of the last certain tick, every other member is told when the -// newcomer's fighter appears (a moment from now) and to send the newcomer their controls from that certain tick on. -void Session::join_grant(Game& game, int member, const Profile& profile) -{ - int from = confirmed_get(game); - SessionEvent event = { - .tick = tick + JOIN_LEAD, .type = EVENT_JOIN, .member = member, .from = from, .profile = profile}; - std::string state; - if (from == tick) - state = state_full_save(game); - else + // Lobby messages: who they're from, then whether they're typing, a chat line or their new name. + int from = 0; + if (!reader.get(from)) + return; + if (type == PACKET_TYPING) { - Snapshot now = game.sim.snapshot_save(); - Arena arenaNow = arena; - game.sim.snapshot_load(snapshots[from % SNAPSHOT_RING]); - arena_restore(game, from % SNAPSHOT_RING); - state = state_full_save(game); - game.sim.snapshot_load(now); - arena = arenaNow; + Uint8 isTyping = 0; + if (reader.get(isTyping) && from != game.net.selfId) + game.chat.typing[from] = isTyping != 0; + return; } - event_add(game, event); - for (auto& [other, log] : logs) - if (other != game.net.selfId) - event_send(game, other, event, 0, -1); - for (SessionEvent& pending : events) - if (pending.tick >= from && !(pending.member == member && pending.type == EVENT_JOIN)) - event_send(game, member, pending, 0, -1); - PacketWriter packet; - packet.put(PEER_STATE).put(from); - packet.bytes += state; - game.net.send(packet.bytes, true); - SDL_Log("Session: %d joins at tick %d, from tick %d (%zu bytes)", member, event.tick, from, state.size()); + Uint16 length = 0; + if (!reader.get(length) || reader.at + length > bytes.size()) + return; + std::string_view raw(bytes.data() + reader.at, length); + if (type == PACKET_CHAT) + { + std::string text = text_clean(raw, CHAT_MESSAGE_MAX); + if (!text.empty()) + game.chat.say(game, from, text); + return; + } + std::string name = text_clean(raw, PLAYER_NAME_MAX); + if (type != PACKET_RENAME || name.empty() || name == names[from]) + return; + std::string before = name_get(game, from); + names[from] = name; + for (NetMember& member : game.net.members) + if (member.id == from) + member.name = name; + game.chat.system(before + " changed their name to " + name_get(game, from) + "."); } -// The host takes a member out at the tick after the last of their controls it has, and sends everyone those last -// controls, so all agree what the fighter did before it went. -void Session::leave_decide(Game& game, int member) +// This screen is in a room now, as its tick starts: the game loads and plays on from there, its effects and camera +// starting afresh. Older rooms' events go. +void Session::state_load(Game& game, PacketReader& reader, int serial, int at) { - InputLog& log = logs[member]; - int at = std::max(log.contiguous + 1, log.start); - SessionEvent event = {.tick = at, .type = EVENT_LEAVE, .member = member}; - event_add(game, event); - int from = std::max(log.start, at - LEAVE_INPUTS_MAX); - for (auto& [other, otherLog] : logs) - if (other != game.net.selfId && other != member) - event_send(game, other, event, from, at - 1); - SDL_Log("Session: %d leaves at tick %d", member, at); + if (!::state_load(game.sim, reader.bytes.substr(reader.at))) + { + SDL_Log("Session: the server's game state didn't load (a different version?)"); + game.net.error = "Couldn't load the lobby's game (different versions?)"; + return; + } + room = serial; + tick = confirmed = paced = at; + rewindTo = -1; + lead = LEAD_TARGET; + frames.clear(); + checksums.clear(); + held.clear(); + events.erase(events.begin(), events.lower_bound(serial)); + for (SessionTick& slot : ticks) + slot.tick = -1; + isJoined = true; + isDesynced = false; + isViewChanged = true; + game.effects.clear(); + game.ui.spectated = -1; + for (int i = 0; i < game.sim.fighterCount; i++) + { + int owner = game.sim.match.owners[i]; + if (owner >= 0 && !names.contains(owner)) + names[owner] = text_clean(game.sim.match.names[i], PLAYER_NAME_MAX); + } + local_find(game); } -// Sends an event, with the member's controls from one tick to another (none when from is past to). -void Session::event_send(Game& game, int to, const SessionEvent& event, int inputsFrom, int inputsTo) +// The server's frames, kept until they're confirmed, and how early this screen's controls are reaching it. +void Session::frames_take(PacketReader& reader) { - InputLog& log = logs[event.member]; - std::vector inputs; - for (int at = inputsFrom; at <= inputsTo && log.has(at); at++) - inputs.push_back(log.controls[at % INPUT_RING]); - PacketWriter packet; - packet.put(PEER_EVENT).put(event).put(inputsFrom).put((Uint16)inputs.size()); - for (Controls& controls : inputs) - packet.put(controls); - game.net.send(packet.bytes, true); -} - -// Takes an event in: a join says when the newcomer's controls start counting and that they need this screen's from -// the certain tick on; a leave, when the member's stop. One for a tick already played rewinds to it. -void Session::event_add(Game& game, const SessionEvent& event) -{ - bool isQueueing = event.type == EVENT_QUEUE || event.type == EVENT_UNQUEUE; - for (SessionEvent& known : events) - if (known.member == event.member && known.type == event.type && (!isQueueing || known.tick == event.tick)) + int first = 0; + Uint16 count = 0; + if (!reader.get(lead) || !reader.get(first) || !reader.get(count)) + return; + for (int at = first; at < first + count; at++) + { + Uint16 known = 0; + Uint8 fighters = 0; + if (!reader.get(known) || !reader.get(fighters) || fighters > MAX_FIGHTERS) return; - events.push_back(event); - std::stable_sort(events.begin(), events.end(), [](const SessionEvent& a, const SessionEvent& b) { - return a.tick != b.tick ? a.tick < b.tick : a.type < b.type; - }); - int self = game.net.selfId; - if (event.type == EVENT_JOIN) - { - if (event.member != self) - names[event.member] = text_clean(event.profile.name, PLAYER_NAME_MAX); - game.chat.system(name_get(game, event.member) + " joined the lobby."); - // A newcomer only knows the names the game had when it started, so everyone tells them theirs now. - if (event.member != self && isJoined) - rename_send(game, event.member, true); - logs[event.member].restart(event.tick); - if (event.member == self) - ownStart = ownNext = event.tick; - else - { - SessionPeer& peer = peers[event.member]; - peer.isSending = true; - peer.sendFrom = std::max(event.from, ownStart); - } + SessionFrame frame = {.count = fighters, .events = known}; + for (int i = 0; i < fighters; i++) + if (!reader.get(frame.controls[i])) + return; + if (at >= confirmed) + frames[at] = frame; } - if (event.type == EVENT_QUEUE) - { - Rules rules = rules_clean(event.rules); - game.chat.system(name_get(game, event.member) + " is hosting a Deathmatch for " + - std::to_string(rules.players) + " players."); - } - if (event.type == EVENT_LEAVE) - { - auto kick = game.net.kicks.find(event.member); - if (kick == game.net.kicks.end()) - game.chat.system(name_get(game, event.member) + " left the lobby."); - else - game.chat.system(name_get(game, event.member) + " was kicked by the host." + - (kick->second.empty() ? "" : " Reason: " + kick->second)); - logs[event.member].until = event.tick; - peers[event.member].isSending = false; - } - if (isJoined && event.tick < tick) - rollbackTo = rollbackTo < 0 ? event.tick : std::min(rollbackTo, event.tick); } -// Sends each member this screen's controls they don't have yet (from the last they confirmed, up to a packetful), -// with this screen's tick and how far it has theirs, for timing and acknowledging. Sent every tick even when empty. +// Confirms every played tick the server's frames have come for (once the room's events before them are in too). A +// frame that differs from what was played rewinds the game to it (the frames stay until then, for the replay to play). +// One without this screen's edit (it came too late) sends the edit again. +void Session::confirm(Game& game) +{ + std::map& known = events[room]; + int contiguous = 0; + while (known.contains(contiguous)) + contiguous++; + for (auto frame = frames.find(confirmed); frame != frames.end() && confirmed < tick; frame = frames.find(confirmed)) + { + if (frame->second.events > contiguous) + break; + SessionTick& slot = ticks[confirmed % SESSION_RING]; + bool isSame = slot.tick == confirmed; + for (int i = 0; i < frame->second.count && isSame; i++) + isSame = controls_same(frame->second.controls[i], slot.controls[i]); + if (!isSame) + rewindTo = rewindTo < 0 ? confirmed : std::min(rewindTo, confirmed); + for (int i = 0; i < frame->second.count && slot.tick == confirmed; i++) + { + int owner = slot.owners[i]; + Controls& played = frame->second.controls[i]; + if (owner >= 0) + held[owner] = played; + if (owner == game.net.selfId && slot.controls[i].edit >= 0 && played.edit != slot.controls[i].edit) + game.input.edit_send(DEVICE_KEYBOARD, slot.controls[i].edit); + } + confirmed++; + } +} + +// Plays the game again from the earliest tick that went differently than guessed, silently, up to where it was. +void Session::rewind(Game& game) +{ + if (rewindTo < 0 || rewindTo >= tick) + { + rewindTo = -1; + return; + } + SessionTick& slot = ticks[rewindTo % SESSION_RING]; + if (slot.tick != rewindTo) + { + SDL_Log("Session: can't rewind to tick %d, it's gone", rewindTo); + rewindTo = -1; + return; + } + int end = tick; + game.sim.snapshot_load(slot.state); + tick = rewindTo; + rewindTo = -1; + while (tick < end) + step(game, false); + rollbacks++; +} + +// Plays one tick, keeping the state from before it to rewind to: the room's events due then, and every fighter's +// controls: the server's when they've come, this screen's own, or a guess that everyone else keeps doing what they +// last did (without one-off edits or weapon switches). Bots play themselves. Replays are silent. +void Session::step(Game& game, bool isLive) +{ + Sim& sim = game.sim; + SessionTick& slot = ticks[tick % SESSION_RING]; + slot.tick = tick; + slot.state = sim.snapshot_save(); + slot.checksum = sim.checksum_get(); + for (auto& [number, event] : events[room]) + if (event.tick == tick) + sim.event_apply(event); + std::copy(std::begin(sim.match.owners), std::end(sim.match.owners), slot.owners); + + auto frame = frames.find(tick); + for (int i = 0; i < sim.fighterCount; i++) + { + int owner = sim.match.owners[i]; + Controls controls = {}; + if (frame != frames.end() && i < frame->second.count) + controls = frame->second.controls[i]; + else if (owner == game.net.selfId) + controls = own[tick % SESSION_RING]; + else if (owner >= 0) + { + controls = held[owner]; + controls.edit = controls.slot = -1; + } + sim.fighters[i].controls = slot.controls[i] = controls; + } + sim.match.isOnline = true; + game.scene.isMuted = !isLive; + sim.tick(); + game.scene.isMuted = false; + tick++; +} + +// This screen's controls for every tick it's played that isn't confirmed yet (up to a packet's worth, newest last), +// over the lossy channel, with the last tick it's confirmed. void Session::inputs_send(Game& game) { - int self = game.net.selfId; - InputLog& own = logs[self]; - for (auto& [member, peer] : peers) - { - if (!peer.isSending || member == self) - continue; - int first = std::max({peer.ack + 1, peer.sendFrom, ownStart}); - int last = std::min(ownNext - 1, first + INPUT_PACKET_MAX - 1); - Uint16 count = last >= first ? last - first + 1 : 0; - PacketWriter packet; - 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(packet.bytes, false); - } + int first = std::max(confirmed, tick - RESEND_MAX); + int count = tick - first; + if (count <= 0) + return; + PacketWriter packet; + packet.put(PACKET_INPUTS).put(room).put(confirmed - 1).put(first).put((Uint16)count); + for (int at = first; at < tick; at++) + packet.put(own[at % SESSION_RING]); + game.net.send(packet.bytes, false); } -// Every CHECKSUM_INTERVAL ticks, once a tick is certain, this screen's checksum of it goes to everyone and is compared -// with theirs: a difference means the machines' games have drifted apart. -void Session::checksums_exchange(Game& game) -{ - int confirmed = confirmed_get(game); - for (int at = (checksumSent / CHECKSUM_INTERVAL + 1) * CHECKSUM_INTERVAL; at <= confirmed; at += CHECKSUM_INTERVAL) - { - if (snapshotTicks[at % SNAPSHOT_RING] != at) - continue; - checksumSent = at; - PacketWriter packet; - packet.put(PEER_CHECKSUM).put(at).put(checksums[at % SNAPSHOT_RING]); - for (auto& [member, peer] : peers) - if (peer.isSending) - game.net.send(packet.bytes, true); - } - for (auto entry = remoteChecksums.begin(); entry != remoteChecksums.end();) - { - auto [at, value] = *entry; - if (at > confirmed) - break; - if (snapshotTicks[at % SNAPSHOT_RING] == at && checksums[at % SNAPSHOT_RING] != value && !isDesynced) - { - isDesynced = true; - SDL_Log("Session: desync at tick %d (%08x here, %08x there)", at, checksums[at % SNAPSHOT_RING], value); - } - entry = remoteChecksums.erase(entry); - } -} - -// Which fighter is this screen's (the first, while it hasn't appeared yet), and that only it takes the keyboard. +// This screen's fighter in the room (the keyboard's), if it has one. void Session::local_find(Game& game) { - game.local = 0; + int own = fighter_get(game, game.net.selfId); + game.local = std::max(own, 0); for (int i = 0; i < MAX_FIGHTERS; i++) - { - bool isOwn = i < game.sim.fighterCount && game.sim.match.owners[i] == game.net.selfId; - game.devices[i] = isOwn ? DEVICE_KEYBOARD : DEVICE_NONE; - game.sim.fighters[i].isBot = false; - if (isOwn) - game.local = i; - } + game.devices[i] = i == own ? DEVICE_KEYBOARD : DEVICE_NONE; } -// Which fighter a member plays, or -1 if they have none (yet). +// Which fighter a member plays here, or -1 if they have none (yet). int Session::fighter_get(Game& game, int member) { - for (int i = 0; i < game.sim.fighterCount; i++) - if (game.sim.match.owners[i] == member) - return i; - return -1; + return game.sim.match.fighter_find(game.sim, member); +} + +// Whether this screen's player is in a game queued in the lobby and hasn't left it. +bool Session::is_playing_arena(Game& game) +{ + int own = fighter_get(game, game.net.selfId); + return isJoined && game.sim.match.state != MATCH_PRACTICE && own >= 0 && !game.sim.match.isGone[own]; +} + +// Whether this screen's fighter has a game queued in the sandbox. +bool Session::is_queued(Game& game) +{ + int own = fighter_get(game, game.net.selfId); + return isJoined && own >= 0 && game.sim.match.state == MATCH_PRACTICE && game.sim.match.queues[own].isActive; +} + +// Watching another room's game is still to come. +bool Session::is_watching() +{ + return false; +} + +// Leaves the queued game (forfeiting it if it's still on), through this screen's controls so it happens on the same +// tick everywhere. +void Session::arena_leave(Game& game) +{ + game.input.edit_send(DEVICE_KEYBOARD, edit_encode({.type = EDIT_LEAVE})); } // A member's name as they chose it, or the server's for them until it's known. @@ -809,8 +452,8 @@ std::string Session::base_name_get(Game& game, int member) return "Player"; } -// A member's name as shown: numbered when it repeats an earlier player's (see Ui::name_get). One whose fighter hasn't -// appeared yet (just joining) is numbered as it will be, after everyone already in. +// A member's name as shown: numbered when it repeats an earlier player's (see Ui::name_get). One without a fighter here +// is numbered as it would be, after everyone in. std::string Session::name_get(Game& game, int member) { int fighter = fighter_get(game, member); @@ -823,49 +466,33 @@ std::string Session::name_get(Game& game, int member) return same > 1 ? name + " (" + std::to_string(same) + ")" : name; } -// Sends a message to every other member over the reliable channel. -void Session::broadcast(Game& game, const std::string& bytes) +// A chat line or a new name for the lobby, which the server passes to everyone (this screen too). +void Session::text_send(Game& game, PacketType type, const std::string& text) { - for (NetMember& member : game.net.members) - if (member.id != game.net.selfId) - game.net.send(bytes, true); + PacketWriter packet; + packet.put(type).put((Uint16)text.size()); + packet.bytes += text; + game.net.send(packet.bytes, true); } -// A chat message from this screen: to everyone (when there's anyone), and into this screen's own log. +// A chat message from this screen: to the lobby through the server, or straight into the log offline. 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(PEER_CHAT).put((Uint16)text.size()); - packet.bytes += text; - broadcast(game, packet.bytes); - game.chat.say(game, game.net.selfId, text); + text_send(game, PACKET_CHAT, text); } // Tells everyone this screen's chat box opened or closed (for the "..." over its fighter). void Session::typing_send(Game& game, bool isTyping) { if (isActive) - broadcast(game, PacketWriter().put(PEER_TYPING).put((Uint8)isTyping).bytes); + game.net.send(PacketWriter().put(PACKET_TYPING).put((Uint8)isTyping).bytes, true); } -// Sends this screen's name to one member (or everyone, for -1). A quiet one isn't announced in the chat. -void Session::rename_send(Game& game, int to, bool isQuiet) -{ - std::string& name = names[game.net.selfId]; - PacketWriter packet; - packet.put(PEER_RENAME).put((Uint8)isQuiet).put((Uint16)name.size()); - packet.bytes += name; - if (to < 0) - broadcast(game, packet.bytes); - else - game.net.send(packet.bytes, true); -} - -// Notices this screen's name or colour changing (in Customize, or by a chat command). A new name goes to everyone once -// it's done being typed, and is announced; a new colour goes through the game as an edit, so every machine agrees -// (it's refused if someone else has it). +// Notices this screen's name or colour changing (in Customize, or by a chat command). A new name goes to the lobby once +// it's done being typed (at most every RENAME_WAIT), and a new colour goes through the game as an edit, so everyone +// agrees (it's refused if someone else has it). void Session::profile_watch(Game& game) { int self = game.net.selfId; @@ -882,10 +509,7 @@ void Session::profile_watch(Game& game) } isRenameWaiting = false; nextRename = SDL_GetTicks() + RENAME_WAIT; - std::string before = name_get(game, self); - names[self] = name; - game.chat.system(before + " changed their name to " + name_get(game, self) + "."); - rename_send(game, -1, false); + text_send(game, PACKET_RENAME, name); } int color = game.settings.color_get(); if (color >= 0 && color != sentColor) @@ -933,400 +557,19 @@ void Session::kick(Game& game, const std::string& name, const std::string& reaso game.net.kick_send(found, text_clean(reason, KICK_REASON_MAX)); } -// Asks the host to queue a game with these rules in the lobby's sandbox (this screen's fighter standing in its area), -// or to stop queueing it. -void Session::queue_send(Game& game, const Rules& rules) +// Asks the server to queue a game with these rules in the lobby's sandbox (this screen's fighter standing in its +// area), or to stop queueing it. +void Session::queue_send(Game& game, const Rules& rules, bool isOpen) { - if (!isJoined) - return; - if (game.net.hostId == game.net.selfId) - return queue_decide(game, game.net.selfId, true, rules); - game.net.send(PacketWriter().put(PEER_QUEUE).put((Uint8)1).put(rules).bytes, true); + if (isJoined) + game.net.send(PacketWriter().put(PACKET_QUEUE).put((Uint8)isOpen).put(rules).bytes, true); } -void Session::unqueue_send(Game& game) -{ - if (!isJoined) - return; - if (game.net.hostId == game.net.selfId) - return queue_decide(game, game.net.selfId, false, {}); - game.net.send(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. -void Session::queue_decide(Game& game, int member, bool isOpen, const Rules& rules) -{ - SessionEvent event = {.tick = tick + QUEUE_LEAD, - .type = isOpen ? EVENT_QUEUE : EVENT_UNQUEUE, - .member = member, - .rules = rules_clean(rules)}; - event_add(game, event); - for (auto& [other, log] : logs) - if (other != game.net.selfId) - event_send(game, other, event, 0, -1); -} - -// Whether this screen's fighter has a game queued in the sandbox. -bool Session::is_queued(Game& game) -{ - Match& lobby = isSwapped ? arena.state.match : game.sim.match; - int count = isSwapped ? arena.state.fighterCount : game.sim.fighterCount; - for (int i = 0; i < count; i++) - if (lobby.owners[i] == game.net.selfId) - return lobby.queues[i].isActive; - return false; -} - -bool Session::is_arena_on() -{ - return isActive && arena.isActive; -} - -const Arena& Session::arena_get() -{ - return arena; -} - -// Whether this screen's player is in the arena's game and hasn't left it. -bool Session::is_playing_arena(Game& game) -{ - if (!arena.isActive) - return false; - Match& match = isSwapped ? game.sim.match : arena.state.match; - int count = isSwapped ? game.sim.fighterCount : arena.state.fighterCount; - for (int i = 0; i < count; i++) - if (match.owners[i] == game.net.selfId && !match.isGone[i]) - return true; - return false; -} - -bool Session::is_watching() -{ - return isWatching && arena.isActive; -} - -void Session::watch_start(Game& game) -{ - isWatching = arena.isActive && !is_playing_arena(game); -} - -void Session::watch_stop(Game&) -{ - isWatching = false; -} - -// Leaves the arena's game (forfeiting it if it's still on), through this screen's controls so it happens on the same -// tick everywhere. -void Session::arena_leave(Game& game) -{ - game.input.edit_send(DEVICE_KEYBOARD, edit_encode({.type = EDIT_LEAVE})); -} - -// The arena's players still in it, by name: "Sneed, Bob and Alice". -std::string Session::arena_names_get(Game& game) -{ - Match& match = isSwapped ? game.sim.match : arena.state.match; - int count = isSwapped ? game.sim.fighterCount : arena.state.fighterCount; - std::vector names; - for (int i = 0; i < count; i++) - if (match.owners[i] >= 0 && !match.isGone[i]) - names.push_back(base_name_get(game, match.owners[i])); - std::string text; - for (size_t i = 0; i < names.size(); i++) - text += (i == 0 ? "" : i + 1 == names.size() ? " and " : ", ") + names[i]; - return text; -} - -// Swaps the arena's game with the one in place: its fighters, match, attacks, world, random generator and bots (with -// their map), and which fighter is this screen's. -void Session::arena_swap(Game& game) -{ - std::swap(game.sim.fighters, arena.state.fighters); - std::swap(game.sim.fighterCount, arena.state.fighterCount); - std::swap(game.sim.match, arena.state.match); - std::swap(game.sim.weapons, arena.state.weapons); - std::swap(game.sim.world, arena.state.world); - std::swap(game.sim.rng, arena.state.rng); - std::swap(game.sim.bot, arena.bot); - std::swap(game.devices, arena.devices); - std::swap(game.local, arena.local); -} - -// With the arena swapped in: this screen's fighter in it (the keyboard's), if it's playing; watching, the first. -void Session::arena_locals_find(Game& game) -{ - game.local = 0; - for (int i = 0; i < MAX_FIGHTERS; i++) - { - bool isOwn = i < game.sim.fighterCount && game.sim.match.owners[i] == game.net.selfId && !game.sim.match.isGone[i]; - game.devices[i] = isOwn ? DEVICE_KEYBOARD : DEVICE_NONE; - if (isOwn) - game.local = i; - } -} - -// The queued game is ready: everyone in its area goes into a fresh arena to play it, and the queue's done. Its players' -// chat starts afresh for it. -void Session::arena_start(Game& game, int queuer) -{ - Match& lobby = game.sim.match; - int members[MAX_FIGHTERS]; - int count = lobby.queue_members_get(game.sim, queuer, members); - Rules rules = lobby.queues[queuer].rules; - lobby.queues[queuer].isActive = false; - arena_build(game, rules, members, count); - std::string key = "start:" + std::to_string(arena.serial); - if (!announced.contains(key) && is_playing_arena(game)) - game.chat.clear(); - announce(game, key, "A Deathmatch began: " + arena_names_get(game) + "."); -} - -// Builds the arena for these sandbox fighters (by index) to play by the rules, bots filling the rest if the rules say -// to: their players, names and colours come along, and they're away from the sandbox while it lasts. It starts from -// a seed of the sandbox's random generator and the tick, so every machine builds the same one. -void Session::arena_build(Game& game, const Rules& rules, const int* members, int count) -{ - Match& lobby = game.sim.match; - int owners[MAX_FIGHTERS]; - int colors[MAX_FIGHTERS]; - char names[MAX_FIGHTERS][PLAYER_NAME_MAX + 1]; - for (int k = 0; k < count; k++) - { - int i = members[k]; - owners[k] = lobby.owners[i]; - colors[k] = lobby.colors[i]; - SDL_memcpy(names[k], lobby.names[i], sizeof(names[k])); - lobby.isAway[i] = true; - lobby.isQueueReady[i] = false; - lobby.queuedIn[i] = -1; - lobby.queues[i].isActive = false; - std::erase_if(game.sim.weapons.attacks, [&](Attack& attack) { return attack.owner == i; }); - } - Uint32 seed = game.sim.rng.state ^ (Uint32)tick * ARENA_SEED_MIX; - int players = rules.isBotFill ? std::max(rules.players, count) : count; - int botLevel = game.sim.bot.level; - - arena.isActive = true; - arena.serial = tick; - arena.state = Snapshot{}; - arena.bot = Bot{}; - arena.bot.level = botLevel; - arena_swap(game); - Match& match = game.sim.match; - match = Match{}; - match.rules = rules; - match.rules.players = players; - game.sim.rng.state = seed ? seed : RNG_SEED; - game.sim.fighterCount = players; - bool isTaken[COLOR_COUNT] = {}; - for (int k = 0; k < count; k++) - isTaken[colors[k] % COLOR_COUNT] = true; - for (int k = 0; k < players; k++) - { - game.sim.fighters[k] = Fighter{}; - game.sim.fighters[k].init(game.sim, k); - match.owners[k] = k < count ? owners[k] : -1; - if (k < count) - { - match.colors[k] = colors[k]; - SDL_memcpy(match.names[k], names[k], sizeof(match.names[k])); - continue; - } - match.colors[k] = std::find(isTaken, isTaken + COLOR_COUNT, false) - isTaken; - isTaken[match.colors[k]] = true; - } - arena_locals_find(game); - match.start(game.sim); - arena.graphSerial = arena.serial; - arena_locals_find(game); - arena_swap(game); -} - -// After the arena's tick: anyone who left it is back in the sandbox (and it's said so); with no players left, it's -// over; and once a finished match's players all ask to play again, it starts afresh with them. -void Session::arena_after(Game& game) -{ - Match& lobby = game.sim.match; - Match& match = arena.state.match; - int count = arena.state.fighterCount; - int members[MAX_FIGHTERS]; - int humans = 0; - bool isAgain = match.state == MATCH_OVER; - for (int k = 0; k < count; k++) - { - int owner = match.owners[k]; - if (owner < 0) - continue; - int i = fighter_get(game, owner); - if (match.isGone[k]) - { - if (i >= 0 && lobby.isAway[i]) - { - lobby.isAway[i] = false; - announce(game, "left:" + std::to_string(arena.serial) + ":" + std::to_string(owner), - name_get(game, owner) + (match.isForfeited[k] ? " forfeited the match." : " left the match.")); - } - continue; - } - isAgain &= match.isReady[k] && i >= 0; - if (i >= 0) - members[humans] = i; - humans++; - } - if (humans == 0) - { - arena_end(game); - return; - } - // Set to play again, it waits a moment (as a queue does) before starting afresh. - bool isSet = isAgain && (humans >= PLAYERS_MIN || match.rules.isBotFill); - match.againTicks = isSet ? match.againTicks + 1 : 0; - if (match.againTicks < QUEUE_START_TICKS) - return; - Rules rules = match.rules; - if (!rules.isBotFill) - rules.players = humans; - arena_build(game, rules, members, humans); - announce(game, "again:" + std::to_string(arena.serial), "Playing again: " + arena_names_get(game) + "."); -} - -// The arena's game is over and everyone's back in the sandbox. -void Session::arena_end(Game& game) -{ - arena.isActive = false; - isWatching = false; - std::fill(std::begin(game.sim.match.isAway), std::end(game.sim.match.isAway), false); -} - -// Keeps the arena as it is before a tick, beside the sandbox's snapshot, to rewind to (none if it's not on). -void Session::arena_save(Game& game, int slot) -{ - arenaSerials[slot] = arena.isActive ? arena.serial : -1; - if (!arena.isActive) - return; - arena_swap(game); - arenaSnapshots[slot] = game.sim.snapshot_save(); - arena_swap(game); -} - -// Puts the arena back as it was kept for a tick (mapping it again for the bots if it's another one than they know). -void Session::arena_restore(Game& game, int slot) -{ - int serial = arenaSerials[slot]; - arena.isActive = serial >= 0; - if (!arena.isActive) - return; - arena.serial = serial; - arena_swap(game); - game.sim.snapshot_load(arenaSnapshots[slot]); - if (arena.graphSerial != serial) - game.sim.bot.graph_build(game.sim.world); - arena.graphSerial = serial; - arena_locals_find(game); - arena_swap(game); -} - -// The checksum of the sandbox and, while it's on, the arena. -Uint32 Session::checksum_full(Game& game) -{ - Uint32 hash = game.sim.checksum_get(); - if (!arena.isActive) - return hash; - arena_swap(game); - hash = (hash ^ game.sim.checksum_get()) * ARENA_SEED_MIX + (Uint32)arena.serial; - arena_swap(game); - return hash; -} - -// The whole shared state for a newcomer: the sandbox's, then the arena's (after the tick it started) if it's on. -std::string Session::state_full_save(Game& game) -{ - std::string lobby = state_save(game.sim); - PacketWriter packet; - packet.put((Uint32)lobby.size()); - packet.bytes += lobby; - packet.put(arena.isActive ? arena.serial : -1); - if (!arena.isActive) - return packet.bytes; - arena_swap(game); - packet.bytes += state_save(game.sim); - arena_swap(game); - return packet.bytes; -} - -bool Session::state_full_load(Game& game, const std::string& bytes) -{ - PacketReader reader = {bytes}; - Uint32 length = 0; - if (!reader.get(length) || reader.at + length > bytes.size()) - return false; - std::string lobby = bytes.substr(reader.at, length); - reader.at += length; - int serial = -1; - if (!reader.get(serial) || !state_load(game.sim, lobby)) - return false; - arena.isActive = serial >= 0; - if (!arena.isActive) - return true; - arena.serial = serial; - arena.bot = Bot{}; - arena.bot.level = game.sim.bot.level; - arena_swap(game); - bool isLoaded = state_load(game.sim, bytes.substr(reader.at)); - arena.graphSerial = serial; - arena_locals_find(game); - arena_swap(game); - arena.isActive = isLoaded; - return isLoaded; -} - -// Says something in the chat once for a happening in the game, however many times replays go over it. -void Session::announce(Game& game, const std::string& key, const std::string& text) -{ - if (announced.insert(key).second) - game.chat.system(text); -} - -// Swaps the arena into the game for this screen's own work (its menu, drawing and camera) while it plays in it or -// watches it; returns whether it did. A change of what's shown starts its effects and camera afresh. -bool Session::view_enter(Game& game) -{ - if (isSwapped || !isActive) - return isSwapped; - bool isArena = arena.isActive && (isWatching || is_playing_arena(game)); - int shown = isArena ? arena.serial : -1; - if (shown != shownSerial) - { - shownSerial = shown; - isViewChanged = true; - game.effects.clear(); - game.ui.spectated = -1; - } - if (!isArena) - return false; - arena_swap(game); - isSwapped = true; - return true; -} - -void Session::view_leave(Game& game) -{ - if (!isSwapped) - return; - arena_swap(game); - isSwapped = false; -} - -// How long a member's connection takes there and back, in milliseconds: another member's from this screen, and this -// screen's own to the host (none when it's the host). Below 0 while it's not known yet. +// How long a member's connection to the server takes there and back, in milliseconds: only this screen's own is known. float Session::ping_get(Game& game, int member) { - Net& net = game.net; - if (member < 0 || (member == net.selfId && net.hostId == net.selfId)) - return PING_NONE; - int to = member == net.selfId ? net.hostId : member; - for (NetMember& netMember : net.members) - if (netMember.id == to) + for (NetMember& netMember : game.net.members) + if (netMember.id == member && member == game.net.selfId) return netMember.ping; return PING_NONE; } diff --git a/src/session.h b/src/session.h index eb93e23..699ad59 100644 --- a/src/session.h +++ b/src/session.h @@ -1,124 +1,58 @@ #pragma once -#include "bot.h" #include "input.h" -#include "match.h" -#include "rollback.h" -#include "trinket.h" -#include "weapon.h" +#include "sim.h" #include -#include #include -#include struct Game; -constexpr int INPUT_RING = 1024; -constexpr int SNAPSHOT_RING = 64; -// A member's connection has no ping to show (it's this screen's own, hosting). +constexpr int SESSION_RING = 64; +// A member's connection has no ping to show. constexpr float PING_NONE = -2; -enum SessionEventType -{ - EVENT_JOIN, - EVENT_LEAVE, - // A member queues a game in the lobby's sandbox, with its rules, or stops queueing one. - EVENT_QUEUE, - EVENT_UNQUEUE -}; - -// A change to who's playing, at a set tick, so every machine makes it at the same point in the game (and again when -// rolling back over it). From is, for a join, the tick the newcomer's copy of the game starts at. -struct SessionEvent -{ - int tick = 0; - int type = EVENT_JOIN; - int member = -1; - int from = 0; - Profile profile; - Rules rules; -}; - -// The queued game being played, a second game beside the lobby's sandbox (see Session): its state between ticks -// (while it isn't swapped into the game), its bots' map of it, which fighter is this screen's in it and the devices -// that go with that, and the tick it started (each play of it, a fresh one). -struct Arena +// One tick of the room as this screen played it: the state as it started (and its checksum), and once the tick's +// events were in, who played each fighter and the controls each was given. +struct SessionTick { Snapshot state; - Bot bot; - int devices[MAX_FIGHTERS] = {}; - int local = 0; - int serial = -1; - int graphSerial = -1; - bool isActive = false; + Controls controls[MAX_FIGHTERS]; + int owners[MAX_FIGHTERS]; + Uint32 checksum = 0; + int tick = -1; }; -// One member's controls by tick, in a ring that keeps the last INPUT_RING ticks. They're needed from start (when the -// member's fighter appears) until until (when it goes). Contiguous is the last tick up to which every tick from start -// on is known, so up to which the member's play is certain. Used is what a tick was -// simulated with when the member's controls had to be guessed, to spot a wrong guess when the real ones come. -struct InputLog +// The server's frame for a tick: every fighter's controls as it played them, after the events before them. +struct SessionFrame { - Controls controls[INPUT_RING]; - int ticks[INPUT_RING]; - Controls used[INPUT_RING]; - int usedTicks[INPUT_RING]; - int start = 0; - int until = INT32_MAX; - int contiguous = -1; - int latest = -1; - - InputLog(); - void restart(int from); - bool has(int tick) const; - bool put(int tick, const Controls& controls); - Controls guess(int tick) const; + Controls controls[MAX_FIGHTERS]; + int count = 0; + int events = 0; }; -// What this screen knows of another member: the last tick of this screen's inputs they've confirmed having, the first -// one they need, whether to send them inputs at all yet, and the tick they last said they were on. -struct SessionPeer -{ - int ack = -1; - int sendFrom = 0; - bool isSending = false; - int remoteTick = 0; -}; - -// Online play as one game on every machine: rollback netcode. Each member's controls go to everyone, a few ticks -// ahead of when they're used (the input delay); a member whose controls for a tick haven't arrived yet is guessed to -// keep doing what they last did, and when the real ones turn out different, the game rewinds to that tick and plays -// forward again (silently). A machine that gets too far ahead of what it knows, or of the others, waits. -// -// The lobby's host decides who comes and goes, and when: a newcomer gets the game's state as of a tick everyone's -// controls are certain for, fast-forwards from there, and appears at a tick a moment ahead, on every machine at once. -// Someone leaving is taken out at the tick after their last known controls. Checksums of certain ticks are compared, -// to catch machines drifting apart. -// -// Lobby members can queue a game in the sandbox (see Match::queue_open). Once it's ready, it starts as a second game, -// the arena, beside the sandbox: both step every tick on every machine, each with their own fighters, the players -// in the arena standing faded in the sandbox meanwhile. This screen shows one of the two (the arena while it plays in -// it, or watches it): that one is swapped into the game around this screen's own work, which otherwise always sees -// the sandbox. +// Online play: this screen runs the room it's in (the lobby's sandbox, or a game queued in it), which the server runs +// too (see server/server.cpp). It plays ahead of the server, its own controls sent for the ticks it plays and everyone +// else's guessed to keep doing what they last did. The server's frames say what everyone really did: when one differs +// from what was played, the game rewinds to that tick and plays forward again (silently). It keeps its lead over the +// server by playing a tick more, or one less, now and then, as the server says how early its controls come. class Session { public: bool isActive = false; bool isJoined = false; bool isDesynced = false; - // Set when what this screen shows changes between the sandbox and the arena, for the camera to jump. + // Set when the room changes, for the camera to jump. bool isViewChanged = false; - int tick = 0; - int delay = 2; int rollbacks = 0; int stalls = 0; - void host_start(Game& game); - void join_start(Game& game); - void stop(Game& game); void update(Game& game, Controls local); + void stop(Game& game); bool is_wanted(Game& game); - Profile profile_get(Game& game); + bool is_watching(); + bool is_playing_arena(Game& game); + bool is_queued(Game& game); + void arena_leave(Game& game); int fighter_get(Game& game, int member); std::string base_name_get(Game& game, int member); std::string name_get(Game& game, int member); @@ -126,83 +60,48 @@ public: void typing_send(Game& game, bool isTyping); void kick(Game& game, const std::string& name, const std::string& reason); int rename_wait_get(); - void queue_send(Game& game, const Rules& rules); - void unqueue_send(Game& game); - bool is_queued(Game& game); - bool view_enter(Game& game); - void view_leave(Game& game); - bool is_arena_on(); - bool is_playing_arena(Game& game); - bool is_watching(); - void watch_start(Game& game); - void watch_stop(Game& game); - void arena_leave(Game& game); - const Arena& arena_get(); - std::string arena_names_get(Game& game); + void queue_send(Game& game, const Rules& rules, bool isOpen = true); float ping_get(Game& game, int member); private: - std::map logs; - std::map peers; - std::vector events; - std::vector departed; - Snapshot snapshots[SNAPSHOT_RING]; - Snapshot arenaSnapshots[SNAPSHOT_RING]; - int arenaSerials[SNAPSHOT_RING]; - Arena arena; - // Whether this screen watches the arena (not being in it), whether the arena's swapped into the game now, which - // one it showed last (to start afresh on a change), and the chat lines already said for things in the game (which - // replays would otherwise say again). - bool isWatching = false; - bool isSwapped = false; - int shownSerial = -1; - std::set announced; - int snapshotTicks[SNAPSHOT_RING]; - Uint32 checksums[SNAPSHOT_RING]; - std::map remoteChecksums; - int checksumSent = -1; - int ownNext = 0; - int ownStart = 0; - int rollbackTo = -1; - bool isRequested = false; + SessionTick ticks[SESSION_RING]; + Controls own[SESSION_RING]; + std::map frames; + // Each room's events by number, kept from before its state comes in case they come first. + std::map> events; + std::map checksums; + std::map held; // Every member's chosen name (cleaned), kept current by their renames, and the colour this screen last asked for. std::map names; + int room = 0; + // The next tick to play, the first not yet confirmed, and the earliest that has to be played again. + int tick = 0; + int confirmed = 0; + int rewindTo = -1; + // How early (in ticks) this screen's controls last reached the server, and when its pace last changed. + int lead = 0; + int paced = 0; int sentColor = -1; + // When a newcomer gives up waiting for the WebRTC link, and whether its fighter has been brought in. + Uint64 linkDeadline = 0; + bool isProfileSent = false; + // With SAB_NET_STATS set, how it's keeping up goes to the log every few seconds. + bool isStatsLogged = false; // Names can change only every RENAME_WAIT, so a player can't dodge being kicked by renaming: when this screen's can - // next, whether it's told its pending one is waiting, and when each other member's last rename came. + // next, and whether it's told its pending one is waiting. Uint64 nextRename = 0; bool isRenameWaiting = false; - std::map renamedAt; + void start(Game& game); void packets_take(Game& game); - void packet_handle(Game& game, int from, const std::string& bytes, bool isReliable); - void members_watch(Game& game); - void join_grant(Game& game, int member, const Profile& profile); - void leave_decide(Game& game, int member); - void event_add(Game& game, const SessionEvent& event); - void event_send(Game& game, int to, const SessionEvent& event, int inputsFrom, int inputsTo); - void inputs_send(Game& game); + void packet_handle(Game& game, const std::string& bytes); + void state_load(Game& game, PacketReader& reader, int serial, int at); + void frames_take(PacketReader& reader); + void confirm(Game& game); + void rewind(Game& game); void step(Game& game, bool isLive); - void events_apply(Game& game, int at); - bool is_needed(int member, int at); - int confirmed_get(Game& game); - void checksums_exchange(Game& game); + void inputs_send(Game& game); void local_find(Game& game); - void broadcast(Game& game, const std::string& bytes); - void rename_send(Game& game, int to, bool isQuiet); void profile_watch(Game& game); - void controls_give(Game& game, bool isArena); - void arena_swap(Game& game); - void arena_start(Game& game, int queuer); - void arena_build(Game& game, const Rules& rules, const int* members, int count); - void arena_after(Game& game); - void arena_end(Game& game); - void arena_locals_find(Game& game); - void arena_save(Game& game, int slot); - void arena_restore(Game& game, int slot); - Uint32 checksum_full(Game& game); - std::string state_full_save(Game& game); - bool state_full_load(Game& game, const std::string& bytes); - void announce(Game& game, const std::string& key, const std::string& text); - void queue_decide(Game& game, int member, bool isOpen, const Rules& rules); + void text_send(Game& game, PacketType type, const std::string& text); };