mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 22:07:05 +02:00
A Jam Session takes the score of the line just landed from the score HUD's text, with the script native ParseInt, which is the C library's atoi. atoi stops at the first character that is not a digit, so a line shown as 5,432 was read as 5 and added 5 to the player's total. Reported for Jam Sessions and Spot Battles: lines of 5,000 and more adding one or two points. ParseInt now reads groups of three digits whole, set apart by a comma, a full stop, an apostrophe or a space: "5,432" is 5432. Text that is not a grouped number is read exactly as before. The first few grouped scores are logged, to confirm the cause in play.
823 lines
39 KiB
C++
823 lines
39 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/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 <Windows.h>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <iostream>
|
|
#include <thread>
|
|
#include <map>
|
|
#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{};
|
|
}
|
|
|
|
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; }
|
|
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> &) noexcept {
|
|
last_token = token;
|
|
calls.push_back({"spawn", series, host, 0, 0, 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();
|
|
}
|
|
|
|
// 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 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.
|
|
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();
|
|
}
|
|
} // 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 {
|
|
guest_flow();
|
|
leader_flow();
|
|
removal_flow();
|
|
spot_battle_flow();
|
|
grouped_number_checks();
|
|
skate_flow();
|
|
alone_flow();
|
|
challenge_flow();
|
|
beacon_flow();
|
|
level_change_flow();
|
|
std::cout << "Throwdown relay: guest, leader, removal, Spot Battle, S.K.A.T.E., last-player, coop challenge, party beacon and level change flows passed.\n";
|
|
return 0;
|
|
} catch (const std::exception &e) {
|
|
std::cerr << e.what() << '\n';
|
|
return 1;
|
|
}
|
|
}
|