Files
AyeZeeBB 76330e40bb Skater slot, menu and throwdown fixes; MY MODS rework; lobby parties
Skater slot, menu and throwdown fixes; MY MODS rework; lobby parties

Saved skaters (Extension/Customization, Extension/Skater)
- The selected slot, and slots before it, came up as the standard
  skater after a start or map change. The game asks for a slot's saved
  outfit once per level, and that happened before the cosmetics catalog
  could be read. The first selection now waits for the catalog (60 s at
  most), and later map changes check against the catalog already read.
- The retry from abdda77 is removed: the record it wrote to is a
  temporary on the game's stack, so it could never work.

Pause menu (Extension/UI/NativeMenu)
- ReSkate's entries stopped working after enough map changes: each
  level change took new slots in a fixed table of 1024 for the same
  buttons. A button now keeps its slot, and a full table hands out the
  slot that has gone longest unused.

Throwdowns (Extension/Throwdowns, Runtime/runtime_init.cpp)
- Multiplayer crashed when someone placed a throwdown after a map
  change. The event types are freed with the level but were cached for
  the process. They are now cleared when a level is left, checked
  before use, and looked for again in each level.

MY MODS (Launcher/gui_mods.cpp, gui.cpp)
- Search, a filter and an order; a switch per mod; a menu per row.
- Multi-select with tick boxes, Shift-click and Ctrl+A, and a bar to
  enable, disable, move, update or uninstall everything ticked.

Lobby parties (Extension/Multiplayer/Session)
- A lobby is no longer one party. Its host keeps parties the way a
  dedicated server does: invite, accept, join, leave, kick, promote,
  open and close, with "/p" party chat. The "Lobby party" switch is
  gone. Protocol version unchanged (38).

Mod merge (Engine/Vfs), follow-ups to e715b9b
- Files rewritten into the merged patch are tracked by position, so
  another mod's added assets no longer shift the lookup.
- Two mods adding different assets under one name is now noted, with
  whose copy the maps get.
- mod_merge_unshift_tests compiles with the launcher tests on.
2026-10-04 13:27:21 -05:00

285 lines
14 KiB
C++

#include "session_internal.h"
#include "Extension/Multiplayer/Hud/native_party.h"
#include <algorithm>
// Parties. Whoever hosts owns them, in a PartyBook: a dedicated server (Server/server_party.cpp)
// or, in a lobby, the host's game (the second half of this file, which answers requests the way
// the server does). Nobody is in a party until they form one, and every roster says who is in
// which. The native party UI, map markers and coop challenges read them from here.
namespace dingosdk::multiplayer::session_detail {
namespace {
// An invite the host passed on lapses there after a minute; drop ours a little later.
constexpr std::uint64_t invite_lifetime_us = 65000000;
std::string_view refusal(PartyBook::Result result) {
switch (result) {
case PartyBook::Result::ok: return {};
case PartyBook::Result::self: return "That's you.";
case PartyBook::Result::same_party: return "You're already in a party together.";
case PartyBook::Result::full: return "That party is full.";
case PartyBook::Result::not_leader: return "Only the party leader can do that.";
case PartyBook::Result::not_member: return "You're not in a party with them.";
case PartyBook::Result::no_invite: return "That invite has expired.";
case PartyBook::Result::closed: return "That party is invite-only.";
case PartyBook::Result::no_party: return "They're not in a party.";
case PartyBook::Result::busy: return "They already have too many party invites.";
case PartyBook::Result::renewed: return "They already have your party invite; it stays open for another minute.";
}
return "That can't be done.";
}
std::string player_name(Session &s, std::uint64_t id) {
if (const auto *peer = find_peer(s, id); peer && !peer->member.name.empty()) return peer->member.name;
return s.transport.name(id);
}
// The host itself, or a guest it has admitted.
bool in_lobby(Session &s, std::uint64_t id) {
if (id == s.transport.status().local_id) return true;
const auto *peer = find_peer(s, id);
return peer && peer->handshaken;
}
// A line only `to` reads: in the host's own chat, or sent to the guest as the host's notice.
void tell(Session &s, std::uint64_t to, std::string_view text, std::uint64_t now) {
if (to == s.transport.status().local_id) return add_chat(s, 0, "ReSkate", std::string(text));
auto notice = packet(s, PacketKind::admin, now);
notice.text = clean_chat_text(text);
send_packet(s, to, notice, true, false);
}
// An invite for `to`, or word that one can no longer be accepted.
void notify(Session &s, std::uint64_t to, PartyAction action, std::uint64_t player, std::uint64_t now) {
auto notice = packet(s, PacketKind::party, now);
notice.party_action = action;
notice.party_player = player;
if (to == s.transport.status().local_id) receive_party(s, notice, now);
else send_packet(s, to, notice, true, false);
}
void party_notice(Session &s, std::uint32_t party, std::string_view text, std::uint64_t now, std::uint64_t except = 0) {
const auto *details = s.parties.party(party);
if (!details) return;
for (const auto member : details->members)
if (member != except && in_lobby(s, member)) tell(s, member, text, now);
}
} // namespace
std::uint32_t party_of(const Session &s, std::uint64_t id) {
if (!id) return 0;
if (id == s.transport.status().local_id) return s.local_party;
for (const auto &peer : active_peers(s))
if (peer.handshaken && peer.member.id == id) return peer.member.party;
return 0;
}
bool party_member(const Session &s, std::uint64_t id) {
return s.local_party && id != s.transport.status().local_id && party_of(s, id) == s.local_party;
}
std::uint64_t party_leader(const Session &s) {
if (!s.local_party) return 0;
if (s.local_party_leader) return s.transport.status().local_id;
for (const auto &peer : active_peers(s))
if (peer.handshaken && peer.member.party == s.local_party && peer.member.party_leader) return peer.member.id;
return 0;
}
void set_local_party(Session &s, const Member &local) {
const bool joined = local.party && local.party != s.local_party;
if (local.party != s.local_party || local.party_leader != s.local_party_leader || local.party_open != s.local_party_open)
++s.party_revision;
s.local_party = local.party;
s.local_party_leader = local.party_leader;
s.local_party_open = local.party_open;
// Invites into the party we are now in are moot.
if (joined)
std::erase_if(s.party_invites, [&](const auto &invite) { return party_of(s, invite.from) == local.party; });
}
void receive_party(Session &s, const Packet &p, std::uint64_t now) {
const auto from = p.party_player;
auto *inviter = find_peer(s, from);
const auto name = inviter && !inviter->member.name.empty() ? inviter->member.name : s.transport.name(from);
if (p.party_action == PartyAction::invited) {
std::erase_if(s.party_invites, [&](const auto &invite) { return invite.from == from; });
if (s.party_invites.size() >= 8) s.party_invites.erase(s.party_invites.begin());
s.party_invites.push_back({from, now});
++s.party_revision;
// The game's own invite toast (Accept / Decline) when it can show one; chat either way.
const bool toast = post_native_party_invite(from);
add_chat(s, 0, "ReSkate", name + " invited you to their party. " +
(toast ? "Accept it from the notification, the Multiplayer menu or /party accept."
: "Accept it in the Multiplayer menu or type /party accept."));
} else if (p.party_action == PartyAction::withdrawn) {
const auto before = s.party_invites.size();
std::erase_if(s.party_invites, [&](const auto &invite) { return invite.from == from; });
if (s.party_invites.size() != before) ++s.party_revision;
}
publish(s);
}
void expire_party_invites(Session &s, std::uint64_t now) {
const auto before = s.party_invites.size();
std::erase_if(s.party_invites, [&](const auto &invite) {
return now < invite.received || now - invite.received > invite_lifetime_us || !find_peer(s, invite.from);
});
if (s.party_invites.size() != before) ++s.party_revision;
}
std::string send_party_request(Session &s, PartyAction action, std::uint64_t player) {
if (s.mode != Mode::host && s.mode != Mode::join) return "Parties need a multiplayer session.";
if (!valid_party_request(action, player) || action == PartyAction::invited || action == PartyAction::withdrawn)
return "That party request isn't valid.";
if (player && !find_peer(s, player)) return "That player is not in the session.";
const auto now = now_us();
if (s.mode == Mode::host) {
host_party_request(s, s.transport.status().local_id, action, player, now);
return {};
}
auto request = packet(s, PacketKind::party, now);
request.party_action = action;
request.party_player = player;
if (!send_packet(s, s.host_id, request, true, false))
return dedicated_host(s) ? "Could not reach the server." : "Could not reach the host.";
return {};
}
// ---------------------------------------------------------------------------
// A lobby's host. The same answers, word for word, as a dedicated server gives
// (Server/server_party.cpp), so a party works the same wherever it is formed.
void host_party_request(Session &s, std::uint64_t me, PartyAction action, std::uint64_t player, std::uint64_t now) {
if (s.mode != Mode::host || !in_lobby(s, me)) return;
if (player && !in_lobby(s, player)) return tell(s, me, "That player is not in the lobby.", now);
const auto my_name = player_name(s, me);
const auto their_name = player ? player_name(s, player) : std::string{};
auto &parties = s.parties;
using R = PartyBook::Result;
auto result = R::ok;
switch (action) {
case PartyAction::invite:
result = parties.invite(me, player, now);
if (result == R::ok) {
notify(s, player, PartyAction::invited, me, now);
tell(s, me, "Invited " + their_name + " to your party.", now);
}
break;
case PartyAction::accept:
case PartyAction::join: {
const auto before = parties.party_of(me);
result = action == PartyAction::accept ? parties.accept(me, player, now) : parties.join(me, player, now);
if (result == R::ok) {
const auto party = parties.party_of(me);
if (before && before != party) party_notice(s, before, my_name + " left the party.", now);
party_notice(s, party, my_name + " joined the party.", now, me);
tell(s, me, "You joined " + their_name + "'s party.", now);
} else if (result == R::closed) {
// Ask the leader instead: they can invite.
const auto *details = parties.party(parties.party_of(player));
if (details && in_lobby(s, details->leader)) {
tell(s, details->leader, my_name + " would like to join your party. Invite them from their player card "
"or with /party invite " + my_name, now);
return tell(s, me, "That party is invite-only; its leader was asked to invite you.", now);
}
}
break;
}
case PartyAction::decline:
result = parties.decline(me, player);
if (result == R::ok) tell(s, player, my_name + " declined your party invite.", now);
break;
case PartyAction::leave: {
const auto party = parties.party_of(me);
result = parties.leave(me);
if (result == R::ok) {
party_notice(s, party, my_name + " left the party.", now);
tell(s, me, "You left the party.", now);
}
break;
}
case PartyAction::kick: {
const auto party = parties.party_of(me);
result = parties.kick(me, player);
if (result == R::ok) {
tell(s, player, "You were removed from the party.", now);
party_notice(s, party, their_name + " was removed from the party.", now);
}
break;
}
case PartyAction::promote:
result = parties.promote(me, player);
if (result == R::ok) party_notice(s, parties.party_of(me), their_name + " now leads the party.", now);
break;
case PartyAction::open:
case PartyAction::close: {
const auto *before = parties.party(parties.party_of(me));
const bool was_open = before && before->open;
result = parties.set_open(me, action == PartyAction::open);
if (result == R::ok && was_open != (action == PartyAction::open))
party_notice(s, parties.party_of(me), action == PartyAction::open ? "The party is open: anyone can join."
: "The party is invite-only.", now);
break;
}
case PartyAction::invited:
case PartyAction::withdrawn: return; // the host's own notices
}
if (result != R::ok) tell(s, me, refusal(result), now);
if (parties.revision() != s.parties_revision) s.roster_dirty = true;
}
void host_party_chat(Session &s, std::uint64_t from, std::string_view text, std::uint64_t now) {
if (s.mode != Mode::host) return;
const auto party = s.parties.party_of(from);
if (!party) return tell(s, from, "You're not in a party.", now);
const auto line = clean_chat_text(text);
if (line.empty()) return tell(s, from, "/p <message>", now);
// Relayed as the sender's own chat line, only to the rest of their party.
const auto local = s.transport.status().local_id;
auto message = packet(s, PacketKind::chat, now);
message.text = clean_chat_text("[Party] " + line);
if (from != local) {
const auto *sender = find_peer(s, from);
if (!sender) return;
message.source = from;
message.epoch = sender->member.epoch;
}
for (const auto member : s.parties.party(party)->members) {
if (member == from) continue;
if (member == local) add_chat(s, from, player_name(s, from), message.text);
else send_packet(s, member, message, true, false);
}
}
void tick_host_parties(Session &s, std::uint64_t now) {
if (s.mode != Mode::host) return;
auto &parties = s.parties;
// A player who left the lobby is out of their party, and their invites go with them.
std::vector<std::uint64_t> gone;
const auto check = [&](std::uint64_t id) {
if (!in_lobby(s, id) && std::find(gone.begin(), gone.end(), id) == gone.end()) gone.push_back(id);
};
for (const auto &[id, details] : parties.parties())
for (const auto member : details.members) check(member);
for (const auto &invite : parties.invites()) { check(invite.from); check(invite.to); }
for (const auto id : gone) {
const auto party = parties.party_of(id);
const auto name = player_name(s, id);
parties.remove(id);
if (party) party_notice(s, party, name + " left the lobby.", now);
}
for (const auto &lapsed : parties.expire(now)) {
if (in_lobby(s, lapsed.to)) notify(s, lapsed.to, PartyAction::withdrawn, lapsed.from, now);
if (in_lobby(s, lapsed.from) && in_lobby(s, lapsed.to))
tell(s, lapsed.from, "Your party invite to " + player_name(s, lapsed.to) + " expired.", now);
}
for (const auto &withdrawn : parties.take_withdrawn())
if (in_lobby(s, withdrawn.to)) notify(s, withdrawn.to, PartyAction::withdrawn, withdrawn.from, now);
if (parties.revision() != s.parties_revision) s.roster_dirty = true;
}
void fill_roster_parties(Session &s, std::vector<Member> &members) {
for (auto &m : members) {
const auto party = s.parties.party_of(m.id);
const auto *details = s.parties.party(party);
m.party = party;
m.party_leader = details && details->leader == m.id;
m.party_open = m.party_leader && details->open;
}
// The book holds only players in the lobby (tick_host_parties), so this only guards the
// roster's rules: every listed party has two members and one leader.
for (auto &m : members)
if (m.party && std::count_if(members.begin(), members.end(), [&](const Member &o) { return o.party == m.party; }) < 2)
m.party = 0, m.party_leader = m.party_open = false;
for (auto &m : members)
if (m.party && std::none_of(members.begin(), members.end(),
[&](const Member &o) { return o.party == m.party && o.party_leader; }))
m.party_leader = true; // the first listed member of a party missing its leader
s.parties_revision = s.parties.revision();
}
} // namespace dingosdk::multiplayer::session_detail