mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-23 10:45:55 +02:00
Plugins register modes (card, replaces, order, flag, players) and publish documents through reskate.throwdowns.ui: the setup page, the waiting card, HUD slots themed on the game's HUD_<theme>_* widgets, the match panel, flags and match restrictions. SmokeyDawg58's native plumbing (throwdown_menu.cpp and its .inl files) renders them and posts throwdown_ui events back. kit::flag is the one native flag registration. General services added for it: reskate.files, reskate.ui.dialog, reskate.skater push/hold/bone_cam, reskate.camera.pad_flight, reskate.physics competition, reskate.session.world, reskate.tricks feed/gesture, and world entities. His ten modes are now Lua in his plugin: the rules, referee and wire (td/rules, referee, wire), and every page and HUD document (td/ui*.lua). Extension/Modes and reskate.modes are gone, along with every race and 1-UP branch in the SDK. The plugin test drives each mode through kit events. A native setup page is never written to while it closes into flag placement: its rows are not republished, the stock page is put back once the exit is over, and the stock Players slider is left alone while a plugin flag is out. This fixes the crashes on the Deathrace button and on 1-UP's Confirm.
1070 lines
54 KiB
C++
1070 lines
54 KiB
C++
// The throwdown relay's state machine without the game: the natives it drives are
|
|
// recorded here, and messages are exchanged by hand as other players would send them.
|
|
#include "Extension/Throwdowns/throwdown_relay.h"
|
|
#include "Extension/Throwdowns/custom_modes.h"
|
|
#include "Extension/Throwdowns/throwdown_lab.h"
|
|
#include "Extension/Throwdowns/throwdown_wire.h"
|
|
#include "Extension/Throwdowns/native_throwdowns.h"
|
|
#include "Extension/Throwdowns/grouped_number.h"
|
|
#include "Extension/Multiplayer/Hud/native_party.h"
|
|
#include "Extension/Settings/named_settings.h"
|
|
#include "Extension/Throwdowns/throwdown_kit.h"
|
|
#include "Extension/Throwdowns/native_flag_session.h"
|
|
#include <Windows.h>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <iostream>
|
|
#include <thread>
|
|
#include <map>
|
|
#include <set>
|
|
#include <algorithm>
|
|
#include <stdexcept>
|
|
|
|
namespace {
|
|
struct Call {
|
|
std::string what, series;
|
|
std::uint32_t id{}, mmid{};
|
|
std::int32_t value{};
|
|
std::uint64_t token{};
|
|
};
|
|
std::vector<Call> calls;
|
|
std::map<std::string, std::string> settings_changed;
|
|
bool leaderboard_live = true;
|
|
bool types_ready = true; // stands in for the event types of the level being found
|
|
std::uint64_t last_token{};
|
|
// The kit's store and native flag, as the relay sees them: the plugins' documents, the events
|
|
// it posts and the flags it hands to the native flag registration.
|
|
std::vector<dingosdk::throwdowns::kit::Documents> kit_documents;
|
|
std::vector<dingosdk::throwdowns::kit::Event> kit_events;
|
|
struct Adopted { std::string mode; std::uint32_t game{}, mmid{}; unsigned capacity{}; };
|
|
std::vector<Adopted> adopted;
|
|
bool accept_adopt=true;
|
|
std::set<std::uint32_t> custom_queues;
|
|
}
|
|
namespace dingosdk::throwdowns::kit {
|
|
std::vector<Documents> documents() {return kit_documents;}
|
|
void post_to(std::string_view plugin,std::string action,std::string mode,std::vector<std::string> args) {
|
|
kit_events.push_back({std::string(plugin),std::move(action),std::move(mode),std::move(args)});
|
|
}
|
|
namespace flag {
|
|
bool adopt(std::string_view mode,std::uint32_t game,std::uint32_t mmid,const Vec3&,float,unsigned,unsigned capacity) {
|
|
adopted.push_back({std::string(mode),game,mmid,capacity});
|
|
if(accept_adopt)for(auto& d:kit_documents)d.flags.clear(); // the plugin stops offering it
|
|
return accept_adopt;
|
|
}
|
|
}
|
|
}
|
|
namespace dingosdk::multiplayer::native_flag {
|
|
bool known_queue(std::uint32_t mmid) noexcept {return custom_queues.contains(mmid);}
|
|
}
|
|
|
|
namespace dingosdk {
|
|
std::string change_named_setting(std::string_view name, std::string_view value, bool) {
|
|
settings_changed[std::string(name)] = std::string(value);
|
|
return "ok";
|
|
}
|
|
} // namespace dingosdk
|
|
namespace dingosdk::multiplayer {
|
|
std::uint32_t local_native_player_id() noexcept { return 2; }
|
|
std::uint64_t native_party_player_info(std::uintptr_t, std::size_t) noexcept { return 0; }
|
|
void spectate_party_member(std::uint64_t) noexcept {}
|
|
bool prepare_throwdown_injection() noexcept { calls.push_back({"prepare"}); return types_ready; }
|
|
std::string last_instance;
|
|
std::uint32_t last_capacity{};
|
|
bool queue_throwdown_spawn(std::uint64_t token, std::uint32_t host, const std::string &series,
|
|
const std::vector<std::uint8_t> &, const std::vector<std::uint8_t> &,
|
|
const std::string &instance, std::uint32_t capacity) noexcept {
|
|
last_token = token;
|
|
last_instance = instance;
|
|
last_capacity = capacity;
|
|
calls.push_back({"spawn", series, host, 0, 0, token});
|
|
return true;
|
|
}
|
|
bool queue_throwdown_host(std::uint64_t token, const std::string &series, const std::array<float, 3> &at, std::uint32_t most_players) noexcept {
|
|
last_token = token;
|
|
calls.push_back({"host", series, most_players, 0, static_cast<std::int32_t>(at[0]), token});
|
|
return true;
|
|
}
|
|
bool queue_custom_throwdown_flag(std::uint64_t token,std::uint32_t host,const std::string& key,
|
|
const std::array<float,3>& at,float yaw) noexcept {
|
|
last_token=token;calls.push_back({"custom flag",key,host,static_cast<std::uint32_t>(at[0]),static_cast<std::int32_t>(yaw),token});return true;
|
|
}
|
|
bool queue_throwdown_add(std::uint32_t player, const std::string &series, std::uint32_t mmid) noexcept {
|
|
calls.push_back({"add", series, player, mmid});
|
|
return true;
|
|
}
|
|
bool queue_throwdown_remove(std::uint32_t player) noexcept { calls.push_back({"remove", {}, player}); return true; }
|
|
bool queue_throwdown_start(const std::string &series, std::uint32_t mmid) noexcept {
|
|
calls.push_back({"start", series, 0, mmid});
|
|
return true;
|
|
}
|
|
bool queue_throwdown_destroy(std::uint32_t mmid) noexcept { calls.push_back({"destroy", {}, 0, mmid}); return true; }
|
|
bool queue_throwdown_score(std::uint32_t player, std::int32_t score) noexcept {
|
|
if (!leaderboard_live) return false;
|
|
calls.push_back({"score", {}, player, 0, score});
|
|
return true;
|
|
}
|
|
bool queue_throwdown_row(std::uint32_t player, std::uint8_t board, bool add, std::int32_t value) noexcept {
|
|
if (!leaderboard_live) return false;
|
|
calls.push_back({add ? "row add" : "row set", {}, player, board, value});
|
|
return true;
|
|
}
|
|
bool queue_throwdown_attempt(std::uint32_t player, bool landed, const std::array<std::uint8_t, 28> &trick) noexcept {
|
|
if (!leaderboard_live) return false;
|
|
calls.push_back({landed ? "landed" : "missed", {}, player, trick[0]});
|
|
return true;
|
|
}
|
|
bool queue_throwdown_destroy_event(std::uint32_t player) noexcept {
|
|
calls.push_back({"destroy event", {}, player});
|
|
return true;
|
|
}
|
|
bool queue_challenge_start(const std::string &series, const std::string &id, bool) noexcept {
|
|
calls.push_back({"challenge start", series + " " + id});
|
|
return true;
|
|
}
|
|
bool queue_challenge_attempt(std::uint32_t player, std::vector<std::uint8_t> criteria, std::vector<std::uint8_t>) noexcept {
|
|
calls.push_back({"challenge attempt", {}, player, 0, static_cast<std::int32_t>(criteria.size())});
|
|
return true;
|
|
}
|
|
bool queue_challenge_slam(std::uint32_t player, std::int32_t identifier) noexcept {
|
|
calls.push_back({"challenge slam", {}, player, 0, identifier});
|
|
return true;
|
|
}
|
|
bool queue_challenge_leave(std::uint32_t player) noexcept { calls.push_back({"challenge leave", {}, player}); return true; }
|
|
bool queue_beacon_move(std::uint32_t player, const std::array<float, 16> &location) noexcept {
|
|
calls.push_back({"beacon move", {}, player, 0, static_cast<std::int32_t>(location[12])});
|
|
return true;
|
|
}
|
|
bool queue_beacon_remove(std::uint32_t player) noexcept { calls.push_back({"beacon remove", {}, player}); return true; }
|
|
std::optional<ChallengeCelebration> test_celebration;
|
|
std::optional<ChallengeCelebration> take_challenge_celebration() noexcept { return std::exchange(test_celebration, std::nullopt); }
|
|
bool queue_throwdown_end_turn(std::uint32_t player) noexcept {
|
|
if (!leaderboard_live) return false; // stands in for "event handle unknown"
|
|
calls.push_back({"end turn", {}, player});
|
|
return true;
|
|
}
|
|
} // namespace dingosdk::multiplayer
|
|
|
|
using namespace dingosdk::multiplayer;
|
|
namespace {
|
|
using Kind = ThrowdownMessage::Kind;
|
|
using Local = ThrowdownLocalAction::Kind;
|
|
constexpr std::uint64_t me = 76561198000000010ULL, leader = 76561198000000011ULL, other = 76561198000000012ULL;
|
|
void check(bool value, const char *why) {
|
|
if (value) return;
|
|
for (const auto &c : calls)
|
|
std::cerr << " " << c.what << " id " << std::hex << c.id << " mmid " << c.mmid << std::dec << " value " << c.value << '\n';
|
|
throw std::runtime_error(why);
|
|
}
|
|
std::vector<ThrowdownMessage> tick(const ThrowdownRelayInput &input) {
|
|
std::vector<ThrowdownMessage> out;
|
|
for (const auto &bytes : tick_throwdown_relay(0, input)) {
|
|
const auto m = decode_throwdown(bytes);
|
|
check(m.has_value(), "The relay produced an undecodable message");
|
|
out.push_back(*m);
|
|
}
|
|
return out;
|
|
}
|
|
void receive(std::uint64_t sender, const ThrowdownMessage &m) {
|
|
const auto bytes = encode_throwdown(m);
|
|
receive_throwdown_relay(sender, bytes);
|
|
}
|
|
ThrowdownMessage make(Kind kind, std::uint64_t owner, std::uint32_t id) {
|
|
ThrowdownMessage m; m.kind = kind; m.leader = owner; m.id = id;
|
|
return m;
|
|
}
|
|
ThrowdownMessage offer(std::uint64_t owner, std::uint32_t id, const char *series = "JamSession") {
|
|
auto m = make(Kind::offer, owner, id);
|
|
m.series = series; m.placement = {1, 2, 3}; m.settings = {4};
|
|
return m;
|
|
}
|
|
const Call *find(const char *what) {
|
|
for (const auto &c : calls) if (c.what == what) return &c;
|
|
return nullptr;
|
|
}
|
|
std::size_t count(const char *what) {
|
|
std::size_t n{};
|
|
for (const auto &c : calls) n += c.what == what;
|
|
return n;
|
|
}
|
|
void local(Local kind, std::uint32_t mmid = 0) {
|
|
ThrowdownLocalAction a{kind};
|
|
a.mmid = mmid; a.series = "JamSession";
|
|
throwdown_relay_local(std::move(a));
|
|
}
|
|
// The session is over; the world stays loaded.
|
|
const ThrowdownRelayInput no_session{0, {}, true, 0};
|
|
void end_session() {
|
|
tick(no_session);
|
|
calls.clear();
|
|
}
|
|
|
|
void native_queue_banner_lifetime() {
|
|
const auto before=native_queue_hud_state().generation;
|
|
local(Local::created,501);auto queue=native_queue_hud_state();
|
|
check(queue.generation>before && queue.waiting,"Creating a native queue enables its waiting notice");
|
|
local(Local::force_started,501);check(native_queue_hud_state().waiting,"A Start request alone does not assume the event successfully began");
|
|
local(Local::entered,501);check(!native_queue_hud_state().waiting,"Actual activity entry retires the long-timer waiting banner");
|
|
local(Local::ended,501);check(!native_queue_hud_state().waiting,"A completed Skate Jam cannot resurrect its waiting banner");
|
|
local(Local::joined,502);auto rejoined=native_queue_hud_state();
|
|
check(rejoined.generation>queue.generation && rejoined.waiting,"A new enrollment gets a fresh waiting notice");
|
|
local(Local::left,502);check(!native_queue_hud_state().waiting,"Leave retires the queue notice immediately");
|
|
local(Local::joined,503);local(Local::quit,503);
|
|
check(!native_queue_hud_state().waiting,"Quit cannot leave a stock waiting banner alive");
|
|
local(Local::created,504);retire_native_queue_hud();
|
|
check(!native_queue_hud_state().waiting,"A real level teardown retires its waiting notice");
|
|
local(Local::created,505);check(native_queue_hud_state().waiting,"The next map's fresh queue is visible");
|
|
end_session();
|
|
}
|
|
|
|
// Another player's drop: shown, joined natively, started by its leader, scored.
|
|
void guest_flow() {
|
|
const ThrowdownRelayInput in{me, {{leader, 3}, {other, 5}}, true, 77};
|
|
check(tick(in).empty(), "An idle relay sent something");
|
|
check(settings_changed["DingoThrowdowns.QueueStartTimer"] == "36000", "Queues kept their own start timer with players in the session");
|
|
check(settings_changed["DingoThrowdowns.EnableForceStartThrowdown"] == "1", "Force start stayed off with players in the session");
|
|
receive(leader, offer(leader, 0x1234));
|
|
tick(in);
|
|
const auto *spawn = find("spawn");
|
|
check(spawn && spawn->id == 0x303 && spawn->series == "JamSession", "The leader's drop was not spawned hosted by their virtual id");
|
|
// A second offer (resend) must not spawn again.
|
|
receive(leader, offer(leader, 0x1234));
|
|
tick(in);
|
|
check(count("spawn") == 1, "A resent offer spawned a second copy");
|
|
throwdown_relay_spawned(last_token, 0x5555, 4);
|
|
receive(other, make(Kind::join, leader, 0x1234));
|
|
tick(in);
|
|
const auto *add = find("add");
|
|
check(add && add->id == 0x305 && add->mmid == 0x5555, "Another player's join was not replayed into the local copy");
|
|
local(Local::joined, 0x5555);
|
|
auto out = tick(in);
|
|
check(out.size() == 1 && out[0].kind == Kind::join && out[0].leader == leader && out[0].id == 0x1234,
|
|
"Joining the local copy was not reported against the leader's drop");
|
|
auto start = make(Kind::start, leader, 0x1234);
|
|
start.order = {leader, other, me};
|
|
receive(leader, start);
|
|
tick(in);
|
|
const auto *started = find("start");
|
|
check(started && started->mmid == 0x5555, "The leader's start did not start the joined copy");
|
|
ThrowdownLocalAction entered{Local::entered};
|
|
entered.participants = {0x303, 0x305, 2};
|
|
throwdown_relay_local(std::move(entered));
|
|
tick(in);
|
|
auto score = make(Kind::score, leader, 0x1234);
|
|
score.value = 500;
|
|
receive(other, score);
|
|
tick(in);
|
|
const auto *row = find("score");
|
|
check(row && row->id == 0x305 && row->value == 500, "A remote score did not become that player's row");
|
|
ThrowdownLocalAction own{Local::score};
|
|
own.score = 300;
|
|
throwdown_relay_local(std::move(own));
|
|
out = tick(in);
|
|
check(out.size() == 1 && out[0].kind == Kind::score && out[0].leader == leader && out[0].id == 0x1234 && out[0].value == 300,
|
|
"The local score was not sent against the running throwdown");
|
|
local(Local::ended);
|
|
tick(in);
|
|
score.value = 900;
|
|
receive(other, score);
|
|
tick(in);
|
|
check(count("score") == 1, "A score after the end was still written");
|
|
end_session();
|
|
}
|
|
|
|
// The local player's own drop: offered, joined by others, force-started, scored.
|
|
void private_draft_relay_flow() {
|
|
const ThrowdownRelayInput in{me,{{other,4}},true,78};tick(in);
|
|
custom_queues.insert(0x999);
|
|
ThrowdownLocalAction exited{Local::exited};exited.series="SpotBattle";exited.mmid=0x999;
|
|
exited.placement={9};exited.settings={8};throwdown_relay_local(std::move(exited));
|
|
const auto out=tick(in);
|
|
check(std::none_of(out.begin(),out.end(),[](const auto& m){return m.kind==Kind::offer;}),"A custom draft leaked through the stock donor relay");
|
|
custom_queues.clear();end_session();
|
|
}
|
|
void leader_flow() {
|
|
const ThrowdownRelayInput in{me, {{other, 4}}, true, 78};
|
|
tick(in);
|
|
ThrowdownLocalAction exited{Local::exited};
|
|
exited.series = "JamSession"; exited.mmid = 0x42; exited.placement = {9}; exited.settings = {8};
|
|
throwdown_relay_local(std::move(exited));
|
|
auto out = tick(in);
|
|
check(out.size() == 1 && out[0].kind == Kind::offer && out[0].leader == me && out[0].id == 0x42 &&
|
|
out[0].placement == std::vector<std::uint8_t>{9} && out[0].settings == std::vector<std::uint8_t>{8},
|
|
"A configured drop was not offered to the other players");
|
|
receive(other, make(Kind::join, me, 0x42));
|
|
tick(in);
|
|
const auto *add = find("add");
|
|
check(add && add->id == 0x304 && add->mmid == 0x42, "A remote join did not enter the local player's queue");
|
|
receive(other, make(Kind::join, me, 0x42));
|
|
tick(in);
|
|
check(count("add") == 1, "A repeated join was added twice");
|
|
local(Local::force_started, 0x42);
|
|
out = tick(in);
|
|
check(out.size() == 1 && out[0].kind == Kind::start && out[0].order == std::vector<std::uint64_t>{me, other},
|
|
"Force start was not relayed with the queue's players");
|
|
ThrowdownLocalAction entered{Local::entered};
|
|
entered.participants = {2, 0x304};
|
|
throwdown_relay_local(std::move(entered));
|
|
out = tick(in);
|
|
check(out.empty(), "Entering the event sent a second start");
|
|
auto score = make(Kind::score, me, 0x42);
|
|
score.value = 700;
|
|
receive(other, score);
|
|
tick(in);
|
|
const auto *row = find("score");
|
|
check(row && row->id == 0x304 && row->value == 700, "A guest's score did not reach the leader's leaderboard");
|
|
end_session();
|
|
|
|
// Cancelling on the details page closes the drop for everyone.
|
|
tick(in);
|
|
exited = {Local::exited};
|
|
exited.series = "JamSession"; exited.mmid = 0x43; exited.placement = {9};
|
|
throwdown_relay_local(std::move(exited));
|
|
tick(in);
|
|
ThrowdownLocalAction cancel{Local::exited};
|
|
cancel.cancelling = true;
|
|
throwdown_relay_local(std::move(cancel));
|
|
out = tick(in);
|
|
check(out.size() == 1 && out[0].kind == Kind::close && out[0].id == 0x43, "A cancelled drop was not closed");
|
|
// Modes with no relay stay local.
|
|
exited = {Local::exited};
|
|
exited.series = "Race"; exited.mmid = 0x44; exited.placement = {9};
|
|
throwdown_relay_local(std::move(exited));
|
|
check(tick(in).empty(), "A mode with no relay (Race) was offered");
|
|
end_session();
|
|
}
|
|
|
|
// Copies that must go: full, started without us, closed, leader gone, session over.
|
|
void removal_flow() {
|
|
ThrowdownRelayInput in{me, {{leader, 3}, {other, 5}}, true, 79};
|
|
tick(in);
|
|
receive(leader, offer(leader, 7));
|
|
tick(in);
|
|
throwdown_relay_spawned(last_token, 0x70, 2);
|
|
tick(in);
|
|
receive(other, make(Kind::join, leader, 7));
|
|
tick(in);
|
|
check(count("destroy") == 1 && find("destroy")->mmid == 0x70, "A full copy the local player had not joined stayed");
|
|
calls.clear();
|
|
|
|
receive(leader, offer(leader, 8));
|
|
tick(in);
|
|
throwdown_relay_spawned(last_token, 0x80, 4);
|
|
tick(in);
|
|
auto start = make(Kind::start, leader, 8);
|
|
start.order = {leader, other};
|
|
receive(leader, start);
|
|
tick(in);
|
|
check(count("destroy") == 1 && !find("start"), "A copy the local player had not joined was started");
|
|
calls.clear();
|
|
|
|
receive(leader, offer(leader, 9));
|
|
tick(in);
|
|
// Closed while its spawn is outstanding: destroyed once the MMID is known.
|
|
receive(leader, make(Kind::close, leader, 9));
|
|
tick(in);
|
|
check(!find("destroy"), "A copy was destroyed before it existed");
|
|
throwdown_relay_spawned(last_token, 0x90, 4);
|
|
tick(in);
|
|
check(count("destroy") == 1 && find("destroy")->mmid == 0x90, "A copy closed while spawning stayed");
|
|
calls.clear();
|
|
|
|
receive(leader, offer(leader, 10));
|
|
tick(in);
|
|
throwdown_relay_spawned(last_token, 0xa0, 4);
|
|
tick(in);
|
|
in.peers = {{other, 5}}; // the leader left the session
|
|
tick(in);
|
|
check(count("destroy") == 1 && find("destroy")->mmid == 0xa0, "A departed leader's copy stayed");
|
|
calls.clear();
|
|
|
|
in.peers = {{leader, 3}, {other, 5}};
|
|
receive(leader, offer(leader, 11));
|
|
tick(in);
|
|
throwdown_relay_spawned(last_token, 0xb0, 4);
|
|
tick(in);
|
|
// Offers from someone who is not the leader, or from outside the session, are ignored.
|
|
receive(other, offer(leader, 12));
|
|
receive(76561198000000099ULL, offer(76561198000000099ULL, 13));
|
|
tick(in);
|
|
check(count("spawn") == 1, "A forged or foreign offer spawned a copy");
|
|
tick(no_session);
|
|
check(count("destroy") == 1 && find("destroy")->mmid == 0xb0, "Ending the session left a copy in the world");
|
|
check(settings_changed["DingoThrowdowns.QueueStartTimer"] == "1", "The solo queue timer was not restored");
|
|
calls.clear();
|
|
}
|
|
// Spot Battle: one turn order everywhere, both leaderboards and turn ends relayed in order.
|
|
void spot_battle_flow() {
|
|
const ThrowdownRelayInput in{me, {{leader, 3}, {other, 5}}, true, 81};
|
|
tick(in);
|
|
std::vector<std::uint32_t> ids{2, 0x305, 0x303};
|
|
check(!throwdown_relay_order(ids), "Participants were reordered outside a linked queue");
|
|
receive(leader, offer(leader, 0x20, "SpotBattle"));
|
|
tick(in);
|
|
check(find("spawn") && find("spawn")->series == "SpotBattle", "A Spot Battle was not shown to the other players");
|
|
throwdown_relay_spawned(last_token, 0x600, 4);
|
|
receive(other, make(Kind::join, leader, 0x20));
|
|
tick(in);
|
|
local(Local::joined, 0x600);
|
|
tick(in);
|
|
check(throwdown_relay_order(ids) && ids == std::vector<std::uint32_t>{0x303, 2, 0x305},
|
|
"The turn order is not leader first, then by Steam ID");
|
|
std::vector<std::uint32_t> stranger{2, 0x399, 0x303};
|
|
check(throwdown_relay_order(stranger) && stranger == std::vector<std::uint32_t>{0x303, 2, 0x399},
|
|
"An unknown participant did not keep its place after the known players");
|
|
auto start = make(Kind::start, leader, 0x20);
|
|
start.order = {leader, me, other};
|
|
receive(leader, start);
|
|
tick(in);
|
|
ThrowdownLocalAction entered{Local::entered};
|
|
entered.participants = {0x303, 2, 0x305};
|
|
throwdown_relay_local(std::move(entered));
|
|
tick(in);
|
|
|
|
ThrowdownLocalAction mine{Local::turn_started};
|
|
mine.player = 2;
|
|
throwdown_relay_local(std::move(mine));
|
|
tick(in);
|
|
ThrowdownLocalAction row{Local::row};
|
|
row.board = 1; row.add = true; row.score = 150;
|
|
throwdown_relay_local(std::move(row));
|
|
ThrowdownLocalAction someone_else{Local::turn_ended};
|
|
someone_else.player = 0x305; // another player's turn ending here is not ours to send
|
|
throwdown_relay_local(std::move(someone_else));
|
|
ThrowdownLocalAction ended_turn{Local::turn_ended};
|
|
ended_turn.player = 2;
|
|
throwdown_relay_local(std::move(ended_turn));
|
|
ThrowdownLocalAction jam_score{Local::score};
|
|
jam_score.score = 150;
|
|
throwdown_relay_local(std::move(jam_score));
|
|
auto out = tick(in);
|
|
check(out.size() == 2 && out[0].kind == Kind::row && out[0].board == 1 && out[0].add && out[0].value == 150 &&
|
|
out[0].leader == leader && out[0].id == 0x20 && out[1].kind == Kind::turn_end && out[1].value == 1,
|
|
"Own Spot Battle rows and turn end were not relayed (or a Jam score was)");
|
|
|
|
// Held while the leaderboard (and event handle) is not there, then replayed in order.
|
|
leaderboard_live = false;
|
|
calls.clear();
|
|
auto remote_row = make(Kind::row, leader, 0x20);
|
|
remote_row.board = 0; remote_row.add = false; remote_row.value = 400;
|
|
receive(other, remote_row);
|
|
auto their_end = make(Kind::turn_end, leader, 0x20);
|
|
their_end.value = 1;
|
|
receive(other, their_end);
|
|
tick(in);
|
|
check(calls.empty(), "A remote row went out before the leaderboard existed");
|
|
leaderboard_live = true;
|
|
tick(in);
|
|
check(calls.size() == 1 && calls[0].what == "row set", "A remote turn end went out before that turn started here");
|
|
ThrowdownLocalAction theirs{Local::turn_started};
|
|
theirs.player = 0x305;
|
|
throwdown_relay_local(std::move(theirs));
|
|
tick(in);
|
|
check(calls.size() == 2 && calls[0].what == "row set" && calls[0].id == 0x305 && calls[0].mmid == 0 &&
|
|
calls[0].value == 400 && calls[1].what == "end turn" && calls[1].id == 0x305,
|
|
"Remote rows and turn ends were not replayed in order as that player");
|
|
local(Local::ended);
|
|
tick(in);
|
|
check(!throwdown_relay_order(ids), "The shared order outlived the throwdown");
|
|
end_session();
|
|
}
|
|
// S.K.A.T.E.: each attempt replayed once, in the owner's same turn.
|
|
void skate_flow() {
|
|
const ThrowdownRelayInput in{me, {{leader, 3}, {other, 5}}, true, 82};
|
|
tick(in);
|
|
receive(leader, offer(leader, 0x30, "ThrowdownSkate"));
|
|
tick(in);
|
|
check(find("spawn") && find("spawn")->series == "ThrowdownSkate", "A S.K.A.T.E. throwdown was not shown to the other players");
|
|
throwdown_relay_spawned(last_token, 0x700, 4);
|
|
receive(other, make(Kind::join, leader, 0x30));
|
|
tick(in);
|
|
local(Local::joined, 0x700);
|
|
tick(in);
|
|
auto start = make(Kind::start, leader, 0x30);
|
|
start.order = {leader, me, other};
|
|
receive(leader, start);
|
|
tick(in);
|
|
ThrowdownLocalAction entered{Local::entered};
|
|
entered.participants = {0x303, 2, 0x305};
|
|
throwdown_relay_local(std::move(entered));
|
|
tick(in);
|
|
const auto turn_started = [](std::uint32_t id) {
|
|
ThrowdownLocalAction a{Local::turn_started};
|
|
a.player = id;
|
|
throwdown_relay_local(std::move(a));
|
|
};
|
|
const auto remote_attempt = [](std::uint64_t from, int turn, bool landed, std::uint8_t mark) {
|
|
auto m = make(Kind::attempt, leader, 0x30);
|
|
m.value = turn; m.add = landed; m.trick[0] = mark;
|
|
receive(from, m);
|
|
};
|
|
calls.clear();
|
|
|
|
// The leader sets a trick on their first turn.
|
|
turn_started(0x303);
|
|
remote_attempt(leader, 1, true, 0xa1);
|
|
tick(in);
|
|
check(calls.size() == 1 && calls[0].what == "landed" && calls[0].id == 0x303 && calls[0].mmid == 0xa1,
|
|
"The leader's attempt was not replayed as theirs in their turn");
|
|
check(throwdown_relay_hides(other) && !throwdown_relay_hides(leader) && !throwdown_relay_hides(76561198000000099ULL),
|
|
"Only the player who is up should be shown (and nobody outside the throwdown hidden)");
|
|
// Another player's attempt that arrives before their turn starts here waits for it.
|
|
remote_attempt(other, 1, false, 0xb1);
|
|
tick(in);
|
|
check(calls.size() == 1, "An attempt was replayed before its turn started here");
|
|
turn_started(2);
|
|
ThrowdownLocalAction mine{Local::attempt};
|
|
mine.player = 2; mine.add = true; mine.trick[0] = 0xc1;
|
|
throwdown_relay_local(std::move(mine));
|
|
auto out = tick(in);
|
|
check(out.size() == 1 && out[0].kind == Kind::attempt && out[0].value == 1 && out[0].add && out[0].trick[0] == 0xc1,
|
|
"The local attempt was not relayed with its turn number");
|
|
ThrowdownLocalAction skate_end{Local::turn_ended};
|
|
skate_end.player = 2;
|
|
throwdown_relay_local(std::move(skate_end));
|
|
check(tick(in).empty(), "A S.K.A.T.E. turn end was relayed (turns end by themselves after attempts)");
|
|
ThrowdownLocalAction watching{Local::attempt};
|
|
watching.player = 0x305; // a wipeout while someone else is up: the local server rejects it
|
|
throwdown_relay_local(std::move(watching));
|
|
check(tick(in).empty(), "An attempt made outside the local player's turn was relayed");
|
|
turn_started(0x305);
|
|
tick(in);
|
|
check(calls.size() == 2 && calls[1].what == "missed" && calls[1].id == 0x305 && calls[1].mmid == 0xb1,
|
|
"A held attempt did not go once its turn started");
|
|
|
|
// Round 2: out of time on the local turn goes out as a miss.
|
|
turn_started(2);
|
|
ThrowdownLocalAction timeout{Local::timer_failed};
|
|
timeout.player = 2;
|
|
throwdown_relay_local(std::move(timeout));
|
|
out = tick(in);
|
|
check(out.size() == 1 && out[0].kind == Kind::attempt && out[0].value == 2 && !out[0].add,
|
|
"A local timeout was not relayed as a miss");
|
|
// This machine's timer failed the other player's turn first: their late attempt is dropped.
|
|
turn_started(0x305);
|
|
ThrowdownLocalAction local_timer{Local::timer_failed};
|
|
local_timer.player = 0x305;
|
|
throwdown_relay_local(std::move(local_timer));
|
|
tick(in);
|
|
remote_attempt(other, 2, true, 0xb2);
|
|
remote_attempt(leader, 1, true, 0xa9); // for a turn that is over here
|
|
tick(in);
|
|
check(calls.size() == 2, "An attempt was replayed after this machine had already decided that turn");
|
|
// The other player quits: their turns resolve as misses here, without them.
|
|
receive(other, make(Kind::leave, leader, 0x30));
|
|
tick(in);
|
|
turn_started(0x305);
|
|
tick(in);
|
|
check(calls.size() == 3 && calls[2].what == "missed" && calls[2].id == 0x305,
|
|
"A player who quit was not given a miss on their turn");
|
|
throwdown_relay_local({Local::quit});
|
|
out = tick(in);
|
|
check(out.size() == 1 && out[0].kind == Kind::leave, "Quitting was not relayed");
|
|
check(calls.size() == 4 && calls[3].what == "destroy event" && (calls[3].id == 0x303 || calls[3].id == 0x305),
|
|
"The event left behind on the local server was not ended");
|
|
calls.pop_back();
|
|
check(!throwdown_relay_hides(other), "Players stayed hidden after the local player quit");
|
|
turn_started(0x305);
|
|
tick(in);
|
|
check(calls.size() == 3, "Turns were still resolved after the local player quit");
|
|
local(Local::ended);
|
|
tick(in);
|
|
check(!throwdown_relay_hides(other), "Players stayed hidden after the throwdown");
|
|
end_session();
|
|
}
|
|
// A running S.K.A.T.E. throwdown with the leader (and `other`, if `third`), joined by the local player.
|
|
void join_running_skate(const ThrowdownRelayInput &in, bool third) {
|
|
tick(in);
|
|
receive(leader, offer(leader, 0x50, "ThrowdownSkate"));
|
|
tick(in);
|
|
throwdown_relay_spawned(last_token, 0x900, 4);
|
|
if (third) receive(other, make(Kind::join, leader, 0x50));
|
|
tick(in);
|
|
local(Local::joined, 0x900);
|
|
tick(in);
|
|
auto start = make(Kind::start, leader, 0x50);
|
|
start.order = third ? std::vector<std::uint64_t>{leader, me, other} : std::vector<std::uint64_t>{leader, me};
|
|
receive(leader, start);
|
|
tick(in);
|
|
ThrowdownLocalAction entered{Local::entered};
|
|
entered.participants = third ? std::vector<std::uint32_t>{0x303, 2, 0x305} : std::vector<std::uint32_t>{0x303, 2};
|
|
throwdown_relay_local(std::move(entered));
|
|
tick(in);
|
|
take_throwdown_relay_notices();
|
|
calls.clear();
|
|
}
|
|
// Everyone else quits or leaves: the last player's throwdown ends instead of running on alone.
|
|
void alone_flow() {
|
|
const ThrowdownRelayInput two{me, {{leader, 3}}, true, 91};
|
|
join_running_skate(two, false);
|
|
receive(leader, make(Kind::leave, leader, 0x50));
|
|
tick(two);
|
|
check(count("destroy event") == 1 && find("destroy event")->id == 2,
|
|
"The last player's throwdown was not ended when the other player quit");
|
|
const auto notices = take_throwdown_relay_notices();
|
|
check(notices.size() == 1 && notices[0].find("S.K.A.T.E.") != std::string::npos,
|
|
"The last player was not told why the throwdown ended");
|
|
check(!throwdown_relay_hides(leader), "Players stayed hidden after the throwdown ended");
|
|
tick(two);
|
|
check(count("destroy event") == 1, "The throwdown was ended twice");
|
|
end_session();
|
|
|
|
// The other player drops out of the session mid-throwdown.
|
|
join_running_skate(two, false);
|
|
const ThrowdownRelayInput gone{me, {}, true, 91};
|
|
tick(gone);
|
|
check(count("destroy event") == 1 && find("destroy event")->id == 2,
|
|
"The throwdown went on after the only other player left the session");
|
|
end_session();
|
|
|
|
// With three, one leaving the session is a quit: their turn resolves, the others play on.
|
|
const ThrowdownRelayInput three{me, {{leader, 3}, {other, 5}}, true, 91};
|
|
join_running_skate(three, true);
|
|
ThrowdownLocalAction up{Local::turn_started};
|
|
up.player = 0x305;
|
|
throwdown_relay_local(std::move(up));
|
|
const ThrowdownRelayInput left{me, {{leader, 3}}, true, 91};
|
|
tick(left);
|
|
check(count("destroy event") == 0, "A throwdown with two players left was ended");
|
|
check(count("missed") == 1 && find("missed")->id == 0x305,
|
|
"A player who left the session was not given a miss on their turn");
|
|
receive(leader, make(Kind::leave, leader, 0x50));
|
|
tick(left);
|
|
check(count("destroy event") == 1 && find("destroy event")->id == 2,
|
|
"The throwdown was not ended once the last other player quit too");
|
|
end_session();
|
|
}
|
|
// Coop challenges: a start near other players invites them; attempts and leaves are replayed.
|
|
void challenge_flow() {
|
|
// The local player leads: another player 20 m away, a third far off.
|
|
ThrowdownRelayInput in{me, {{leader, 3, "Lead", true, true}, {other, 5, "Far", false, true}}, true, 77};
|
|
tick(in);
|
|
take_throwdown_relay_notices();
|
|
const auto players = throwdown_relay_challenge_players("OTS", "Plot-014-OTS-03");
|
|
check(players && *players == std::vector<std::uint32_t>{0x303}, "A challenge start did not invite the nearby player only");
|
|
ThrowdownLocalAction started{Local::challenge_started};
|
|
started.series = "OTS"; started.challenge = "Plot-014-OTS-03"; started.participants = {2, 0x303};
|
|
throwdown_relay_local(std::move(started));
|
|
auto out = tick(in);
|
|
check(out.size() == 1 && out[0].kind == Kind::challenge_start && out[0].order == std::vector<std::uint64_t>{me, leader} &&
|
|
out[0].challenge == "Plot-014-OTS-03",
|
|
"The coop challenge start was not sent with the shared order");
|
|
const auto key = out[0].id;
|
|
check(take_throwdown_relay_notices().size() == 1, "The leader was not told who joined");
|
|
ThrowdownLocalAction attempt{Local::challenge_attempt};
|
|
attempt.criteria.assign(0x14 * 2, 1); attempt.indexes.assign(8, 0);
|
|
throwdown_relay_local(std::move(attempt));
|
|
out = tick(in);
|
|
check(out.size() == 1 && out[0].kind == Kind::challenge_attempt && out[0].criteria.size() == 0x28,
|
|
"The local attempt was not relayed");
|
|
auto remote = make(Kind::challenge_attempt, me, key);
|
|
remote.criteria.assign(0x14, 2); remote.indexes.assign(4, 0);
|
|
receive(leader, remote);
|
|
auto hit = make(Kind::challenge_slam, me, key); hit.value = 9;
|
|
receive(leader, hit);
|
|
tick(in);
|
|
check(count("challenge attempt") == 1 && find("challenge attempt")->id == 0x303 &&
|
|
count("challenge slam") == 1 && find("challenge slam")->value == 9,
|
|
"A remote attempt or slam hit was not replayed as that player");
|
|
receive(other, remote); // not a participant
|
|
tick(in);
|
|
check(count("challenge attempt") == 1, "A non-participant's attempt was replayed");
|
|
// The celebration: the other player stands at Player2 with the local skater's lift over its spot
|
|
// (a root is not at the feet), and a pose already at spot 0 on their own machine moves over whole.
|
|
{
|
|
ChallengeCelebration party{};
|
|
party.count = 2;
|
|
party.spots[0] = {1, 1, 1, 0, 0, 0, 0, 1, 4.f, -870.f, 77.f, 0};
|
|
party.spots[1] = {1, 1, 1, 0, 0, 0, 0, 1, 5.5f, -870.f, 77.f, 0};
|
|
test_celebration = party;
|
|
auto posed = in;
|
|
posed.position = std::array<float, 3>{4.f, -869.f, 77.f};
|
|
tick(posed);
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(850)); // the local skater gets there first
|
|
tick(posed);
|
|
take_throwdown_relay_notices();
|
|
const auto same = [](float a, float b) { return std::fabs(a - b) < 0.01f; };
|
|
const auto there = throwdown_relay_celebration_offset(leader, {4.2f, -869.f, 77.f});
|
|
check(there && same((*there)[0], 1.5f) && same((*there)[1], 0.f) && same((*there)[2], 0.f),
|
|
"A player in their own celebration was not moved over to their spot whole");
|
|
const auto away = throwdown_relay_celebration_offset(leader, {100.f, 20.f, 30.f});
|
|
check(away && same(100.f + (*away)[0], 5.5f) && same(20.f + (*away)[1], -869.f) && same(30.f + (*away)[2], 77.f),
|
|
"A player elsewhere was not stood at their spot at the local skater's height");
|
|
check(!throwdown_relay_celebration_offset(other, {0.f, 0.f, 0.f}), "A non-participant was placed in the celebration");
|
|
}
|
|
receive(leader, make(Kind::challenge_leave, me, key));
|
|
tick(in);
|
|
check(count("challenge leave") == 1 && find("challenge leave")->id == 0x303, "A remote quit did not let that player go");
|
|
throwdown_relay_local({Local::challenge_ended});
|
|
tick(in);
|
|
calls.clear();
|
|
|
|
// Invites off: a start stays the local player's own.
|
|
set_challenge_invites(false);
|
|
tick(in);
|
|
check(!throwdown_relay_challenge_players("OTS", "Plot-2"), "A start invited players with invites off");
|
|
set_challenge_invites(true);
|
|
|
|
// Outside the party: a nearby player is not invited, and their challenge doesn't pull us in.
|
|
const ThrowdownRelayInput strangers{me, {{leader, 3, "Lead", true, false}, {other, 5, "Far", false, true}}, true, 77};
|
|
tick(strangers);
|
|
check(!throwdown_relay_challenge_players("OTS", "Plot-2"), "A start invited a nearby player outside the party");
|
|
auto foreign = make(Kind::challenge_start, leader, 20);
|
|
foreign.series = "Session"; foreign.challenge = "Plot-020-Session-01"; foreign.order = {leader, me};
|
|
receive(leader, foreign);
|
|
out = tick(strangers);
|
|
check(out.size() == 1 && out[0].kind == Kind::challenge_optout && count("challenge start") == 0,
|
|
"A challenge from outside the party pulled the player in");
|
|
tick(in);
|
|
|
|
// A guest: the leader's start runs the same challenge here with the leader as a virtual player.
|
|
auto start = make(Kind::challenge_start, leader, 11);
|
|
start.series = "Session"; start.challenge = "Plot-020-Session-01"; start.order = {leader, me};
|
|
receive(leader, start);
|
|
tick(in);
|
|
check(count("challenge start") == 1 && find("challenge start")->series == "Session Plot-020-Session-01",
|
|
"A guest did not start its copy of the leader's challenge");
|
|
const auto guest_players = throwdown_relay_challenge_players("Session", "Plot-020-Session-01");
|
|
check(guest_players && *guest_players == std::vector<std::uint32_t>{0x303}, "The guest copy did not get the leader");
|
|
auto early = make(Kind::challenge_attempt, leader, 11);
|
|
early.criteria.assign(0x14, 3); early.indexes.assign(4, 0);
|
|
receive(leader, early);
|
|
tick(in);
|
|
check(count("challenge attempt") == 0, "An attempt was replayed before the local copy started");
|
|
ThrowdownLocalAction guest_started{Local::challenge_started};
|
|
guest_started.series = "Session"; guest_started.challenge = "Plot-020-Session-01"; guest_started.participants = {2, 0x303};
|
|
throwdown_relay_local(std::move(guest_started));
|
|
tick(in);
|
|
check(count("challenge attempt") == 1 && find("challenge attempt")->id == 0x303,
|
|
"An attempt that arrived before the copy started was not replayed once it did");
|
|
// The leader drops out of the session: the copy lets them go.
|
|
const ThrowdownRelayInput alone{me, {{other, 5, "Far", false, true}}, true, 77};
|
|
tick(alone);
|
|
check(count("challenge leave") == 1 && find("challenge leave")->id == 0x303, "A departed player stayed in the copy");
|
|
throwdown_relay_local({Local::challenge_ended});
|
|
tick(alone);
|
|
take_throwdown_relay_notices();
|
|
|
|
// Flagged for game speed mid-challenge: the local player leaves their copy.
|
|
{
|
|
auto again = make(Kind::challenge_start, leader, 13);
|
|
again.series = "Session"; again.challenge = "Plot-020-Session-01"; again.order = {leader, me};
|
|
receive(leader, again);
|
|
tick(in);
|
|
ThrowdownLocalAction flagged_start{Local::challenge_started};
|
|
flagged_start.series = "Session"; flagged_start.challenge = "Plot-020-Session-01"; flagged_start.participants = {2, 0x303};
|
|
throwdown_relay_local(std::move(flagged_start));
|
|
tick(in);
|
|
calls.clear();
|
|
ThrowdownRelayInput barred{me, {}, true, 77};
|
|
barred.barred = true;
|
|
tick(barred);
|
|
check(count("challenge leave") == 1 && find("challenge leave")->id == 2,
|
|
"A player flagged for game speed stayed in the coop challenge");
|
|
take_throwdown_relay_notices();
|
|
tick(in);
|
|
}
|
|
|
|
// Busy (another challenge running here): the invite is declined.
|
|
receive(other, start); // not the leader
|
|
auto busy = start; busy.id = 12;
|
|
set_challenge_invites(false);
|
|
receive(leader, busy);
|
|
out = tick(in);
|
|
check(out.size() == 1 && out[0].kind == Kind::challenge_optout, "A declined invite did not opt out");
|
|
set_challenge_invites(true);
|
|
end_session();
|
|
}
|
|
// Party beacons: the local one is relayed; another player's is shown here as theirs and goes
|
|
// when they remove it or leave.
|
|
void beacon_flow() {
|
|
const ThrowdownRelayInput in{me, {{leader, 3, "Lead", true, true}, {other, 5, "Far", false, false}}, true, 90};
|
|
tick(in);
|
|
calls.clear();
|
|
ThrowdownLocalAction placed{Local::beacon_placed};
|
|
placed.location[12] = 10; placed.location[14] = 5;
|
|
throwdown_relay_local(placed);
|
|
auto out = tick(in);
|
|
check(out.size() == 1 && out[0].kind == Kind::beacon && out[0].add && out[0].location[12] == 10,
|
|
"The local beacon was not relayed");
|
|
const auto revision = out[0].id;
|
|
// A second first placement while one stands is refused natively: nothing moves.
|
|
placed.location[12] = 50;
|
|
throwdown_relay_local(placed);
|
|
check(tick(in).empty(), "A refused second placement was relayed");
|
|
placed.add = true; // the move the player agreed to
|
|
throwdown_relay_local(placed);
|
|
out = tick(in);
|
|
check(out.size() == 1 && out[0].location[12] == 50 && out[0].id != revision, "A moved beacon was not relayed");
|
|
|
|
auto theirs = make(Kind::beacon, leader, 1);
|
|
theirs.add = true; theirs.location[12] = 7;
|
|
receive(leader, theirs);
|
|
tick(in);
|
|
check(count("beacon move") == 1 && find("beacon move")->id == 0x303 && find("beacon move")->value == 7,
|
|
"Another player's beacon was not shown as theirs");
|
|
receive(leader, theirs); // the periodic resend
|
|
tick(in);
|
|
check(count("beacon move") == 1, "A resent beacon was shown again");
|
|
theirs.id = 2; theirs.location[12] = 8;
|
|
receive(leader, theirs);
|
|
tick(in);
|
|
check(count("beacon move") == 2 && calls.back().value == 8, "A moved beacon was not moved here");
|
|
auto spoof = theirs; spoof.leader = other;
|
|
receive(leader, spoof);
|
|
tick(in);
|
|
check(count("beacon move") == 2, "A beacon was shown for someone other than its sender");
|
|
auto removed = make(Kind::beacon, leader, 3);
|
|
receive(leader, removed);
|
|
tick(in);
|
|
check(count("beacon remove") == 1 && find("beacon remove")->id == 0x303, "A removed beacon stayed");
|
|
// Leaving the session takes it too.
|
|
theirs.id = 4;
|
|
receive(leader, theirs);
|
|
tick(in);
|
|
const ThrowdownRelayInput gone{me, {{other, 5, "Far", false, false}}, true, 90};
|
|
tick(gone);
|
|
check(count("beacon remove") == 2, "A departed player's beacon stayed");
|
|
// The local beacon removed.
|
|
throwdown_relay_local({Local::beacon_removed});
|
|
out = tick(gone);
|
|
check(out.size() == 1 && out[0].kind == Kind::beacon && !out[0].add, "The local beacon's removal was not relayed");
|
|
end_session();
|
|
}
|
|
// A level change takes the event types with it. The relay asks for them again instead of
|
|
// remembering that it once had them, and shows a drop only when they are back.
|
|
// A plugin's drop for chosen players: spawned where the skater stands, shown only to them (an invite
|
|
// before each offer), joined by them alone, and started once they all are in.
|
|
void plugin_host_flow() {
|
|
ThrowdownRelayInput in{me, {{leader, 3}, {other, 5}}, true, 81};
|
|
in.position = std::array<float, 3>{12, 0, 0};
|
|
tick(in);
|
|
calls.clear();
|
|
check(throwdown_relay_host("Bowling", {leader}, 0).find("JamSession") != std::string::npos, "A mode that is not linked was taken");
|
|
check(throwdown_relay_host("JamSession", {leader}, 0).empty(), "A plugin's drop was refused");
|
|
tick(in);
|
|
const auto *host = find("host");
|
|
check(host && host->series == "JamSession" && host->value == 12 && host->id == 2, "The drop was not spawned where the skater stands");
|
|
check(throwdown_relay_host_status() == "spawning", "The drop's status did not say it is spawning");
|
|
throwdown_relay_host_placement(last_token, {9, 8, 7});
|
|
throwdown_relay_spawned(last_token, 0x77, 4);
|
|
auto out = tick(in);
|
|
check(out.size() == 2 && out[0].kind == Kind::invite && out[0].order == std::vector<std::uint64_t>{leader} && out[1].kind == Kind::offer &&
|
|
out[1].placement == std::vector<std::uint8_t>{9, 8, 7} && out[1].id == 0x77,
|
|
"The drop was not offered after an invite naming its players");
|
|
check(throwdown_relay_host_status() == "open", "The drop's status did not say it is open");
|
|
receive(other, make(Kind::join, me, 0x77));
|
|
tick(in);
|
|
check(!find("add"), "A player who was not invited joined the drop");
|
|
receive(leader, make(Kind::join, me, 0x77));
|
|
tick(in);
|
|
const auto *add = find("add");
|
|
check(add && add->id == 0x303 && add->mmid == 0x77, "An invited player's join was not replayed into the drop");
|
|
tick(in);
|
|
const auto *started = find("start");
|
|
check(started && started->mmid == 0x77 && throwdown_relay_host_status() == "started", "The drop did not start once everyone invited joined");
|
|
end_session();
|
|
// Another leader's drop for someone else: nothing shows it here.
|
|
ThrowdownRelayInput guest{me, {{leader, 3}, {other, 5}}, true, 82};
|
|
tick(guest);
|
|
calls.clear();
|
|
auto invite = make(Kind::invite, leader, 0x99);
|
|
invite.order = {other};
|
|
receive(leader, invite);
|
|
receive(leader, offer(leader, 0x99));
|
|
tick(guest);
|
|
check(!find("spawn"), "A drop for other players was shown here");
|
|
invite.id = 0x9a;
|
|
invite.order = {me};
|
|
receive(leader, invite);
|
|
receive(leader, offer(leader, 0x9a));
|
|
tick(guest);
|
|
check(find("spawn") != nullptr, "A drop this player was invited to was not shown");
|
|
end_session();
|
|
}
|
|
|
|
// An older client's plugin mode flag (a Kind::mode tag before a mode_series offer): ignored, even
|
|
// where the plugin has that mode; the kit's flags travel as flag messages (custom_flag_flow).
|
|
void legacy_mode_tags_are_ignored() {
|
|
using namespace dingosdk::multiplayer::custom_throwdowns;
|
|
Mode mode;
|
|
mode.plugin = "demo"; mode.id = "duel"; mode.name = "Duel"; mode.flag = "duel"; mode.players = 3;
|
|
check(set_mode(mode).empty(), "The test mode registers");
|
|
calls.clear();
|
|
const ThrowdownRelayInput in{me, {{leader, 3}}, true, 82};
|
|
tick(in);
|
|
auto tag = make(Kind::mode, leader, 0x71);
|
|
tag.challenge = "demo/duel"; tag.series = "duel"; tag.value = 3;
|
|
receive(leader, tag);
|
|
receive(leader, offer(leader, 0x71, "ReSkateMode"));
|
|
const auto out = tick(in);
|
|
check(!find("spawn") && out.empty(), "An older client's mode flag was shown or answered");
|
|
end_session();
|
|
remove_plugin("demo");
|
|
}
|
|
|
|
void level_change_flow() {
|
|
using namespace std::chrono_literals;
|
|
const ThrowdownRelayInput in{me, {{leader, 3}}, true, 77};
|
|
tick(in);
|
|
types_ready = false;
|
|
std::this_thread::sleep_for(300ms); // past the relay's quarter-second re-check
|
|
calls.clear();
|
|
receive(leader, offer(leader, 0x4321));
|
|
tick(in);
|
|
check(count("prepare") == 1 && !find("spawn"), "A drop was shown while the level's event types were not found");
|
|
types_ready = true;
|
|
std::this_thread::sleep_for(300ms);
|
|
tick(in);
|
|
check(count("spawn") == 1, "The drop was not shown once the event types were found again");
|
|
end_session();
|
|
}
|
|
dingosdk::throwdowns::kit::Flag kit_flag(std::uint64_t owner,std::uint32_t game,float x,float yaw,const char* mode="demo/lap") {
|
|
dingosdk::throwdowns::kit::Flag f;
|
|
f.id="flag-"+std::to_string(game);f.mode=mode;f.leader=owner;f.game=game;f.at={x,2,30};f.yaw=yaw;f.players=1;f.capacity=4;
|
|
return f;
|
|
}
|
|
void publish_flags(std::vector<dingosdk::throwdowns::kit::Flag> flags) {
|
|
kit_documents.clear();
|
|
if(flags.empty())return;
|
|
dingosdk::throwdowns::kit::Documents d;d.plugin="demo";d.flags=std::move(flags);
|
|
kit_documents.push_back(std::move(d));
|
|
}
|
|
// Other players' plugin mode flags (kit documents' `flags`, or the leader's relay): native
|
|
// registrations hosted by the leader's virtual id, joined with the stock Join.
|
|
void custom_flag_flow() {
|
|
using namespace dingosdk::multiplayer::custom_throwdowns;
|
|
Mode mode;
|
|
mode.plugin="demo";mode.id="lap";mode.name="Lap";mode.flag="lap";
|
|
check(set_mode(mode).empty(),"The flag test mode registers");
|
|
const ThrowdownRelayInput in{me,{{leader,3,"Host"},{other,5,"Other"}},true,777};
|
|
publish_flags({kit_flag(leader,23,100,90)});
|
|
tick(in);
|
|
check(count("custom flag")==1 && find("custom flag")->id==0x303 && find("custom flag")->series=="demo/lap" &&
|
|
find("custom flag")->mmid==100 && find("custom flag")->value==90,"a plugin's flag spawns a native registration at its spot, hosted by its remote player");
|
|
const auto first=last_token;
|
|
tick(in);check(count("custom flag")==1,"repeat documents do not duplicate a physical flag");
|
|
throwdown_relay_spawned(first,501,10);tick(in);
|
|
check(custom_throwdown_flag_key(501)=="demo/lap","native POI/bubble resolves its own queue's mode");
|
|
check(custom_throwdown_flag_available(leader,23) && !custom_throwdown_flag_available(other,23) && !custom_throwdown_flag_available(leader,24),
|
|
"only the exact advertised native flag takes over the A interaction");
|
|
kit_documents.front().flags.front().players=2;kit_documents.front().flags.front().stats={{"Gates","5"}};
|
|
tick(in);
|
|
const auto info=custom_throwdown_flag_info(501);
|
|
check(info && info->players==2 && info->stats.size()==1 && info->stats.front().second=="5" && info->id=="flag-23" &&
|
|
count("custom flag")==1 && count("destroy")==0,"updated players/stats do not respawn or flash the flag");
|
|
local(Local::joined,501);tick(in);
|
|
check(adopted.size()==1 && adopted.back().mmid==501 && adopted.back().game==23 && adopted.back().mode=="demo/lap" &&
|
|
adopted.back().capacity==4 && count("start")==0 && count("destroy")==0,
|
|
"native Join hands the registration to the flag session without starting or destroying a Spot Battle");
|
|
check(!kit_events.empty() && kit_events.back().plugin=="demo" && kit_events.back().action=="join" &&
|
|
kit_events.back().mode=="demo/lap" &&
|
|
kit_events.back().args==std::vector<std::string>{"flag-23",std::to_string(leader),"23"},"the mode's plugin hears join {flag id, leader, game}");
|
|
check(custom_throwdown_flag_key(501).empty(),"the flag session takes over the joined flag's lifetime");
|
|
|
|
calls.clear();publish_flags({kit_flag(leader,24,101,45)});tick(in);
|
|
const auto pending=last_token;publish_flags({});tick(in);
|
|
throwdown_relay_spawned(pending,502,10);tick(in);
|
|
check(count("destroy")==1 && find("destroy")->mmid==502 && custom_throwdown_flag_key(502).empty(),"withdrawal while spawning destroys the late flag instead of reviving a stale invitation");
|
|
|
|
calls.clear();publish_flags({kit_flag(leader,25,102,30)});tick(in);
|
|
throwdown_relay_spawned(last_token,503,10);tick(in);
|
|
accept_adopt=false;local(Local::joined,503);tick(in);
|
|
check(count("remove")==1 && find("remove")->id==2 && count("start")==0,"a refused native Join removes only the local donor participant");
|
|
accept_adopt=true;publish_flags({});tick(in);
|
|
check(count("destroy")==1 && find("destroy")->mmid==503,"withdrawn open flags are removed");
|
|
|
|
calls.clear();publish_flags({kit_flag(leader,26,103,20)});tick(in);
|
|
const auto same_tick=last_token;
|
|
throwdown_relay_spawned(same_tick,504,10);local(Local::joined,504);tick(in);
|
|
check(adopted.back().mmid==504 && count("destroy")==0,"native Join in the spawn callback's tick resolves the correct flag");
|
|
|
|
calls.clear();publish_flags({kit_flag(leader,27,104,0,"elsewhere/lap")});tick(in);
|
|
check(count("custom flag")==0,"a flag of a mode no plugin here registered is not shown");
|
|
auto closed=kit_flag(leader,28,105,0);closed.open=false;publish_flags({closed});tick(in);
|
|
check(count("custom flag")==0,"a closed flag is not shown");
|
|
publish_flags({});end_session();
|
|
|
|
// The local plugin's own flag goes to the other players; theirs come back the same way.
|
|
{
|
|
const ThrowdownRelayInput host{me,{{other,4}},true,778};
|
|
tick(host);
|
|
auto own=kit_flag(0,31,106,15);own.stats={{"Laps","3"}};
|
|
publish_flags({own});
|
|
auto out=tick(host);
|
|
const auto sent=std::find_if(out.begin(),out.end(),[](const auto& m){return m.kind==Kind::flag;});
|
|
check(sent!=out.end() && sent->leader==me && sent->id==31 && sent->challenge=="demo/lap","the local plugin's flag was not sent to the other players");
|
|
const auto body=decode_flag_body(sent->placement);
|
|
check(body && body->id=="flag-31" && body->at[0]==106 && body->yaw==15 && body->capacity==4 && body->stats.size()==1 &&
|
|
!body->withdrawn,"the sent flag lost its card");
|
|
out=tick(host);
|
|
check(std::none_of(out.begin(),out.end(),[](const auto& m){return m.kind==Kind::flag;}),"an unchanged flag was resent at once");
|
|
publish_flags({});out=tick(host);
|
|
const auto gone=std::find_if(out.begin(),out.end(),[](const auto& m){return m.kind==Kind::flag;});
|
|
check(gone!=out.end() && gone->id==31 && decode_flag_body(gone->placement)->withdrawn,"a taken-down flag was not withdrawn");
|
|
end_session();
|
|
|
|
const ThrowdownRelayInput guest{me,{{leader,3}},true,779};
|
|
tick(guest);
|
|
auto remote=make(Kind::flag,leader,41);remote.challenge="demo/lap";
|
|
ThrowdownFlagBody flag;flag.id="remote";flag.at={107,2,30};flag.yaw=5;flag.capacity=6;
|
|
remote.placement=encode_flag_body(flag);
|
|
receive(leader,remote);tick(guest);
|
|
check(count("custom flag")==1 && find("custom flag")->mmid==107 && find("custom flag")->id==0x303,"a flag another player's relay sent was not shown");
|
|
throwdown_relay_spawned(last_token,505,10);tick(guest);
|
|
check(custom_throwdown_flag_info(505) && custom_throwdown_flag_info(505)->id=="remote","the sent flag's id did not reach the native card");
|
|
flag.withdrawn=true;remote.placement=encode_flag_body(flag);
|
|
receive(leader,remote);tick(guest);
|
|
check(count("destroy")==1 && find("destroy")->mmid==505,"a withdrawn flag stayed up");
|
|
end_session();
|
|
}
|
|
remove_plugin("demo");
|
|
kit_events.clear();adopted.clear();
|
|
}
|
|
} // namespace
|
|
|
|
// A score read from text: what atoi reads, and digit groups read whole.
|
|
void grouped_number_checks() {
|
|
using dingosdk::throwdowns_text::parse_grouped_int;
|
|
const auto same = [](const char *text, int value, bool grouped) {
|
|
const auto parsed = parse_grouped_int(text);
|
|
return parsed.value == value && parsed.grouped == grouped;
|
|
};
|
|
if (!(same("5432", 5432, false) && same("0", 0, false) && same(" -17", -17, false) && same("+9", 9, false) &&
|
|
same("", 0, false) && same("abc", 0, false) && same("12abc", 12, false) && same(nullptr, 0, false)))
|
|
throw std::runtime_error("A plain number was not read as atoi reads it");
|
|
if (!(same("5,432", 5432, true) && same("1,250", 1250, true) && same("12,345,678", 12345678, true) &&
|
|
same("5.432", 5432, true) && same("5 432", 5432, true) && same("5'432", 5432, true) &&
|
|
same("5\xC2\xA0" "432", 5432, true) && same("5\xE2\x80\xAF" "432", 5432, true) && same("-1,000 pts", -1000, true)))
|
|
throw std::runtime_error("A grouped score was cut short");
|
|
if (!(same("12.5", 12, false) && same("5,43", 5, false) && same("5,4321", 5, false) && same("1234,567", 1234, false) &&
|
|
same("5, 432", 5, false) && same("5,,432", 5, false) && same("1,234.5", 1234, true)))
|
|
throw std::runtime_error("Text that is not a grouped number was read as one");
|
|
if (!(same("99999999999", INT_MAX, false) && same("-99999999999", INT_MIN, false) && same("999,999,999,999", INT_MAX, true)))
|
|
throw std::runtime_error("A number out of range did not saturate");
|
|
}
|
|
int main() {
|
|
try {
|
|
native_queue_banner_lifetime();
|
|
guest_flow();
|
|
private_draft_relay_flow();
|
|
leader_flow();
|
|
removal_flow();
|
|
spot_battle_flow();
|
|
grouped_number_checks();
|
|
skate_flow();
|
|
alone_flow();
|
|
challenge_flow();
|
|
beacon_flow();
|
|
level_change_flow();
|
|
plugin_host_flow();
|
|
legacy_mode_tags_are_ignored();
|
|
custom_flag_flow();
|
|
std::cout << "Throwdown relay: guest, leader, removal, Spot Battle, S.K.A.T.E., last-player, coop challenge, party beacon, level change, plugin drop, legacy mode tag and custom flag flows passed.\n";
|
|
return 0;
|
|
} catch (const std::exception &e) {
|
|
std::cerr << e.what() << '\n';
|
|
return 1;
|
|
}
|
|
}
|