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 <noreply@anthropic.com>
This commit is contained in:
shweetandClaude Opus 5.5 committed 2026-11-08 01:54:27 -05:00
1 parent 465f75f6d7
commit 9613e85706
7 files changed
+181 -105

No files matched your search

+78
View File
@@ -1,6 +1,7 @@
#include "match.h"
#include "sim.h"
#include <algorithm>
#include <cstring>
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<Attack>& 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;
}
+20
View File
@@ -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:
+31
View File
@@ -22,8 +22,10 @@
//
// A signal's payload is either a description {sdp, kind} or a candidate {candidate, mid}.
#include <cstring>
#include <string>
#include <string_view>
#include <type_traits>
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 <class T> PacketWriter& put(const T& value)
{
static_assert(std::is_trivially_copyable_v<T>);
bytes.append(reinterpret_cast<const char*>(&value), sizeof(T));
return *this;
}
};
struct PacketReader
{
const std::string& bytes;
size_t at = 0;
template <class T> bool get(T& value)
{
static_assert(std::is_trivially_copyable_v<T>);
if (at + sizeof(T) > bytes.size())
return false;
std::memcpy(static_cast<void*>(&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)
+4 -89
View File
@@ -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 <class T> PacketWriter& put(const T& value)
{
static_assert(std::is_trivially_copyable_v<T>);
bytes.append(reinterpret_cast<const char*>(&value), sizeof(T));
return *this;
}
};
struct PacketReader
{
const std::string& bytes;
size_t at = 0;
template <class T> bool get(T& value)
{
static_assert(std::is_trivially_copyable_v<T>);
if (at + sizeof(T) > bytes.size())
return false;
std::memcpy(static_cast<void*>(&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<Attack>& 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;
-16
View File
@@ -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,
+47
View File
@@ -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)
{
+1
View File
@@ -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();