Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Turbo Cloud

A Habbo Hotel emulator built with C# and [Orleans](https://learn.microsoft.com/en-us/dotnet/orleans/).

## Plugin Development

Turbo Cloud uses a plugin system where game features are implemented as loadable plugins. During development, plugins are hot-reloaded in-process when you rebuild them - no server restart needed.

### Prerequisites

- .NET 9 SDK
- A plugin project that references `Turbo.Contracts`

### Plugin Project Setup

Your plugin's `.csproj` must copy `manifest.json` to the build output:

```xml
<ItemGroup>
<Content Include="manifest.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
```

### Emulator Configuration

In `appsettings.Development.json`, point `DevPluginPaths` at your plugin's build output directory:

```json
{
"Turbo": {
"Plugin": {
"DevPluginPaths": [
"C:/path/to/your-plugin/bin/Debug/net9.0"
]
}
}
}
```

You can list multiple plugin paths if you're developing several plugins at once.

### Dev Workflow

Open two terminals:

**Terminal 1** - Run the emulator:
```
dotnet run --project Turbo.Main
```

**Terminal 2** - Watch your plugin for changes:
```
cd C:/path/to/your-plugin
dotnet watch build
```

Now when you edit a `.cs` file and save, `dotnet watch` rebuilds the plugin automatically. The emulator detects the new DLL and hot-reloads the plugin in-process. Message handlers and services are swapped live - connected clients stay connected.

### How It Works

1. You edit a `.cs` file in your plugin project
2. `dotnet watch build` detects the change and rebuilds
3. The new DLL lands in your plugin's `bin/Debug/net9.0/`
4. The emulator's file watcher detects the DLL change
5. The plugin is unloaded and reloaded with the new assembly
6. Message handlers and services are swapped atomically

### Released Plugins

For production/released plugins, place them in the `plugins/` directory inside the emulator's output folder. Each plugin should be in its own subdirectory with a `manifest.json`:

```
plugins/
MyPlugin/
manifest.json
MyPlugin.dll
```

During development, `DevPluginPaths` takes precedence - if the same plugin key exists in both `plugins/` and a dev path, the dev version is loaded.

### Limitations

- **Grain types cannot be hot-reloaded.** Orleans requires grain types to be registered at silo startup. If your plugin adds new grain types, a server restart is needed.
- **Memory may grow over many reloads.** Assembly unloading relies on .NET's `AssemblyLoadContext` which may not fully release memory if type references are retained. Restart the emulator periodically during long dev sessions if memory grows.
40 changes: 31 additions & 9 deletions Turbo.Main/Console/ConsoleCommandService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ private async Task LoopAsync(CancellationToken ct)
}
}

private Task HandleCommandAsync(string input, CancellationToken ct)
private async Task HandleCommandAsync(string input, CancellationToken ct)
{
var parts = input.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var cmd = parts[0].ToLowerInvariant();
Expand All @@ -61,7 +61,9 @@ private Task HandleCommandAsync(string input, CancellationToken ct)
switch (cmd)
{
case "help":
System.Console.WriteLine("Available commands: help, quit, reload-plugins");
System.Console.WriteLine(
"Available commands: help, quit, reload-plugins, reload-plugin <key>"
);
break;

case "quit":
Expand All @@ -71,22 +73,42 @@ private Task HandleCommandAsync(string input, CancellationToken ct)
break;

case "reload-plugins":
var pluginMgr = _services.GetRequiredService<PluginManager>();
//await pluginMgr.LoadAll(true, false, ct);
try
{
var pluginMgr = _services.GetRequiredService<PluginManager>();
await pluginMgr.LoadAllAsync(true, ct).ConfigureAwait(false);
System.Console.WriteLine("Plugins reloaded.");
}
catch (Exception ex)
{
System.Console.WriteLine($"Reload failed: {ex.Message}");
}
break;

case "reload-plugin":
{
pluginMgr = _services.GetRequiredService<PluginManager>();
//await pluginMgr.Reload(args[0], ct);
if (args.Length == 0)
{
System.Console.WriteLine("Usage: reload-plugin <key>");
break;
}

try
{
var pluginMgr = _services.GetRequiredService<PluginManager>();
await pluginMgr.ReloadAsync(args[0], ct).ConfigureAwait(false);
System.Console.WriteLine($"Plugin '{args[0]}' reloaded.");
}
catch (Exception ex)
{
System.Console.WriteLine($"Reload failed for '{args[0]}': {ex.Message}");
}
break;
}

default:
System.Console.WriteLine("Unknown command: {Command}", cmd);
System.Console.WriteLine($"Unknown command: {cmd}");
break;
}

return Task.CompletedTask;
}
}
4 changes: 4 additions & 0 deletions Turbo.Plugins/Configuration/PluginConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,8 @@ public class PluginConfig

public string PluginFolderPath { get; init; } =
Path.Combine(AppContext.BaseDirectory, "plugins");

public int DebounceMs { get; init; } = 500;

public string[] DevPluginPaths { get; init; } = [];
}
9 changes: 6 additions & 3 deletions Turbo.Plugins/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@ public static IServiceCollection AddTurboPlugins(
HostApplicationBuilder builder
)
{
services.Configure<PluginConfig>(
builder.Configuration.GetSection(PluginConfig.SECTION_NAME)
);
var pluginSection = builder.Configuration.GetSection(PluginConfig.SECTION_NAME);

services.Configure<PluginConfig>(pluginSection);

services.AddSingleton<PluginManager>();
services.AddHostedService<PluginBootstrapper>();

if (builder.Environment.IsDevelopment())
services.AddHostedService<PluginHotReloadService>();

return services;
}

Expand Down
Loading