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