diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index 4f7a3bdf..1212189c 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -31,6 +31,8 @@ struct TestEnvironment final : Environment { std::vector> teleports; int rays{}; std::optional skater() override { return local_skater; } + std::optional score_now; + std::optional score() override { return score_now; } Teleport teleport(const Vec3& position, float heading) override { if (teleport_forbidden) return Teleport::forbidden; if (teleport_unavailable || !local_skater) return Teleport::unavailable; @@ -455,6 +457,53 @@ void the_skater_can_be_read_moved_and_the_world_probed() { running(host, "mover", environment); } +void tricks_reach_plugins_that_ask_for_them() { + TestEnvironment environment; + Host host(environment, Side::client); + host.apply({script("bare", R"( + local ok, err = pcall(reskate.on, "trick_landed", function() end) + assert(not ok and err:find("events.tricks"), err) + ok, err = pcall(reskate.score.current) + assert(not ok and err:find("events.tricks"), err) + reskate.log("bare refused") + )")}); + check(environment.logged("bare refused") && !host.wants_tricks(), "Tricks need events.tricks, and none is read without it"); + host.apply({script("bare", "reskate.log('bare')"), script("tricks", R"( + reskate.on("trick_started", function(e) reskate.log(string.format("started at %.0f %.0f %.0f", e.at.x, e.at.y, e.at.z)) end) + reskate.on("trick_landed", function(e) + reskate.log(string.format("landed %.0f x%.1f from %.0f to %.0f in %.0f ms", e.points, e.multiplier, e.started_at.x, e.landed_at.x, e.ms)) + end) + reskate.on("line_broken", function(e) reskate.log("broken by a " .. e.reason) end) + reskate.console.command("score", "x", function() + local s = reskate.score.current() + reskate.log(s and string.format("score %.0f x%.1f %s", s.line, s.multiplier, tostring(s.in_trick)) or "no score") + end) + )", R"("console", "events.tricks")")}); + check(host.wants_tricks(), "With a plugin that has events.tricks, tricks are read"); + TrickNews started; + started.started_at = {1, 2, 3}; + host.trick(started); + TrickNews landed; + landed.kind = TrickNews::Kind::landed; + landed.points = 250; + landed.multiplier = 2; + landed.started_at = {1, 2, 3}; + landed.landed_at = {9, 2, 3}; + landed.ms = 1200; + host.trick(landed); + TrickNews broken; + broken.kind = TrickNews::Kind::broken; + broken.reason = "slam"; + host.trick(broken); + check(environment.logged("[tricks] started at 1 2 3") && environment.logged("[tricks] landed 250 x2.0 from 1 to 9 in 1200 ms") && + environment.logged("[tricks] broken by a slam"), "Each trick event reaches it, with its values\n" + environment.all()); + host.run_console("score"); + environment.score_now = ScoreState{708, 2, true}; + host.run_console("score"); + check(environment.logged("[tricks] no score") && environment.logged("[tricks] score 708 x2.0 true"), "and the score HUD as it is"); + running(host, "tricks", environment); +} + void level_events_reach_plugins() { TestEnvironment environment; Host host(environment, Side::client); @@ -554,7 +603,7 @@ void reskate_test_passes_its_own_checks() { 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: 46 passed, 0 failed, 4 skipped") != std::string::npos && + check(output.find("ptest: 47 passed, 0 failed, 7 skipped") != 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"); // With a skater on the ground, its skater checks run too. @@ -563,6 +612,16 @@ void reskate_test_passes_its_own_checks() { check(host.run_console("ptest skater"), "ptest skater"); check(environment.printed("ptest: 5 passed, 0 failed (skater 5/5)"), "The skater checks pass with a skater:\n" + printed_lines(environment)); check(host.run_console("ptest tp") && environment.teleports.size() == 1, "and ptest tp teleports it"); + // ptest trick waits for a trick and says what its events held. + check(host.run_console("ptest trick"), "ptest trick"); + host.trick({TrickNews::Kind::started, 0, 0, {1, 0, 1}}); + host.trick({TrickNews::Kind::landed, 250, 2, {1, 0, 1}, {4, 0, 5}, 900}); + check(environment.printed("ptest trick: started at (1.0, 0.0, 1.0)") && + environment.printed("ptest trick: landed 250 points at x2.0 in 900 ms, from (1.0, 0.0, 1.0) to (4.0, 0.0, 5.0)"), + "ptest trick prints the trick"); + environment.lines.clear(); + check(host.run_console("ptest tricks") && environment.printed("ptest: 3 passed, 0 failed, 1 skipped"), + "and the tricks checks pass once one landed"); running(host, "reskate-test", environment); } @@ -571,17 +630,18 @@ void reskate_hostile_is_stopped_or_refused_every_time() { Host host(environment, Side::client); host.apply({contributed("reskate-hostile")}); running(host, "reskate-hostile", environment); - struct Case { const char* attack; bool stops; int ticks; }; + struct Case { const char* attack; bool stops; int ticks; bool trick = false; }; 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}, {"teleport-spam", false, 1}, {"raycast-flood", false, 1}, + {"recursion", false, 1}, {"escapes", false, 1}, {"teleport-spam", false, 1}, {"raycast-flood", false, 1}, {"trick-loop", true, 0, true}, }; 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); + if (item.trick) host.trick({TrickNews::Kind::landed, 100, 1}); const auto state = status_of(host, "reskate-hostile").state; check((state == "stopped") == item.stops, std::string(item.attack) + " left it " + state + "\n" + environment.all()); } @@ -611,6 +671,7 @@ int main() { a_plugin_over_its_time_budget_is_stopped(); a_plugin_reloads_when_its_files_change(); the_skater_can_be_read_moved_and_the_world_probed(); + tricks_reach_plugins_that_ask_for_them(); level_events_reach_plugins(); timers_run_when_due(); a_package_with_a_program_or_on_the_wrong_side_is_not_loaded(); diff --git a/Extension/Plugins/plugin_api.cpp b/Extension/Plugins/plugin_api.cpp index f98f5da1..de50b81c 100644 --- a/Extension/Plugins/plugin_api.cpp +++ b/Extension/Plugins/plugin_api.cpp @@ -13,7 +13,14 @@ // luaL_error, or a C++ exception, which Luau turns into a Lua error with its message. namespace dingosdk::plugins { namespace { -constexpr std::array events{"load", "unload", "tick", "level_loaded", "level_unloading"}; +// Events and the permission each needs (none for the base set). +struct Event { + std::string_view name, permission; +}; +constexpr std::array events{{ + {"load", {}}, {"unload", {}}, {"tick", {}}, {"level_loaded", {}}, {"level_unloading", {}}, + {"trick_started", "events.tricks"}, {"trick_landed", "events.tricks"}, {"line_broken", "events.tricks"}, +}}; constexpr std::size_t maximum_key = 128; constexpr std::size_t maximum_command = 48; constexpr std::size_t maximum_listing = 1000; @@ -197,8 +204,10 @@ struct ApiFunctions { auto& plugin = plugin_of(L); const std::string event = luaL_checkstring(L, 1); luaL_checktype(L, 2, LUA_TFUNCTION); - if (std::find(events.begin(), events.end(), event) == events.end()) - luaL_error(L, "there is no event \"%s\" in this ReSkate", event.c_str()); + const auto known = std::find_if(events.begin(), events.end(), [&](const Event& e) { return e.name == event; }); + if (known == events.end()) luaL_error(L, "there is no event \"%s\" in this ReSkate", event.c_str()); + if (!known->permission.empty() && !plugin.has(known->permission)) + luaL_error(L, "the %s event needs the \"%s\" permission in reskate-plugin.json", event.c_str(), std::string(known->permission).c_str()); auto& list = plugin.handlers_[event]; if (list.size() >= plugin.limits_.handlers) luaL_error(L, "more than %d handlers for one event", static_cast(plugin.limits_.handlers)); lua_pushvalue(L, 2); @@ -493,6 +502,24 @@ struct ApiFunctions { else lua_pushnil(L); return 1; } + // events.tricks: the score HUD now (the line's score, its multiplier, a trick going on), or nil. + static int score(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "events.tricks", "reskate.score.current"); + const auto now = plugin.services_.score(); + if (!now) { + lua_pushnil(L); + return 1; + } + lua_createtable(L, 0, 3); + lua_pushnumber(L, now->line); + lua_setfield(L, -2, "line"); + lua_pushnumber(L, now->multiplier); + lua_setfield(L, -2, "multiplier"); + lua_pushboolean(L, now->in_trick); + lua_setfield(L, -2, "in_trick"); + return 1; + } // Modules come from the plugin's own package, beside its entry: require("./rules") or // require("rules") is /rules.luau or rules.lua. Each runs once. static int require(lua_State* L) { @@ -620,6 +647,11 @@ void Api::install(Plugin& plugin, lua_State* L) { readonly(); lua_setfield(L, -2, "skater"); + lua_createtable(L, 0, 1); + set_function(L, "current", ApiFunctions::score); + readonly(); + lua_setfield(L, -2, "score"); + lua_createtable(L, 0, 1); set_function(L, "raycast", ApiFunctions::raycast); readonly(); diff --git a/Extension/Plugins/plugin_host.cpp b/Extension/Plugins/plugin_host.cpp index e7a1abfb..9d216924 100644 --- a/Extension/Plugins/plugin_host.cpp +++ b/Extension/Plugins/plugin_host.cpp @@ -1,6 +1,7 @@ #include "plugin_host.h" #include "plugin_api.h" #include "Engine/Core/Console/console_core.h" +#include #include #include @@ -130,6 +131,19 @@ void Host::set_level(std::string level) { } } +void Host::trick(const TrickNews& news) { + for (auto& [id, entry] : entries_) { + if (!entry.plugin || !entry.plugin->has("events.tricks")) continue; + entry.plugin->trick(news); + collect(entry); + } +} + +bool Host::wants_tricks() const { + return std::any_of(entries_.begin(), entries_.end(), + [](const auto& item) { return item.second.plugin && item.second.plugin->has("events.tricks"); }); +} + bool Host::run_console(std::string_view text) { if (commands_.empty()) return false; const auto parsed = parse_console_command(text); diff --git a/Extension/Plugins/plugin_host.h b/Extension/Plugins/plugin_host.h index bc5591a7..4c3fc2e9 100644 --- a/Extension/Plugins/plugin_host.h +++ b/Extension/Plugins/plugin_host.h @@ -35,6 +35,7 @@ public: virtual std::optional skater() { return std::nullopt; } virtual Teleport teleport(const Vec3&, float) { return Teleport::unavailable; } virtual std::optional raycast(const Vec3&, const Vec3&) { return std::nullopt; } + virtual std::optional score() { return std::nullopt; } }; struct Status { @@ -58,6 +59,10 @@ public: void apply(const std::vector& found); void tick(double dt_ms); void set_level(std::string level); + // A trick of the local skater, to every plugin with events.tricks. + void trick(const TrickNews& news); + // Whether a running plugin has events.tricks: the score HUD is read only then. + bool wants_tricks() const; // Runs a plugin's console command when the text starts with one; false when none matched. bool run_console(std::string_view text); std::vector status() const; @@ -91,6 +96,7 @@ private: std::optional skater() override { return environment_.skater(); } Teleport teleport(const Vec3& position, float heading) override { return environment_.teleport(position, heading); } std::optional raycast(const Vec3& from, const Vec3& to) override { return environment_.raycast(from, to); } + std::optional score() override { return environment_.score(); } Environment& environment_; Side side_; diff --git a/Extension/Plugins/plugin_runtime.cpp b/Extension/Plugins/plugin_runtime.cpp index 6a2690e4..60c418c6 100644 --- a/Extension/Plugins/plugin_runtime.cpp +++ b/Extension/Plugins/plugin_runtime.cpp @@ -5,6 +5,8 @@ #include "Engine/Game/Multiplayer/session_tools.h" #include "Extension/Console/commands.h" #include "Extension/Profile/local_profile_runtime.h" +#include "Extension/Skater/score_hud.h" +#include "Extension/Skater/trick_watch.h" #include "Extension/Trainer/trainer.h" #include #include @@ -53,6 +55,8 @@ public: return teleport_local_skater_heading(position, heading) ? Teleport::sent : Teleport::unavailable; } std::optional raycast(const Vec3& from, const Vec3& to) override { return local_surface_hit(from, to); } + std::optional score() override { return score_now; } + std::optional score_now; // read each tick while a plugin wants tricks private: std::filesystem::path storage_; @@ -68,6 +72,55 @@ std::vector found; // under found_mutex std::uint64_t found_generation{}; // under found_mutex std::uint64_t applied_generation{}; std::chrono::steady_clock::time_point last_tick{}; +std::uintptr_t base{}; +// The local skater's tricks (Extension/Skater/trick_watch.h), and where and when the one going on began. +tricks::TrickWatch trick_watch; +Vec3 started_at{}; +std::chrono::steady_clock::time_point started_time{}; +std::string watched_level; + +// The score HUD into the trick watch, and what it saw to the plugins. Client thread. +void read_tricks() { + const auto hud = tricks::read_score_hud(base); + if (!hud) { + environment->score_now.reset(); + return; + } + const auto skater = trainer::telemetry(); + tricks::TrickSignals signals; + signals.action = hud->action; + signals.line_score = hud->line_score; + signals.multiplier = hud->multiplier; + signals.slam = hud->slam; + // A wipeout is physics states 300-399 (as Domination reads it). + signals.wipeout = skater.skater && skater.physics_state >= 300 && skater.physics_state < 400; + const auto event = trick_watch.update(signals); + environment->score_now = ScoreState{hud->line_score, hud->multiplier, trick_watch.in_trick()}; + const auto now = std::chrono::steady_clock::now(); + TrickNews news; + switch (event.kind) { + case tricks::TrickEvent::Kind::none: return; + case tricks::TrickEvent::Kind::started: + started_at = skater.position; + started_time = now; + news.kind = TrickNews::Kind::started; + news.started_at = started_at; + break; + case tricks::TrickEvent::Kind::landed: + news.kind = TrickNews::Kind::landed; + news.points = event.points; + news.multiplier = event.multiplier; + news.started_at = started_at; + news.landed_at = skater.position; + news.ms = std::chrono::duration(now - started_time).count(); + break; + case tricks::TrickEvent::Kind::broken: + news.kind = TrickNews::Kind::broken; + news.reason = signals.slam ? "slam" : "wipeout"; + break; + } + host->trick(news); +} void watch() { FileChecks checks; @@ -96,9 +149,10 @@ std::filesystem::path local_storage() { } } -bool start() noexcept { +bool start(std::uintptr_t image_base) noexcept { try { if (host) return true; + base = image_base; std::array path{}; const auto length = GetModuleFileNameW(nullptr, path.data(), static_cast(path.size())); if (!length || length >= path.size()) return false; @@ -136,6 +190,8 @@ void on_client_tick() noexcept { const double dt = last_tick == std::chrono::steady_clock::time_point{} ? 0 : std::chrono::duration(now - last_tick).count(); last_tick = now; + if (host->wants_tricks()) read_tricks(); + else environment->score_now.reset(); host->tick(dt); } catch (const std::exception& error) { logging::log(logging::Level::error, logging::Channel::plugins, "Plugin tick failed: {}", error.what()); @@ -144,7 +200,14 @@ void on_client_tick() noexcept { void set_level(std::string_view level) noexcept { if (!host) return; - try { host->set_level(std::string(level)); } + try { + // A new level starts a new line: a trick going on before it scores nothing. + if (level != watched_level) { + watched_level = std::string(level); + trick_watch = tricks::TrickWatch{}; + } + host->set_level(std::string(level)); + } catch (const std::exception& error) { logging::log(logging::Level::error, logging::Channel::plugins, "Plugin level change failed: {}", error.what()); } diff --git a/Extension/Plugins/plugin_runtime.h b/Extension/Plugins/plugin_runtime.h index 0f7f30ec..7a264acc 100644 --- a/Extension/Plugins/plugin_runtime.h +++ b/Extension/Plugins/plugin_runtime.h @@ -1,4 +1,5 @@ #pragma once +#include #include #include @@ -8,8 +9,9 @@ // // Every function but start is for the client thread, the one that runs console commands. namespace dingosdk::plugins { -// Once, at startup. Plugins load at the first client tick after discovery finds them. -bool start() noexcept; +// Once, at startup, with the game's image base (the score HUD is read from it). Plugins load at the +// first client tick after discovery finds them. +bool start(std::uintptr_t image_base) noexcept; void on_client_tick() noexcept; // The level being played, or empty without one: plugins hear level_loaded and level_unloading. void set_level(std::string_view level) noexcept; diff --git a/Extension/Plugins/plugin_vm.cpp b/Extension/Plugins/plugin_vm.cpp index a9e3f6aa..49544140 100644 --- a/Extension/Plugins/plugin_vm.cpp +++ b/Extension/Plugins/plugin_vm.cpp @@ -281,6 +281,13 @@ void Plugin::unload() { } void Plugin::fire(std::string_view event, const std::vector& arguments) { + fire_with(event, [&](lua_State* L) { + for (const auto& argument : arguments) lua_pushlstring(L, argument.data(), argument.size()); + return static_cast(arguments.size()); + }); +} + +void Plugin::fire_with(std::string_view event, const std::function& push) { if (state_ != State::running) return; const auto found = handlers_.find(event); if (found == handlers_.end()) return; @@ -289,8 +296,54 @@ void Plugin::fire(std::string_view event, const std::vector& argume for (const int handler : handlers) { if (state_ != State::running) return; lua_getref(L_, handler); - for (const auto& argument : arguments) lua_pushlstring(L_, argument.data(), argument.size()); - call(static_cast(arguments.size()), what, limits_.call_ms); + int count = 0; + try { + count = push(L_); + } catch (const std::exception& error) { + // Out of memory building the event: the VM is closed next, so its stack is left as it is. + stop(std::string("its event could not be handed over: ") + error.what()); + return; + } + call(count, what, limits_.call_ms); + } +} + +void Plugin::trick(const TrickNews& news) { + if (state_ != State::running || !has("events.tricks")) return; + const auto vector = [](lua_State* L, const Vec3& v) { lua_pushvector(L, v[0], v[1], v[2]); }; + switch (news.kind) { + case TrickNews::Kind::started: + fire_with("trick_started", [&](lua_State* L) { + lua_createtable(L, 0, 1); + vector(L, news.started_at); + lua_setfield(L, -2, "at"); + return 1; + }); + break; + case TrickNews::Kind::landed: + fire_with("trick_landed", [&](lua_State* L) { + lua_createtable(L, 0, 5); + lua_pushnumber(L, news.points); + lua_setfield(L, -2, "points"); + lua_pushnumber(L, news.multiplier); + lua_setfield(L, -2, "multiplier"); + vector(L, news.started_at); + lua_setfield(L, -2, "started_at"); + vector(L, news.landed_at); + lua_setfield(L, -2, "landed_at"); + lua_pushnumber(L, news.ms); + lua_setfield(L, -2, "ms"); + return 1; + }); + break; + case TrickNews::Kind::broken: + fire_with("line_broken", [&](lua_State* L) { + lua_createtable(L, 0, 1); + lua_pushlstring(L, news.reason.data(), news.reason.size()); + lua_setfield(L, -2, "reason"); + return 1; + }); + break; } } diff --git a/Extension/Plugins/plugin_vm.h b/Extension/Plugins/plugin_vm.h index 7ee94b1b..ba236c1b 100644 --- a/Extension/Plugins/plugin_vm.h +++ b/Extension/Plugins/plugin_vm.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -55,6 +56,18 @@ struct SkaterState { float air_time{}; // seconds into the jump going on }; enum class Teleport { sent, unavailable, forbidden }; +// A trick of the local skater, read from the score HUD (Extension/Skater/trick_watch.h). +struct TrickNews { + enum class Kind { started, landed, broken } kind{Kind::started}; + float points{}, multiplier{}; // landed + Vec3 started_at{}, landed_at{}; + double ms{}; // landed: from its start + std::string reason; // broken: "slam" or "wipeout" +}; +struct ScoreState { + float line{}, multiplier{}; + bool in_trick{}; +}; // What a plugin's VM can reach outside itself. The host implements it. class Services { @@ -72,6 +85,8 @@ public: virtual Teleport teleport(const Vec3& position, float heading) = 0; // Where the line first meets the world's collision, or empty when it is clear (or not loaded). virtual std::optional raycast(const Vec3& from, const Vec3& to) = 0; + // The score HUD now, or empty without one. + virtual std::optional score() = 0; }; enum class State { loading, running, stopped }; @@ -95,6 +110,8 @@ public: void tick(double dt_ms); void level_changed(const std::string& previous, const std::string& current); + // trick_started, trick_landed or line_broken, to a plugin with events.tricks. + void trick(const TrickNews& news); // A console command it registered: the words after the command's name. void run_command(const std::string& name, const std::vector& arguments); void save_storage(); @@ -133,6 +150,8 @@ private: // The compiled chunk of a source file on the main stack, or false and the error on it. bool load_chunk(lua_State* L, const std::string& relative, const std::filesystem::path& file); void fire(std::string_view event, const std::vector& arguments); + // Each handler of `event` with what `push` puts on the stack (it returns how many values). + void fire_with(std::string_view event, const std::function& push); void close(); std::string id_; diff --git a/Runtime/runtime_init.cpp b/Runtime/runtime_init.cpp index 394a63fa..5974c6cf 100644 --- a/Runtime/runtime_init.cpp +++ b/Runtime/runtime_init.cpp @@ -445,7 +445,7 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { const bool camera_hook = dingosdk::start_camera_observer(r.base); record("{\"event\":\"camera_observer_initialized\",\"active\":" + std::string(camera_hook ? "true" : "false") + "}"); (void)dingosdk::start_no_bail(r.base); - const bool plugins = dingosdk::plugins::start(); + const bool plugins = dingosdk::plugins::start(r.base); record("{\"event\":\"plugins_initialized\",\"active\":" + std::string(plugins ? "true" : "false") + "}"); const bool meat = dingosdk::hall_of_meat::start(r.base); record("{\"event\":\"hall_of_meat_initialized\",\"active\":" + std::string(meat ? "true" : "false") + "}"); diff --git a/contrib/plugins/reskate-hostile/plugin/main.lua b/contrib/plugins/reskate-hostile/plugin/main.lua index 2a6fcc9f..6b71ae2b 100644 --- a/contrib/plugins/reskate-hostile/plugin/main.lua +++ b/contrib/plugins/reskate-hostile/plugin/main.lua @@ -73,6 +73,11 @@ attack("raycast-flood", "keeps running: refused past 64 raycasts a tick", functi for i = 1, 100000 do reskate.world.raycast(vector.create(0, 100, 0), vector.create(0, -100, 0)) end end) +attack("trick-loop", "stopped at the next trick landed", function() + reskate.on("trick_landed", function() while true do end end) + reskate.console.print("hostile: now land a trick") +end) + -- Each returns true when that way out worked. local escapes = { {"io", function() return io ~= nil end}, diff --git a/contrib/plugins/reskate-hostile/reskate-plugin.json b/contrib/plugins/reskate-hostile/reskate-plugin.json index 326c3239..21b97eb7 100644 --- a/contrib/plugins/reskate-hostile/reskate-plugin.json +++ b/contrib/plugins/reskate-hostile/reskate-plugin.json @@ -2,5 +2,5 @@ "api": 1, "entry": "plugin/main.lua", "side": "client", - "permissions": ["console", "skater.read", "skater.teleport", "world.raycast"] + "permissions": ["console", "skater.read", "skater.teleport", "world.raycast", "events.tricks"] } diff --git a/contrib/plugins/reskate-test/plugin/checks/tricks.lua b/contrib/plugins/reskate-test/plugin/checks/tricks.lua new file mode 100644 index 00000000..282507cf --- /dev/null +++ b/contrib/plugins/reskate-test/plugin/checks/tricks.lua @@ -0,0 +1,63 @@ +local h = require("harness") +local t = h.module("tricks") + +-- What the trick events said so far, and `ptest trick` waiting for one. +local seen = {started = 0, landed = 0, broken = 0} +local last_landed = nil +local waiting_until = nil + +local function shown(v) + return string.format("(%.1f, %.1f, %.1f)", v.x, v.y, v.z) +end + +reskate.on("trick_started", function(e) + seen.started += 1 + if waiting_until then reskate.console.print("ptest trick: started at " .. shown(e.at)) end +end) +reskate.on("trick_landed", function(e) + seen.landed += 1 + last_landed = e + if waiting_until then + waiting_until = nil + reskate.console.print(string.format("ptest trick: landed %.0f points at x%.1f in %.0f ms, from %s to %s", + e.points, e.multiplier, e.ms, shown(e.started_at), shown(e.landed_at))) + end +end) +reskate.on("line_broken", function(e) + seen.broken += 1 + if waiting_until then reskate.console.print("ptest trick: the line broke: " .. e.reason) end +end) +reskate.on("tick", function() + if waiting_until and reskate.time.now() > waiting_until then + waiting_until = nil + reskate.console.print("ptest trick: no trick landed in 30 s") + end +end) + +-- By hand: ptest trick, then land a trick within 30 s. +reskate.console.command("ptest trick", "Waits 30 s for a trick and prints what the trick events say", function() + waiting_until = reskate.time.now() + 30000 + reskate.console.print("ptest trick: land a trick") +end) + +t.check("a trick event needs a function", function() + h.refused(reskate.on, "trick_landed", 5) +end) +t.check("score.current is nil without a score HUD, else the line", function() + local s = reskate.score.current() + if s == nil then return h.skip("no score HUD (in the menu, or on foot)") end + assert(type(s.line) == "number" and s.line >= 0, "line " .. tostring(s.line)) + assert(type(s.multiplier) == "number" and s.multiplier >= 0, "multiplier " .. tostring(s.multiplier)) + assert(type(s.in_trick) == "boolean") +end) +t.check("a landed trick says its points, multiplier, places and time", function() + if last_landed == nil then return h.skip("no trick landed since it loaded") end + local e = last_landed + assert(e.points >= 0 and e.multiplier >= 0 and e.ms >= 0, "points, multiplier, ms") + assert(typeof(e.started_at) == "vector" and typeof(e.landed_at) == "vector", "places are vectors") + assert(vector.magnitude(e.landed_at - e.started_at) < 200, "it went more than 200 m in one trick") +end) +t.check("tricks land after they start", function() + if seen.landed == 0 then return h.skip("no trick landed since it loaded") end + assert(seen.started >= seen.landed, seen.started .. " started, " .. seen.landed .. " landed") +end) diff --git a/contrib/plugins/reskate-test/plugin/main.lua b/contrib/plugins/reskate-test/plugin/main.lua index 8f839193..e198e820 100644 --- a/contrib/plugins/reskate-test/plugin/main.lua +++ b/contrib/plugins/reskate-test/plugin/main.lua @@ -2,7 +2,7 @@ -- `ptest ` 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", "skater"} do +for _, name in {"base", "time", "level", "package", "json", "require", "console", "storage", "sandbox", "skater", "tricks"} do require("checks/" .. name) end