Merge pull request #16707 from IntelOrca/plugin/intransient-2

Add initial functionality for intransient plugins. Intransient plugins are a new type of plugin that will start when OpenRCT2 launches, and remain loaded until shutdown. This provides plugins with the ability to hook map change and provide functionality on other screens such as the title menu.

**Example:**
```js
registerPlugin({
    type: 'intransient',
   ...
});
```

The [title sequence editor](https://github.com/OpenRCT2/title-sequence-editor) will be an intransient plugin so that it can be used from the title screen like the built in one.

Three new APIs have been introduced alongside this.
* `context.mode`
* `context.subscribe('map.change', callback)`
* `context.subscribe('map.changed', callback)`
* `ui.registerToolboxMenuItem(text, callback)`
This commit is contained in:
Ted John 2022-03-22 21:48:26 +00:00 committed by GitHub
commit a2f7ffcb71
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 617 additions and 142 deletions

View File

@ -16,6 +16,8 @@
- Feature: [#16097] The Looping Roller Coaster can now draw all elements from the LIM Launched Roller Coaster.
- Feature: [#16132, #16389] The Corkscrew, Twister and Vertical Drop Roller Coasters can now draw inline twists.
- Feature: [#16144] [Plugin] Add ImageManager to API.
- Feature: [#16707] [Plugin] Implement intransient plugins.
- Feature: [#16707] [Plugin] New API for current mode, map.change hook and toolbox menu items on title screen.
- Feature: [#16731] [Plugin] New API for fetching and manipulating a staff member's patrol area.
- Feature: [#16800] [Plugin] Add lift hill speed properties to API.
- Feature: [#16806] Parkobj can load sprites from RCT image archives.

View File

@ -15,7 +15,7 @@
// /// <reference path="/path/to/openrct2.d.ts" />
//
export type PluginType = "local" | "remote";
export type PluginType = "local" | "remote" | "intransient";
declare global {
/**
@ -173,7 +173,7 @@ declare global {
/**
* The user's current configuration.
*/
configuration: Configuration;
readonly configuration: Configuration;
/**
* Shared generic storage for all plugins. Data is persistent across instances
@ -183,7 +183,7 @@ declare global {
* the `set` method, do not rely on the file being saved by modifying your own
* objects. Functions and other internal structures will not be persisted.
*/
sharedStorage: Configuration;
readonly sharedStorage: Configuration;
/**
* Gets the storage for the current plugin if no name is specified.
@ -199,6 +199,12 @@ declare global {
*/
getParkStorage(pluginName?: string): Configuration;
/**
* The current mode / screen the game is in. Can be used for example to check
* whether the game is currently on the title screen or in the scenario editor.
*/
readonly mode: GameMode;
/**
* Render the current state of the map and save to disc.
* Useful for server administration and timelapse creation.
@ -286,6 +292,12 @@ declare global {
subscribe(hook: "guest.generation", callback: (e: GuestGenerationArgs) => void): IDisposable;
subscribe(hook: "vehicle.crash", callback: (e: VehicleCrashArgs) => void): IDisposable;
subscribe(hook: "map.save", callback: () => void): IDisposable;
subscribe(hook: "map.change", callback: () => void): IDisposable;
/**
* Can only be used in intransient plugins.
*/
subscribe(hook: "map.changed", callback: () => void): IDisposable;
/**
* Registers a function to be called every so often in realtime, specified by the given delay.
@ -365,6 +377,13 @@ declare global {
transparent?: boolean;
}
type GameMode =
"normal" |
"title" |
"scenario_editor" |
"track_designer" |
"track_manager";
type ObjectType =
"ride" |
"small_scenery" |
@ -387,7 +406,7 @@ declare global {
"interval.tick" | "interval.day" |
"network.chat" | "network.action" | "network.join" | "network.leave" |
"ride.ratings.calculate" | "action.location" | "vehicle.crash" |
"map.save";
"map.change" | "map.changed" | "map.save";
type ExpenditureType =
"ride_construction" |
@ -2106,6 +2125,14 @@ declare global {
registerMenuItem(text: string, callback: () => void): void;
/**
* Registers a new item in the toolbox menu on the title screen.
* Only available to intransient plugins.
* @param text The menu item text.
* @param callback The function to call when the menu item is clicked.
*/
registerToolboxMenuItem(text: string, callback: () => void): void;
registerShortcut(desc: ShortcutDesc): void;
}

View File

@ -10,14 +10,17 @@ Each script is a single physical javascript file within the `plugin` directory i
OpenRCT2 will load every single file with the extension `.js` in this directory recursively. So if you want to prevent a plug-in from being used, you must move it outside this directory, or rename it so the filename does not end with `.js`.
There are two types of scripts:
There are three types of scripts:
* Local
* Remote
* Intransient
Local scripts can **not** alter the game state. This allows each player to enable any local script for their own game without other players needing to also enable the same script. These scripts tend to provide extra tools for productivity, or new windows containing information.
Remote scripts on the other hand can alter the game state in certain contexts, thus must be enabled for every player in a multiplayer game. Players **cannot** enable or disable remote scripts for multiplayer servers they join. Instead the server will upload any remote scripts that have been enabled on the server to each player. This allows servers to enable scripts without players needing to manually download or enable the same script on their end.
Intransient scripts are similar to local scripts, in that they can **not** alter the game state. However they are loaded at the very start of launching OpenRCT2 and remain loaded until shutdown. This allows the plugin to provide functionality across different screens such as the title screen.
The authors must also define a licence for the plug-in, making it clear to the community whether that plug-in can be altered, copied, etc. A good reference material is listed on [ChooseALlicense](https://choosealicense.com/appendix/), try to pick one of them and use its corresponding identifier, as listed on [SPDX](https://spdx.org/licenses/).
## Writing Scripts
@ -92,7 +95,7 @@ Debugging has not yet been implemented, but is planned. In the meantime, you can
> What does the error 'Game state is not mutable in this context' mean?
This means you are attempting to modify the game state (e.g. change the park, map or guests etc.) in a context where you should not be doing so. This might be because your script is defined as `local`, meaning it must work independently of other players, not having the script enabled, or a remote script attempting to modify the game in the main function or a user interface event.
This means you are attempting to modify the game state (e.g. change the park, map or guests etc.) in a context where you should not be doing so. This might be because your script is defined as `local` or `intransient`, meaning it must work independently of other players, not having the script enabled, or a remote script attempting to modify the game in the main function or a user interface event.
Any changes to the game state must be synchronised across all players so that the same changes happen on the same tick for every player. This prevents the game going out of sync. To do this you must only change game state in a compatible hook such as `interval.day` or in the execute method of a game action. Game actions allow players to make specific changes to the game providing they have the correct permissions and the server allows it.

View File

@ -279,7 +279,6 @@ namespace OpenRCT2::Scripting
duk_error(scriptEngine.GetContext(), DUK_ERR_ERROR, "Invalid parameters.");
}
}
} // namespace OpenRCT2::Scripting
#endif

View File

@ -23,15 +23,24 @@ enum class CursorID : uint8_t;
namespace OpenRCT2::Scripting
{
enum class CustomToolbarMenuItemKind
{
Standard,
Toolbox,
};
class CustomToolbarMenuItem
{
public:
std::shared_ptr<Plugin> Owner;
CustomToolbarMenuItemKind Kind;
std::string Text;
DukValue Callback;
CustomToolbarMenuItem(std::shared_ptr<Plugin> owner, const std::string& text, DukValue callback)
CustomToolbarMenuItem(
std::shared_ptr<Plugin> owner, CustomToolbarMenuItemKind kind, const std::string& text, DukValue callback)
: Owner(owner)
, Kind(kind)
, Text(text)
, Callback(callback)
{

View File

@ -214,7 +214,14 @@ namespace OpenRCT2::Scripting
parkImporter->Import();
auto old = gLoadKeepWindowsOpen;
gLoadKeepWindowsOpen = true;
// Unless we are already in the game, we have to re-create the windows
// so that the game toolbars are created.
if (gScreenFlags == SCREEN_FLAGS_PLAYING)
{
gLoadKeepWindowsOpen = true;
}
if (isScenario)
scenario_begin();
else

View File

@ -313,7 +313,21 @@ namespace OpenRCT2::Scripting
{
auto& execInfo = _scriptEngine.GetExecInfo();
auto owner = execInfo.GetCurrentPlugin();
CustomMenuItems.emplace_back(owner, text, callback);
CustomMenuItems.emplace_back(owner, CustomToolbarMenuItemKind::Standard, text, callback);
}
void registerToolboxMenuItem(const std::string& text, DukValue callback)
{
auto& execInfo = _scriptEngine.GetExecInfo();
auto owner = execInfo.GetCurrentPlugin();
if (owner->GetMetadata().Type == PluginType::Intransient)
{
CustomMenuItems.emplace_back(owner, CustomToolbarMenuItemKind::Toolbox, text, callback);
}
else
{
duk_error(_scriptEngine.GetContext(), DUK_ERR_ERROR, "Plugin must be intransient.");
}
}
void registerShortcut(DukValue desc)
@ -364,6 +378,7 @@ namespace OpenRCT2::Scripting
dukglue_register_method(ctx, &ScUi::showScenarioSelect, "showScenarioSelect");
dukglue_register_method(ctx, &ScUi::activateTool, "activateTool");
dukglue_register_method(ctx, &ScUi::registerMenuItem, "registerMenuItem");
dukglue_register_method(ctx, &ScUi::registerToolboxMenuItem, "registerToolboxMenuItem");
dukglue_register_method(ctx, &ScUi::registerShortcut, "registerShortcut");
}

View File

@ -286,9 +286,14 @@ private:
auto parkHandle = TitleSequenceGetParkHandle(*_sequence, saveIndex);
if (parkHandle != nullptr)
{
game_notify_map_change();
loadSuccess = LoadParkFromStream(parkHandle->Stream.get(), parkHandle->HintPath);
}
if (!loadSuccess)
if (loadSuccess)
{
game_notify_map_changed();
}
else
{
if (_sequence->Saves.size() > saveIndex)
{
@ -305,9 +310,14 @@ private:
auto scenario = GetScenarioRepository()->GetByInternalName(command.Scenario);
if (scenario != nullptr)
{
game_notify_map_change();
loadSuccess = LoadParkFromFile(scenario->path);
}
if (!loadSuccess)
if (loadSuccess)
{
game_notify_map_changed();
}
else
{
Console::Error::WriteLine("Failed to load: \"%s\" for the title sequence.", command.Scenario);
return false;

View File

@ -337,6 +337,7 @@ public:
}
if (gScreenFlags & SCREEN_FLAGS_TRACK_MANAGER)
{
game_notify_map_change();
game_unload_scripts();
title_load();
}

View File

@ -126,7 +126,7 @@ static void WindowServerStartClose(rct_window* w)
static void WindowServerStartScenarioselectCallback(const utf8* path)
{
network_set_password(_password);
game_notify_map_change();
if (context_load_park_from_file(path))
{
network_begin_server(gConfigNetwork.default_port, gConfigNetwork.listen_address.c_str());
@ -135,8 +135,10 @@ static void WindowServerStartScenarioselectCallback(const utf8* path)
static void WindowServerStartLoadsaveCallback(int32_t result, const utf8* path)
{
if (result == MODAL_RESULT_OK && context_load_park_from_file(path))
if (result == MODAL_RESULT_OK)
{
game_notify_map_change();
context_load_park_from_file(path);
network_begin_server(gConfigNetwork.default_port, gConfigNetwork.listen_address.c_str());
}
}
@ -185,6 +187,7 @@ static void WindowServerStartMouseup(rct_window* w, rct_widgetindex widgetIndex)
w->Invalidate();
break;
case WIDX_START_SERVER:
network_set_password(_password);
WindowScenarioselectOpen(WindowServerStartScenarioselectCallback, false);
break;
case WIDX_LOAD_SERVER:

View File

@ -9,6 +9,7 @@
#include <openrct2-ui/interface/Dropdown.h>
#include <openrct2-ui/interface/Widget.h>
#include <openrct2-ui/scripting/CustomMenu.h>
#include <openrct2-ui/windows/Window.h>
#include <openrct2/Context.h>
#include <openrct2/Editor.h>
@ -31,6 +32,16 @@ enum {
WIDX_NEW_VERSION,
};
enum
{
DDIDX_SCENARIO_EDITOR,
DDIDX_CONVERT_SAVED_GAME,
DDIDX_TRACK_DESIGNER,
DDIDX_TRACK_MANAGER,
DDIDX_OPEN_CONTENT_FOLDER,
DDIDX_CUSTOM_BEGIN = 6,
};
static ScreenRect _filterRect;
static constexpr ScreenSize MenuButtonDims = { 82, 82 };
static constexpr ScreenSize UpdateButtonDims = { MenuButtonDims.width * 4, 28 };
@ -106,8 +117,10 @@ rct_window* WindowTitleMenuOpen()
static void WindowTitleMenuScenarioselectCallback(const utf8* path)
{
game_notify_map_change();
OpenRCT2::GetContext()->LoadParkFromFile(path, false, true);
game_load_scripts();
game_notify_map_changed();
}
static void WindowTitleMenuMouseup(rct_window* w, rct_widgetindex widgetIndex)
@ -166,36 +179,89 @@ static void WindowTitleMenuMousedown(rct_window* w, rct_widgetindex widgetIndex,
{
if (widgetIndex == WIDX_GAME_TOOLS)
{
gDropdownItems[0].Format = STR_SCENARIO_EDITOR;
gDropdownItems[1].Format = STR_CONVERT_SAVED_GAME_TO_SCENARIO;
gDropdownItems[2].Format = STR_ROLLER_COASTER_DESIGNER;
gDropdownItems[3].Format = STR_TRACK_DESIGNS_MANAGER;
gDropdownItems[4].Format = STR_OPEN_USER_CONTENT_FOLDER;
int32_t i = 0;
gDropdownItems[i++].Format = STR_SCENARIO_EDITOR;
gDropdownItems[i++].Format = STR_CONVERT_SAVED_GAME_TO_SCENARIO;
gDropdownItems[i++].Format = STR_ROLLER_COASTER_DESIGNER;
gDropdownItems[i++].Format = STR_TRACK_DESIGNS_MANAGER;
gDropdownItems[i++].Format = STR_OPEN_USER_CONTENT_FOLDER;
#ifdef ENABLE_SCRIPTING
auto hasCustomItems = false;
const auto& customMenuItems = OpenRCT2::Scripting::CustomMenuItems;
if (!customMenuItems.empty())
{
for (const auto& item : customMenuItems)
{
if (item.Kind == OpenRCT2::Scripting::CustomToolbarMenuItemKind::Toolbox)
{
// Add seperator
if (!hasCustomItems)
{
hasCustomItems = true;
gDropdownItems[i++].Format = STR_EMPTY;
}
gDropdownItems[i].Format = STR_STRING;
auto sz = item.Text.c_str();
std::memcpy(&gDropdownItems[i].Args, &sz, sizeof(const char*));
i++;
}
}
}
#endif
int32_t yOffset = 0;
if (i > 5)
{
yOffset = -(widget->height() + 5 + (i * 12));
}
WindowDropdownShowText(
{ w->windowPos.x + widget->left, w->windowPos.y + widget->top }, widget->height() + 1, TRANSLUCENT(w->colours[0]),
Dropdown::Flag::StayOpen, 5);
{ w->windowPos.x + widget->left, w->windowPos.y + widget->top + yOffset }, widget->height() + 1,
TRANSLUCENT(w->colours[0]), Dropdown::Flag::StayOpen, i);
}
}
static void InvokeCustomToolboxMenuItem(size_t index)
{
#ifdef ENABLE_SCRIPTING
const auto& customMenuItems = OpenRCT2::Scripting::CustomMenuItems;
size_t i = 0;
for (const auto& item : customMenuItems)
{
if (item.Kind == OpenRCT2::Scripting::CustomToolbarMenuItemKind::Toolbox)
{
if (i == index)
{
item.Invoke();
break;
}
i++;
}
}
#endif
}
static void WindowTitleMenuDropdown(rct_window* w, rct_widgetindex widgetIndex, int32_t dropdownIndex)
{
if (widgetIndex == WIDX_GAME_TOOLS)
{
switch (dropdownIndex)
{
case 0:
case DDIDX_SCENARIO_EDITOR:
Editor::Load();
break;
case 1:
case DDIDX_CONVERT_SAVED_GAME:
Editor::ConvertSaveToScenario();
break;
case 2:
case DDIDX_TRACK_DESIGNER:
Editor::LoadTrackDesigner();
break;
case 3:
case DDIDX_TRACK_MANAGER:
Editor::LoadTrackManager();
break;
case 4:
case DDIDX_OPEN_CONTENT_FOLDER:
{
auto context = OpenRCT2::GetContext();
auto env = context->GetPlatformEnvironment();
@ -203,6 +269,9 @@ static void WindowTitleMenuDropdown(rct_window* w, rct_widgetindex widgetIndex,
uiContext->OpenFolder(env->GetDirectoryPath(OpenRCT2::DIRBASE::USER));
break;
}
default:
InvokeCustomToolboxMenuItem(dropdownIndex - DDIDX_CUSTOM_BEGIN);
break;
}
}
}

View File

@ -535,8 +535,10 @@ static void WindowTopToolbarMousedown(rct_window* w, rct_widgetindex widgetIndex
static void WindowTopToolbarScenarioselectCallback(const utf8* path)
{
window_close_by_class(WC_EDITOR_OBJECT_SELECTION);
game_notify_map_change();
GetContext()->LoadParkFromFile(path, false, true);
game_load_scripts();
game_notify_map_changed();
}
/**
@ -3314,10 +3316,13 @@ static void TopToolbarInitMapMenu(rct_window* w, rct_widget* widget)
gDropdownItems[i++].Format = STR_EMPTY;
for (const auto& item : customMenuItems)
{
gDropdownItems[i].Format = STR_STRING;
auto sz = item.Text.c_str();
std::memcpy(&gDropdownItems[i].Args, &sz, sizeof(const char*));
i++;
if (item.Kind == OpenRCT2::Scripting::CustomToolbarMenuItemKind::Standard)
{
gDropdownItems[i].Format = STR_STRING;
auto sz = item.Text.c_str();
std::memcpy(&gDropdownItems[i].Args, &sz, sizeof(const char*));
i++;
}
}
}
#endif
@ -3355,9 +3360,18 @@ static void TopToolbarMapMenuDropdown(int16_t dropdownIndex)
#ifdef ENABLE_SCRIPTING
const auto& customMenuItems = OpenRCT2::Scripting::CustomMenuItems;
auto customIndex = static_cast<size_t>(dropdownIndex - customStartIndex);
if (customMenuItems.size() > customIndex)
size_t i = 0;
for (const auto& item : customMenuItems)
{
customMenuItems[customIndex].Invoke();
if (item.Kind == OpenRCT2::Scripting::CustomToolbarMenuItemKind::Standard)
{
if (i == customIndex)
{
item.Invoke();
break;
}
i++;
}
}
#endif
}

View File

@ -174,6 +174,10 @@ namespace OpenRCT2
// NOTE: We must shutdown all systems here before Instance is set back to null.
// If objects use GetContext() in their destructor things won't go well.
#ifdef ENABLE_SCRIPTING
_scriptEngine.StopUnloadRegisterAllPlugins();
#endif
GameActions::ClearQueue();
#ifndef DISABLE_NETWORK
_network.Close();
@ -503,6 +507,10 @@ namespace OpenRCT2
_gameState = std::make_unique<GameState>();
_gameState->InitAll(DEFAULT_MAP_SIZE);
#ifdef ENABLE_SCRIPTING
_scriptEngine.Initialise();
#endif
_titleScreen = std::make_unique<TitleScreen>(*_gameState);
_uiContext->Initialise();
@ -918,6 +926,7 @@ namespace OpenRCT2
#endif // DISABLE_NETWORK
{
game_load_scripts();
game_notify_map_changed();
}
break;
}

View File

@ -89,6 +89,10 @@ uint32_t gCurrentRealTimeTicks;
rct_string_id gGameCommandErrorTitle;
rct_string_id gGameCommandErrorText;
#ifdef ENABLE_SCRIPTING
static bool _mapChangedExpected;
#endif
using namespace OpenRCT2;
void game_reset_speed()
@ -502,14 +506,42 @@ void game_load_init()
void game_load_scripts()
{
#ifdef ENABLE_SCRIPTING
GetContext()->GetScriptEngine().LoadPlugins();
GetContext()->GetScriptEngine().LoadTransientPlugins();
#endif
}
void game_unload_scripts()
{
#ifdef ENABLE_SCRIPTING
GetContext()->GetScriptEngine().UnloadPlugins();
GetContext()->GetScriptEngine().UnloadTransientPlugins();
#endif
}
void game_notify_map_change()
{
#ifdef ENABLE_SCRIPTING
// Ensure we don't get a two lots of change events
if (_mapChangedExpected)
return;
using namespace OpenRCT2::Scripting;
auto& scriptEngine = GetContext()->GetScriptEngine();
auto& hookEngine = scriptEngine.GetHookEngine();
hookEngine.Call(HOOK_TYPE::MAP_CHANGE, false);
_mapChangedExpected = true;
#endif
}
void game_notify_map_changed()
{
#ifdef ENABLE_SCRIPTING
using namespace OpenRCT2::Scripting;
auto& scriptEngine = GetContext()->GetScriptEngine();
auto& hookEngine = scriptEngine.GetHookEngine();
hookEngine.Call(HOOK_TYPE::MAP_CHANGED, false);
_mapChangedExpected = false;
#endif
}
@ -692,10 +724,12 @@ static void game_load_or_quit_no_save_prompt_callback(int32_t result, const utf8
{
if (result == MODAL_RESULT_OK)
{
game_notify_map_change();
game_unload_scripts();
window_close_by_class(WC_EDITOR_OBJECT_SELECTION);
context_load_park_from_file(path);
game_load_scripts();
game_notify_map_changed();
}
}
@ -736,6 +770,7 @@ void game_load_or_quit_no_save_prompt()
}
gGameSpeed = 1;
gFirstTimeSaving = true;
game_notify_map_change();
game_unload_scripts();
title_load();
break;

View File

@ -160,6 +160,8 @@ void load_from_sv6(const char* path);
void game_load_init();
void game_load_scripts();
void game_unload_scripts();
void game_notify_map_change();
void game_notify_map_changed();
void pause_toggle();
bool game_is_paused();
bool game_is_not_paused();

View File

@ -203,6 +203,11 @@ void NetworkBase::Close()
_pendingPlayerLists.clear();
_pendingPlayerInfo.clear();
# ifdef ENABLE_SCRIPTING
auto& scriptEngine = GetContext().GetScriptEngine();
scriptEngine.RemoveNetworkPlugins();
# endif
gfx_invalidate_screen();
_requireClose = false;
@ -403,6 +408,7 @@ bool NetworkBase::BeginServer(uint16_t port, const std::string& address)
_advertiser = CreateServerAdvertiser(listening_port);
game_load_scripts();
game_notify_map_changed();
return true;
}
@ -2680,6 +2686,9 @@ void NetworkBase::Client_Handle_MAP([[maybe_unused]] NetworkConnection& connecti
GameActions::ResumeQueue();
context_force_close_window_by_class(WC_NETWORK_STATUS);
game_unload_scripts();
game_notify_map_change();
bool has_to_free = false;
uint8_t* data = &chunk_buffer[0];
size_t data_size = size;
@ -2688,6 +2697,7 @@ void NetworkBase::Client_Handle_MAP([[maybe_unused]] NetworkConnection& connecti
{
game_load_init();
game_load_scripts();
game_notify_map_changed();
_serverState.tick = gCurrentTicks;
// window_network_status_open("Loaded new map from network");
_serverState.state = NetworkServerState::Ok;

View File

@ -31,6 +31,8 @@ static const EnumMap<HOOK_TYPE> HooksLookupTable({
{ "action.location", HOOK_TYPE::ACTION_LOCATION },
{ "guest.generation", HOOK_TYPE::GUEST_GENERATION },
{ "vehicle.crash", HOOK_TYPE::VEHICLE_CRASH },
{ "map.change", HOOK_TYPE::MAP_CHANGE },
{ "map.changed", HOOK_TYPE::MAP_CHANGED },
{ "map.save", HOOK_TYPE::MAP_SAVE },
});
@ -97,6 +99,15 @@ bool HookEngine::HasSubscriptions(HOOK_TYPE type) const
return !hookList.Hooks.empty();
}
bool HookEngine::IsValidHookForPlugin(HOOK_TYPE type, Plugin& plugin) const
{
if (type == HOOK_TYPE::MAP_CHANGED && plugin.GetMetadata().Type != PluginType::Intransient)
{
return false;
}
return true;
}
void HookEngine::Call(HOOK_TYPE type, bool isGameStateMutable)
{
auto& hookList = GetHookList(type);

View File

@ -40,6 +40,8 @@ namespace OpenRCT2::Scripting
ACTION_LOCATION,
GUEST_GENERATION,
VEHICLE_CRASH,
MAP_CHANGE,
MAP_CHANGED,
MAP_SAVE,
COUNT,
UNDEFINED = -1,
@ -87,6 +89,7 @@ namespace OpenRCT2::Scripting
void UnsubscribeAll(std::shared_ptr<const Plugin> owner);
void UnsubscribeAll();
bool HasSubscriptions(HOOK_TYPE type) const;
bool IsValidHookForPlugin(HOOK_TYPE type, Plugin& plugin) const;
void Call(HOOK_TYPE type, bool isGameStateMutable);
void Call(HOOK_TYPE type, const DukValue& arg, bool isGameStateMutable);
void Call(

View File

@ -23,7 +23,7 @@
using namespace OpenRCT2::Scripting;
Plugin::Plugin(duk_context* context, const std::string& path)
Plugin::Plugin(duk_context* context, std::string_view path)
: _context(context)
, _path(path)
{
@ -73,10 +73,16 @@ void Plugin::Load()
}
_metadata = GetMetadata(DukValue::take_from_stack(_context));
_hasLoaded = true;
}
void Plugin::Start()
{
if (!_hasLoaded)
{
throw std::runtime_error("Plugin has not been loaded.");
}
const auto& mainFunc = _metadata.Main;
if (mainFunc.context() == nullptr)
{
@ -115,6 +121,12 @@ void Plugin::ThrowIfStopping() const
}
}
void Plugin::Unload()
{
_metadata.Main = {};
_hasLoaded = false;
}
void Plugin::LoadCodeFromFile()
{
_code = File::ReadAllText(_path);
@ -174,6 +186,8 @@ PluginType Plugin::ParsePluginType(std::string_view type)
return PluginType::Local;
if (type == "remote")
return PluginType::Remote;
if (type == "intransient")
return PluginType::Intransient;
throw std::invalid_argument("Unknown plugin type.");
}
@ -192,4 +206,9 @@ int32_t Plugin::GetTargetAPIVersion() const
return 33;
}
bool Plugin::IsTransient() const
{
return _metadata.Type != PluginType::Intransient;
}
#endif

View File

@ -33,6 +33,12 @@ namespace OpenRCT2::Scripting
* modify game state in certain contexts.
*/
Remote,
/**
* Scripts that run when the game starts and only unload explicitly rather than when the
* park changes.
*/
Intransient,
};
struct PluginMetadata
@ -53,11 +59,12 @@ namespace OpenRCT2::Scripting
std::string _path;
PluginMetadata _metadata{};
std::string _code;
bool _hasLoaded{};
bool _hasStarted{};
bool _isStopping{};
public:
std::string GetPath() const
std::string_view GetPath() const
{
return _path;
};
@ -87,10 +94,15 @@ namespace OpenRCT2::Scripting
return _isStopping;
}
bool IsLoaded() const
{
return _hasLoaded;
}
int32_t GetTargetAPIVersion() const;
Plugin() = default;
Plugin(duk_context* context, const std::string& path);
Plugin(duk_context* context, std::string_view path);
Plugin(const Plugin&) = delete;
Plugin(Plugin&&) = delete;
@ -101,6 +113,9 @@ namespace OpenRCT2::Scripting
void StopEnd();
void ThrowIfStopping() const;
void Unload();
bool IsTransient() const;
private:
void LoadCodeFromFile();

View File

@ -386,6 +386,9 @@ ScriptEngine::ScriptEngine(InteractiveConsole& console, IPlatformEnvironment& en
void ScriptEngine::Initialise()
{
if (_initialised)
throw std::runtime_error("Script engine already initialised.");
auto ctx = static_cast<duk_context*>(_context);
ScCheats::Register(ctx);
ScClimate::Register(ctx);
@ -436,24 +439,55 @@ void ScriptEngine::Initialise()
dukglue_register_global(ctx, std::make_shared<ScScenario>(), "scenario");
_initialised = true;
_pluginsLoaded = false;
_pluginsStarted = false;
_transientPluginsEnabled = false;
_transientPluginsStarted = false;
InitSharedStorage();
LoadSharedStorage();
ClearParkStorage();
}
void ScriptEngine::LoadPlugins()
void ScriptEngine::RefreshPlugins()
{
if (!_initialised)
// Get a list of removed and added plugin files
auto pluginFiles = GetPluginFiles();
std::vector<std::string> plugins;
std::vector<std::string> removedPlugins;
std::vector<std::string> addedPlugins;
for (const auto& plugin : _plugins)
{
Initialise();
if (plugin->HasPath())
{
plugins.push_back(std::string(plugin->GetPath()));
}
}
if (_pluginsLoaded)
std::set_difference(
plugins.begin(), plugins.end(), pluginFiles.begin(), pluginFiles.end(), std::back_inserter(removedPlugins));
std::set_difference(
pluginFiles.begin(), pluginFiles.end(), plugins.begin(), plugins.end(), std::back_inserter(addedPlugins));
// Unregister plugin files that were removed
for (const auto& plugin : removedPlugins)
{
UnloadPlugins();
UnregisterPlugin(plugin);
}
// Register plugin files that were added
for (const auto& plugin : addedPlugins)
{
RegisterPlugin(plugin);
}
// Turn on hot reload if not already enabled
if (!_hotReloadingInitialised && gConfigPlugin.enable_hot_reloading && network_get_mode() == NETWORK_MODE_NONE)
{
SetupHotReloading();
}
}
std::vector<std::string> ScriptEngine::GetPluginFiles() const
{
// Scan for .js files in plugin directory
std::vector<std::string> pluginFiles;
auto base = _env.GetDirectoryPath(DIRBASE::USER, DIRID::PLUGIN);
if (Path::DirectoryExists(base))
{
@ -464,17 +498,99 @@ void ScriptEngine::LoadPlugins()
auto path = std::string(scanner->GetPath());
if (ShouldLoadScript(path))
{
LoadPlugin(path);
pluginFiles.push_back(path);
}
}
}
return pluginFiles;
}
if (gConfigPlugin.enable_hot_reloading && network_get_mode() == NETWORK_MODE_NONE)
bool ScriptEngine::ShouldLoadScript(std::string_view path)
{
// A lot of JavaScript is often found in a node_modules directory tree and is most likely unwanted, so ignore it
return path.find("/node_modules/") == std::string_view::npos && path.find("\\node_modules\\") == std::string_view::npos;
}
void ScriptEngine::UnregisterPlugin(std::string_view path)
{
try
{
auto pluginIt = std::find_if(_plugins.begin(), _plugins.end(), [path](const std::shared_ptr<Plugin>& plugin) {
return plugin->GetPath() == path;
});
auto& plugin = *pluginIt;
StopPlugin(plugin);
UnloadPlugin(plugin);
LogPluginInfo(plugin, "Unregistered");
_plugins.erase(pluginIt);
}
catch (const std::exception& e)
{
_console.WriteLineError(e.what());
}
}
void ScriptEngine::RegisterPlugin(std::string_view path)
{
try
{
auto plugin = std::make_shared<Plugin>(_context, path);
// We must load the plugin to get the metadata for it
ScriptExecutionInfo::PluginScope scope(_execInfo, plugin, false);
plugin->Load();
// Unload the plugin now, metadata is kept
plugin->Unload();
LogPluginInfo(plugin, "Registered");
_plugins.push_back(std::move(plugin));
}
catch (const std::exception& e)
{
_console.WriteLineError(e.what());
}
}
void ScriptEngine::StartIntransientPlugins()
{
LoadSharedStorage();
for (auto& plugin : _plugins)
{
if (!plugin->HasStarted() && !plugin->IsTransient())
{
SetupHotReloading();
LoadPlugin(plugin);
StartPlugin(plugin);
}
}
_pluginsLoaded = true;
_pluginsStarted = false;
_intransientPluginsStarted = true;
}
void ScriptEngine::StopUnloadRegisterAllPlugins()
{
std::vector<std::string> pluginPaths;
for (auto& plugin : _plugins)
{
pluginPaths.push_back(std::string(plugin->GetPath()));
StopPlugin(plugin);
}
for (auto& plugin : _plugins)
{
UnloadPlugin(plugin);
}
for (auto& pluginPath : pluginPaths)
{
UnregisterPlugin(pluginPath);
}
}
void ScriptEngine::LoadTransientPlugins()
{
_transientPluginsEnabled = true;
}
void ScriptEngine::LoadPlugin(const std::string& path)
@ -487,18 +603,19 @@ void ScriptEngine::LoadPlugin(std::shared_ptr<Plugin>& plugin)
{
try
{
ScriptExecutionInfo::PluginScope scope(_execInfo, plugin, false);
plugin->Load();
auto metadata = plugin->GetMetadata();
if (metadata.MinApiVersion <= OPENRCT2_PLUGIN_API_VERSION)
if (!plugin->IsLoaded())
{
LogPluginInfo(plugin, "Loaded");
_plugins.push_back(std::move(plugin));
}
else
{
LogPluginInfo(plugin, "Requires newer API version: v" + std::to_string(metadata.MinApiVersion));
const auto& metadata = plugin->GetMetadata();
if (metadata.MinApiVersion <= OPENRCT2_PLUGIN_API_VERSION)
{
ScriptExecutionInfo::PluginScope scope(_execInfo, plugin, false);
plugin->Load();
LogPluginInfo(plugin, "Loaded");
}
else
{
LogPluginInfo(plugin, "Requires newer API version: v" + std::to_string(metadata.MinApiVersion));
}
}
}
catch (const std::exception& e)
@ -507,6 +624,32 @@ void ScriptEngine::LoadPlugin(std::shared_ptr<Plugin>& plugin)
}
}
void ScriptEngine::UnloadPlugin(std::shared_ptr<Plugin>& plugin)
{
if (plugin->IsLoaded())
{
plugin->Unload();
LogPluginInfo(plugin, "Unloaded");
}
}
void ScriptEngine::StartPlugin(std::shared_ptr<Plugin> plugin)
{
if (!plugin->HasStarted() && ShouldStartPlugin(plugin))
{
ScriptExecutionInfo::PluginScope scope(_execInfo, plugin, false);
try
{
LogPluginInfo(plugin, "Started");
plugin->Start();
}
catch (const std::exception& e)
{
_console.WriteLineError(e.what());
}
}
}
void ScriptEngine::StopPlugin(std::shared_ptr<Plugin> plugin)
{
if (plugin->HasStarted())
@ -523,13 +666,19 @@ void ScriptEngine::StopPlugin(std::shared_ptr<Plugin> plugin)
_hookEngine.UnsubscribeAll(plugin);
plugin->StopEnd();
LogPluginInfo(plugin, "Stopped");
}
}
bool ScriptEngine::ShouldLoadScript(const std::string& path)
void ScriptEngine::ReloadPlugin(std::shared_ptr<Plugin> plugin)
{
// A lot of JavaScript is often found in a node_modules directory tree and is most likely unwanted, so ignore it
return path.find("/node_modules/") == std::string::npos && path.find("\\node_modules\\") == std::string::npos;
StopPlugin(plugin);
{
ScriptExecutionInfo::PluginScope scope(_execInfo, plugin, false);
plugin->Load();
LogPluginInfo(plugin, "Reloaded");
}
StartPlugin(plugin);
}
void ScriptEngine::SetupHotReloading()
@ -537,11 +686,15 @@ void ScriptEngine::SetupHotReloading()
try
{
auto base = _env.GetDirectoryPath(DIRBASE::USER, DIRID::PLUGIN);
_pluginFileWatcher = std::make_unique<FileWatcher>(base);
_pluginFileWatcher->OnFileChanged = [this](const std::string& path) {
std::lock_guard<std::mutex> guard(_changedPluginFilesMutex);
_changedPluginFiles.emplace(path);
};
if (Path::DirectoryExists(base))
{
_pluginFileWatcher = std::make_unique<FileWatcher>(base);
_pluginFileWatcher->OnFileChanged = [this](const std::string& path) {
std::lock_guard<std::mutex> guard(_changedPluginFilesMutex);
_changedPluginFiles.emplace(path);
};
_hotReloadingInitialised = true;
}
}
catch (const std::exception& e)
{
@ -549,6 +702,19 @@ void ScriptEngine::SetupHotReloading()
}
}
void ScriptEngine::DoAutoReloadPluginCheck()
{
if (_hotReloadingInitialised)
{
auto tick = Platform::GetTicks();
if (tick - _lastHotReloadCheckTick > 1000)
{
AutoReloadPlugins();
_lastHotReloadCheckTick = tick;
}
}
}
void ScriptEngine::AutoReloadPlugins()
{
if (_changedPluginFiles.size() > 0)
@ -564,12 +730,7 @@ void ScriptEngine::AutoReloadPlugins()
auto& plugin = *findResult;
try
{
StopPlugin(plugin);
ScriptExecutionInfo::PluginScope scope(_execInfo, plugin, false);
plugin->Load();
LogPluginInfo(plugin, "Reloaded");
plugin->Start();
ReloadPlugin(plugin);
}
catch (const std::exception& e)
{
@ -581,39 +742,53 @@ void ScriptEngine::AutoReloadPlugins()
}
}
void ScriptEngine::UnloadPlugins()
void ScriptEngine::UnloadTransientPlugins()
{
StopPlugins();
// Stop them all first
for (auto& plugin : _plugins)
{
LogPluginInfo(plugin, "Unloaded");
if (plugin->IsTransient())
{
StopPlugin(plugin);
}
}
_plugins.clear();
_pluginsLoaded = false;
_pluginsStarted = false;
// Now unload them
for (auto& plugin : _plugins)
{
if (plugin->IsTransient())
{
UnloadPlugin(plugin);
}
}
_transientPluginsEnabled = false;
_transientPluginsStarted = false;
}
void ScriptEngine::StartPlugins()
void ScriptEngine::StartTransientPlugins()
{
LoadSharedStorage();
// Load transient plugins
for (auto& plugin : _plugins)
{
if (!plugin->HasStarted() && ShouldStartPlugin(plugin))
if (plugin->IsTransient() && !plugin->IsLoaded() && ShouldStartPlugin(plugin))
{
ScriptExecutionInfo::PluginScope scope(_execInfo, plugin, false);
try
{
LogPluginInfo(plugin, "Started");
plugin->Start();
}
catch (const std::exception& e)
{
_console.WriteLineError(e.what());
}
LoadPlugin(plugin);
}
}
_pluginsStarted = true;
// Start transient plugins
for (auto& plugin : _plugins)
{
if (plugin->IsTransient() && plugin->IsLoaded() && !plugin->HasStarted())
{
StartPlugin(plugin);
}
}
_transientPluginsStarted = true;
}
bool ScriptEngine::ShouldStartPlugin(const std::shared_ptr<Plugin>& plugin)
@ -632,48 +807,34 @@ bool ScriptEngine::ShouldStartPlugin(const std::shared_ptr<Plugin>& plugin)
return true;
}
void ScriptEngine::StopPlugins()
{
for (auto& plugin : _plugins)
{
if (plugin->HasStarted())
{
StopPlugin(plugin);
LogPluginInfo(plugin, "Stopped");
}
}
_pluginsStarted = false;
}
void ScriptEngine::Tick()
{
PROFILED_FUNCTION();
if (!_initialised)
{
Initialise();
}
if (_pluginsLoaded)
{
if (!_pluginsStarted)
{
StartPlugins();
}
else
{
auto tick = Platform::GetTicks();
if (tick - _lastHotReloadCheckTick > 1000)
{
AutoReloadPlugins();
_lastHotReloadCheckTick = tick;
}
}
}
CheckAndStartPlugins();
UpdateIntervals();
UpdateSockets();
ProcessREPL();
DoAutoReloadPluginCheck();
}
void ScriptEngine::CheckAndStartPlugins()
{
auto startIntransient = !_intransientPluginsStarted;
auto startTransient = !_transientPluginsStarted && _transientPluginsEnabled;
if (startIntransient || startTransient)
{
RefreshPlugins();
}
if (startIntransient)
{
StartIntransientPlugins();
}
if (startTransient)
{
StartTransientPlugins();
}
}
void ScriptEngine::ProcessREPL()
@ -716,8 +877,9 @@ DukValue ScriptEngine::ExecutePluginCall(
return ExecutePluginCall(plugin, func, dukUndefined, args, isGameStateMutable);
}
// Must pass plugin by-value, a JS function could destroy the original reference
DukValue ScriptEngine::ExecutePluginCall(
const std::shared_ptr<Plugin>& plugin, const DukValue& func, const DukValue& thisValue, const std::vector<DukValue>& args,
std::shared_ptr<Plugin> plugin, const DukValue& func, const DukValue& thisValue, const std::vector<DukValue>& args,
bool isGameStateMutable)
{
DukStackFrame frame(_context);
@ -760,7 +922,23 @@ void ScriptEngine::AddNetworkPlugin(std::string_view code)
{
auto plugin = std::make_shared<Plugin>(_context, std::string());
plugin->SetCode(code);
LoadPlugin(plugin);
_plugins.push_back(plugin);
}
void ScriptEngine::RemoveNetworkPlugins()
{
auto it = _plugins.begin();
while (it != _plugins.end())
{
if (!(*it)->HasPath())
{
it = _plugins.erase(it);
}
else
{
it++;
}
}
}
GameActions::Result ScriptEngine::QueryOrExecuteCustomGameAction(std::string_view id, std::string_view args, bool isExecute)

View File

@ -46,7 +46,7 @@ namespace OpenRCT2
namespace OpenRCT2::Scripting
{
static constexpr int32_t OPENRCT2_PLUGIN_API_VERSION = 50;
static constexpr int32_t OPENRCT2_PLUGIN_API_VERSION = 51;
// Versions marking breaking changes.
static constexpr int32_t API_VERSION_33_PEEP_DEPRECATION = 33;
@ -145,8 +145,10 @@ namespace OpenRCT2::Scripting
IPlatformEnvironment& _env;
DukContext _context;
bool _initialised{};
bool _pluginsLoaded{};
bool _pluginsStarted{};
bool _hotReloadingInitialised{};
bool _transientPluginsEnabled{};
bool _transientPluginsStarted{};
bool _intransientPluginsStarted{};
std::queue<std::tuple<std::promise<void>, std::string>> _evalQueue;
std::vector<std::shared_ptr<Plugin>> _plugins;
uint32_t _lastHotReloadCheckTick{};
@ -209,16 +211,18 @@ namespace OpenRCT2::Scripting
std::string GetParkStorageAsJSON();
void SetParkStorageFromJSON(std::string_view value);
void LoadPlugins();
void UnloadPlugins();
void Initialise();
void LoadTransientPlugins();
void UnloadTransientPlugins();
void StopUnloadRegisterAllPlugins();
void Tick();
std::future<void> Eval(const std::string& s);
DukValue ExecutePluginCall(
const std::shared_ptr<Plugin>& plugin, const DukValue& func, const std::vector<DukValue>& args,
bool isGameStateMutable);
DukValue ExecutePluginCall(
const std::shared_ptr<Plugin>& plugin, const DukValue& func, const DukValue& thisValue,
const std::vector<DukValue>& args, bool isGameStateMutable);
std::shared_ptr<Plugin> plugin, const DukValue& func, const DukValue& thisValue, const std::vector<DukValue>& args,
bool isGameStateMutable);
void LogPluginInfo(const std::shared_ptr<Plugin>& plugin, std::string_view message);
@ -228,6 +232,7 @@ namespace OpenRCT2::Scripting
}
void AddNetworkPlugin(std::string_view code);
void RemoveNetworkPlugins();
[[nodiscard]] GameActions::Result QueryOrExecuteCustomGameAction(
std::string_view id, std::string_view args, bool isExecute);
@ -246,15 +251,23 @@ namespace OpenRCT2::Scripting
# endif
private:
void Initialise();
void StartPlugins();
void StopPlugins();
void RefreshPlugins();
std::vector<std::string> GetPluginFiles() const;
void UnregisterPlugin(std::string_view path);
void RegisterPlugin(std::string_view path);
void CheckAndStartPlugins();
void StartIntransientPlugins();
void StartTransientPlugins();
void LoadPlugin(const std::string& path);
void LoadPlugin(std::shared_ptr<Plugin>& plugin);
void UnloadPlugin(std::shared_ptr<Plugin>& plugin);
void StartPlugin(std::shared_ptr<Plugin> plugin);
void StopPlugin(std::shared_ptr<Plugin> plugin);
bool ShouldLoadScript(const std::string& path);
void ReloadPlugin(std::shared_ptr<Plugin> plugin);
static bool ShouldLoadScript(std::string_view path);
bool ShouldStartPlugin(const std::shared_ptr<Plugin>& plugin);
void SetupHotReloading();
void DoAutoReloadPluginCheck();
void AutoReloadPlugins();
void ProcessREPL();
void RemoveCustomGameActions(const std::shared_ptr<Plugin>& plugin);

View File

@ -11,6 +11,7 @@
#ifdef ENABLE_SCRIPTING
# include "../../../OpenRCT2.h"
# include "../../../actions/GameAction.h"
# include "../../../interface/Screenshot.h"
# include "../../../localisation/Formatting.h"
@ -110,6 +111,19 @@ namespace OpenRCT2::Scripting
return result;
}
std::string mode_get()
{
if (gScreenFlags & SCREEN_FLAGS_TITLE_DEMO)
return "title";
else if (gScreenFlags & SCREEN_FLAGS_SCENARIO_EDITOR)
return "scenario_editor";
else if (gScreenFlags & SCREEN_FLAGS_TRACK_DESIGNER)
return "track_designer";
else if (gScreenFlags & SCREEN_FLAGS_TRACK_MANAGER)
return "track_manager";
return "normal";
}
void captureImage(const DukValue& options)
{
auto ctx = GetContext()->GetScriptEngine().GetContext();
@ -278,6 +292,11 @@ namespace OpenRCT2::Scripting
duk_error(ctx, DUK_ERR_ERROR, "Not in a plugin context");
}
if (!_hookEngine.IsValidHookForPlugin(hookType, *owner))
{
duk_error(ctx, DUK_ERR_ERROR, "Hook type not available for this plugin type.");
}
auto cookie = _hookEngine.Subscribe(hookType, owner, callback);
return std::make_shared<ScDisposable>([this, hookType, cookie]() { _hookEngine.Unsubscribe(hookType, cookie); });
}
@ -434,6 +453,7 @@ namespace OpenRCT2::Scripting
dukglue_register_property(ctx, &ScContext::configuration_get, nullptr, "configuration");
dukglue_register_property(ctx, &ScContext::sharedStorage_get, nullptr, "sharedStorage");
dukglue_register_method(ctx, &ScContext::getParkStorage, "getParkStorage");
dukglue_register_property(ctx, &ScContext::mode_get, nullptr, "mode");
dukglue_register_method(ctx, &ScContext::captureImage, "captureImage");
dukglue_register_method(ctx, &ScContext::getObject, "getObject");
dukglue_register_method(ctx, &ScContext::getAllObjects, "getAllObjects");

View File

@ -336,6 +336,7 @@ bool TitleScreen::TryLoadSequence(bool loadPreview)
if (!loadPreview)
{
GetContext()->GetGameState()->InitAll(DEFAULT_MAP_SIZE);
game_notify_map_changed();
}
return false;
}