mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-23 18:45:57 +02:00
Plugins: the reskate-test and reskate-hostile plugins, and trick reading and a facing teleport in the core
- contrib/plugins/reskate-test calls every plugin API and says what worked: ptest all, ptest <module>, ptest list. Its 45 checks cover the base set, timers, the level, package files, JSON, require, the console, storage and the sandbox. It is the example to copy for a plugin. - contrib/plugins/reskate-hostile attacks the sandbox, one attack per command (hostile list): loops, memory and buffer bombs, caught errors, error and log floods, timers, recursion, an unload that never ends, and 17 ways out. Each must end stopped or refused. - dingosdk_plugin_tests runs both from the repository: every reskate-test check passes, and every hostile attack is stopped or refused with nothing escaping. - Taken from Domination (PR 279) for the plugin API to use: the trick watch (when a trick starts, lands and what it scored, or a slam breaks it; Domination's spots left out) in Extension/Skater/trick_watch.h with its tests, the score HUD reader in score_hud.h, and teleport_local_skater_heading, whose hunks are the PR's own. Neither is wired to anything yet.
This commit is contained in:
1 parent
6d542536e1
commit
a3e75ba10e
34 files changed
+978
-1
No files matched your search
@@ -412,7 +412,13 @@ if(DINGOSDK_BUILD_PLUGIN_TESTS AND WIN32)
|
||||
add_executable(dingosdk_plugin_tests Extension/Plugins/Test/plugin_tests.cpp)
|
||||
target_include_directories(dingosdk_plugin_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
target_link_libraries(dingosdk_plugin_tests PRIVATE dingosdk_plugins)
|
||||
# It also runs the test plugins in contrib/plugins.
|
||||
target_compile_definitions(dingosdk_plugin_tests PRIVATE "RESKATE_SOURCE_DIR=\"${PROJECT_SOURCE_DIR}\"")
|
||||
add_test(NAME plugins COMMAND dingosdk_plugin_tests)
|
||||
# Tricks read from the score HUD's values (Extension/Skater/trick_watch.h), what plugins hear.
|
||||
add_executable(dingosdk_trick_watch_tests Extension/Skater/Test/trick_watch_tests.cpp Extension/Skater/trick_watch.cpp)
|
||||
target_include_directories(dingosdk_trick_watch_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
add_test(NAME trick_watch COMMAND dingosdk_trick_watch_tests)
|
||||
endif()
|
||||
get_property(project_targets DIRECTORY PROPERTY BUILDSYSTEM_TARGETS)
|
||||
foreach(target IN LISTS project_targets)
|
||||
|
||||
@@ -631,9 +631,14 @@ void update_position_teleport() {
|
||||
!read(local_runtime().base + addr::local_placements::teleport_manager, manager) || !manager ||
|
||||
!read(manager, state) || state != 0) return;
|
||||
const auto at = *r.position_teleport;
|
||||
const auto heading = r.position_teleport_heading; // set only by teleport_local_skater_heading
|
||||
r.position_teleport.reset();
|
||||
r.position_teleport_heading.reset();
|
||||
// Upright at the spot. The native teleport manager handles streaming, ground checks and physics reset.
|
||||
alignas(16) const std::array<float, 16> transform{1,0,0,0, 0,1,0,0, 0,0,1,0, at[0], at[1], at[2], 1};
|
||||
// With a heading h the root's rows are right (cos h, 0, -sin h), up, forward (sin h, 0, cos h):
|
||||
// h = 0 is the identity, facing +Z (measured: the skater faces h within 0.002 degrees).
|
||||
const float sh = heading ? std::sin(*heading) : 0.0f, ch = heading ? std::cos(*heading) : 1.0f;
|
||||
alignas(16) const std::array<float, 16> transform{ch,0,-sh,0, 0,1,0,0, sh,0,ch,0, at[0], at[1], at[2], 1};
|
||||
alignas(16) std::array<std::uint64_t, 2> transition{};
|
||||
r.transition_ctor(transition.data());
|
||||
struct Destroy { decltype(r.transition_destroy) call; void* value; ~Destroy() { call(value); } } destroy{r.transition_destroy, transition.data()};
|
||||
|
||||
@@ -78,6 +78,9 @@ struct PlacementsRuntime {
|
||||
// A teleport of the local skater to a position (console tp, the host's tpall).
|
||||
std::optional<std::array<float, 3>> position_teleport;
|
||||
std::uint64_t position_teleport_at{};
|
||||
// The heading the pending position teleport faces (teleport_local_skater_heading), radians about
|
||||
// world up; unset means the identity rotation the other teleports use (facing +Z).
|
||||
std::optional<float> position_teleport_heading;
|
||||
std::optional<profile::PlacedObject> inflight;
|
||||
std::uint64_t sent_at{}, next_poll{}, map_since{}, map_generation{}, next_id{1};
|
||||
bool failed{}, restore_failed{};
|
||||
|
||||
@@ -438,6 +438,62 @@ void discovery_finds_the_plugins_among_the_mods_left_on() {
|
||||
std::sort(again.begin(), again.end(), [](const auto& a, const auto& b) { return a.id < b.id; });
|
||||
check(again[0].fingerprint != before, "A changed file changes the fingerprint, which reloads it");
|
||||
}
|
||||
|
||||
// contrib/plugins, as they are in the repository.
|
||||
Candidate contributed(const std::string& id) {
|
||||
const auto directory = std::filesystem::path(RESKATE_SOURCE_DIR) / "contrib" / "plugins" / id;
|
||||
return {id, directory, "1", check_package_files(directory)};
|
||||
}
|
||||
std::string printed_lines(const TestEnvironment& environment) {
|
||||
std::string text;
|
||||
for (const auto& line : environment.lines) text += line + "\n";
|
||||
return text;
|
||||
}
|
||||
|
||||
void reskate_test_passes_its_own_checks() {
|
||||
TestEnvironment environment;
|
||||
std::filesystem::remove_all(scratch / "storage" / "reskate-test");
|
||||
Host host(environment, Side::client);
|
||||
host.apply({contributed("reskate-test")});
|
||||
running(host, "reskate-test", environment);
|
||||
ticks(host, environment, 5);
|
||||
check(host.run_console("ptest all"), "ptest is its command");
|
||||
ticks(host, environment, 120); // its later checks run on timers
|
||||
const auto output = printed_lines(environment);
|
||||
check(output.find("ptest: 45 passed, 0 failed") != std::string::npos &&
|
||||
output.find("FAIL") == std::string::npos, "Every reskate-test check passes:\n" + output + environment.all());
|
||||
check(host.run_console("ptest echo a b") && environment.printed("ptest echo: a b"), "and its echo command answers");
|
||||
running(host, "reskate-test", environment);
|
||||
}
|
||||
|
||||
void reskate_hostile_is_stopped_or_refused_every_time() {
|
||||
TestEnvironment environment;
|
||||
Host host(environment, Side::client);
|
||||
host.apply({contributed("reskate-hostile")});
|
||||
running(host, "reskate-hostile", environment);
|
||||
struct Case { const char* attack; bool stops; int ticks; };
|
||||
const Case cases[] = {
|
||||
{"loop", true, 0}, {"caught-loop", true, 0}, {"tick-loop", true, 1}, {"memory", true, 0},
|
||||
{"caught-memory", true, 0}, {"huge-string", true, 0}, {"buffer", true, 0}, {"coroutines", true, 0},
|
||||
{"errors", true, 10}, {"slow", true, 60 * 5}, {"log-flood", false, 1}, {"timers", false, 1},
|
||||
{"recursion", false, 1}, {"escapes", false, 1},
|
||||
};
|
||||
for (const auto& item : cases) {
|
||||
check(host.reload("reskate-hostile").find("reloaded") != std::string::npos, std::string("reloads before ") + item.attack);
|
||||
check(host.run_console(std::string("hostile ") + item.attack), item.attack);
|
||||
ticks(host, environment, item.ticks);
|
||||
const auto state = status_of(host, "reskate-hostile").state;
|
||||
check((state == "stopped") == item.stops, std::string(item.attack) + " left it " + state + "\n" + environment.all());
|
||||
}
|
||||
check(!environment.printed("ESCAPED"), "No attack got out:\n" + printed_lines(environment));
|
||||
check(environment.printed("hostile escapes: 17 of 17 refused"), "and every escape was tried\n" + printed_lines(environment));
|
||||
host.reload("reskate-hostile");
|
||||
host.run_console("hostile slow-unload");
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
host.switch_off("reskate-hostile");
|
||||
const auto ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
|
||||
check(ms < 500, "An unload handler that never ends is cut off: " + std::to_string(ms) + " ms");
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
@@ -458,6 +514,8 @@ int main() {
|
||||
timers_run_when_due();
|
||||
a_package_with_a_program_or_on_the_wrong_side_is_not_loaded();
|
||||
discovery_finds_the_plugins_among_the_mods_left_on();
|
||||
reskate_test_passes_its_own_checks();
|
||||
reskate_hostile_is_stopped_or_refused_every_time();
|
||||
std::filesystem::remove_all(scratch);
|
||||
std::cout << "plugin tests passed\n";
|
||||
return 0;
|
||||
|
||||
@@ -56,6 +56,19 @@ bool teleport_local_skater(const std::array<float, 3>& position) {
|
||||
for (const auto v : position)
|
||||
if (!std::isfinite(v) || std::abs(v) > 1e6f) return false;
|
||||
r.position_teleport = position;
|
||||
r.position_teleport_heading.reset(); // the identity rotation
|
||||
r.position_teleport_at = GetTickCount64();
|
||||
return true;
|
||||
}
|
||||
bool teleport_local_skater_heading(const std::array<float, 3>& position, float heading) {
|
||||
std::lock_guard lock(local_runtime().native_mutex);
|
||||
auto& r = placements_runtime();
|
||||
if (!local_runtime().active || !r.teleport || !r.transition_ctor || !r.transition_destroy) return false;
|
||||
for (const auto v : position)
|
||||
if (!std::isfinite(v) || std::abs(v) > 1e6f) return false;
|
||||
if (!std::isfinite(heading)) return false;
|
||||
r.position_teleport = position;
|
||||
r.position_teleport_heading = heading;
|
||||
r.position_teleport_at = GetTickCount64();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -85,6 +85,8 @@ bool teleport_to_local_placed_object(std::string_view map, std::uint64_t token);
|
||||
// Teleports the local skater to a world position (sent within 5 s, when the skater can be
|
||||
// moved). False when the game's teleport is unavailable.
|
||||
bool teleport_local_skater(const std::array<float, 3>& position);
|
||||
// The same, facing `heading` (radians about world up; 0 = +Z, the way the other teleports face).
|
||||
bool teleport_local_skater_heading(const std::array<float, 3>& position, float heading);
|
||||
// The highest collision surface straight down at (x, z) between world heights `top` and
|
||||
// `bottom`, from the client physics world (the park editor's native ray). Empty when nothing
|
||||
// is there yet (collision still streaming) or the query is unavailable. Client update thread.
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// Reading tricks from the score HUD's values, apart from the game: a trick's start, its points
|
||||
// once it lands, a slam, and a trick whose points never come. Moved with the trick watch from
|
||||
// Domination (PR 279), which tests its spots on top.
|
||||
#include "Extension/Skater/trick_watch.h"
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
|
||||
using namespace dingosdk::tricks;
|
||||
namespace {
|
||||
void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); }
|
||||
TrickSignals tick(bool action, float score, float multiplier, bool slam = false, bool wipeout = false) {
|
||||
TrickSignals in;
|
||||
in.action = action;
|
||||
in.line_score = score;
|
||||
in.multiplier = multiplier;
|
||||
in.slam = slam;
|
||||
in.wipeout = wipeout;
|
||||
return in;
|
||||
}
|
||||
// The last event over `ticks` updates.
|
||||
TrickEvent feed(TrickWatch& watch, const TrickSignals& in, int ticks) {
|
||||
TrickEvent last;
|
||||
for (int i = 0; i < ticks; ++i) {
|
||||
const auto e = watch.update(in);
|
||||
if (e.kind != TrickEvent::Kind::none) last = e;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
using K = TrickEvent::Kind;
|
||||
|
||||
void a_trick_starts_then_lands_with_its_points() {
|
||||
TrickWatch w;
|
||||
feed(w, tick(false, 0, 0), 5);
|
||||
check(w.update(tick(true, 0, 0)).kind == K::started, "A trick starts the tick ActionActive goes on");
|
||||
check(feed(w, tick(true, 0, 0), 30).kind == K::none && w.in_trick(), "and says it once");
|
||||
check(w.update(tick(false, 0, 0)).kind == K::none && w.waiting(), "Its points are not in the tick it ends");
|
||||
const auto e = w.update(tick(false, 11, 1.5f));
|
||||
check(e.kind == K::landed && std::abs(e.points - 11) < 0.01f && std::abs(e.multiplier - 1.5f) < 0.01f,
|
||||
"Landed: 11 points, the first trick of a line");
|
||||
}
|
||||
|
||||
void a_second_trick_in_a_line_scores_its_own_points() {
|
||||
TrickWatch w;
|
||||
feed(w, tick(false, 0, 0), 5);
|
||||
feed(w, tick(true, 0, 0), 30);
|
||||
w.update(tick(false, 0, 0));
|
||||
w.update(tick(false, 11, 1.5f));
|
||||
feed(w, tick(true, 11, 1.5f), 20);
|
||||
w.update(tick(false, 11, 1.5f));
|
||||
const auto f = w.update(tick(false, 708, 2.0f));
|
||||
const float points = (708 / 2.0f - 11 / 1.5f) * 2.0f;
|
||||
check(f.kind == K::landed && std::abs(f.points - points) < 0.01f, "The multiplier's rise scales only the new trick's points");
|
||||
feed(w, tick(true, 708, 2.0f), 20);
|
||||
w.update(tick(false, 708, 2.0f));
|
||||
const auto g = w.update(tick(false, 958, 2.0f));
|
||||
check(g.kind == K::landed && std::abs(g.points - 250) < 0.01f, "and the next one its own again");
|
||||
}
|
||||
|
||||
void a_slam_ends_the_trick_for_nothing() {
|
||||
TrickWatch w;
|
||||
feed(w, tick(false, 0, 1), 2);
|
||||
feed(w, tick(true, 0, 1), 10);
|
||||
feed(w, tick(false, 300, 1), 2);
|
||||
feed(w, tick(true, 300, 1), 10);
|
||||
const auto e = w.update(tick(true, 300, 1, true));
|
||||
check(e.kind == K::broken && !w.in_trick(), "A slam ends the trick going on");
|
||||
check(feed(w, tick(false, 0, 0), 30).kind == K::none, "which scores nothing");
|
||||
check(feed(w, tick(false, 0, 0, false, true), 2).kind == K::none, "A wipeout with nothing going on says nothing");
|
||||
feed(w, tick(true, 0, 1), 5);
|
||||
check(w.update(tick(false, 0, 1, false, true)).kind == K::broken, "and one while the points are awaited breaks it");
|
||||
}
|
||||
|
||||
void a_trick_whose_points_never_come_is_skipped() {
|
||||
TrickWatch w(5);
|
||||
feed(w, tick(false, 50, 1), 2);
|
||||
feed(w, tick(true, 50, 1), 10);
|
||||
const auto none = feed(w, tick(false, 50, 1), 10);
|
||||
check(!w.waiting(), "Waiting gives up after its ticks");
|
||||
feed(w, tick(true, 50, 1), 10);
|
||||
const auto e = feed(w, tick(false, 90, 1), 2);
|
||||
check(none.kind == K::none && e.kind == K::landed && std::abs(e.points - 40) < 0.01f, "and the next trick is still read");
|
||||
}
|
||||
|
||||
void the_line_ending_is_not_a_trick() {
|
||||
TrickWatch w;
|
||||
feed(w, tick(false, 500, 2), 5);
|
||||
check(feed(w, tick(false, 0, 0), 5).kind == K::none, "The line's score going back to 0 is no event");
|
||||
check(feed(w, tick(false, 100, 1), 5).kind == K::none, "nor is a rise with no trick before it");
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
try {
|
||||
a_trick_starts_then_lands_with_its_points();
|
||||
a_second_trick_in_a_line_scores_its_own_points();
|
||||
a_slam_ends_the_trick_for_nothing();
|
||||
a_trick_whose_points_never_come_is_skipped();
|
||||
the_line_ending_is_not_a_trick();
|
||||
std::cout << "trick watch tests passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "trick watch test failed: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
#include "score_hud.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "Engine/Core/Platform/memory.h"
|
||||
#include "Engine/Game/Abi/native_data.h"
|
||||
#include "Engine/Game/Build/addresses.h"
|
||||
#include "Engine/Game/Build/20260929/engine.h"
|
||||
#include "Engine/Game/Build/20260929/hud_corner.h"
|
||||
#include "Extension/UI/NativeMenu/native_menu_data.h"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <format>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
namespace dingosdk::tricks {
|
||||
namespace {
|
||||
namespace menu_data = multiplayer::menu_data;
|
||||
using Address = std::uintptr_t;
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
// ScoringHUDViewModel's fields, by the hashes of their names in the game's own type data
|
||||
// (ui/features/hud/scoring/ScoringHUDViewModel; HudWidgetActive 0x3aa2f739 and ExtraInfoStyle
|
||||
// 0x0426783f are the ones hud_corner.h already uses).
|
||||
struct Watched {
|
||||
const char* name;
|
||||
std::uint32_t hash;
|
||||
};
|
||||
enum : std::size_t { line_score, is_slam, multiplier, action_active };
|
||||
constexpr std::array<Watched, 4> watched{{
|
||||
{"LineScore", 1086322279u},
|
||||
{"IsSlam", 2943226809u},
|
||||
{"Multiplier", 1782700230u},
|
||||
{"ActionActive", 2058053468u},
|
||||
}};
|
||||
constexpr std::size_t value_bytes = 16; // read per field
|
||||
|
||||
struct Bound {
|
||||
bool present{};
|
||||
std::uint16_t offset{};
|
||||
unsigned size{}, kind{};
|
||||
std::array<unsigned char, value_bytes> now{};
|
||||
bool read{};
|
||||
};
|
||||
struct Hud {
|
||||
Clock::time_point next_look;
|
||||
Address manager{};
|
||||
menu_data::Value model;
|
||||
std::array<Bound, watched.size()> fields{};
|
||||
bool warned{};
|
||||
};
|
||||
Hud hud; // client thread
|
||||
|
||||
// A score field's value: the game's widget logic keeps scores as floats (kind 19, as LineTimer
|
||||
// and Multiplier); an integer reading is taken if the float one is not sensible.
|
||||
float score_of(const Bound& field) {
|
||||
if (!field.present || !field.read || field.size != 4) return 0;
|
||||
float f{};
|
||||
std::int32_t i{};
|
||||
std::memcpy(&f, field.now.data(), 4);
|
||||
std::memcpy(&i, field.now.data(), 4);
|
||||
if (std::isfinite(f) && (f == 0 || (std::abs(f) > 1e-3f && std::abs(f) < 1e9f))) return f;
|
||||
return static_cast<float>(i);
|
||||
}
|
||||
bool flag_of(const Bound& field) { return field.present && field.read && field.now[0] != 0; }
|
||||
|
||||
// Runs `use(context)` on the game's UI model registry under its model lock (as Hall of Meat's
|
||||
// HUD code does). False while there is no registry.
|
||||
template <class Use> bool with_ui_models(Address base, Use&& use) {
|
||||
if (!base) return false;
|
||||
const auto ui = menu_data::read<Address>(base + addr::engine::ui_manager);
|
||||
const auto manager = ui ? menu_data::read<Address>(ui + 0x140) : 0;
|
||||
if (!manager) return false;
|
||||
const menu_data::Context context(base, manager);
|
||||
game::ModelWriteLock lock(manager);
|
||||
use(context);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool bind_model(Hud& h, const menu_data::Context& context) {
|
||||
if (h.manager != context.manager) {
|
||||
h.manager = context.manager;
|
||||
h.model = {};
|
||||
}
|
||||
if (h.model.handle && context.type_of(h.model.handle) == h.model.type) return true;
|
||||
h.model = {};
|
||||
const auto now = Clock::now();
|
||||
if (now < h.next_look) return false;
|
||||
h.next_look = now + std::chrono::seconds(1);
|
||||
const auto roots = context.roots({addr::hud_corner::score_view_schema});
|
||||
if (roots.empty()) return false;
|
||||
const auto model = roots.front().model;
|
||||
std::string layout;
|
||||
bool any = false;
|
||||
for (std::size_t n = 0; n < watched.size(); ++n) {
|
||||
auto& field = h.fields[n];
|
||||
field = {};
|
||||
try {
|
||||
const auto member = context.member(model.type, watched[n].hash);
|
||||
field.present = any = true;
|
||||
field.offset = member.offset;
|
||||
field.size = menu_data::size(member.type);
|
||||
field.kind = menu_data::kind(member.type);
|
||||
layout += std::format(" {}@+0x{:x}:{}b/k{}", watched[n].name, field.offset, field.size, field.kind);
|
||||
} catch (const std::exception&) {
|
||||
layout += std::format(" {}:missing", watched[n].name);
|
||||
}
|
||||
}
|
||||
if (!any) return false;
|
||||
h.model = model;
|
||||
logging::write(logging::Level::info, logging::Channel::skater, "Score HUD model found:" + layout);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<ScoreHud> read_score_hud(std::uintptr_t image_base) noexcept {
|
||||
auto& h = hud;
|
||||
bool ok = false;
|
||||
try {
|
||||
with_ui_models(image_base, [&](const menu_data::Context& context) {
|
||||
if (!bind_model(h, context)) return;
|
||||
const auto at = context.address(h.model);
|
||||
for (auto& field : h.fields)
|
||||
field.read = field.present && memory::peek_bytes(at + field.offset, field.now.data(), std::min<unsigned>(field.size, value_bytes));
|
||||
ok = true;
|
||||
});
|
||||
h.warned = false;
|
||||
} catch (const std::exception& error) {
|
||||
h.model = {};
|
||||
if (!std::exchange(h.warned, true))
|
||||
logging::log(logging::Level::warning, logging::Channel::skater, "The score HUD cannot be read: {}", error.what());
|
||||
} catch (...) {
|
||||
h.model = {};
|
||||
}
|
||||
if (!ok) return std::nullopt;
|
||||
const auto& f = h.fields;
|
||||
return ScoreHud{flag_of(f[action_active]), flag_of(f[is_slam]), score_of(f[line_score]), score_of(f[multiplier])};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
|
||||
// skate.'s score HUD as values (ScoringHUDViewModel, read through the game's UI models), what
|
||||
// trick_watch.h turns into tricks. Moved from Domination (PR 279).
|
||||
namespace dingosdk::tricks {
|
||||
struct ScoreHud {
|
||||
bool action{}; // a trick is going on
|
||||
bool slam{};
|
||||
float line_score{}, multiplier{};
|
||||
};
|
||||
// Client thread. Empty while there is no score HUD model (the menu, a level loading), or when this
|
||||
// game build's model has none of the fields; the model is looked for at most once a second.
|
||||
std::optional<ScoreHud> read_score_hud(std::uintptr_t image_base) noexcept;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#include "trick_watch.h"
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
|
||||
namespace dingosdk::tricks {
|
||||
namespace {
|
||||
// The line's points before the multiplier.
|
||||
float unscaled(float score, float multiplier) { return multiplier > 0.01f ? score / multiplier : score; }
|
||||
}
|
||||
|
||||
TrickEvent TrickWatch::update(const TrickSignals& in) {
|
||||
if (!std::exchange(seen_, true)) {
|
||||
score_ = in.line_score;
|
||||
multiplier_ = in.multiplier;
|
||||
}
|
||||
// A slam or a wipeout: skate. drops the line, and the trick going on scores nothing.
|
||||
if (in.slam || in.wipeout) {
|
||||
const bool had = action_ || waiting_;
|
||||
action_ = false;
|
||||
waiting_ = 0;
|
||||
score_ = in.line_score;
|
||||
multiplier_ = in.multiplier;
|
||||
return had ? TrickEvent{TrickEvent::Kind::broken} : TrickEvent{};
|
||||
}
|
||||
if (in.action) {
|
||||
if (action_) return {};
|
||||
// A trick starts (a previous one whose points never came scored nothing).
|
||||
action_ = true;
|
||||
waiting_ = 0;
|
||||
return {TrickEvent::Kind::started};
|
||||
}
|
||||
if (action_) { // the trick ended this tick: its points show in the line's score shortly
|
||||
action_ = false;
|
||||
waiting_ = wait_ticks_;
|
||||
}
|
||||
if (waiting_ > 0) {
|
||||
if (in.line_score > score_ + 0.5f) {
|
||||
const float scale = in.multiplier > 0.01f ? in.multiplier : 1.0f;
|
||||
const float points = std::max(0.0f, (unscaled(in.line_score, in.multiplier) - unscaled(score_, multiplier_)) * scale);
|
||||
score_ = in.line_score;
|
||||
multiplier_ = in.multiplier;
|
||||
waiting_ = 0;
|
||||
return {TrickEvent::Kind::landed, points, scale};
|
||||
}
|
||||
if (--waiting_ > 0) return {};
|
||||
}
|
||||
// No trick pending: the line's score follows whatever else moves it (its end resets it to 0).
|
||||
score_ = in.line_score;
|
||||
multiplier_ = in.multiplier;
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
|
||||
// When a trick lands and what it was worth, from the score HUD's values each tick (score_hud.h).
|
||||
// Apart from the game, so its rules are tested alone. Moved from Domination (PR 279), which adds
|
||||
// its spots on top: the start event is where a caller records where a trick began.
|
||||
//
|
||||
// Measured in play (a recording of the score HUD's values in a play session, 2026-10-14):
|
||||
// ActionActive is on while a trick goes on; LineScore rises one tick after it goes off. LineScore
|
||||
// looks like the line's points so far times the Multiplier (a rise in the multiplier also raises
|
||||
// earlier tricks' share), so a trick's points are taken from the unscaled rise, at the new
|
||||
// multiplier (not yet checked against the game's own trick list).
|
||||
namespace dingosdk::tricks {
|
||||
// The score HUD's values (ScoringHUDViewModel), and whether the skater is in a wipeout (physics
|
||||
// states 300-399).
|
||||
struct TrickSignals {
|
||||
bool action{};
|
||||
float line_score{}, multiplier{};
|
||||
bool slam{}, wipeout{};
|
||||
};
|
||||
struct TrickEvent {
|
||||
enum class Kind { none, started, landed, broken } kind{Kind::none};
|
||||
float points{}; // landed: the trick's points at the multiplier it raised the line to
|
||||
float multiplier{}; // landed: that multiplier
|
||||
};
|
||||
class TrickWatch {
|
||||
public:
|
||||
// `wait_ticks`: how long after a trick ends its points may take to show in the line's score.
|
||||
explicit TrickWatch(int wait_ticks = 20) : wait_ticks_(wait_ticks) {}
|
||||
TrickEvent update(const TrickSignals& signals);
|
||||
bool in_trick() const noexcept { return action_; }
|
||||
// Waiting for the points of a trick that just ended.
|
||||
bool waiting() const noexcept { return waiting_ > 0; }
|
||||
|
||||
private:
|
||||
int wait_ticks_;
|
||||
bool seen_{}, action_{};
|
||||
int waiting_{};
|
||||
float score_{}, multiplier_{}; // the line's score and multiplier before the trick
|
||||
};
|
||||
}
|
||||
@@ -181,6 +181,8 @@ add_library(dingosdk_runtime SHARED
|
||||
Extension/Trainer/trainer_waypoint.cpp
|
||||
Extension/Skater/offboard_flight.cpp
|
||||
Extension/Skater/camera_observer.cpp
|
||||
Extension/Skater/trick_watch.cpp
|
||||
Extension/Skater/score_hud.cpp
|
||||
Extension/Boot/offline_boot.cpp
|
||||
Extension/Progression/progression_service_guard.cpp
|
||||
Extension/Progression/mission_progression_override.cpp
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Test plugins
|
||||
|
||||
Two plugins that check ReSkate's plugin host (`Extension/Plugins`). To try one in game, copy its
|
||||
folder into the game's `Mods` folder and open the console.
|
||||
|
||||
- **reskate-test** calls every plugin API and says what worked. `ptest all` runs every check,
|
||||
`ptest <module>` one module, and `ptest list` names them. It prints only the failures and a
|
||||
summary line. Each API ReSkate adds gets a module in `plugin/checks/`. It's also the example to
|
||||
copy when making a plugin.
|
||||
- **reskate-hostile** attacks the sandbox on purpose, one attack per command. `hostile list` names
|
||||
them with what should happen. An attack that gets it stopped is expected: `plugin on
|
||||
reskate-hostile` starts it again for the next one. Any line starting with `ESCAPED` is a hole in
|
||||
the sandbox.
|
||||
|
||||
Both also run without the game, in `dingosdk_plugin_tests` (`DINGOSDK_BUILD_PLUGIN_TESTS`).
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "ReSkate_Hostile",
|
||||
"version_number": "0.1.0",
|
||||
"website_url": "https://github.com/Dingo-Shenanigans/ReSkate",
|
||||
"description": "Attacks the ReSkate plugin sandbox on purpose, to check it holds. For testing only.",
|
||||
"dependencies": []
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
-- reskate-hostile: a plugin written to break the sandbox, one attack per command. Every attack
|
||||
-- must end with this plugin stopped or refused and the game running as before. `hostile list`
|
||||
-- names them; after one that stops it, `plugin on reskate-hostile` starts it again.
|
||||
local attacks, order = {}, {}
|
||||
local function attack(name, expect, fn)
|
||||
attacks[name] = {expect = expect, fn = fn}
|
||||
table.insert(order, name)
|
||||
end
|
||||
|
||||
attack("loop", "stopped: longer than 50 ms", function()
|
||||
while true do end
|
||||
end)
|
||||
attack("caught-loop", "stopped: longer than 50 ms, though it catches the error", function()
|
||||
while true do pcall(function() while true do end end) end
|
||||
end)
|
||||
attack("tick-loop", "stopped at the next tick", function()
|
||||
reskate.on("tick", function() while true do end end)
|
||||
end)
|
||||
attack("memory", "stopped: more than 64 MB", function()
|
||||
local t = {}
|
||||
while true do t[#t + 1] = string.rep("x", 8000000) end
|
||||
end)
|
||||
attack("caught-memory", "stopped: more than 64 MB, though it catches the error", function()
|
||||
pcall(function()
|
||||
local t = {}
|
||||
while true do t[#t + 1] = string.rep("y", 8000000) end
|
||||
end)
|
||||
reskate.console.print("ESCAPED: carried on after a memory bomb")
|
||||
end)
|
||||
attack("huge-string", "stopped: more than 64 MB", function()
|
||||
local s = string.rep("x", 100000000)
|
||||
return #s
|
||||
end)
|
||||
attack("buffer", "stopped: more than 64 MB", function()
|
||||
local b = buffer.create(1e9)
|
||||
return buffer.len(b)
|
||||
end)
|
||||
attack("coroutines", "stopped: its time or its memory", function()
|
||||
while true do coroutine.wrap(function() end)() end
|
||||
end)
|
||||
attack("errors", "stopped after 5 errors in 10 s", function()
|
||||
reskate.on("tick", function() error("hostile: an error every tick") end)
|
||||
end)
|
||||
attack("slow", "stopped after 3 s over 2 ms a tick", function()
|
||||
reskate.on("tick", function()
|
||||
local start = os.clock()
|
||||
while os.clock() - start < 0.004 do end
|
||||
end)
|
||||
end)
|
||||
attack("log-flood", "keeps running: 50 of the 1000 lines are logged", function()
|
||||
for i = 1, 1000 do reskate.log("hostile flood " .. i) end
|
||||
end)
|
||||
attack("timers", "keeps running: refused past 1024 timers", function()
|
||||
for i = 1, 2000 do reskate.time.after(600000, function() end) end
|
||||
end)
|
||||
attack("recursion", "keeps running: an error, not a crash", function()
|
||||
local function deep(n) return deep(n + 1) + 1 end
|
||||
return deep(1)
|
||||
end)
|
||||
attack("slow-unload", "an unload handler that never ends: cut off at 20 ms when it is switched off", function()
|
||||
reskate.on("unload", function() while true do end end)
|
||||
reskate.console.print("hostile: now switch it off: plugin off reskate-hostile")
|
||||
end)
|
||||
|
||||
-- Each returns true when that way out worked.
|
||||
local escapes = {
|
||||
{"io", function() return io ~= nil end},
|
||||
{"loadstring or load", function() return loadstring ~= nil or load ~= nil end},
|
||||
{"dofile or loadfile", function() return dofile ~= nil or loadfile ~= nil end},
|
||||
{"package", function() return package ~= nil end},
|
||||
{"os beyond the clock", function() return os.execute ~= nil or os.getenv ~= nil or os.remove ~= nil or os.exit ~= nil end},
|
||||
{"changing reskate", function() return pcall(function() reskate.log = nil end) end},
|
||||
{"rawset on reskate", function() return pcall(rawset, reskate, "x", 1) end},
|
||||
{"a metatable on reskate", function() return pcall(setmetatable, reskate, {}) end},
|
||||
{"changing a library", function() return pcall(function() string.rep = nil end) end},
|
||||
{"the string metatable", function() return pcall(function() getmetatable("").__index = {} end) end},
|
||||
{"reading outside the package", function() return pcall(reskate.package.read, "../../../Windows/win.ini") end},
|
||||
{"a junction-style path", function() return pcall(reskate.package.read, "..\\..\\Windows\\win.ini") end},
|
||||
{"require outside", function() return pcall(require, "C:/Windows/win.ini") end},
|
||||
{"require up", function() return pcall(require, "../manifest") end},
|
||||
{"taking a plugin command", function() return pcall(reskate.console.command, "plugin off", "x", function() end) end},
|
||||
{"taking a ReSkate command", function() return pcall(reskate.console.command, "load", "x", function() end) end},
|
||||
{"storage without the permission", function() return pcall(reskate.storage.get, "x") end},
|
||||
}
|
||||
attack("escapes", "keeps running: every way out refused", function()
|
||||
local escaped = 0
|
||||
for _, try in escapes do
|
||||
local ok, worked = pcall(try[2])
|
||||
if ok and worked then
|
||||
escaped += 1
|
||||
reskate.console.print("ESCAPED: " .. try[1])
|
||||
end
|
||||
end
|
||||
reskate.console.print("hostile escapes: " .. (#escapes - escaped) .. " of " .. #escapes .. " refused")
|
||||
end)
|
||||
|
||||
reskate.console.command("hostile", "Attacks the plugin sandbox: hostile list, hostile <attack>", function(args)
|
||||
local name = args[1]
|
||||
if not name or name == "list" then
|
||||
for _, n in order do reskate.console.print(n .. ": " .. attacks[n].expect) end
|
||||
return
|
||||
end
|
||||
local found = attacks[name]
|
||||
if not found then
|
||||
reskate.console.print("hostile: there is no attack " .. name .. " (hostile list names them)")
|
||||
return
|
||||
end
|
||||
reskate.console.print("hostile: " .. name .. ", expected: " .. found.expect)
|
||||
found.fn()
|
||||
end)
|
||||
|
||||
reskate.log("reskate-hostile loaded: hostile list names its attacks")
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"api": 1,
|
||||
"entry": "plugin/main.lua",
|
||||
"side": "client",
|
||||
"permissions": ["console"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"name": "sample", "list": [1, 2, 3], "nested": {"yes": true}}
|
||||
@@ -0,0 +1 @@
|
||||
ptest sample
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "ReSkate_Test",
|
||||
"version_number": "0.1.0",
|
||||
"website_url": "https://github.com/Dingo-Shenanigans/ReSkate",
|
||||
"description": "Checks every ReSkate plugin API: run ptest all in the console.",
|
||||
"dependencies": []
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
local h = require("harness")
|
||||
local t = h.module("base")
|
||||
|
||||
local ticks, loaded, last_dt = 0, false, nil
|
||||
reskate.on("load", function() loaded = true end)
|
||||
reskate.on("tick", function(dt)
|
||||
ticks += 1
|
||||
last_dt = dt
|
||||
end)
|
||||
|
||||
t.check("api_version is 1", function() assert(reskate.api_version == 1, "it is " .. tostring(reskate.api_version)) end)
|
||||
t.check("id is the mod folder's name", function() assert(type(reskate.id) == "string" and #reskate.id > 0) end)
|
||||
t.check("the load handler ran", function() assert(loaded) end)
|
||||
t.check("tick handlers run, with the time since the last tick", function()
|
||||
assert(ticks > 0, "no tick yet")
|
||||
assert(type(last_dt) == "number" and last_dt >= 0, "dt is " .. tostring(last_dt))
|
||||
end)
|
||||
t.check("log takes info, warn and error", function()
|
||||
reskate.log("ptest: an info line")
|
||||
reskate.log("ptest: a warning line", "warn")
|
||||
end)
|
||||
t.check("print goes to the log", function() print("ptest: printed", 1, true) end)
|
||||
t.check("a made-up log level is refused", function() h.refused(reskate.log, "x", "loud") end)
|
||||
t.check("a made-up event is refused", function() h.refused(reskate.on, "made_up", function() end) end)
|
||||
t.check("an event needs a function", function() h.refused(reskate.on, "tick", 5) end)
|
||||
@@ -0,0 +1,25 @@
|
||||
local h = require("harness")
|
||||
local t = h.module("console")
|
||||
|
||||
-- Try it by hand: ptest echo a b c
|
||||
reskate.console.command("ptest echo", "Prints its words back (reskate-test)", function(args)
|
||||
reskate.console.print("ptest echo: " .. table.concat(args, " "))
|
||||
end)
|
||||
|
||||
t.check("print works", function() reskate.console.print("ptest: console.print works") end)
|
||||
t.check("a ReSkate command's name is refused", function()
|
||||
h.refused(reskate.console.command, "load mine", "x", function() end)
|
||||
h.refused(reskate.console.command, "plugin mine", "x", function() end)
|
||||
h.refused(reskate.console.command, "help mine", "x", function() end)
|
||||
end)
|
||||
t.check("names must be 1 to 3 lowercase words", function()
|
||||
for _, name in {"", "Bad", "a b c d", " lead", "trail ", "two spaces", "sym!"} do
|
||||
h.refused(reskate.console.command, name, "x", function() end)
|
||||
end
|
||||
end)
|
||||
t.check("a name registered already is refused", function()
|
||||
h.refused(reskate.console.command, "ptest", "x", function() end)
|
||||
end)
|
||||
t.check("a command needs a function", function()
|
||||
h.refused(reskate.console.command, "ptest other", "x", 5)
|
||||
end)
|
||||
@@ -0,0 +1,22 @@
|
||||
local h = require("harness")
|
||||
local t = h.module("json")
|
||||
|
||||
t.check("encode and decode round trip", function()
|
||||
local back = reskate.json.decode(reskate.json.encode({a = {1, 2, 3}, b = "text", c = true, d = 1.5}))
|
||||
assert(back.a[3] == 3 and back.b == "text" and back.c == true and back.d == 1.5)
|
||||
end)
|
||||
t.check("encode can be pretty", function()
|
||||
assert(reskate.json.encode({a = 1}, true):find("\n", 1, true))
|
||||
end)
|
||||
t.check("bad JSON is refused", function()
|
||||
h.refused(reskate.json.decode, "{nope")
|
||||
h.refused(reskate.json.decode, "")
|
||||
end)
|
||||
t.check("values JSON cannot hold are refused", function()
|
||||
h.refused(reskate.json.encode, print)
|
||||
h.refused(reskate.json.encode, 0 / 0)
|
||||
h.refused(reskate.json.encode, {[{}] = 1})
|
||||
local cycle = {}
|
||||
cycle.me = cycle
|
||||
h.refused(reskate.json.encode, cycle)
|
||||
end)
|
||||
@@ -0,0 +1,15 @@
|
||||
local t = require("harness").module("level")
|
||||
|
||||
local heard = {}
|
||||
reskate.on("level_loaded", function(name) table.insert(heard, "loaded " .. name) end)
|
||||
reskate.on("level_unloading", function(name) table.insert(heard, "leaving " .. name) end)
|
||||
|
||||
t.check("current is nil, or a table with the level's name", function()
|
||||
local level = reskate.level.current()
|
||||
assert(level == nil or (type(level) == "table" and type(level.name) == "string" and #level.name > 0))
|
||||
end)
|
||||
t.check("level events name the level", function()
|
||||
for _, line in heard do
|
||||
assert(#line > #"loaded ", "an event without a name: " .. line)
|
||||
end
|
||||
end)
|
||||
@@ -0,0 +1,27 @@
|
||||
local h = require("harness")
|
||||
local t = h.module("package")
|
||||
|
||||
t.check("read returns a file's text", function()
|
||||
local text = reskate.package.read("data/sample.txt")
|
||||
assert(text and text:sub(1, 12) == "ptest sample", tostring(text))
|
||||
end)
|
||||
t.check("read returns nil for a file that is not there", function()
|
||||
assert(reskate.package.read("data/missing.txt") == nil)
|
||||
end)
|
||||
t.check("read_json returns the file as tables", function()
|
||||
local data = reskate.package.read_json("data/sample.json")
|
||||
assert(data.name == "sample" and data.list[3] == 3 and data.nested.yes == true)
|
||||
end)
|
||||
t.check("list names a folder's files, sorted", function()
|
||||
local names = table.concat(reskate.package.list("data"), ",")
|
||||
assert(names == "sample.json,sample.txt", names)
|
||||
end)
|
||||
t.check("list with no folder is the package, folders ending in /", function()
|
||||
local names = table.concat(reskate.package.list(), ",")
|
||||
assert(names:find("plugin/", 1, true) and names:find("reskate-plugin.json", 1, true), names)
|
||||
end)
|
||||
t.check("paths out of the package are refused", function()
|
||||
for _, path in {"../x", "/x", "C:/Windows/win.ini", "data\\sample.txt", "data/../../x", "data/./sample.txt"} do
|
||||
h.refused(reskate.package.read, path)
|
||||
end
|
||||
end)
|
||||
@@ -0,0 +1,17 @@
|
||||
local h = require("harness")
|
||||
local t = h.module("require")
|
||||
|
||||
t.check("a module runs once, and every require gets it", function()
|
||||
local first = require("lib/counter")
|
||||
assert(require("./lib/counter") == first, "a second copy")
|
||||
assert(first.loads() == 1, "it ran " .. first.loads() .. " times")
|
||||
end)
|
||||
t.check("a module that requires itself is an error", function()
|
||||
local why = h.refused(require, "lib/cycle_a")
|
||||
assert(why:find("requires itself", 1, true), why)
|
||||
end)
|
||||
t.check("a module that is not there is an error", function() h.refused(require, "lib/missing") end)
|
||||
t.check("a module outside the package is refused", function()
|
||||
h.refused(require, "../manifest")
|
||||
h.refused(require, "C:/Windows/win")
|
||||
end)
|
||||
@@ -0,0 +1,28 @@
|
||||
local h = require("harness")
|
||||
local t = h.module("sandbox")
|
||||
|
||||
t.check("no files, programs or code loading", function()
|
||||
assert(io == nil and package == nil and dofile == nil and loadfile == nil, "a file library")
|
||||
assert(loadstring == nil and load == nil, "code loading")
|
||||
assert(os.execute == nil and os.remove == nil and os.rename == nil and os.getenv == nil and os.exit == nil, "os")
|
||||
end)
|
||||
t.check("the reskate table cannot be changed", function()
|
||||
h.refused(function() reskate.log = nil end)
|
||||
h.refused(function() reskate.storage.get = print end)
|
||||
h.refused(rawset, reskate, "x", 1)
|
||||
h.refused(setmetatable, reskate, {})
|
||||
end)
|
||||
t.check("the libraries cannot be changed", function()
|
||||
h.refused(function() string.rep = nil end)
|
||||
h.refused(function() table.insert = nil end)
|
||||
h.refused(function() getmetatable("").__index = {} end)
|
||||
end)
|
||||
t.check("its own globals work", function()
|
||||
ptest_global = 1
|
||||
assert(ptest_global == 1)
|
||||
ptest_global = nil
|
||||
end)
|
||||
t.check("endless recursion is an error, not a crash", function()
|
||||
local function deep(n) return deep(n + 1) + 1 end
|
||||
h.refused(deep, 1)
|
||||
end)
|
||||
@@ -0,0 +1,49 @@
|
||||
local h = require("harness")
|
||||
local t = h.module("storage")
|
||||
|
||||
-- Its keys start with ptest. and are removed after each check.
|
||||
local function clean()
|
||||
for _, key in reskate.storage.keys() do
|
||||
if key:sub(1, 6) == "ptest." then reskate.storage.set(key, nil) end
|
||||
end
|
||||
end
|
||||
|
||||
t.check("set and get keep tables, lists, text and numbers", function()
|
||||
reskate.storage.set("ptest.table", {a = 1, list = {1, 2, 3}, name = "x", yes = true})
|
||||
local back = reskate.storage.get("ptest.table")
|
||||
clean()
|
||||
assert(back.a == 1 and back.list[3] == 3 and back.name == "x" and back.yes == true)
|
||||
end)
|
||||
t.check("add counts, from nothing", function()
|
||||
assert(reskate.storage.add("ptest.count") == 1)
|
||||
assert(reskate.storage.add("ptest.count", 4) == 5)
|
||||
reskate.storage.set("ptest.text", "x")
|
||||
local why = h.refused(reskate.storage.add, "ptest.text")
|
||||
clean()
|
||||
assert(why:find("number", 1, true), why)
|
||||
end)
|
||||
t.check("nil removes a key", function()
|
||||
reskate.storage.set("ptest.gone", 1)
|
||||
reskate.storage.set("ptest.gone", nil)
|
||||
assert(reskate.storage.get("ptest.gone") == nil)
|
||||
end)
|
||||
t.check("keys lists them", function()
|
||||
reskate.storage.set("ptest.listed", 1)
|
||||
local found = table.find(reskate.storage.keys(), "ptest.listed")
|
||||
clean()
|
||||
assert(found)
|
||||
end)
|
||||
t.check("a plugin has 1 MB", function()
|
||||
local why = h.refused(reskate.storage.set, "ptest.big", string.rep("x", 2000000))
|
||||
assert(why:find("full", 1, true), why)
|
||||
assert(reskate.storage.get("ptest.big") == nil, "the failed write was kept")
|
||||
end)
|
||||
t.check("bad keys and values are refused", function()
|
||||
h.refused(reskate.storage.set, "", 1)
|
||||
h.refused(reskate.storage.set, string.rep("k", 200), 1)
|
||||
h.refused(reskate.storage.set, "ptest.f", print)
|
||||
local cycle = {}
|
||||
cycle.me = cycle
|
||||
h.refused(reskate.storage.set, "ptest.c", cycle)
|
||||
clean()
|
||||
end)
|
||||
@@ -0,0 +1,30 @@
|
||||
local h = require("harness")
|
||||
local t = h.module("time")
|
||||
|
||||
t.check("now is a number that moves forward", function()
|
||||
local a = reskate.time.now()
|
||||
assert(type(a) == "number" and a > 0)
|
||||
assert(reskate.time.now() >= a)
|
||||
end)
|
||||
t.later("after runs once", 2000, function(done)
|
||||
local count = 0
|
||||
reskate.time.after(50, function() count += 1 end)
|
||||
reskate.time.after(400, function() done(count == 1, "it ran " .. count .. " times") end)
|
||||
end)
|
||||
t.later("every repeats, and cancel stops it", 2000, function(done)
|
||||
local count = 0
|
||||
local id = reskate.time.every(50, function() count += 1 end)
|
||||
reskate.time.after(300, function()
|
||||
reskate.time.cancel(id)
|
||||
local at = count
|
||||
reskate.time.after(200, function()
|
||||
done(count >= 3 and count == at, "it counted " .. count .. ", and " .. at .. " when cancelled")
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
t.check("negative, NaN and zero repeats are refused", function()
|
||||
h.refused(reskate.time.after, -1, print)
|
||||
h.refused(reskate.time.after, 0 / 0, print)
|
||||
h.refused(reskate.time.every, 0, print)
|
||||
h.refused(reskate.time.after, 10, "not a function")
|
||||
end)
|
||||
@@ -0,0 +1,104 @@
|
||||
-- The test runner: each file in checks/ is a module of checks, and `ptest` runs them. A check
|
||||
-- passes when its function returns without an error; a later check passes when it calls done(true)
|
||||
-- in time. Only failures and the summary are printed, to stay under the 50 lines a second a plugin has.
|
||||
local harness = {}
|
||||
local modules, order = {}, {}
|
||||
local running = nil
|
||||
|
||||
function harness.module(name)
|
||||
if not modules[name] then
|
||||
modules[name] = {}
|
||||
table.insert(order, name)
|
||||
end
|
||||
local list = modules[name]
|
||||
local m = {}
|
||||
function m.check(title, fn)
|
||||
table.insert(list, {title = title, fn = fn})
|
||||
end
|
||||
-- fn(done): call done(ok, why) within `within` ms of the run starting.
|
||||
function m.later(title, within, fn)
|
||||
table.insert(list, {title = title, fn = fn, within = within})
|
||||
end
|
||||
return m
|
||||
end
|
||||
|
||||
-- What the call raised; an error when it was allowed.
|
||||
function harness.refused(fn, ...)
|
||||
local ok, err = pcall(fn, ...)
|
||||
if ok then error("was allowed", 2) end
|
||||
return tostring(err)
|
||||
end
|
||||
|
||||
function harness.names()
|
||||
return table.clone(order)
|
||||
end
|
||||
|
||||
function harness.has(name)
|
||||
return modules[name] ~= nil
|
||||
end
|
||||
|
||||
local function finish(run)
|
||||
running = nil
|
||||
local shown = 0
|
||||
for _, line in run.failures do
|
||||
shown += 1
|
||||
if shown > 20 then
|
||||
reskate.console.print("FAIL ... and " .. (#run.failures - 20) .. " more")
|
||||
break
|
||||
end
|
||||
reskate.console.print("FAIL " .. line)
|
||||
end
|
||||
local parts = {}
|
||||
for _, name in run.names do
|
||||
local tally = run.per[name]
|
||||
table.insert(parts, name .. " " .. tally.passed .. "/" .. tally.total)
|
||||
end
|
||||
reskate.console.print("ptest: " .. run.passed .. " passed, " .. run.failed .. " failed (" .. table.concat(parts, ", ") .. ")")
|
||||
end
|
||||
|
||||
local function record(run, name, title, ok, why)
|
||||
local tally = run.per[name]
|
||||
if ok then
|
||||
run.passed += 1
|
||||
tally.passed += 1
|
||||
else
|
||||
run.failed += 1
|
||||
table.insert(run.failures, name .. ": " .. title .. ": " .. tostring(why or "failed"))
|
||||
end
|
||||
end
|
||||
|
||||
function harness.run(names)
|
||||
if running then
|
||||
reskate.console.print("ptest: a run is still going on")
|
||||
return
|
||||
end
|
||||
local run = {names = names, passed = 0, failed = 0, pending = 0, failures = {}, per = {}}
|
||||
running = run
|
||||
for _, name in names do
|
||||
run.per[name] = {passed = 0, total = #modules[name]}
|
||||
end
|
||||
for _, name in names do
|
||||
for _, item in modules[name] do
|
||||
if item.within then
|
||||
run.pending += 1
|
||||
local finished = false
|
||||
local function done(ok, why)
|
||||
if finished then return end
|
||||
finished = true
|
||||
record(run, name, item.title, ok, why)
|
||||
run.pending -= 1
|
||||
if run.pending == 0 then finish(run) end
|
||||
end
|
||||
reskate.time.after(item.within, function() done(false, "did not finish within " .. item.within .. " ms") end)
|
||||
local ok, err = pcall(item.fn, done)
|
||||
if not ok then done(false, err) end
|
||||
else
|
||||
local ok, err = pcall(item.fn)
|
||||
record(run, name, item.title, ok, err)
|
||||
end
|
||||
end
|
||||
end
|
||||
if run.pending == 0 then finish(run) end
|
||||
end
|
||||
|
||||
return harness
|
||||
@@ -0,0 +1,5 @@
|
||||
-- For the require checks: counts how many times this module ran.
|
||||
ptest_counter_loads = (ptest_counter_loads or 0) + 1
|
||||
return {
|
||||
loads = function() return ptest_counter_loads end,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
-- For the require checks: a and b require each other.
|
||||
return require("lib/cycle_b")
|
||||
@@ -0,0 +1,2 @@
|
||||
-- For the require checks: a and b require each other.
|
||||
return require("lib/cycle_a")
|
||||
@@ -0,0 +1,22 @@
|
||||
-- reskate-test: calls every plugin API and says what worked. Run `ptest all` in the console, or
|
||||
-- `ptest <module>` for one (`ptest list` names them). Each API ReSkate adds gets a module here.
|
||||
local harness = require("harness")
|
||||
|
||||
for _, name in {"base", "time", "level", "package", "json", "require", "console", "storage", "sandbox"} do
|
||||
require("checks/" .. name)
|
||||
end
|
||||
|
||||
reskate.console.command("ptest", "Checks the plugin API: ptest all, ptest <module>, ptest list", function(args)
|
||||
local what = args[1] or "all"
|
||||
if what == "list" then
|
||||
reskate.console.print("ptest modules: " .. table.concat(harness.names(), ", "))
|
||||
elseif what == "all" then
|
||||
harness.run(harness.names())
|
||||
elseif harness.has(what) then
|
||||
harness.run({what})
|
||||
else
|
||||
reskate.console.print("ptest: there is no module " .. what .. " (ptest list names them)")
|
||||
end
|
||||
end)
|
||||
|
||||
reskate.log("reskate-test loaded: ptest all checks every plugin API")
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"api": 1,
|
||||
"entry": "plugin/main.lua",
|
||||
"side": "client",
|
||||
"permissions": ["console", "storage", "overlay", "menu", "ui.screen", "input", "hud", "events.tricks",
|
||||
"events.skater", "skater.read", "skater.teleport", "skater.control", "skater.animation",
|
||||
"trainer", "camera", "world.raycast", "world.objects", "world.environment", "world.ai",
|
||||
"travel", "session.read", "session.skaters", "session.nametags", "net.session", "net.http",
|
||||
"voice.control", "chat.read", "chat.send", "throwdowns", "replay", "music",
|
||||
"progression.read", "settings.read", "audio.ui", "mods.read"]
|
||||
}
|
||||
Reference in new issue
Block a user