From 9613e85706460dee18434fc6f9101e592b3e83aa Mon Sep 17 00:00:00 2001 From: shweet Date: Sun, 8 Nov 2026 01:54:27 -0500 Subject: [PATCH] Share online membership and game setup with the server Profile, member join/leave and building a fresh game move into the sim library, and the packet reader and writer into protocol.h, so the server can run lobbies exactly as clients do. Co-Authored-By: Claude Opus 5.5 --- src/match.cpp | 78 +++++++++++++++++++++++++++++++++++++++++ src/match.h | 20 +++++++++++ src/protocol.h | 31 +++++++++++++++++ src/session.cpp | 93 +++---------------------------------------------- src/session.h | 16 --------- src/sim.cpp | 47 +++++++++++++++++++++++++ src/sim.h | 1 + 7 files changed, 181 insertions(+), 105 deletions(-) diff --git a/src/match.cpp b/src/match.cpp index 29cd821..a5f1f6f 100644 --- a/src/match.cpp +++ b/src/match.cpp @@ -1,6 +1,7 @@ #include "match.h" #include "sim.h" #include +#include constexpr Burst FINISH_BURST = {.gravity = 200, .life = 1, .size = 3, .speed = 240, .count = 40}; constexpr float RACE_GRACE = 15; @@ -746,3 +747,80 @@ void Match::leaves_take(Sim& sim) std::fill(std::begin(isReady), std::end(isReady), false); sim.cues->sound_play(SOUND_FINISH); } + +// A fighter's loadout, slots and levels as a profile, to bring it back as it was (a name and colour are added by whoever +// knows them). +Profile profile_get(Fighter& fighter) +{ + Profile profile; + profile.weaponSlots = fighter.weaponSlots; + profile.trinketSlots = fighter.trinketSlots; + std::copy(std::begin(fighter.weaponLoadout), std::end(fighter.weaponLoadout), profile.weaponLoadout); + std::copy(std::begin(fighter.trinketLoadout), std::end(fighter.trinketLoadout), profile.trinketLoadout); + std::copy(std::begin(fighter.weaponSlotLevels), std::end(fighter.weaponSlotLevels), profile.weaponSlotLevels); + std::copy(std::begin(fighter.trinketSlotLevels), std::end(fighter.trinketSlotLevels), profile.trinketSlotLevels); + std::copy(std::begin(fighter.weaponItemLevels), std::end(fighter.weaponItemLevels), profile.weaponItemLevels); + std::copy(std::begin(fighter.trinketItemLevels), std::end(fighter.trinketItemLevels), profile.trinketItemLevels); + return profile; +} + +// Online, a member's fighter appears, set up from their profile, in the colour they asked for if it's free. Returns +// it, or -1 when the game is full. +int Match::member_join(Sim& sim, int member, const Profile& profile) +{ + if (sim.fighterCount >= MAX_FIGHTERS) + return -1; + int index = sim.fighterCount++; + Fighter& fighter = sim.fighters[index]; + fighter = Fighter{}; + fighter.init(sim, index); + fighter.slots_set(std::clamp(profile.weaponSlots, 1, SLOT_MAX), std::clamp(profile.trinketSlots, 1, SLOT_MAX)); + std::copy(std::begin(profile.weaponLoadout), std::end(profile.weaponLoadout), fighter.weaponLoadout); + std::copy(std::begin(profile.trinketLoadout), std::end(profile.trinketLoadout), fighter.trinketLoadout); + std::copy(std::begin(profile.weaponSlotLevels), std::end(profile.weaponSlotLevels), fighter.weaponSlotLevels); + std::copy(std::begin(profile.trinketSlotLevels), std::end(profile.trinketSlotLevels), fighter.trinketSlotLevels); + std::copy(std::begin(profile.weaponItemLevels), std::end(profile.weaponItemLevels), fighter.weaponItemLevels); + std::copy(std::begin(profile.trinketItemLevels), std::end(profile.trinketItemLevels), fighter.trinketItemLevels); + owners[index] = member; + std::memcpy(names[index], profile.name, sizeof(names[index])); + names[index][PLAYER_NAME_MAX] = '\0'; + bool isTaken[COLOR_COUNT] = {}; + for (int i = 0; i < index; i++) + isTaken[colors[i]] = true; + bool isFree = profile.color >= 0 && profile.color < COLOR_COUNT && !isTaken[profile.color]; + colors[index] = isFree ? profile.color : (std::find(isTaken, isTaken + COLOR_COUNT, false) - isTaken) % COLOR_COUNT; + return index; +} + +// Online, a member's fighter goes with its attacks, and the fighters after it move down a place. +void Match::member_leave(Sim& sim, int member) +{ + int index = fighter_find(sim, member); + if (index < 0) + return; + std::vector& attacks = sim.weapons.attacks; + std::erase_if(attacks, [&](Attack& attack) { return attack.owner == index; }); + for (Attack& attack : attacks) + attack.owner -= attack.owner > index; + for (int i = index; i + 1 < sim.fighterCount; i++) + { + sim.fighters[i] = sim.fighters[i + 1]; + sim.fighters[i].index = i; + sim.bot.states[i] = sim.bot.states[i + 1]; + owners[i] = owners[i + 1]; + colors[i] = colors[i + 1]; + std::memcpy(names[i], names[i + 1], sizeof(names[i])); + } + queue_slots_shift(index, sim.fighterCount); + sim.fighterCount--; + owners[sim.fighterCount] = -1; +} + +// Which fighter a member plays, or -1 for none. +int Match::fighter_find(Sim& sim, int member) +{ + for (int i = 0; i < sim.fighterCount; i++) + if (owners[i] == member) + return i; + return -1; +} diff --git a/src/match.h b/src/match.h index 00e7ebe..4bcd4b4 100644 --- a/src/match.h +++ b/src/match.h @@ -142,8 +142,25 @@ inline int pick_encode(bool isTrinket, int slot, int item) {.type = EDIT_ITEM, .isTrinket = isTrinket, .index = slot, .value = item < 0 ? EDIT_EMPTY : item}); } +// A player's setup as they join online: their name, the colour they'd like (-1 for any), and their sandbox slots, +// loadouts, levels and stacking, which their fighter starts with. Plain data, sent as it sits in memory. +struct Profile +{ + char name[PLAYER_NAME_MAX + 1] = {}; + int color = -1; + int weaponSlots = SLOTS_DEFAULT; + int trinketSlots = SLOTS_DEFAULT; + int weaponLoadout[SLOT_MAX] = {}; + int trinketLoadout[SLOT_MAX] = {}; + int weaponSlotLevels[SLOT_MAX] = {}; + int trinketSlotLevels[SLOT_MAX] = {}; + int weaponItemLevels[WEAPON_COUNT] = {}; + int trinketItemLevels[TRINKET_COUNT] = {}; +}; + // The default rules for a mode: a best-of-three deathmatch for two, or a three minute race for four. Rules rules_get(int mode); +Profile profile_get(Fighter& fighter); Rules rules_clean(Rules rules); // Online, a game queued in the lobby's sandbox by one of its fighters: its rules, and the area on the ground where @@ -221,6 +238,9 @@ public: int queue_startable_get(Sim& sim); bool is_queue_set(Sim& sim, int queuer); void queue_slots_shift(int from, int count); + int member_join(Sim& sim, int member, const Profile& profile); + void member_leave(Sim& sim, int member); + int fighter_find(Sim& sim, int member); int humans_left_get(Sim& sim); private: diff --git a/src/protocol.h b/src/protocol.h index 042cc56..b11bd90 100644 --- a/src/protocol.h +++ b/src/protocol.h @@ -22,8 +22,10 @@ // // A signal's payload is either a description {sdp, kind} or a candidate {candidate, mid}. +#include #include #include +#include constexpr int PROTOCOL_VERSION = 2; constexpr int LOBBY_MEMBERS_MAX = 4; @@ -36,6 +38,35 @@ constexpr int KICK_REASON_MAX = 60; constexpr const char SERVER_URL_DEFAULT[] = "wss://shweetz.net/smack-a-brother/ws"; constexpr int SERVER_URL_LENGTH = 128; +// Packets are plain values one after another, as they sit in memory (every platform agrees on these layouts). +struct PacketWriter +{ + std::string bytes; + + template PacketWriter& put(const T& value) + { + static_assert(std::is_trivially_copyable_v); + bytes.append(reinterpret_cast(&value), sizeof(T)); + return *this; + } +}; + +struct PacketReader +{ + const std::string& bytes; + size_t at = 0; + + template bool get(T& value) + { + static_assert(std::is_trivially_copyable_v); + if (at + sizeof(T) > bytes.size()) + return false; + std::memcpy(static_cast(&value), bytes.data() + at, sizeof(T)); + at += sizeof(T); + return true; + } +}; + // Text from elsewhere (another player, or the server) made safe to show: only printable ASCII (what the game's font // has; anything else, emoji included, is dropped), cut to max characters, with spaces at its ends trimmed. inline std::string text_clean(std::string_view raw, size_t max) diff --git a/src/session.cpp b/src/session.cpp index db82e5e..c7b712a 100644 --- a/src/session.cpp +++ b/src/session.cpp @@ -35,35 +35,6 @@ enum PacketType : Uint8 PACKET_QUEUE }; -// Packets are plain values one after another, as they sit in memory (every platform agrees on these layouts). -struct PacketWriter -{ - std::string bytes; - - template PacketWriter& put(const T& value) - { - static_assert(std::is_trivially_copyable_v); - bytes.append(reinterpret_cast(&value), sizeof(T)); - return *this; - } -}; - -struct PacketReader -{ - const std::string& bytes; - size_t at = 0; - - template bool get(T& value) - { - static_assert(std::is_trivially_copyable_v); - if (at + sizeof(T) > bytes.size()) - return false; - std::memcpy(static_cast(&value), bytes.data() + at, sizeof(T)); - at += sizeof(T); - return true; - } -}; - // Field by field, since the padding between them is whatever was in memory. static bool controls_same(const Controls& a, const Controls& b) { @@ -125,18 +96,9 @@ Controls InputLog::guess(int at) const // 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; - Fighter& fighter = game.local_get(); + Profile profile = ::profile_get(game.local_get()); SDL_strlcpy(profile.name, game.settings.name, sizeof(profile.name)); profile.color = game.settings.color_get(); - profile.weaponSlots = fighter.weaponSlots; - profile.trinketSlots = fighter.trinketSlots; - std::copy(std::begin(fighter.weaponLoadout), std::end(fighter.weaponLoadout), profile.weaponLoadout); - std::copy(std::begin(fighter.trinketLoadout), std::end(fighter.trinketLoadout), profile.trinketLoadout); - std::copy(std::begin(fighter.weaponSlotLevels), std::end(fighter.weaponSlotLevels), profile.weaponSlotLevels); - std::copy(std::begin(fighter.trinketSlotLevels), std::end(fighter.trinketSlotLevels), profile.trinketSlotLevels); - std::copy(std::begin(fighter.weaponItemLevels), std::end(fighter.weaponItemLevels), profile.weaponItemLevels); - std::copy(std::begin(fighter.trinketItemLevels), std::end(fighter.trinketItemLevels), profile.trinketItemLevels); return profile; } @@ -449,57 +411,10 @@ void Session::events_apply(Game& game, int at) { if (event.tick != at) continue; - if (event.type == EVENT_JOIN && game.sim.fighterCount < MAX_FIGHTERS) - { - int index = game.sim.fighterCount++; - Fighter& fighter = game.sim.fighters[index]; - fighter = Fighter{}; - fighter.init(game.sim, index); - const Profile& profile = event.profile; - fighter.slots_set(std::clamp(profile.weaponSlots, 1, SLOT_MAX), - std::clamp(profile.trinketSlots, 1, SLOT_MAX)); - std::copy(std::begin(profile.weaponLoadout), std::end(profile.weaponLoadout), fighter.weaponLoadout); - std::copy(std::begin(profile.trinketLoadout), std::end(profile.trinketLoadout), fighter.trinketLoadout); - std::copy(std::begin(profile.weaponSlotLevels), std::end(profile.weaponSlotLevels), - fighter.weaponSlotLevels); - std::copy(std::begin(profile.trinketSlotLevels), std::end(profile.trinketSlotLevels), - fighter.trinketSlotLevels); - std::copy(std::begin(profile.weaponItemLevels), std::end(profile.weaponItemLevels), - fighter.weaponItemLevels); - std::copy(std::begin(profile.trinketItemLevels), std::end(profile.trinketItemLevels), - fighter.trinketItemLevels); - match.owners[index] = event.member; - SDL_strlcpy(match.names[index], profile.name, sizeof(match.names[index])); - bool isTaken[COLOR_COUNT] = {}; - for (int i = 0; i < index; i++) - isTaken[match.colors[i]] = true; - int color = profile.color >= 0 && profile.color < COLOR_COUNT && !isTaken[profile.color] - ? profile.color - : std::find(isTaken, isTaken + COLOR_COUNT, false) - isTaken; - match.colors[index] = color % COLOR_COUNT; - } + if (event.type == EVENT_JOIN) + match.member_join(game.sim, event.member, event.profile); if (event.type == EVENT_LEAVE) - { - int index = std::find(match.owners, match.owners + game.sim.fighterCount, event.member) - match.owners; - if (index >= game.sim.fighterCount) - continue; - std::vector& attacks = game.sim.weapons.attacks; - std::erase_if(attacks, [&](Attack& attack) { return attack.owner == index; }); - for (Attack& attack : attacks) - attack.owner -= attack.owner > index; - for (int i = index; i + 1 < game.sim.fighterCount; i++) - { - game.sim.fighters[i] = game.sim.fighters[i + 1]; - game.sim.fighters[i].index = i; - game.sim.bot.states[i] = game.sim.bot.states[i + 1]; - match.owners[i] = match.owners[i + 1]; - match.colors[i] = match.colors[i + 1]; - SDL_memcpy(match.names[i], match.names[i + 1], sizeof(match.names[i])); - } - match.queue_slots_shift(index, game.sim.fighterCount); - game.sim.fighterCount--; - match.owners[game.sim.fighterCount] = -1; - } + 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; diff --git a/src/session.h b/src/session.h index 147be20..eb93e23 100644 --- a/src/session.h +++ b/src/session.h @@ -18,22 +18,6 @@ constexpr int SNAPSHOT_RING = 64; // A member's connection has no ping to show (it's this screen's own, hosting). constexpr float PING_NONE = -2; -// A player's setup as they join online: their name, the colour they'd like (-1 for any), and their sandbox slots, -// loadouts, levels and stacking, which their fighter starts with. Plain data, sent as it sits in memory. -struct Profile -{ - char name[PLAYER_NAME_MAX + 1] = {}; - int color = -1; - int weaponSlots = SLOTS_DEFAULT; - int trinketSlots = SLOTS_DEFAULT; - int weaponLoadout[SLOT_MAX] = {}; - int trinketLoadout[SLOT_MAX] = {}; - int weaponSlotLevels[SLOT_MAX] = {}; - int trinketSlotLevels[SLOT_MAX] = {}; - int weaponItemLevels[WEAPON_COUNT] = {}; - int trinketItemLevels[TRINKET_COUNT] = {}; -}; - enum SessionEventType { EVENT_JOIN, diff --git a/src/sim.cpp b/src/sim.cpp index b107172..5c9c121 100644 --- a/src/sim.cpp +++ b/src/sim.cpp @@ -26,6 +26,53 @@ void Sim::tick() world.update(*this); } +// A fresh online game for some of a lobby's fighters (by index) to play by the rules, bots filling the rest when the +// rules say to. Their members, names and colours come along, and it starts from the seed, so everyone builds the same +// one. The bots keep their difficulty. +void Sim::game_start(Match& lobby, const int* members, int count, const Rules& rules, Uint32 seed) +{ + int owners[MAX_FIGHTERS]; + int colors[MAX_FIGHTERS]; + char names[MAX_FIGHTERS][PLAYER_NAME_MAX + 1]; + for (int k = 0; k < count; k++) + { + owners[k] = lobby.owners[members[k]]; + colors[k] = lobby.colors[members[k]]; + std::memcpy(names[k], lobby.names[members[k]], sizeof(names[k])); + } + int players = rules.isBotFill ? std::max(rules.players, count) : count; + int level = bot.level; + bot = Bot{}; + bot.level = level; + weapons = Weapons{}; + world = World{}; + match = Match{}; + match.rules = rules; + match.rules.players = players; + match.isOnline = true; + rng.state = seed ? seed : RNG_SEED; + 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++) + { + fighters[k] = Fighter{}; + fighters[k].init(*this, k); + match.owners[k] = k < count ? owners[k] : -1; + if (k < count) + { + match.colors[k] = colors[k]; + std::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; + } + match.start(*this); +} + // Moves a level by a step within the levels there are, cueing whether it went up or down. void level_step(Sim& sim, int& level, int step) { diff --git a/src/sim.h b/src/sim.h index 3c4d4cd..dd6cfe9 100644 --- a/src/sim.h +++ b/src/sim.h @@ -56,6 +56,7 @@ struct Sim void init(Uint32 seed); void tick(); + void game_start(Match& lobby, const int* members, int count, const Rules& rules, Uint32 seed); Snapshot snapshot_save(); void snapshot_load(Snapshot& snapshot); Uint32 checksum_get();