diff --git a/examples/plugins/php/src/CircleCommand.php b/examples/plugins/php/src/CircleCommand.php index e5dbdf2..af0ca8f 100644 --- a/examples/plugins/php/src/CircleCommand.php +++ b/examples/plugins/php/src/CircleCommand.php @@ -13,14 +13,16 @@ use Dragonfly\PluginLib\Events\EventContext; use Dragonfly\PluginLib\Util\EnumResolver; -class CircleCommand extends Command { - protected string $name = 'circle'; - protected string $description = 'Spawn particles in a circle around all players'; +class CircleCommand extends Command +{ + protected string $name = "circle"; + protected string $description = "Spawn particles in a circle around all players"; /** @var Optional */ public Optional $particle; - public function execute(CommandSender $sender, EventContext $ctx): void { + public function execute(CommandSender $sender, EventContext $ctx): void + { if (!$sender instanceof Player) { $sender->sendMessage("§cThis command can only be run by a player."); return; @@ -35,12 +37,14 @@ public function execute(CommandSender $sender, EventContext $ctx): void { } } else { $particleId = ParticleType::PARTICLE_FLAME; - $particleName = 'flame'; + $particleName = "flame"; } $world = $sender->getWorld(); - $correlationId = uniqid('circle_', true); - $ctx->onActionResult($correlationId, function (ActionResult $result) use ($ctx, $world, $particleId) { + $correlationId = uniqid("circle_", true); + $ctx->onActionResult($correlationId, function ( + ActionResult $result, + ) use ($ctx, $world, $particleId) { $playersResult = $result->getWorldPlayers(); if ($playersResult === null) { return; @@ -58,7 +62,7 @@ public function execute(CommandSender $sender, EventContext $ctx): void { $cy = $pos->getY(); $cz = $pos->getZ(); for ($i = 0; $i < $points; $i++) { - $angle = (2 * M_PI / $points) * $i; + $angle = ((2 * M_PI) / $points) * $i; $x = $cx + $radius * cos($angle); $z = $cz + $radius * sin($angle); @@ -73,19 +77,28 @@ public function execute(CommandSender $sender, EventContext $ctx): void { }); $ctx->worldQueryPlayers($world, $correlationId); - $sender->sendMessage("§aSpawning {$particleName} circles around all players!"); + $sender->sendMessage( + "§aSpawning {$particleName} circles around all players!", + ); } /** * @return array}> */ - public function serializeParamSpec(): array { - $names = EnumResolver::lowerNames(ParticleType::class, ['PARTICLE_TYPE_UNSPECIFIED']); - return $this->withEnum(parent::serializeParamSpec(), 'particle', $names); + public function serializeParamSpec(): array + { + $names = EnumResolver::lowerNames(ParticleType::class, [ + "PARTICLE_TYPE_UNSPECIFIED", + ]); + return $this->withEnum( + parent::serializeParamSpec(), + "particle", + $names, + ); } - private function resolveParticleId(string $input): ?int { - return EnumResolver::value(ParticleType::class, $input, 'PARTICLE_'); + private function resolveParticleId(string $input): ?int + { + return EnumResolver::value(ParticleType::class, $input, "PARTICLE_"); } } - diff --git a/examples/plugins/rust/.gitignore b/examples/plugins/rust/.gitignore new file mode 100644 index 0000000..3972d0e --- /dev/null +++ b/examples/plugins/rust/.gitignore @@ -0,0 +1,16 @@ +# Cargo build artifacts +target/ + +# Cargo.lock for libraries (keep for applications) +# See: https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# IDE files +.idea/ +.vscode/ +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db diff --git a/examples/plugins/rust/Cargo.toml b/examples/plugins/rust/Cargo.toml new file mode 100644 index 0000000..2c2e8a5 --- /dev/null +++ b/examples/plugins/rust/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "rustic-economy" +version = "0.1.0" +edition = "2024" + +[dependencies] +# required for any base plugin. +dragonfly-plugin = { path = "../../../packages/rust/"} +tokio = { version = "1.48.0", features = ["full"] } + +# used in this plugin specifically but isn't required for all plugins. +sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } diff --git a/examples/plugins/rust/README.md b/examples/plugins/rust/README.md new file mode 100644 index 0000000..bd287e0 --- /dev/null +++ b/examples/plugins/rust/README.md @@ -0,0 +1,88 @@ +## Rustic Economy – Rust example plugin + +`rustic-economy` is a **Rust example plugin** for Dragonfly that demonstrates: + +- A simple **SQLite-backed economy** using `sqlx`. +- The Rust SDK macros `#[derive(Plugin)]` and `#[derive(Command)]`. +- The generated **command system** (`Eco` enum + `EcoHandler` trait). +- Using `Ctx` to reply to the invoking player. + +It is meant as a learning/reference plugin, not a production-ready economy. + +### What this plugin does + +- Stores each player’s balance in a local `economy.db` SQLite database. +- Exposes one command, `/eco` (with aliases `/economy` and `/rustic_eco`): + - `/eco pay ` (`/eco donate `): add money to your own balance. + - `/eco bal` (aliases `/eco balance`, `/eco money`): show your current balance. + +Balances are stored as `REAL`/`f64` for simplicity. For real money, you should use +an integer representation (e.g. cents as `i64`) to avoid floating‑point issues. + +### Files and structure + +- `Cargo.toml`: Rust crate metadata for the example plugin. +- `src/main.rs`: The entire plugin implementation: + - `RusticEconomy` struct holding a `SqlitePool`. + - `impl RusticEconomy { new, get_balance, add_money }` – DB helpers. + - `Eco` command enum + `EcoHandler` impl with `pay` and `bal` handlers. + - `main` function that initialises the DB and runs `PluginRunner`. + +### Requirements + +- Rust (stable) and `cargo`. +- A Dragonfly host that has the Rust SDK wired in (this repo’s Go host). +- SQLite available on the host machine (the plugin writes `economy.db` + next to where it is run). + +### Building the plugin + +From the repo root: + +```bash +cd examples/plugins/rust +cargo build --release +``` + +The compiled binary will be in `target/release/rustic-economy` (or `.exe` on Windows). +Point your Dragonfly `plugins.yaml` at that binary. + +### Example `plugins.yaml` entry + +```yaml +plugins: + - id: rustic-economy + name: Rustic Economy + command: "./examples/plugins/rust/target/release/rustic-economy" + address: "tcp://127.0.0.1:50050" +``` + +Ensure the `id` matches the `#[plugin(id = "rustic-economy", ...)]` attribute in +`src/main.rs`. + +### Running and testing + +1. Start Dragonfly with the plugin enabled via `plugins.yaml`. +2. Join the server as a player. +3. Run economy commands in chat: + - `/eco pay 10` – adds 10 to your balance and shows the new total. + - `/eco bal` – prints your current balance. +4. Check that `economy.db` is created and populated in the working directory. + +If any DB or send‑chat errors occur, the plugin logs them to stderr and replies +with a generic error message so players aren’t exposed to internals. + +### How it uses the Rust SDK + +- `#[derive(Plugin)]` + `#[plugin(...)]` describe plugin metadata and register + the `Eco` command with the host. +- `#[derive(Command)]` generates a `EcoHandler` trait and argument parsing from + `types::CommandEvent` into the `Eco` enum. +- `Ctx<'_>` is used to send replies: `ctx.reply("...".to_string()).await`. +- `PluginRunner::run(plugin, "tcp://127.0.0.1:50050")` connects the plugin + process to the Dragonfly host and runs the event loop. + +Use this example as a starting point when building stateful Rust plugins that +compose the SDK’s command and event systems with your own storage layer. + + diff --git a/examples/plugins/rust/src/main.rs b/examples/plugins/rust/src/main.rs new file mode 100644 index 0000000..b191066 --- /dev/null +++ b/examples/plugins/rust/src/main.rs @@ -0,0 +1,151 @@ +/// Rustic Economy: a small example plugin backed by SQLite. +/// +/// This example demonstrates how to: +/// - Use `#[derive(Plugin)]` to declare plugin metadata and register commands. +/// - Use `#[derive(Command)]` to define a typed command enum. +/// - Hold state (a `SqlitePool`) inside your plugin struct. +/// - Use `Ctx` to reply to the invoking player. +/// - Use `#[event_handler]` for event subscriptions (even when you don't +/// implement any event methods yet). +use dragonfly_plugin::{ + Command, Plugin, PluginRunner, command::Ctx, event::EventHandler, event_handler, types, +}; +use sqlx::{SqlitePool, sqlite::SqlitePoolOptions}; + +#[derive(Plugin)] +#[plugin( + id = "rustic-economy", + name = "Rustic Economy", + version = "0.3.0", + api = "1.0.0", + commands(Eco) +)] +struct RusticEconomy { + db: SqlitePool, +} + +/// Database helpers for the Rustic Economy example. +impl RusticEconomy { + async fn new() -> Result> { + // Create database connection + let db = SqlitePoolOptions::new() + .max_connections(5) + .connect("sqlite:economy.db") + .await?; + + // Create table if it doesn't exist. + // + // NOTE: This example stores balances as REAL/f64 for simplicity. + // For real-world money you should use an integer representation + // (e.g. cents as i64) to avoid floating point rounding issues. + sqlx::query( + "CREATE TABLE IF NOT EXISTS users ( + uuid TEXT PRIMARY KEY, + balance REAL NOT NULL DEFAULT 0.0 + )", + ) + .execute(&db) + .await?; + + Ok(Self { db }) + } + + async fn get_balance(&self, uuid: &str) -> Result { + let result: Option<(f64,)> = sqlx::query_as("SELECT balance FROM users WHERE uuid = ?") + .bind(uuid) + .fetch_optional(&self.db) + .await?; + + Ok(result.map(|(bal,)| bal).unwrap_or(0.0)) + } + + async fn add_money(&self, uuid: &str, amount: f64) -> Result { + // Insert or update user balance + sqlx::query( + "INSERT INTO users (uuid, balance) VALUES (?, ?) + ON CONFLICT(uuid) DO UPDATE SET balance = balance + ?", + ) + .bind(uuid) + .bind(amount) + .bind(amount) + .execute(&self.db) + .await?; + + self.get_balance(uuid).await + } +} + +#[derive(Command)] +#[command( + name = "eco", + description = "Rustic Economy commands.", + aliases("economy", "rustic_eco") +)] +pub enum Eco { + #[subcommand(aliases("donate"))] + Pay { amount: f64 }, + #[subcommand(aliases("balance", "money"))] + Bal, +} + +impl EcoHandler for RusticEconomy { + async fn pay(&self, ctx: Ctx<'_>, amount: f64) { + match self.add_money(&ctx.sender, amount).await { + Ok(new_balance) => { + if let Err(e) = ctx + .reply(format!( + "Added ${:.2}! New balance: ${:.2}", + amount, new_balance + )) + .await + { + eprintln!("Failed to send payment reply: {}", e); + } + } + Err(e) => { + eprintln!("Database error: {}", e); + if let Err(send_err) = ctx + .reply("Error processing payment!".to_string()) + .await + { + eprintln!("Failed to send error reply: {}", send_err); + } + } + } + } + + async fn bal(&self, ctx: Ctx<'_>) { + match self.get_balance(&ctx.sender).await { + Ok(balance) => { + if let Err(e) = ctx + .reply(format!("Your balance: ${:.2}", balance)) + .await + { + eprintln!("Failed to send balance reply: {}", e); + } + } + Err(e) => { + eprintln!("Database error: {}", e); + if let Err(send_err) = ctx + .reply("Error checking balance!".to_string()) + .await + { + eprintln!("Failed to send error reply: {}", send_err); + } + } + } + } +} + +#[event_handler] +impl EventHandler for RusticEconomy {} + +#[tokio::main] +async fn main() -> Result<(), Box> { + println!("Starting the plugin..."); + println!("Initializing database..."); + + let plugin = RusticEconomy::new().await?; + + PluginRunner::run(plugin, "tcp://127.0.0.1:50050").await +} diff --git a/packages/rust/Cargo.toml b/packages/rust/Cargo.toml index 0f66308..e1f3f43 100644 --- a/packages/rust/Cargo.toml +++ b/packages/rust/Cargo.toml @@ -1,14 +1,41 @@ +[workspace] +resolver = "3" +members = [".", "macro", "example", "xtask"] + +[profile.dev.package] +insta.opt-level = 3 +similar.opt-level = 3 + +[workspace.dependencies] +dragonfly-plugin-macro = { path = "macro", version = "0.3" } + [package] name = "dragonfly-plugin" -version = "0.1.0" +version = "0.3.0" edition = "2021" -license = "MIT OR Apache-2.0" +license = "MIT" repository = "https://github.com/secmc/dragonfly-plugins" description = "Dragonfly gRPC plugin SDK for Rust" +homepage = "https://github.com/secmc/dragonfly-plugins" +keywords = ["dragonfly", "plugin", "grpc", "macro"] [lib] path = "src/lib.rs" [dependencies] +async-trait = "0.1.89" +prettyplease = "0.2.37" prost = "0.13" -tonic = { version = "0.12", features = ["transport"] } \ No newline at end of file +tokio = { version = "1.48.0", features = ["net"] } +tokio-stream = "0.1.17" +tonic = { version = "0.12", features = ["transport"] } +tower = "0.5" +hyper-util = { version = "0.1", features = ["tokio"] } +dragonfly-plugin-macro = { workspace = true, optional = true } + +[dev-dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } + +[features] +default = ["macros"] +macros = ["dep:dragonfly-plugin-macro"] diff --git a/packages/rust/MAINTAINING.md b/packages/rust/MAINTAINING.md new file mode 100644 index 0000000..fc8a37f --- /dev/null +++ b/packages/rust/MAINTAINING.md @@ -0,0 +1,127 @@ +# Maintaining the Plugin API + +This document is for developers who need to update, extend, or maintain the `dragonfly-plugin` API. This is **not** for plugin _users_; that documentation is in the main `README.md`. + +## The Golden Rule: Do Not Edit Generated Files + +This entire API is code-generated from a "Single Source of Truth." **You must not edit the generated Rust files by hand.** Any manual changes will be overwritten. + +The _only_ files you will ever edit manually are: + +1. The `.proto` files (Phase 1). +2. The `xtask` generator code (Phase 3's _generator_). +3. The `dragonfly-plugin-macros` crate code. +4. The `src/*` code in case of BC changes or etc. + +## The 3-Phase Generation Pipeline + +Our architecture is a 3-phase pipeline. Understanding this flow is critical to maintaining the API. + +--- + +### Phase 1: The Protocol (Source of Truth) + +- **What it is:** The `.proto` files that define all our gRPC services, messages, and events. +- **Location:** `../proto` directory. +- **How to change:** This is the **only place** you should start a change. To add a new event (e.g., `PlayerSleepEvent`), you add it to the `EventEnvelope` message in the `.proto` file. + +--- + +### Phase 2: The Raw Bindings (Intermediate) + +- **What it is:** The "raw" Rust code (structs, enums, and gRPC clients) generated directly from the `.proto` files. This code is often ugly, un-idiomatic, and not user-friendly. +- **How it's made:** This code is generated by `buf` which uses `prost` (via the makefile). +- **How to update:** When you have made changes to the proto buf files run the buf tool via `make proto` +- **Warning:** **NEVER EDIT THESE FILES BY HAND.** + +--- + +### Phase 3: The Friendly API (The `xtask` Generator) + +- **What it is:** This is the **public-facing, idiomatic Rust API** that our users love. It includes: + - The `EventHandler` trait and its `async fn on_*` methods. + - The `Server` struct with its friendly helper methods (e.g., `server.send_chat(...)`). + - The `EventContext` struct and its helpers (`.cancel()`, mutation helpers like `.set_message()`). + - The `types::EventType` enum. +- **How it's made:** The `xtask` crate is a custom Rust program that **parses the output of Phase 2** (the prost Rust code in `src/generated/df.plugin.rs`) and generates this API via: + - `generate_actions.rs` → `server/helpers.rs` + - `generate_handlers.rs` → `event/handler.rs` + - `generate_mutations.rs` → `event/mutations.rs` +- **How to update:** You run the `xtask` crate from the terminal. +- **Warning:** **NEVER EDIT THESE FILES BY HAND.** + +--- + +## How to add a new event + +This is the most common maintenance task. Here is the complete workflow. + +**Goal:** Add a new `PlayerSleepEvent`. + +1. **Phase 1: Edit the Protocol** + - Open the relevant `.proto` file (e.g., `events.proto`). + - Add the `PlayerSleepEvent` message. + - Add `PlayerSleepEvent player_sleep = 15;` (or the next available ID) to your main `EventEnvelope`'s `payload` oneof. + +2. **Phase 2: Generate the Raw Bindings** + - Run `make proto` to use the buf tool to generate all the needed `generated/*` files. + +3. **Phase 3: Generate the Friendly API** + - Now that the raw types exist, we can generate the friendly API for them. + - `cd` into the `xtask` directory. + - Run `cargo run`. + - The `xtask` generator will see the new `PlayerSleep` variant and automatically: + - Add `async fn on_player_sleep(...)` to the `PluginEventHandler` trait. + - Add the `case PlayerSleep` arm to the `dispatch_event` function. + +4. **Review and commit** + - You are done. Review the changes in `git`. + - You should see changes in: + 1. The `.proto` file you edited. + 2. The Phase 2 raw bindings file. + 3. The Phase 3 friendly API files (e.g., `event_handler.rs`, `types.rs`). + - Commit all of them. + +- **What xtask generates for events** + - Adds `async fn on_player_sleep(...)` (and similar) to the `EventHandler` trait in `event/handler.rs`. + - Adds the `PlayerSleep` arm to the `dispatch_event` function. + - Adds any required mutation helpers in `event/mutations.rs`. + +## The `event_handler`, `Plugin`, and `Command` proc-macros + +- **Crate:** `dragonfly-plugin-macro` + +### `#[event_handler]` + +- **Purpose:** Attached to an `impl EventHandler for MyPlugin` block. +- **How it works:** Scans the methods you define (`on_player_join`, `on_chat`, etc.) and generates an `impl EventSubscriptions for MyPlugin` that returns a `Vec` with the corresponding variants. +- **Maintenance:** This macro is intentionally simple: it only cares about method names and does not need to change when new events are added. If a user types a non-existent event method, the trait mismatch causes a normal Rust compiler error. + +### `#[derive(Plugin)]` + `#[plugin(...)]` + +- **Purpose:** Implements the `Plugin` trait and wires the plugin metadata into the runtime. +- **Attributes:** `#[plugin(id = \"...\", name = \"...\", version = \"...\", api = \"...\", commands(Foo, Bar))]`. +- **How it works:** + - Generates `Plugin` trait methods based on the literals. + - Generates a `CommandRegistry` implementation that: + - Collects command specs from the listed command types. + - Dispatches `types::CommandEvent` into those command types. + +### `#[derive(Command)]` + `#[subcommand(...)]` + +- **Purpose:** Generates a strongly-typed command parser and handler trait for a struct/enum. +- **How it works:** + - Produces a `spec()` function returning `types::CommandSpec`. + - Implements `TryFrom<&types::CommandEvent>` using the SDK’s `parse_required_arg` / `parse_optional_arg` helpers. + - Generates an `XxxHandler` trait and an `__execute` method that calls into the appropriate handler method on the plugin. +- **Maintenance:** When adding new `ParamType` support (e.g., enums) or changing how arguments map from `String` → Rust types, update the command macro implementation in `macro/src/command/` (parse/model/codegen) to keep codegen consistent with the runtime helpers in `src/command.rs`. + +## Testing xtask and macros + +- **xtask tests:** The `xtask` crate has: + - Unit tests in `utils.rs` for AST helpers. + - Snapshot tests in the generator modules (`generate_actions.rs`, `generate_handlers.rs`, `generate_mutations.rs`) using `insta`, with snapshots under `xtask/src/snapshots/`. + - Edge-case tests that verify generators fail with clear error messages when prost structures are inconsistent (e.g., missing structs or payloads). +- **Macro tests:** The macro crate uses `trybuild` fixtures under `macro/tests/fixtures` to ensure: + - Basic usage of `#[derive(Plugin)]`, `#[event_handler]`, and `#[derive(Command)]` compiles against the public SDK. + - Any breaking change to macro signatures or expectations shows up as a compile error in these tests. diff --git a/packages/rust/README.md b/packages/rust/README.md new file mode 100644 index 0000000..28f3fe8 --- /dev/null +++ b/packages/rust/README.md @@ -0,0 +1,252 @@ +## Dragonfly Rust Plugin SDK + +The `dragonfly-plugin` crate is the **Rust SDK for Dragonfly gRPC plugins**. It gives you: + +- **Derive macros** to describe your plugin (`#[derive(Plugin)]`) and commands (`#[derive(Command)]`). +- A simple **event system** based on an `EventHandler` trait and an `#[event_handler]` macro. +- A `Server` handle with high‑level helpers (like `send_chat`, `teleport`, `world_set_block`, …). +- A `PluginRunner` that connects your process to the Dragonfly host and runs the event loop. + +### Crate and directory layout + +The Rust SDK lives under `packages/rust` as a small workspace: + +- **`dragonfly-plugin` (this crate)**: Public SDK surface used by plugin authors. + - `src/lib.rs`: Re-exports core modules and pulls in this README as crate-level docs. + - `src/command.rs`: Command context (`Ctx`), parsing helpers, and `CommandRegistry` trait. + - `src/event/`: Event system (`EventContext`, `EventHandler`, mutation helpers). + - `src/server/`: `Server` handle and generated helpers for sending actions to the host. + - `src/generated/df.plugin.rs`: Prost/tonic types generated from `proto/types/*.proto` (do not edit). +- **`macro/` (`dragonfly-plugin-macro`)**: Procedural macros for `#[derive(Plugin)]`, `#[derive(Command)]`, + and `#[event_handler]`. This crate is re-exported by `dragonfly-plugin` and is not used directly by plugins. +- **`xtask/`**: Internal code generation tooling that reads `df.plugin.rs` and regenerates + `event/handler.rs`, `event/mutations.rs`, and `server/helpers.rs`. It is not published. +- **`example/`**: A minimal example plugin crate showing recommended usage patterns for the SDK. +- **`tests/`**: Integration tests covering command derivation, event dispatch, server helpers, + and the interaction between the runtime and macros. + +All APIs in this README reflect the **0.3.x line**. Within 0.3.x we intend to keep: + +- The `Plugin`, `EventHandler`, `EventSubscriptions`, and `CommandRegistry` trait shapes. +- The `event_handler`, `Plugin`, and `Command` macros and their attribute syntax. +- The `Server` helpers and `event::EventContext` semantics (including `cancel` and mutation helpers). + +Breaking changes may still happen in a future 0.4.0, but not within 0.3.x. + +--- + +## Quick start + +### 1. Create a new plugin crate + +```sh +cargo new my_plugin --bin +``` + +### 2. Add dependencies + +```toml +[package] +name = "my_plugin" +version = "0.1.0" +edition = "2021" + +[dependencies] +dragonfly-plugin = "0.3" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } # optional, for DB-backed examples +``` + +Only `dragonfly-plugin` and `tokio` are required; other crates (like `sqlx`) are up to your plugin. + +### 3. Define your plugin + +```rust,no_run +use dragonfly_plugin::{ + event::{EventContext, EventHandler}, + event_handler, + types, + Plugin, + PluginRunner, + Server, +}; + +#[derive(Plugin, Default)] +#[plugin( + id = "example-rust", // must match plugins.yaml + name = "Example Rust Plugin", + version = "0.3.0", + api = "1.0.0" +)] +struct MyPlugin; + +#[event_handler] +impl EventHandler for MyPlugin { + async fn on_player_join( + &self, + server: &Server, + event: &mut EventContext<'_, types::PlayerJoinEvent>, + ) { + let player_name = &event.data.name; + println!("Player '{}' has joined.", player_name); + + let welcome = format!( + "Welcome, {}! This server is running a Rust plugin.", + player_name + ); + + // Ignore send errors; they usually mean the host shut down. + let _ = server + .send_chat(event.data.player_uuid.clone(), welcome) + .await; + } + + async fn on_chat( + &self, + _server: &Server, + event: &mut EventContext<'_, types::ChatEvent>, + ) { + let new_message = format!("[Plugin] {}", event.data.message); + event.set_message(new_message); + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + println!("Starting example-rust plugin..."); + PluginRunner::run(MyPlugin, "tcp://127.0.0.1:50050").await +} +``` + +The `#[event_handler]` macro: + +- Detects which `on_*` methods you implement. +- Generates an `EventSubscriptions` impl that subscribes to the corresponding `types::EventType` variants. +- Wires those events into `event::dispatch_event`. + +--- + +## Commands + +The 0.3.x series introduces a **first‑class command system**. + +### Declaring a command + +```rust,no_run +use dragonfly_plugin::{command::Ctx, Command}; + +#[derive(Command)] +#[command( + name = "eco", + description = "Economy commands.", + aliases("economy", "rustic_eco") +)] +pub enum Eco { + #[subcommand(aliases("donate"))] + Pay { amount: f64 }, + + #[subcommand(aliases("balance", "money"))] + Bal, +} +``` + +This generates: + +- A static `Eco::spec() -> types::CommandSpec`. +- A `TryFrom<&types::CommandEvent>` impl that parses args into `Eco`. +- An `EcoHandler` trait with async methods (`pay`, `bal`) and an `__execute` helper. + +### Handling commands in your plugin + +Add the command type to your plugin’s `#[plugin]` attribute, and implement the generated handler trait for your plugin type: + +```rust,ignore +use dragonfly_plugin::{command::Ctx, Command, Plugin}; + +#[derive(Plugin)] +#[plugin( + id = "rustic-economy", + name = "Rustic Economy", + version = "0.1.0", + api = "1.0.0", + commands(Eco) +)] +struct RusticEconomy { + // your state here, e.g. DB pools +} + +impl EcoHandler for RusticEconomy { + async fn pay(&self, ctx: Ctx<'_>, amount: f64) { + // ... + let _ = ctx + .reply(format!("You paid yourself ${:.2}.", amount)) + .await; + } + + async fn bal(&self, ctx: Ctx<'_>) { + // ... + let _ = ctx.reply("Your balance is $0.00".to_string()).await; + } +} +``` + +The `#[derive(Plugin)]` macro then: + +- Reports the command specs in the initial hello handshake. +- Generates a `CommandRegistry` impl that: + - Parses `CommandEvent`s into your command types. + - Cancels the event if a command matches. + - Dispatches into your `EcoHandler` implementation. + +Within 0.3.x the **shape of the command API** (`Ctx`, `CommandRegistry`, `CommandParseError`, and the `Command` derive attributes) is considered stable. + +--- + +## Events, context, and mutations + +- `event::EventContext<'_, T>` wraps each incoming event: + - `data: &T` gives read‑only access. + - `cancel().await` marks the event as cancelled and immediately sends a response. + - Event‑specific methods (like `set_message` for `ChatEvent`) live in generated extensions. +- `event::EventHandler` is a trait with an async method per event type; you usually never write `impl EventHandler` by hand except inside an `#[event_handler]` block. + +You generally do not construct `EventContext` yourself; the runtime does it for you. + +--- + +## Connection and runtime + +Use `PluginRunner::run(plugin, addr)` from your `main` function: + +- For TCP, pass e.g. `"tcp://127.0.0.1:50050"` or `"127.0.0.1:50050"`. +- On Unix hosts you may also pass: + - `"unix:///tmp/dragonfly_plugin.sock"` or + - an absolute path (`"/tmp/dragonfly_plugin.sock"`). + +On non‑Unix platforms, Unix socket addresses will return an error. + +`PluginRunner`: + +- Connects to the host. +- Sends an initial hello (`PluginHello`) with your plugin ID, name, version, API version and commands. +- Subscribes to your `EventSubscriptions`. +- Drives the main event loop until the host sends a shutdown message or closes the stream. + +--- + +## Stability policy for 0.3.x + +Within the 0.3.x series we aim to keep: + +- Trait surfaces for `Plugin`, `EventHandler`, `EventSubscriptions`, `CommandRegistry`. +- Macro names and high‑level attribute syntax (`#[plugin(...)]`, `#[event_handler]`, `#[derive(Command)]`, `#[subcommand(...)]`). +- `Server` helper method names and argument shapes. +- `EventContext` behavior for `cancel`, mutation helpers, and double‑send (panic in debug, log in release). + +We may still: + +- Add new events and actions. +- Add new helpers or mutation methods. +- Improve error messages and diagnostics. + +For details on how the code is generated and how to maintain it, see `MAINTAINING.md`. diff --git a/packages/rust/example/Cargo.toml b/packages/rust/example/Cargo.toml new file mode 100644 index 0000000..8f8c52c --- /dev/null +++ b/packages/rust/example/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "dragonfly-plugin-example" +version = "0.1.0" +edition = "2021" + +[dependencies] +# for your own plugins change this to be like: +# dragonfly-plugin = version or +# use cargo add dragonfly-plugin to get the latest one. +dragonfly-plugin = { path = "../" } +tokio = { version = "1", features = ["full"] } diff --git a/packages/rust/example/src/main.rs b/packages/rust/example/src/main.rs new file mode 100644 index 0000000..f54cf6a --- /dev/null +++ b/packages/rust/example/src/main.rs @@ -0,0 +1,91 @@ +use dragonfly_plugin::{ + Plugin, + PluginRunner, + Server, + event::{EventContext, EventHandler}, + event_handler, + types, // All the raw prost/tonic types +}; + +// --- 2. Define a struct for your plugin's state --- +// It can be empty, or it can hold databases, configs, etc. +// Note `Plugin` is what enables the auto regisration feature +// and the plugin details via plugin(...) +// `subscriptions(xx)` is the events that your handle will sub +// to from the server. +// We add `Default` so it's easy to create. +#[derive(Plugin, Default)] +#[plugin( + id = "example-rust", // A unique ID for your plugin (matches plugins.yaml) + name = "Example Rust Plugin", // A human-readable name + version = "1.0.0", // Your plugin's version + api = "1.0.0", // The API version you're built against +)] +struct MyExamplePlugin; + +// --- 3. Implement the event handlers --- +// #[event_handler] is our magic proc macro that +// detects which handlers you are implementing and +// automatically gathers which events to subscribe to. +// This replaces the prior #[events()] and impl EventSubscriptions +// and manually writing the list in the function definition. +#[event_handler] +impl EventHandler for MyExamplePlugin { + /// This handler runs when a player joins the server. + /// We'll use it to send our "hello world" message. + async fn on_player_join( + &self, + server: &Server, + event: &mut EventContext<'_, types::PlayerJoinEvent>, + ) { + // Log to the plugin's console + println!("Player '{}' has joined the server.", event.data.name); + + // We assume `send_chat` was generated by `xtask` from a `SendChatAction`. + let welcome_message = format!( + "Welcome, {}! This server is running MyExamplePlugin.", + event.data.name + ); + + // We call the auto-generated `server.send_chat` helper. + // On failure we log the error, but otherwise keep behavior unchanged. + if let Err(e) = server + .send_chat(event.data.player_uuid.clone(), welcome_message) + .await + { + eprintln!("Failed to send welcome message: {}", e); + } + } + + /// This handler runs every time a player sends a chat message. + /// We'll use it to edit the message. + async fn on_chat( + &self, + _server: &Server, // We don't need the server handle for this + event: &mut EventContext<'_, types::ChatEvent>, + ) { + // Get the original message from the event's data + let original_message = &event.data.message; + + // Create the new message + let new_message = format!("[Plugin] {}", original_message); + + // Use the auto-generated `set_message` helper to + // mutate the event before the server processes it. + event.set_message(new_message); + } + + // We don't implement `on_player_hurt`, `on_block_break`, etc., +} + +// --- 4. The main function to run the plugin --- +#[tokio::main] +async fn main() -> Result<(), Box> { + println!("Starting the rust plugin..."); + + PluginRunner::run( + MyExamplePlugin, // Pass in an instance of our plugin + "tcp://127.0.0.1:50050", // The server address (e.g., TCP or Unix socket) + ) + .await +} diff --git a/packages/rust/macro/Cargo.toml b/packages/rust/macro/Cargo.toml new file mode 100644 index 0000000..d0b2f45 --- /dev/null +++ b/packages/rust/macro/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "dragonfly-plugin-macro" +version = "0.3.0" +edition = "2021" +license = "MIT" +repository = "https://github.com/secmc/dragonfly-plugins" +documentation = "https://docs.rs/rust-plugin-macro" +description = "Procedural macros for the Dragonfly gRPC plugin SDK" +homepage = "https://github.com/secmc/dragonfly-plugins" +keywords = ["dragonfly", "plugin", "grpc", "macro"] +categories = ["development-tools::procedural-macro-helpers"] + +[lib] +proc-macro = true + +[dependencies] +heck = "0.5.0" +proc-macro2 = "1.0.103" +quote = "1.0" +syn = { version = "2.0", features = ["full", "parsing", "visit"] } + +[dev-dependencies] +trybuild = "1.0" diff --git a/packages/rust/macro/src/command/codegen.rs b/packages/rust/macro/src/command/codegen.rs new file mode 100644 index 0000000..7155c48 --- /dev/null +++ b/packages/rust/macro/src/command/codegen.rs @@ -0,0 +1,361 @@ +use heck::ToSnakeCase; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{Attribute, DeriveInput, Ident, LitStr}; + +use crate::command::{ + model::{collect_command_shape, CommandShape, ParamMeta, VariantMeta}, + parse::CommandInfoParser, +}; + +pub fn generate_command_impls(ast: &DeriveInput, attr: &Attribute) -> TokenStream { + let command_info = match attr.parse_args::() { + Ok(info) => info, + Err(e) => return e.to_compile_error(), + }; + + let cmd_ident = &ast.ident; + let cmd_name_lit = &command_info.name; + let cmd_desc_lit = &command_info.description; + let aliases_lits = &command_info.aliases; + + let shape = match collect_command_shape(ast) { + Ok(s) => s, + Err(e) => return e.to_compile_error(), + }; + + let spec_impl = + generate_spec_impl(cmd_ident, cmd_name_lit, cmd_desc_lit, aliases_lits, &shape); + let try_from_impl = generate_try_from_impl(cmd_ident, cmd_name_lit, aliases_lits, &shape); + let trait_impl = generate_handler_trait(cmd_ident, &shape); + let exec_impl = generate_execute_impl(cmd_ident, &shape); + + quote! { + #spec_impl + #try_from_impl + #trait_impl + #exec_impl + } +} + +fn generate_spec_impl( + cmd_ident: &Ident, + cmd_name_lit: &LitStr, + cmd_desc_lit: &LitStr, + aliases_lits: &[LitStr], + shape: &CommandShape, +) -> TokenStream { + let params_tokens = match shape { + CommandShape::Struct { params } => { + let specs = params.iter().map(param_to_spec); + quote! { vec![ #( #specs ),* ] } + } + CommandShape::Enum { variants } => { + // First param: subcommand enum + let variant_names_iter = variants.iter().map(|v| &v.canonical); + let subcommand_names: Vec<&LitStr> = variants + .iter() + .flat_map(|v| &v.aliases) + .chain(variant_names_iter) + .collect(); + let subcommand_spec = quote! { + dragonfly_plugin::types::ParamSpec { + name: "subcommand".to_string(), + r#type: dragonfly_plugin::types::ParamType::ParamEnum as i32, + optional: false, + suffix: String::new(), + enum_values: vec![ #( #subcommand_names.to_string() ),* ], + } + }; + + // Merge all variant params, marking as optional if not present in all variants + let merged = merge_variant_params(variants); + let merged_specs = merged.iter().map(param_to_spec); + + quote! { vec![ #subcommand_spec, #( #merged_specs ),* ] } + } + }; + + quote! { + impl #cmd_ident { + pub fn spec() -> dragonfly_plugin::types::CommandSpec { + dragonfly_plugin::types::CommandSpec { + name: #cmd_name_lit.to_string(), + description: #cmd_desc_lit.to_string(), + aliases: vec![ #( #aliases_lits.to_string() ),* ], + params: #params_tokens, + } + } + } + } +} + +fn param_to_spec(p: &ParamMeta) -> TokenStream { + let name_str = p.field_ident.to_string(); + let param_type_expr = &p.param_type_expr; + let is_optional = p.is_optional; + + quote! { + dragonfly_plugin::types::ParamSpec { + name: #name_str.to_string(), + r#type: #param_type_expr as i32, + optional: #is_optional, + suffix: String::new(), + enum_values: Vec::new(), + } + } +} + +/// Merge params from all variants: a param is optional if not present in every variant. +fn merge_variant_params(variants: &[VariantMeta]) -> Vec { + use std::collections::HashMap; + + // Collect all unique param names with their metadata + let mut seen: HashMap = HashMap::new(); // name -> (meta, count) + + for variant in variants { + for param in &variant.params { + let name = param.field_ident.to_string(); + seen.entry(name) + .and_modify(|(_, count)| *count += 1) + .or_insert_with(|| (param.clone(), 1)); + } + } + + let variant_count = variants.len(); + + seen.into_iter() + .map(|(_, (mut meta, count))| { + // If not present in all variants, mark as optional + if count < variant_count { + meta.is_optional = true; + } + meta + }) + .collect() +} + +fn generate_try_from_impl( + cmd_ident: &Ident, + cmd_name_lit: &LitStr, + cmd_aliases: &[LitStr], + shape: &CommandShape, +) -> TokenStream { + let body = match shape { + CommandShape::Struct { params } => { + let field_inits = params.iter().map(|p| struct_field_init(p, 0)); + quote! { + Ok(Self { + #( #field_inits, )* + }) + } + } + CommandShape::Enum { variants } => { + let match_arms = variants.iter().map(|v| { + let variant_ident = &v.ident; + let params = &v.params; + + // Build patterns: "canonical" | "alias1" | "alias2" + let mut name_lits = Vec::new(); + name_lits.push(&v.canonical); + for alias in &v.aliases { + name_lits.push(alias); + } + + let subcommand_patterns = quote! { #( #name_lits )|* }; + + if params.is_empty() { + quote! { + #subcommand_patterns => Ok(Self::#variant_ident), + } + } else { + let field_inits = params.iter().map(enum_field_init); + quote! { + #subcommand_patterns => Ok(Self::#variant_ident { + #( #field_inits, )* + }), + } + } + }); + + quote! { + let subcommand = event.args.first() + .ok_or(dragonfly_plugin::command::CommandParseError::Missing("subcommand"))? + .as_str(); + + match subcommand { + #( #match_arms )* + _ => Err(dragonfly_plugin::command::CommandParseError::UnknownSubcommand), + } + } + } + }; + + let mut conditions = Vec::with_capacity(1 + cmd_aliases.len()); + conditions.push(quote! { event.command != #cmd_name_lit }); + + for alias in cmd_aliases { + conditions.push(quote! { && event.command != #alias }); + } + + quote! { + impl ::core::convert::TryFrom<&dragonfly_plugin::types::CommandEvent> for #cmd_ident { + type Error = dragonfly_plugin::command::CommandParseError; + + fn try_from(event: &dragonfly_plugin::types::CommandEvent) -> Result { + if #(#conditions)* { + return Err(dragonfly_plugin::command::CommandParseError::NoMatch); + } + + #body + } + } + } +} + +/// Generate field init for struct commands (args start at index 0). +fn struct_field_init(p: &ParamMeta, offset: usize) -> TokenStream { + let ident = &p.field_ident; + let idx = p.index + offset; + let name_str = ident.to_string(); + let ty = &p.field_ty; + + if p.is_optional { + quote! { + #ident: dragonfly_plugin::command::parse_optional_arg::<#ty>(&event.args, #idx, #name_str)? + } + } else { + quote! { + #ident: dragonfly_plugin::command::parse_required_arg::<#ty>(&event.args, #idx, #name_str)? + } + } +} + +/// Generate field init for enum variant (args start at index 1, after subcommand). +fn enum_field_init(p: &ParamMeta) -> TokenStream { + let ident = &p.field_ident; + let idx = p.index + 1; // +1 because index 0 is the subcommand + let name_str = ident.to_string(); + let ty = &p.field_ty; + + if p.is_optional { + quote! { + #ident: dragonfly_plugin::command::parse_optional_arg::<#ty>(&event.args, #idx, #name_str)? + } + } else { + quote! { + #ident: dragonfly_plugin::command::parse_required_arg::<#ty>(&event.args, #idx, #name_str)? + } + } +} + +fn generate_handler_trait(cmd_ident: &Ident, shape: &CommandShape) -> TokenStream { + let trait_ident = format_ident!("{}Handler", cmd_ident); + + match shape { + CommandShape::Struct { params } => { + // method name = struct name in snake_case, e.g. Ping -> ping + let method_ident = format_ident!("{}", cmd_ident.to_string().to_snake_case()); + + let args = params.iter().map(|p| { + let ident = &p.field_ident; + let ty = &p.field_ty; + quote! { #ident: #ty } + }); + + quote! { + #[allow(async_fn_in_trait)] + pub trait #trait_ident: Send + Sync { + async fn #method_ident( + &self, + ctx: dragonfly_plugin::command::Ctx<'_>, + #( #args ),* + ); + } + } + } + CommandShape::Enum { variants } => { + let methods = variants.iter().map(|v| { + let method_ident = format_ident!("{}", v.ident.to_string().to_snake_case()); + let args = v.params.iter().map(|p| { + let ident = &p.field_ident; + let ty = &p.field_ty; + quote! { #ident: #ty } + }); + quote! { + async fn #method_ident( + &self, + ctx: dragonfly_plugin::command::Ctx<'_>, + #( #args ),* + ); + } + }); + + quote! { + #[allow(async_fn_in_trait)] + pub trait #trait_ident: Send + Sync { + #( #methods )* + } + } + } + } +} + +fn generate_execute_impl(cmd_ident: &Ident, shape: &CommandShape) -> TokenStream { + let trait_ident = format_ident!("{}Handler", cmd_ident); + + match shape { + CommandShape::Struct { params } => { + let method_ident = format_ident!("{}", cmd_ident.to_string().to_snake_case()); + let field_idents: Vec<_> = params.iter().map(|p| &p.field_ident).collect(); + + quote! { + impl #cmd_ident { + pub async fn __execute( + self, + handler: &H, + ctx: dragonfly_plugin::command::Ctx<'_>, + ) { + let Self { #( #field_idents ),* } = self; + handler.#method_ident(ctx, #( #field_idents ),*).await; + } + } + } + } + CommandShape::Enum { variants } => { + let match_arms = variants.iter().map(|v| { + let variant_ident = &v.ident; + let method_ident = format_ident!("{}", v.ident.to_string().to_snake_case()); + let field_idents: Vec<_> = v.params.iter().map(|p| &p.field_ident).collect(); + + if field_idents.is_empty() { + quote! { + Self::#variant_ident => handler.#method_ident(ctx).await, + } + } else { + quote! { + Self::#variant_ident { #( #field_idents ),* } => { + handler.#method_ident(ctx, #( #field_idents ),*).await + } + } + } + }); + + quote! { + impl #cmd_ident { + pub async fn __execute( + self, + handler: &H, + ctx: dragonfly_plugin::command::Ctx<'_>, + ) { + match self { + #( #match_arms )* + } + } + } + } + } + } +} + + diff --git a/packages/rust/macro/src/command/mod.rs b/packages/rust/macro/src/command/mod.rs new file mode 100644 index 0000000..f3a87da --- /dev/null +++ b/packages/rust/macro/src/command/mod.rs @@ -0,0 +1,7 @@ +mod parse; +mod model; +mod codegen; + +pub use codegen::generate_command_impls; + + diff --git a/packages/rust/macro/src/command/model.rs b/packages/rust/macro/src/command/model.rs new file mode 100644 index 0000000..2f804ae --- /dev/null +++ b/packages/rust/macro/src/command/model.rs @@ -0,0 +1,203 @@ +use heck::ToSnakeCase; +use proc_macro2::TokenStream; +use quote::quote; +use syn::{ + Data, DeriveInput, Fields, GenericArgument, Ident, LitStr, PathArguments, Type, TypePath, +}; + +use crate::command::parse::{parse_subcommand_attr, SubcommandAttr}; + +/// Metadata for a single command parameter (struct field or enum variant field). +#[derive(Clone)] +pub(crate) struct ParamMeta { + /// Rust field identifier, e.g. `amount` + pub field_ident: Ident, + /// Full Rust type of the field, e.g. `f64` or `Option` + pub field_ty: Type, + /// Expression for ParamType (e.g. `ParamType::ParamFloat`) + pub param_type_expr: TokenStream, + /// Whether this is optional in the spec + pub is_optional: bool, + /// Position index in args (0-based, relative to this variant/struct) + pub index: usize, +} + +/// Metadata for a single enum variant (subcommand). +pub(crate) struct VariantMeta { + /// Variant identifier, e.g. `Pay` + pub ident: Ident, + /// name for matching, e.g. `"pay"` + pub canonical: LitStr, + /// Aliases e.g give, donate. + pub aliases: Vec, + /// Parameters (fields) for this variant + pub params: Vec, +} + +/// The shape of a command: either a struct or an enum with variants. +pub(crate) enum CommandShape { + Struct { params: Vec }, + Enum { variants: Vec }, +} + +pub(crate) fn collect_command_shape(ast: &DeriveInput) -> syn::Result { + match &ast.data { + Data::Struct(data) => { + let params = collect_params_from_fields(&data.fields)?; + Ok(CommandShape::Struct { params }) + } + Data::Enum(data) => { + let mut variants_meta = Vec::new(); + + for variant in &data.variants { + let ident = variant.ident.clone(); + let default_name = + LitStr::new(ident.to_string().to_snake_case().as_str(), ident.span()); + + let SubcommandAttr { name, aliases } = parse_subcommand_attr(&variant.attrs)?; + let canonical = name.unwrap_or(default_name); + let params = collect_params_from_fields(&variant.fields)?; + + variants_meta.push(VariantMeta { + ident, + canonical, + aliases, + params, + }); + } + + if variants_meta.is_empty() { + return Err(syn::Error::new_spanned( + &ast.ident, + "enum commands must have at least one variant", + )); + } + + Ok(CommandShape::Enum { + variants: variants_meta, + }) + } + Data::Union(_) => Err(syn::Error::new_spanned( + ast, + "unions are not supported for #[derive(Command)]", + )), + } +} + +fn collect_params_from_fields(fields: &Fields) -> syn::Result> { + let fields = match fields { + Fields::Named(named) => &named.named, + Fields::Unit => { + // No params + return Ok(Vec::new()); + } + Fields::Unnamed(_) => { + return Err(syn::Error::new_spanned( + fields, + "tuple structs are not supported for commands; use named fields", + )); + } + }; + + let mut out = Vec::new(); + for (index, field) in fields.iter().enumerate() { + let field_ident = field + .ident + .clone() + .expect("command struct fields must be named"); + let field_ty = field.ty.clone(); + + let (param_type_expr, is_optional) = get_param_type(&field_ty); + + out.push(ParamMeta { + field_ident, + field_ty, + param_type_expr, + is_optional, + index, + }); + } + + Ok(out) +} + +fn get_param_type(ty: &Type) -> (TokenStream, bool) { + if let Some(inner) = option_inner(ty) { + let (inner_param, _inner_opt) = get_param_type(inner); + return (inner_param, true); + } + + if let Type::Reference(r) = ty { + return get_param_type(&r.elem); + } + + if let Type::Path(TypePath { path, .. }) = ty { + if let Some(seg) = path.segments.last() { + let ident = seg.ident.to_string(); + + // Floats + if ident == "f32" || ident == "f64" { + return ( + quote! { dragonfly_plugin::types::ParamType::ParamFloat }, + false, + ); + } + + if matches!( + ident.as_str(), + "i8" | "i16" + | "i32" + | "i64" + | "i128" + | "u8" + | "u16" + | "u32" + | "u64" + | "u128" + | "isize" + | "usize" + ) { + return ( + quote! { dragonfly_plugin::types::ParamType::ParamInt }, + false, + ); + } + + if ident == "bool" { + return ( + quote! { dragonfly_plugin::types::ParamType::ParamBool }, + false, + ); + } + + if ident == "String" { + return ( + quote! { dragonfly_plugin::types::ParamType::ParamString }, + false, + ); + } + } + } + + ( + quote! { dragonfly_plugin::types::ParamType::ParamString }, + false, + ) +} + +fn option_inner(ty: &Type) -> Option<&Type> { + if let Type::Path(TypePath { path, .. }) = ty { + if let Some(seg) = path.segments.last() { + if seg.ident == "Option" { + if let PathArguments::AngleBracketed(args) = &seg.arguments { + for arg in &args.args { + if let GenericArgument::Type(inner) = arg { + return Some(inner); + } + } + } + } + } + } + None +} diff --git a/packages/rust/macro/src/command/parse.rs b/packages/rust/macro/src/command/parse.rs new file mode 100644 index 0000000..cb29573 --- /dev/null +++ b/packages/rust/macro/src/command/parse.rs @@ -0,0 +1,129 @@ +use syn::{ + parse::{Parse, ParseStream}, + punctuated::Punctuated, + spanned::Spanned, + Attribute, Expr, ExprLit, Lit, LitStr, Meta, Token, +}; + +pub(crate) struct CommandInfoParser { + pub name: LitStr, + pub description: LitStr, + pub aliases: Vec, +} + +impl Parse for CommandInfoParser { + fn parse(input: ParseStream) -> syn::Result { + let metas = Punctuated::::parse_terminated(input)?; + + let mut name = None; + let mut description = None; + let mut aliases = Vec::new(); + + for meta in metas { + match meta { + Meta::NameValue(nv) if nv.path.is_ident("name") => { + if let Expr::Lit(ExprLit { + lit: Lit::Str(s), .. + }) = nv.value + { + name = Some(s); + } else { + return Err(syn::Error::new( + nv.value.span(), + "expected string literal for `name`", + )); + } + } + Meta::NameValue(nv) if nv.path.is_ident("description") => { + if let Expr::Lit(ExprLit { + lit: Lit::Str(s), .. + }) = nv.value + { + description = Some(s); + } else { + return Err(syn::Error::new( + nv.value.span(), + "expected string literal for `description`", + )); + } + } + Meta::List(list) if list.path.is_ident("aliases") => { + aliases = list + .parse_args_with(Punctuated::::parse_terminated)? + .into_iter() + .collect(); + } + _ => { + return Err(syn::Error::new( + meta.span(), + "unrecognized command attribute", + )); + } + } + } + + Ok(Self { + name: name.ok_or_else(|| { + syn::Error::new(input.span(), "missing required attribute `name`") + })?, + description: description.unwrap_or_else(|| LitStr::new("", input.span())), + aliases, + }) + } +} + +#[derive(Default)] +pub(crate) struct SubcommandAttr { + pub name: Option, + pub aliases: Vec, +} + +pub(crate) fn parse_subcommand_attr(attrs: &[Attribute]) -> syn::Result { + let mut out = SubcommandAttr::default(); + + for attr in attrs { + if !attr.path().is_ident("subcommand") { + continue; + } + + let metas = attr.parse_args_with(Punctuated::::parse_terminated)?; + + for meta in metas { + match meta { + // name = "pay" + Meta::NameValue(nv) if nv.path.is_ident("name") => { + if let Expr::Lit(ExprLit { + lit: Lit::Str(s), .. + }) = nv.value + { + out.name = Some(s); + } else { + return Err(syn::Error::new_spanned( + nv.value, + "subcommand `name` must be a string literal", + )); + } + } + + // aliases("give", "send") + Meta::List(list) if list.path.is_ident("aliases") => { + out.aliases = list + .parse_args_with(Punctuated::::parse_terminated)? + .into_iter() + .collect(); + } + + _ => { + return Err(syn::Error::new_spanned( + meta, + "unknown subcommand attribute; expected `name = \"...\"` or `aliases(..)`", + )); + } + } + } + } + + Ok(out) +} + + diff --git a/packages/rust/macro/src/lib.rs b/packages/rust/macro/src/lib.rs new file mode 100644 index 0000000..304a90f --- /dev/null +++ b/packages/rust/macro/src/lib.rs @@ -0,0 +1,140 @@ +//! Procedural macros for the `dragonfly-plugin` Rust SDK. +//! +//! This crate exposes three main macros: +//! - `#[derive(Plugin)]` with a `#[plugin(...)]` attribute to describe +//! the plugin metadata and registered commands. +//! - `#[event_handler]` to generate an `EventSubscriptions` implementation +//! based on the `on_*` methods you override in an `impl EventHandler`. +//! - `#[derive(Command)]` together with `#[command(...)]` / +//! `#[subcommand(...)]` to generate strongly-typed command parsers and +//! handler traits. +//! +//! These macros are re-exported by the `dragonfly-plugin` crate, so plugin +//! authors should depend on that crate directly. + +mod command; +mod plugin; + +use heck::ToPascalCase; +use proc_macro::TokenStream; +use quote::{format_ident, quote}; +use syn::{parse_macro_input, Attribute, DeriveInput, ImplItem, ItemImpl}; + +use crate::{command::generate_command_impls, plugin::generate_plugin_impl}; + +#[proc_macro_derive(Plugin, attributes(plugin, events))] +pub fn handler_derive(input: TokenStream) -> TokenStream { + let ast = parse_macro_input!(input as DeriveInput); + + let derive_name = &ast.ident; + + let info_attr = match find_attribute( + &ast, + "plugin", + "Missing `#[plugin(...)]` attribute with metadata.", + ) { + Ok(attr) => attr, + Err(e) => return e.to_compile_error().into(), + }; + + let plugin_impl = generate_plugin_impl(info_attr, derive_name); + + quote! { + #plugin_impl + } + .into() +} + +fn find_attribute<'a>( + ast: &'a syn::DeriveInput, + name: &str, + error: &str, +) -> Result<&'a Attribute, syn::Error> { + ast.attrs + .iter() + .find(|a| a.path().is_ident(name)) + .ok_or_else(|| syn::Error::new(ast.ident.span(), error)) +} + +#[proc_macro_attribute] +pub fn event_handler(_attr: TokenStream, item: TokenStream) -> TokenStream { + let item_clone = item.clone(); + + // Try to parse the input tokens as an `impl` block + let impl_block = match syn::parse::(item) { + Ok(block) => block, + Err(_) => { + // Parse failed, which means the user is probably in the + // middle of typing. Return the original, un-parsed tokens + // to keep the LSP alive + return item_clone; + } + }; + + // ensure its our EventHandler. + let is_event_handler_impl = if let Some((_, trait_path, _)) = &impl_block.trait_ { + trait_path + .segments + .last() + .is_some_and(|segment| segment.ident == "EventHandler") + } else { + return item_clone; + }; + + if !is_event_handler_impl { + // This is an `impl` for some *other* trait. + // We shouldn't touch it. Return the original tokens. + return item_clone; + } + + let mut event_variants = Vec::new(); + for item in &impl_block.items { + if let ImplItem::Fn(method) = item { + let fn_name = method.sig.ident.to_string(); + + if let Some(event_name_snake) = fn_name.strip_prefix("on_") { + let event_name_pascal = event_name_snake.to_pascal_case(); + + let variant_ident = format_ident!("{}", event_name_pascal); + event_variants.push(quote! { types::EventType::#variant_ident }); + } + } + } + + let self_ty = &impl_block.self_ty; + + let subscriptions_impl = quote! { + impl dragonfly_plugin::EventSubscriptions for #self_ty { + fn get_subscriptions(&self) -> Vec { + vec![ + #( #event_variants ),* + ] + } + } + }; + + let original_impl_tokens = quote! { #impl_block }; + + let final_output = quote! { + #original_impl_tokens + #subscriptions_impl + }; + + final_output.into() +} + +#[proc_macro_derive(Command, attributes(command, subcommand))] +pub fn command_derive(input: TokenStream) -> TokenStream { + let ast = parse_macro_input!(input as DeriveInput); + + let command_attr = match find_attribute( + &ast, + "command", + "Missing `#[command(...)]` attribute with metadata.", + ) { + Ok(attr) => attr, + Err(e) => return e.to_compile_error().into(), + }; + + generate_command_impls(&ast, command_attr).into() +} diff --git a/packages/rust/macro/src/plugin.rs b/packages/rust/macro/src/plugin.rs new file mode 100644 index 0000000..5b13dab --- /dev/null +++ b/packages/rust/macro/src/plugin.rs @@ -0,0 +1,198 @@ +use quote::quote; +use syn::{ + parse::{Parse, ParseStream}, + punctuated::Punctuated, + Attribute, Expr, Ident, Lit, LitStr, Meta, Token, +}; + +struct PluginInfoParser { + pub id: LitStr, + pub name: LitStr, + pub version: LitStr, + pub api: LitStr, + pub commands: Vec, +} + +impl Parse for PluginInfoParser { + fn parse(input: ParseStream) -> syn::Result { + let metas = Punctuated::::parse_terminated(input)?; + + let mut id = None; + let mut name = None; + let mut version = None; + let mut api = None; + let mut commands = Vec::new(); + + for meta in metas { + match meta { + Meta::NameValue(nv) => { + let key_ident = nv.path.get_ident().ok_or_else(|| { + syn::Error::new_spanned(&nv.path, "Expected an identifier (e.g., 'id')") + })?; + + let value_str = match &nv.value { + Expr::Lit(expr_lit) => match &expr_lit.lit { + // clone out the reference as we will now + // be owning it and putting it into our generated code + Lit::Str(lit_str) => lit_str.clone(), + _ => { + return Err(syn::Error::new_spanned( + &nv.value, + "Expected a string literal", + )); + } + }, + _ => { + return Err(syn::Error::new_spanned( + &nv.value, + "Expected a string literal", + )); + } + }; + + // Store the value + if key_ident == "id" { + id = Some(value_str); + } else if key_ident == "name" { + name = Some(value_str); + } else if key_ident == "version" { + version = Some(value_str); + } else if key_ident == "api" { + api = Some(value_str); + } else { + return Err(syn::Error::new_spanned( + key_ident, + "Unknown key. Expected 'id', 'name', 'version', or 'api'", + )); + } + } + Meta::List(list) if list.path.is_ident("commands") => { + // Parse commands(Ident, Ident, ...) + commands = list + .parse_args_with(Punctuated::::parse_terminated)? + .into_iter() + .collect(); + } + _ => { + return Err(syn::Error::new_spanned( + meta, + "Expected `key = \"value\"` or `commands(...)` format", + )); + } + }; + } + + // Validate that all required fields were found + // We use `input.span()` to point the error at the whole `#[plugin(...)]` + // attribute if a field is missing. + let id = id.ok_or_else(|| syn::Error::new(input.span(), "Missing required field 'id'"))?; + let name = + name.ok_or_else(|| syn::Error::new(input.span(), "Missing required field 'name'"))?; + let version = version + .ok_or_else(|| syn::Error::new(input.span(), "Missing required field 'version'"))?; + let api = + api.ok_or_else(|| syn::Error::new(input.span(), "Missing required field 'api'"))?; + + Ok(Self { + id, + name, + version, + api, + commands, + }) + } +} + +pub(crate) fn generate_plugin_impl( + attr: &Attribute, + derive_name: &Ident, +) -> proc_macro2::TokenStream { + let plugin_info = match attr.parse_args::() { + Ok(info) => info, + Err(e) => return e.to_compile_error(), + }; + + // gotta define these outside because of quote rules with the . access. + let id_lit = &plugin_info.id; + let name_lit = &plugin_info.name; + let version_lit = &plugin_info.version; + let api_lit = &plugin_info.api; + + let commands = &plugin_info.commands; + + let command_registry_impl = if commands.is_empty() { + // No commands: empty impl uses default (returns empty vec) + quote! { + impl dragonfly_plugin::command::CommandRegistry for #derive_name {} + } + } else { + // Has commands: generate get_commands() and dispatch_commands() + let spec_calls = commands.iter().map(|cmd| { + quote! { #cmd::spec() } + }); + + let dispatch_arms = commands.iter().map(|cmd| { + quote! { + match #cmd::try_from(event.data) { + Ok(cmd) => { + event.cancel().await; + cmd.__execute(self, ctx).await; + return true; + } + Err(dragonfly_plugin::command::CommandParseError::NoMatch) => { + // Try the next registered command. + } + Err( + err @ dragonfly_plugin::command::CommandParseError::Missing(_) + | err @ dragonfly_plugin::command::CommandParseError::Invalid(_) + | err @ dragonfly_plugin::command::CommandParseError::UnknownSubcommand, + ) => { + // Surface parse errors to the player as a friendly message. + let _ = ctx.reply(err.to_string()).await; + return true; + } + } + } + }); + + quote! { + impl dragonfly_plugin::command::CommandRegistry for #derive_name { + fn get_commands(&self) -> Vec { + vec![ #( #spec_calls ),* ] + } + + async fn dispatch_commands( + &self, + server: &dragonfly_plugin::Server, + event: &mut dragonfly_plugin::event::EventContext<'_, dragonfly_plugin::types::CommandEvent>, + ) -> bool { + use ::core::convert::TryFrom; + let ctx = dragonfly_plugin::command::Ctx::new(server, event.data.player_uuid.clone()); + + #( #dispatch_arms )* + + false + } + } + } + }; + + quote! { + impl dragonfly_plugin::Plugin for #derive_name { + fn get_info(&self) -> dragonfly_plugin::PluginInfo<'static> { + dragonfly_plugin::PluginInfo::<'static> { + id: #id_lit, + name: #name_lit, + version: #version_lit, + api_version: #api_lit + } + } + fn get_id(&self) -> &'static str { #id_lit } + fn get_name(&self) -> &'static str { #name_lit } + fn get_version(&self) -> &'static str { #version_lit } + fn get_api_version(&self) -> &'static str { #api_lit } + } + + #command_registry_impl + } +} diff --git a/packages/rust/macro/tests/fixtures/command_basic.rs b/packages/rust/macro/tests/fixtures/command_basic.rs new file mode 100644 index 0000000..75d2beb --- /dev/null +++ b/packages/rust/macro/tests/fixtures/command_basic.rs @@ -0,0 +1,9 @@ +use dragonfly_plugin_macro::Command; + +#[derive(Command)] +#[command(name = "ping", description = "Ping command", aliases("p"))] +pub struct Ping { + times: i32, +} + + diff --git a/packages/rust/macro/tests/fixtures/plugin_basic.rs b/packages/rust/macro/tests/fixtures/plugin_basic.rs new file mode 100644 index 0000000..611b398 --- /dev/null +++ b/packages/rust/macro/tests/fixtures/plugin_basic.rs @@ -0,0 +1,22 @@ +use dragonfly_plugin_macro::{event_handler, Plugin}; + +struct MyPlugin; + +#[event_handler] +impl dragonfly_plugin::EventHandler for MyPlugin { + async fn on_chat( + &self, + _server: &dragonfly_plugin::Server, + _event: &mut dragonfly_plugin::event::EventContext< + '_, + dragonfly_plugin::types::ChatEvent, + >, + ) { + } +} + +#[derive(Plugin)] +#[plugin(id = "example-rust", name = "Example Rust Plugin", version = "0.3.0", api = "1.0.0")] +struct PluginInfoPlugin; + + diff --git a/packages/rust/macro/tests/macros_compile.rs b/packages/rust/macro/tests/macros_compile.rs new file mode 100644 index 0000000..5879271 --- /dev/null +++ b/packages/rust/macro/tests/macros_compile.rs @@ -0,0 +1,8 @@ +#[test] +fn macros_compile_on_basic_examples() { + let t = trybuild::TestCases::new(); + t.pass("tests/fixtures/plugin_basic.rs"); + t.pass("tests/fixtures/command_basic.rs"); +} + + diff --git a/packages/rust/src/command.rs b/packages/rust/src/command.rs new file mode 100644 index 0000000..be5df3f --- /dev/null +++ b/packages/rust/src/command.rs @@ -0,0 +1,195 @@ +//! Command helpers and traits used by the `#[derive(Command)]` macro. +//! +//! Plugin authors usually interact with: +//! - `Ctx`, the per-command execution context (for replying to the sender). +//! - `CommandRegistry`, which is implemented for you by `#[derive(Plugin)]`. +//! - `CommandParseError`, surfaced as friendly messages to players. + +use crate::{server::Server, types}; + +use tokio::sync::mpsc; + +/// Per-command execution context. +/// +/// This context is constructed by the runtime when a command matches, +/// and exposes the `Server` handle plus the UUID of the player that +/// issued the command. +pub struct Ctx<'a> { + pub server: &'a Server, + pub sender: String, +} + +impl<'a> Ctx<'a> { + pub fn new(server: &'a Server, player_uuid: String) -> Self { + Self { + server, + sender: player_uuid, + } + } + + /// Sends a chat message back to the command sender. + /// + /// This is a convenience wrapper around `Server::send_chat`. + pub async fn reply( + &self, + msg: impl Into, + ) -> Result<(), mpsc::error::SendError> { + self.server.send_chat(self.sender.clone(), msg.into()).await + } +} + +/// Trait plugins use to expose commands to the host. +pub trait CommandRegistry { + fn get_commands(&self) -> Vec { + Vec::new() + } + + /// Dispatch to registered commands. Returns true if a command was handled. + #[allow(async_fn_in_trait)] + async fn dispatch_commands( + &self, + _server: &crate::Server, + _event: &mut crate::event::EventContext<'_, types::CommandEvent>, + ) -> bool { + false + } +} + +#[derive(Debug)] +pub enum CommandParseError { + NoMatch, + Missing(&'static str), + Invalid(&'static str), + UnknownSubcommand, +} + +impl std::fmt::Display for CommandParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CommandParseError::NoMatch => { + write!(f, "command did not match") + } + CommandParseError::Missing(name) => { + write!(f, "missing required argument `{name}`") + } + CommandParseError::Invalid(name) => { + write!(f, "invalid value for argument `{name}`") + } + CommandParseError::UnknownSubcommand => { + write!(f, "unknown subcommand") + } + } + } +} + +impl std::error::Error for CommandParseError {} + +/// Parse a required argument at the given index. +pub fn parse_required_arg( + args: &[String], + index: usize, + name: &'static str, +) -> Result +where + T: std::str::FromStr, +{ + let s = args.get(index).ok_or(CommandParseError::Missing(name))?; + s.parse().map_err(|_| CommandParseError::Invalid(name)) +} + +/// Parse an optional argument at the given index. +/// Returns Ok(None) if the argument is missing. +/// Returns Ok(Some(value)) if present and parseable. +/// Returns Err if present but invalid. +pub fn parse_optional_arg( + args: &[String], + index: usize, + name: &'static str, +) -> Result, CommandParseError> +where + T: std::str::FromStr, +{ + match args.get(index) { + None => Ok(None), + Some(s) if s.is_empty() => Ok(None), + Some(s) => s + .parse() + .map(Some) + .map_err(|_| CommandParseError::Invalid(name)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_required_arg_ok() { + let args = vec!["42".to_string()]; + let value: i32 = parse_required_arg(&args, 0, "amount").unwrap(); + assert_eq!(value, 42); + } + + #[test] + fn parse_required_arg_missing() { + let args: Vec = Vec::new(); + let err = parse_required_arg::(&args, 0, "amount").unwrap_err(); + match err { + CommandParseError::Missing(name) => assert_eq!(name, "amount"), + e => panic!("expected Missing, got {e:?}"), + } + } + + #[test] + fn parse_required_arg_invalid() { + let args = vec!["not-a-number".to_string()]; + let err = parse_required_arg::(&args, 0, "amount").unwrap_err(); + match err { + CommandParseError::Invalid(name) => assert_eq!(name, "amount"), + e => panic!("expected Invalid, got {e:?}"), + } + } + + #[test] + fn parse_optional_arg_none_when_missing_or_empty() { + // Missing index + let args: Vec = Vec::new(); + let value: Option = parse_optional_arg(&args, 0, "amount").unwrap(); + assert!(value.is_none()); + + // Present but empty string + let args = vec!["".to_string()]; + let value: Option = parse_optional_arg(&args, 0, "amount").unwrap(); + assert!(value.is_none()); + } + + #[test] + fn parse_optional_arg_some_when_valid() { + let args = vec!["7".to_string()]; + let value: Option = parse_optional_arg(&args, 0, "amount").unwrap(); + assert_eq!(value, Some(7)); + } + + #[test] + fn parse_optional_arg_error_when_invalid() { + let args = vec!["nope".to_string()]; + let err = parse_optional_arg::(&args, 0, "amount").unwrap_err(); + match err { + CommandParseError::Invalid(name) => assert_eq!(name, "amount"), + e => panic!("expected Invalid, got {e:?}"), + } + } + + #[test] + fn display_messages_are_human_friendly() { + let err = CommandParseError::Missing("amount"); + assert!(err.to_string().contains("missing required argument")); + assert!(err.to_string().contains("amount")); + + let err = CommandParseError::Invalid("amount"); + assert!(err.to_string().contains("invalid value for argument")); + + let err = CommandParseError::UnknownSubcommand; + assert!(err.to_string().contains("unknown subcommand")); + } +} diff --git a/packages/rust/src/event/context.rs b/packages/rust/src/event/context.rs new file mode 100644 index 0000000..31c988c --- /dev/null +++ b/packages/rust/src/event/context.rs @@ -0,0 +1,120 @@ +use tokio::sync::mpsc; + +use crate::types::{self, PluginToHost}; + +/// This enum is used internally by `dispatch_event` to +/// determine what action to take after an event handler runs. +#[doc(hidden)] +#[derive(Debug)] +pub enum EventResult { + /// Do nothing, let the default server behavior happen. just sends ack. + None, + /// Cancel the event, stopping default server behavior. + Cancelled, + /// Mutate the event, which is sent back to the server. + Mutated(types::event_result::Update), +} + +/// A smart wrapper for a server event. +/// +/// This struct provides read-only access to the event's data +/// and methods to mutate or cancel it. +pub struct EventContext<'a, T> { + pub data: &'a T, + pub result: EventResult, + + event_id: &'a str, + sender: mpsc::Sender, + plugin_id: String, + sent: bool, +} + +impl<'a, T> EventContext<'a, T> { + #[doc(hidden)] + pub fn new( + event_id: &'a str, + data: &'a T, + sender: mpsc::Sender, + plugin_id: String, + ) -> Self { + Self { + event_id, + data, + result: EventResult::None, + sender, + plugin_id, + sent: false, + } + } + + /// Consumes the context and returns the final result. + #[doc(hidden)] + pub fn into_result(self) -> (String, EventResult) { + (self.event_id.to_string(), self.result) + } + + /// Cancels the event. + /// + pub async fn cancel(&mut self) { + self.result = EventResult::Cancelled; + self.send().await + } + + pub(crate) async fn send_ack_if_needed(&mut self) { + if self.sent { + return; + } + // result is still EventResultUpdate::None, which sends ack + self.send().await; + } + + pub async fn send(&mut self) { + if self.sent { + #[cfg(debug_assertions)] + panic!("Attempted to respond twice to the same event!"); + + #[cfg(not(debug_assertions))] + { + eprintln!("Warning: send() called after response already sent"); + return; + } + } + + self.sent = true; + + let event_id = self.event_id.to_owned(); + + let payload = match &self.result { + // If nothing was changed just send ack. + EventResult::None => types::EventResult { + event_id, + cancel: None, + update: None, + }, + EventResult::Cancelled => types::EventResult { + event_id, + cancel: Some(true), + update: None, + }, + EventResult::Mutated(update) => types::EventResult { + event_id, + cancel: None, + // TODO: later try to fix this clone. + // this gives us best API usage but is memory semantically wrong. + // calling this func or like .cancel should consume event. + // + // but for newbies thats hard to understand. + update: Some(update.clone()), + }, + }; + + let msg = types::PluginToHost { + plugin_id: self.plugin_id.clone(), + payload: Some(types::PluginPayload::EventResult(payload)), + }; + + if let Err(e) = self.sender.send(msg).await { + eprintln!("Failed to send event response: {}", e); + } + } +} diff --git a/packages/rust/src/event/handler.rs b/packages/rust/src/event/handler.rs new file mode 100644 index 0000000..63e63cb --- /dev/null +++ b/packages/rust/src/event/handler.rs @@ -0,0 +1,806 @@ +//! This file is auto-generated by `xtask`. Do not edit manually. +#![allow(async_fn_in_trait)] +use crate::{ + event::EventContext, types, Server, EventSubscriptions, command::CommandRegistry, +}; +pub trait EventHandler: EventSubscriptions + Send + Sync { + ///Handler for the `PlayerJoin` event. + async fn on_player_join( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerJoinEvent>, + ) {} + ///Handler for the `PlayerQuit` event. + async fn on_player_quit( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerQuitEvent>, + ) {} + ///Handler for the `PlayerMove` event. + async fn on_player_move( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerMoveEvent>, + ) {} + ///Handler for the `PlayerJump` event. + async fn on_player_jump( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerJumpEvent>, + ) {} + ///Handler for the `PlayerTeleport` event. + async fn on_player_teleport( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerTeleportEvent>, + ) {} + ///Handler for the `PlayerChangeWorld` event. + async fn on_player_change_world( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerChangeWorldEvent>, + ) {} + ///Handler for the `PlayerToggleSprint` event. + async fn on_player_toggle_sprint( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerToggleSprintEvent>, + ) {} + ///Handler for the `PlayerToggleSneak` event. + async fn on_player_toggle_sneak( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerToggleSneakEvent>, + ) {} + ///Handler for the `Chat` event. + async fn on_chat( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::ChatEvent>, + ) {} + ///Handler for the `PlayerFoodLoss` event. + async fn on_player_food_loss( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerFoodLossEvent>, + ) {} + ///Handler for the `PlayerHeal` event. + async fn on_player_heal( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerHealEvent>, + ) {} + ///Handler for the `PlayerHurt` event. + async fn on_player_hurt( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerHurtEvent>, + ) {} + ///Handler for the `PlayerDeath` event. + async fn on_player_death( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerDeathEvent>, + ) {} + ///Handler for the `PlayerRespawn` event. + async fn on_player_respawn( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerRespawnEvent>, + ) {} + ///Handler for the `PlayerSkinChange` event. + async fn on_player_skin_change( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerSkinChangeEvent>, + ) {} + ///Handler for the `PlayerFireExtinguish` event. + async fn on_player_fire_extinguish( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerFireExtinguishEvent>, + ) {} + ///Handler for the `PlayerStartBreak` event. + async fn on_player_start_break( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerStartBreakEvent>, + ) {} + ///Handler for the `BlockBreak` event. + async fn on_block_break( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::BlockBreakEvent>, + ) {} + ///Handler for the `PlayerBlockPlace` event. + async fn on_player_block_place( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerBlockPlaceEvent>, + ) {} + ///Handler for the `PlayerBlockPick` event. + async fn on_player_block_pick( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerBlockPickEvent>, + ) {} + ///Handler for the `PlayerItemUse` event. + async fn on_player_item_use( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerItemUseEvent>, + ) {} + ///Handler for the `PlayerItemUseOnBlock` event. + async fn on_player_item_use_on_block( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerItemUseOnBlockEvent>, + ) {} + ///Handler for the `PlayerItemUseOnEntity` event. + async fn on_player_item_use_on_entity( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerItemUseOnEntityEvent>, + ) {} + ///Handler for the `PlayerItemRelease` event. + async fn on_player_item_release( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerItemReleaseEvent>, + ) {} + ///Handler for the `PlayerItemConsume` event. + async fn on_player_item_consume( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerItemConsumeEvent>, + ) {} + ///Handler for the `PlayerAttackEntity` event. + async fn on_player_attack_entity( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerAttackEntityEvent>, + ) {} + ///Handler for the `PlayerExperienceGain` event. + async fn on_player_experience_gain( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerExperienceGainEvent>, + ) {} + ///Handler for the `PlayerPunchAir` event. + async fn on_player_punch_air( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerPunchAirEvent>, + ) {} + ///Handler for the `PlayerSignEdit` event. + async fn on_player_sign_edit( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerSignEditEvent>, + ) {} + ///Handler for the `PlayerLecternPageTurn` event. + async fn on_player_lectern_page_turn( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerLecternPageTurnEvent>, + ) {} + ///Handler for the `PlayerItemDamage` event. + async fn on_player_item_damage( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerItemDamageEvent>, + ) {} + ///Handler for the `PlayerItemPickup` event. + async fn on_player_item_pickup( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerItemPickupEvent>, + ) {} + ///Handler for the `PlayerHeldSlotChange` event. + async fn on_player_held_slot_change( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerHeldSlotChangeEvent>, + ) {} + ///Handler for the `PlayerItemDrop` event. + async fn on_player_item_drop( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerItemDropEvent>, + ) {} + ///Handler for the `PlayerTransfer` event. + async fn on_player_transfer( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerTransferEvent>, + ) {} + ///Handler for the `Command` event. + async fn on_command( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::CommandEvent>, + ) {} + ///Handler for the `PlayerDiagnostics` event. + async fn on_player_diagnostics( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::PlayerDiagnosticsEvent>, + ) {} + ///Handler for the `WorldLiquidFlow` event. + async fn on_world_liquid_flow( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::WorldLiquidFlowEvent>, + ) {} + ///Handler for the `WorldLiquidDecay` event. + async fn on_world_liquid_decay( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::WorldLiquidDecayEvent>, + ) {} + ///Handler for the `WorldLiquidHarden` event. + async fn on_world_liquid_harden( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::WorldLiquidHardenEvent>, + ) {} + ///Handler for the `WorldSound` event. + async fn on_world_sound( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::WorldSoundEvent>, + ) {} + ///Handler for the `WorldFireSpread` event. + async fn on_world_fire_spread( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::WorldFireSpreadEvent>, + ) {} + ///Handler for the `WorldBlockBurn` event. + async fn on_world_block_burn( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::WorldBlockBurnEvent>, + ) {} + ///Handler for the `WorldCropTrample` event. + async fn on_world_crop_trample( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::WorldCropTrampleEvent>, + ) {} + ///Handler for the `WorldLeavesDecay` event. + async fn on_world_leaves_decay( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::WorldLeavesDecayEvent>, + ) {} + ///Handler for the `WorldEntitySpawn` event. + async fn on_world_entity_spawn( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::WorldEntitySpawnEvent>, + ) {} + ///Handler for the `WorldEntityDespawn` event. + async fn on_world_entity_despawn( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::WorldEntityDespawnEvent>, + ) {} + ///Handler for the `WorldExplosion` event. + async fn on_world_explosion( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::WorldExplosionEvent>, + ) {} + ///Handler for the `WorldClose` event. + async fn on_world_close( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::WorldCloseEvent>, + ) {} +} +#[doc(hidden)] +pub async fn dispatch_event( + server: &Server, + handler: &(impl EventHandler + CommandRegistry), + envelope: &types::EventEnvelope, +) { + let Some(payload) = &envelope.payload else { + return; + }; + match payload { + types::event_envelope::Payload::PlayerJoin(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_join(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerQuit(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_quit(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerMove(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_move(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerJump(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_jump(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerTeleport(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_teleport(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerChangeWorld(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_change_world(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerToggleSprint(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_toggle_sprint(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerToggleSneak(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_toggle_sneak(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::Chat(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_chat(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerFoodLoss(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_food_loss(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerHeal(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_heal(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerHurt(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_hurt(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerDeath(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_death(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerRespawn(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_respawn(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerSkinChange(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_skin_change(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerFireExtinguish(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_fire_extinguish(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerStartBreak(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_start_break(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::BlockBreak(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_block_break(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerBlockPlace(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_block_place(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerBlockPick(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_block_pick(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerItemUse(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_item_use(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerItemUseOnBlock(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_item_use_on_block(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerItemUseOnEntity(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_item_use_on_entity(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerItemRelease(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_item_release(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerItemConsume(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_item_consume(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerAttackEntity(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_attack_entity(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerExperienceGain(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_experience_gain(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerPunchAir(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_punch_air(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerSignEdit(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_sign_edit(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerLecternPageTurn(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_lectern_page_turn(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerItemDamage(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_item_damage(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerItemPickup(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_item_pickup(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerHeldSlotChange(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_held_slot_change(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerItemDrop(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_item_drop(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerTransfer(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_transfer(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::Command(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + let handled = handler.dispatch_commands(server, &mut context).await; + if !handled { + handler.on_command(server, &mut context).await; + } + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::PlayerDiagnostics(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_player_diagnostics(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::WorldLiquidFlow(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_world_liquid_flow(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::WorldLiquidDecay(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_world_liquid_decay(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::WorldLiquidHarden(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_world_liquid_harden(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::WorldSound(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_world_sound(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::WorldFireSpread(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_world_fire_spread(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::WorldBlockBurn(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_world_block_burn(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::WorldCropTrample(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_world_crop_trample(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::WorldLeavesDecay(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_world_leaves_decay(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::WorldEntitySpawn(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_world_entity_spawn(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::WorldEntityDespawn(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_world_entity_despawn(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::WorldExplosion(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_world_explosion(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::WorldClose(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_world_close(server, &mut context).await; + context.send_ack_if_needed().await; + } + } +} diff --git a/packages/rust/src/event/mod.rs b/packages/rust/src/event/mod.rs new file mode 100644 index 0000000..ee2cf18 --- /dev/null +++ b/packages/rust/src/event/mod.rs @@ -0,0 +1,17 @@ +//! Core event types and helpers for the Rust plugin SDK. +//! +//! Most plugin authors will work with: +//! - [`EventContext`], which wraps each incoming event and lets you cancel +//! or mutate it before the host processes it. +//! - [`EventHandler`], a trait with an async method per event type. You +//! typically implement this inside an `#[event_handler]` block. +//! +//! The concrete event structs (`ChatEvent`, `PlayerJoinEvent`, …) live in +//! [`crate::types`], generated from the protobuf definitions. + +pub mod context; +pub mod handler; +pub mod mutations; + +pub use context::*; +pub use handler::*; diff --git a/packages/rust/src/event/mutations.rs b/packages/rust/src/event/mutations.rs new file mode 100644 index 0000000..5b36b3a --- /dev/null +++ b/packages/rust/src/event/mutations.rs @@ -0,0 +1,426 @@ +//! This file is auto-generated by `xtask`. Do not edit manually. +#![allow(clippy::all)] +use crate::types; +use crate::event::{EventContext, EventResult}; +impl<'a> EventContext<'a, types::ChatEvent> { + fn ensure_mutation_exists(&mut self) { + if !matches!( + self.result, EventResult::Mutated(types::EventResultUpdate::Chat(_)) + ) { + self.result = EventResult::Mutated( + types::EventResultUpdate::Chat(types::ChatMutation::default()), + ); + } + } + ///Sets the `message` for this event. + pub fn set_message(&mut self, message: impl Into>) -> &mut Self { + self.ensure_mutation_exists(); + if let EventResult::Mutated(types::EventResultUpdate::Chat(ref mut m)) = self + .result + { + m.message = message.into(); + } + self + } +} +impl<'a> EventContext<'a, types::BlockBreakEvent> { + fn ensure_mutation_exists(&mut self) { + if !matches!( + self.result, EventResult::Mutated(types::EventResultUpdate::BlockBreak(_)) + ) { + self.result = EventResult::Mutated( + types::EventResultUpdate::BlockBreak( + types::BlockBreakMutation::default(), + ), + ); + } + } + ///Sets the `drops` for this event. + pub fn set_drops( + &mut self, + drops: impl Into>, + ) -> &mut Self { + self.ensure_mutation_exists(); + if let EventResult::Mutated(types::EventResultUpdate::BlockBreak(ref mut m)) = self + .result + { + m.drops = drops.into(); + } + self + } + ///Sets the `xp` for this event. + pub fn set_xp(&mut self, xp: impl Into>) -> &mut Self { + self.ensure_mutation_exists(); + if let EventResult::Mutated(types::EventResultUpdate::BlockBreak(ref mut m)) = self + .result + { + m.xp = xp.into(); + } + self + } +} +impl<'a> EventContext<'a, types::PlayerFoodLossEvent> { + fn ensure_mutation_exists(&mut self) { + if !matches!( + self.result, + EventResult::Mutated(types::EventResultUpdate::PlayerFoodLoss(_)) + ) { + self.result = EventResult::Mutated( + types::EventResultUpdate::PlayerFoodLoss( + types::PlayerFoodLossMutation::default(), + ), + ); + } + } + ///Sets the `to` for this event. + pub fn set_to(&mut self, to: impl Into>) -> &mut Self { + self.ensure_mutation_exists(); + if let EventResult::Mutated( + types::EventResultUpdate::PlayerFoodLoss(ref mut m), + ) = self.result + { + m.to = to.into(); + } + self + } +} +impl<'a> EventContext<'a, types::PlayerHealEvent> { + fn ensure_mutation_exists(&mut self) { + if !matches!( + self.result, EventResult::Mutated(types::EventResultUpdate::PlayerHeal(_)) + ) { + self.result = EventResult::Mutated( + types::EventResultUpdate::PlayerHeal( + types::PlayerHealMutation::default(), + ), + ); + } + } + ///Sets the `amount` for this event. + pub fn set_amount(&mut self, amount: impl Into>) -> &mut Self { + self.ensure_mutation_exists(); + if let EventResult::Mutated(types::EventResultUpdate::PlayerHeal(ref mut m)) = self + .result + { + m.amount = amount.into(); + } + self + } +} +impl<'a> EventContext<'a, types::PlayerHurtEvent> { + fn ensure_mutation_exists(&mut self) { + if !matches!( + self.result, EventResult::Mutated(types::EventResultUpdate::PlayerHurt(_)) + ) { + self.result = EventResult::Mutated( + types::EventResultUpdate::PlayerHurt( + types::PlayerHurtMutation::default(), + ), + ); + } + } + ///Sets the `damage` for this event. + pub fn set_damage(&mut self, damage: impl Into>) -> &mut Self { + self.ensure_mutation_exists(); + if let EventResult::Mutated(types::EventResultUpdate::PlayerHurt(ref mut m)) = self + .result + { + m.damage = damage.into(); + } + self + } + ///Sets the `attack_immunity_ms` for this event. + pub fn set_attack_immunity_ms( + &mut self, + attack_immunity_ms: impl Into>, + ) -> &mut Self { + self.ensure_mutation_exists(); + if let EventResult::Mutated(types::EventResultUpdate::PlayerHurt(ref mut m)) = self + .result + { + m.attack_immunity_ms = attack_immunity_ms.into(); + } + self + } +} +impl<'a> EventContext<'a, types::PlayerDeathEvent> { + fn ensure_mutation_exists(&mut self) { + if !matches!( + self.result, EventResult::Mutated(types::EventResultUpdate::PlayerDeath(_)) + ) { + self.result = EventResult::Mutated( + types::EventResultUpdate::PlayerDeath( + types::PlayerDeathMutation::default(), + ), + ); + } + } + ///Sets the `keep_inventory` for this event. + pub fn set_keep_inventory( + &mut self, + keep_inventory: impl Into>, + ) -> &mut Self { + self.ensure_mutation_exists(); + if let EventResult::Mutated(types::EventResultUpdate::PlayerDeath(ref mut m)) = self + .result + { + m.keep_inventory = keep_inventory.into(); + } + self + } +} +impl<'a> EventContext<'a, types::PlayerRespawnEvent> { + fn ensure_mutation_exists(&mut self) { + if !matches!( + self.result, EventResult::Mutated(types::EventResultUpdate::PlayerRespawn(_)) + ) { + self.result = EventResult::Mutated( + types::EventResultUpdate::PlayerRespawn( + types::PlayerRespawnMutation::default(), + ), + ); + } + } + ///Sets the `position` for this event. + pub fn set_position( + &mut self, + position: impl Into>, + ) -> &mut Self { + self.ensure_mutation_exists(); + if let EventResult::Mutated( + types::EventResultUpdate::PlayerRespawn(ref mut m), + ) = self.result + { + m.position = position.into(); + } + self + } + ///Sets the `world` for this event. + pub fn set_world(&mut self, world: impl Into>) -> &mut Self { + self.ensure_mutation_exists(); + if let EventResult::Mutated( + types::EventResultUpdate::PlayerRespawn(ref mut m), + ) = self.result + { + m.world = world.into(); + } + self + } +} +impl<'a> EventContext<'a, types::PlayerAttackEntityEvent> { + fn ensure_mutation_exists(&mut self) { + if !matches!( + self.result, + EventResult::Mutated(types::EventResultUpdate::PlayerAttackEntity(_)) + ) { + self.result = EventResult::Mutated( + types::EventResultUpdate::PlayerAttackEntity( + types::PlayerAttackEntityMutation::default(), + ), + ); + } + } + ///Sets the `force` for this event. + pub fn set_force(&mut self, force: impl Into>) -> &mut Self { + self.ensure_mutation_exists(); + if let EventResult::Mutated( + types::EventResultUpdate::PlayerAttackEntity(ref mut m), + ) = self.result + { + m.force = force.into(); + } + self + } + ///Sets the `height` for this event. + pub fn set_height(&mut self, height: impl Into>) -> &mut Self { + self.ensure_mutation_exists(); + if let EventResult::Mutated( + types::EventResultUpdate::PlayerAttackEntity(ref mut m), + ) = self.result + { + m.height = height.into(); + } + self + } + ///Sets the `critical` for this event. + pub fn set_critical(&mut self, critical: impl Into>) -> &mut Self { + self.ensure_mutation_exists(); + if let EventResult::Mutated( + types::EventResultUpdate::PlayerAttackEntity(ref mut m), + ) = self.result + { + m.critical = critical.into(); + } + self + } +} +impl<'a> EventContext<'a, types::PlayerExperienceGainEvent> { + fn ensure_mutation_exists(&mut self) { + if !matches!( + self.result, + EventResult::Mutated(types::EventResultUpdate::PlayerExperienceGain(_)) + ) { + self.result = EventResult::Mutated( + types::EventResultUpdate::PlayerExperienceGain( + types::PlayerExperienceGainMutation::default(), + ), + ); + } + } + ///Sets the `amount` for this event. + pub fn set_amount(&mut self, amount: impl Into>) -> &mut Self { + self.ensure_mutation_exists(); + if let EventResult::Mutated( + types::EventResultUpdate::PlayerExperienceGain(ref mut m), + ) = self.result + { + m.amount = amount.into(); + } + self + } +} +impl<'a> EventContext<'a, types::PlayerLecternPageTurnEvent> { + fn ensure_mutation_exists(&mut self) { + if !matches!( + self.result, + EventResult::Mutated(types::EventResultUpdate::PlayerLecternPageTurn(_)) + ) { + self.result = EventResult::Mutated( + types::EventResultUpdate::PlayerLecternPageTurn( + types::PlayerLecternPageTurnMutation::default(), + ), + ); + } + } + ///Sets the `new_page` for this event. + pub fn set_new_page(&mut self, new_page: impl Into>) -> &mut Self { + self.ensure_mutation_exists(); + if let EventResult::Mutated( + types::EventResultUpdate::PlayerLecternPageTurn(ref mut m), + ) = self.result + { + m.new_page = new_page.into(); + } + self + } +} +impl<'a> EventContext<'a, types::PlayerItemPickupEvent> { + fn ensure_mutation_exists(&mut self) { + if !matches!( + self.result, + EventResult::Mutated(types::EventResultUpdate::PlayerItemPickup(_)) + ) { + self.result = EventResult::Mutated( + types::EventResultUpdate::PlayerItemPickup( + types::PlayerItemPickupMutation::default(), + ), + ); + } + } + ///Sets the `item` for this event. + pub fn set_item(&mut self, item: impl Into>) -> &mut Self { + self.ensure_mutation_exists(); + if let EventResult::Mutated( + types::EventResultUpdate::PlayerItemPickup(ref mut m), + ) = self.result + { + m.item = item.into(); + } + self + } +} +impl<'a> EventContext<'a, types::PlayerTransferEvent> { + fn ensure_mutation_exists(&mut self) { + if !matches!( + self.result, + EventResult::Mutated(types::EventResultUpdate::PlayerTransfer(_)) + ) { + self.result = EventResult::Mutated( + types::EventResultUpdate::PlayerTransfer( + types::PlayerTransferMutation::default(), + ), + ); + } + } + ///Sets the `address` for this event. + pub fn set_address( + &mut self, + address: impl Into>, + ) -> &mut Self { + self.ensure_mutation_exists(); + if let EventResult::Mutated( + types::EventResultUpdate::PlayerTransfer(ref mut m), + ) = self.result + { + m.address = address.into(); + } + self + } +} +impl<'a> EventContext<'a, types::WorldExplosionEvent> { + fn ensure_mutation_exists(&mut self) { + if !matches!( + self.result, + EventResult::Mutated(types::EventResultUpdate::WorldExplosion(_)) + ) { + self.result = EventResult::Mutated( + types::EventResultUpdate::WorldExplosion( + types::WorldExplosionMutation::default(), + ), + ); + } + } + ///Sets the `entity_uuids` for this event. + pub fn set_entity_uuids( + &mut self, + entity_uuids: impl Into>, + ) -> &mut Self { + self.ensure_mutation_exists(); + if let EventResult::Mutated( + types::EventResultUpdate::WorldExplosion(ref mut m), + ) = self.result + { + m.entity_uuids = entity_uuids.into(); + } + self + } + ///Sets the `blocks` for this event. + pub fn set_blocks( + &mut self, + blocks: impl Into>, + ) -> &mut Self { + self.ensure_mutation_exists(); + if let EventResult::Mutated( + types::EventResultUpdate::WorldExplosion(ref mut m), + ) = self.result + { + m.blocks = blocks.into(); + } + self + } + ///Sets the `item_drop_chance` for this event. + pub fn set_item_drop_chance( + &mut self, + item_drop_chance: impl Into>, + ) -> &mut Self { + self.ensure_mutation_exists(); + if let EventResult::Mutated( + types::EventResultUpdate::WorldExplosion(ref mut m), + ) = self.result + { + m.item_drop_chance = item_drop_chance.into(); + } + self + } + ///Sets the `spawn_fire` for this event. + pub fn set_spawn_fire(&mut self, spawn_fire: impl Into>) -> &mut Self { + self.ensure_mutation_exists(); + if let EventResult::Mutated( + types::EventResultUpdate::WorldExplosion(ref mut m), + ) = self.result + { + m.spawn_fire = spawn_fire.into(); + } + self + } +} diff --git a/packages/rust/src/lib.rs b/packages/rust/src/lib.rs index 302c57e..dc8506f 100644 --- a/packages/rust/src/lib.rs +++ b/packages/rust/src/lib.rs @@ -1,6 +1,204 @@ -#![allow(clippy::all)] - +#[doc = include_str!("../README.md")] #[path = "generated/df.plugin.rs"] mod df_plugin; -pub use df_plugin::*; \ No newline at end of file +pub mod types { + pub use super::df_plugin::plugin_client::PluginClient; + pub use super::df_plugin::*; + pub use super::df_plugin::{ + action::Kind as ActionKind, event_envelope::Payload as EventPayload, + event_result::Update as EventResultUpdate, host_to_plugin::Payload as HostPayload, + plugin_to_host::Payload as PluginPayload, + }; +} + +pub mod command; +pub mod event; +#[path = "server/server.rs"] +pub mod server; + +use std::error::Error; + +pub use server::*; + +// main usage stuff for plugin devs: +pub use dragonfly_plugin_macro::{event_handler, Command, Plugin}; +pub use event::EventHandler; +use tokio::sync::mpsc; +use tokio_stream::{wrappers::ReceiverStream, StreamExt}; + +#[cfg(unix)] +use hyper_util::rt::TokioIo; +#[cfg(unix)] +use tokio::net::UnixStream; + +use crate::command::CommandRegistry; + +/// Helper function to connect to the server, supporting both Unix sockets and TCP. +async fn connect_to_server( + addr: &str, +) -> Result, Box> { + // Check if it's a Unix socket address (starts with "unix:" or is a path starting with "/") + if addr.starts_with("unix:") || addr.starts_with('/') { + #[cfg(unix)] + { + // Extract the path and convert to owned String for the closure + let path: String = if addr.starts_with("unix://") { + addr[7..].to_string() + } else if addr.starts_with("unix:") { + addr[5..].to_string() + } else { + addr.to_string() + }; + // Create a lazy channel that uses Unix sockets. + // Lazy is required so the hello message gets sent as part of stream + // establishment, avoiding a deadlock with the Go server which waits + // for the hello before sending response headers. + let channel = tonic::transport::Endpoint::try_from("http://[::1]:50051")? + .connect_with_connector_lazy(service_fn(move |_: tonic::transport::Uri| { + let path = path.clone(); + async move { + let stream = UnixStream::connect(&path).await?; + Ok::<_, std::io::Error>(TokioIo::new(stream)) + } + })); + Ok(types::PluginClient::new(channel)) + } + #[cfg(not(unix))] + { + Err("Unix sockets are not supported on this platform".into()) + } + } else { + // Regular TCP connection + Ok(types::PluginClient::connect(addr.to_string()).await?) + } +} + +pub struct PluginRunner {} + +impl PluginRunner { + /// Runs the plugin, connecting to the server and starting the event loop. + pub async fn run(plugin: impl Plugin + 'static, addr: &str) -> Result<(), Box> { + let mut raw_client = connect_to_server(addr).await?; + + let (tx, rx) = mpsc::channel(128); + + // Pre-buffer the hello message so it's sent immediately when stream opens. + // This is required because the Go server blocks on Recv() waiting for the + // hello before sending response headers. + let hello_msg = types::PluginToHost { + plugin_id: plugin.get_id().to_owned(), + payload: Some(types::PluginPayload::Hello(types::PluginHello { + name: plugin.get_name().to_owned(), + version: plugin.get_version().to_owned(), + api_version: plugin.get_api_version().to_owned(), + commands: plugin.get_commands(), + custom_items: vec![], + custom_blocks: vec![], + })), + }; + tx.send(hello_msg).await?; + + let request_stream = ReceiverStream::new(rx); + let mut event_stream = raw_client.event_stream(request_stream).await?.into_inner(); + + let server = Server { + plugin_id: plugin.get_id().to_owned(), + sender: tx.clone(), + }; + + let mut events = plugin.get_subscriptions(); + + // Auto-subscribe to Command if plugin has registered commands + if !plugin.get_commands().is_empty() && !events.contains(&types::EventType::Command) { + events.push(types::EventType::Command); + } + + if !events.is_empty() { + println!("Subscribing to {} event types...", events.len()); + server.subscribe(events).await?; + } + + println!("Plugin '{}' connected and listening.", plugin.get_name()); + + // 8. Run the main event loop + while let Some(Ok(msg)) = event_stream.next().await { + match msg.payload { + // We received a game event + Some(types::HostPayload::Event(envelope)) => { + event::dispatch_event(&server, &plugin, &envelope).await; + } + // The server is shutting us down + Some(types::HostPayload::Shutdown(shutdown)) => { + println!("Server shutting down plugin: {}", shutdown.reason); + break; // Break the loop + } + _ => { /* Ignore other payloads */ } + } + } + + println!("Plugin '{}' disconnected.", plugin.get_name()); + Ok(()) + } +} + +/// A trait that defines which events your plugin will receive. +/// +/// You can implement this trait manually, or you can use the +/// `#[derive(Plugin)]` along with `#[events(Event1, Event2)` +/// implementation to generate it for you. +pub trait EventSubscriptions { + fn get_subscriptions(&self) -> Vec; +} + +/// A struct that defines the details of your plugin. +pub struct PluginInfo<'a> { + pub id: &'a str, + pub name: &'a str, + pub version: &'a str, + pub api_version: &'a str, +} + +/// The final trait required for our plugin to be runnable. +/// +/// These functions get impled automatically by +/// `#[derive(Plugin)` like so: +/// ```rust +/// use dragonfly_plugin::{ +/// PluginRunner, // Our runtime, clearly named +/// Plugin, // The derive macro +/// event::{EventContext, EventHandler}, +/// event_handler, +/// types, +/// Server, +/// }; +/// +/// #[derive(Plugin, Default)] +/// #[plugin( +/// id = "example-rust", +/// name = "Example Rust Plugin", +/// version = "1.0.0", +/// api = "1.0.0" +/// )] +///struct MyPlugin {} +/// +///#[event_handler] +///impl EventHandler for MyPlugin { +/// async fn on_player_join( +/// &self, +/// server: &Server, +/// event: &mut EventContext<'_, types::PlayerJoinEvent>, +/// ) { } +/// } +/// ``` +pub trait Plugin: EventHandler + EventSubscriptions + CommandRegistry { + fn get_info(&self) -> PluginInfo<'_>; + + fn get_id(&self) -> &str; + + fn get_name(&self) -> &str; + + fn get_version(&self) -> &str; + + fn get_api_version(&self) -> &str; +} diff --git a/packages/rust/src/server/helpers.rs b/packages/rust/src/server/helpers.rs new file mode 100644 index 0000000..7eb2699 --- /dev/null +++ b/packages/rust/src/server/helpers.rs @@ -0,0 +1,1428 @@ +//! This file is auto-generated by `xtask`. Do not edit manually. +use crate::{types, Server}; +use tokio::sync::mpsc; +impl Server { + ///Sends a `SendChat` action to the server. + pub async fn send_chat( + &self, + target_uuid: String, + message: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::SendChat(types::SendChatAction { + target_uuid, + message, + })) + .await + } + ///Sends a `Teleport` action to the server. + pub async fn teleport( + &self, + player_uuid: String, + position: impl Into>, + rotation: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::Teleport(types::TeleportAction { + player_uuid, + position: position.into(), + rotation: rotation.into(), + })) + .await + } + ///Sends a `Kick` action to the server. + pub async fn kick( + &self, + player_uuid: String, + reason: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::Kick(types::KickAction { + player_uuid, + reason, + })) + .await + } + ///Sends a `SetGameMode` action to the server. + pub async fn set_game_mode( + &self, + player_uuid: String, + game_mode: types::GameMode, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::SetGameMode(types::SetGameModeAction { + player_uuid, + game_mode: game_mode.into(), + })) + .await + } + ///Sends a `GiveItem` action to the server. + pub async fn give_item( + &self, + player_uuid: String, + item: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::GiveItem(types::GiveItemAction { + player_uuid, + item: item.into(), + })) + .await + } + ///Sends a `ClearInventory` action to the server. + pub async fn clear_inventory( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::ClearInventory( + types::ClearInventoryAction { player_uuid }, + )) + .await + } + ///Sends a `SetHeldItem` action to the server. + pub async fn set_held_item( + &self, + player_uuid: String, + main: impl Into>, + offhand: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::SetHeldItem(types::SetHeldItemAction { + player_uuid, + main: main.into(), + offhand: offhand.into(), + })) + .await + } + ///Sends a `PlayerSetArmour` action to the server. + pub async fn player_set_armour( + &self, + player_uuid: String, + helmet: impl Into>, + chestplate: impl Into>, + leggings: impl Into>, + boots: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerSetArmour( + types::PlayerSetArmourAction { + player_uuid, + helmet: helmet.into(), + chestplate: chestplate.into(), + leggings: leggings.into(), + boots: boots.into(), + }, + )) + .await + } + ///Sends a `PlayerOpenBlockContainer` action to the server. + pub async fn player_open_block_container( + &self, + player_uuid: String, + position: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerOpenBlockContainer( + types::PlayerOpenBlockContainerAction { + player_uuid, + position: position.into(), + }, + )) + .await + } + ///Sends a `PlayerDropItem` action to the server. + pub async fn player_drop_item( + &self, + player_uuid: String, + item: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerDropItem( + types::PlayerDropItemAction { + player_uuid, + item: item.into(), + }, + )) + .await + } + ///Sends a `PlayerSetItemCooldown` action to the server. + pub async fn player_set_item_cooldown( + &self, + player_uuid: String, + item: impl Into>, + duration_ms: i64, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerSetItemCooldown( + types::PlayerSetItemCooldownAction { + player_uuid, + item: item.into(), + duration_ms, + }, + )) + .await + } + ///Sends a `SetHealth` action to the server. + pub async fn set_health( + &self, + player_uuid: String, + health: f64, + max_health: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::SetHealth(types::SetHealthAction { + player_uuid, + health, + max_health: max_health.into(), + })) + .await + } + ///Sends a `SetFood` action to the server. + pub async fn set_food( + &self, + player_uuid: String, + food: i32, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::SetFood(types::SetFoodAction { + player_uuid, + food, + })) + .await + } + ///Sends a `SetExperience` action to the server. + pub async fn set_experience( + &self, + player_uuid: String, + level: impl Into>, + progress: impl Into>, + amount: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::SetExperience( + types::SetExperienceAction { + player_uuid, + level: level.into(), + progress: progress.into(), + amount: amount.into(), + }, + )) + .await + } + ///Sends a `SetVelocity` action to the server. + pub async fn set_velocity( + &self, + player_uuid: String, + velocity: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::SetVelocity(types::SetVelocityAction { + player_uuid, + velocity: velocity.into(), + })) + .await + } + ///Sends a `AddEffect` action to the server. + pub async fn add_effect( + &self, + player_uuid: String, + effect_type: types::EffectType, + level: i32, + duration_ms: i64, + show_particles: bool, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::AddEffect(types::AddEffectAction { + player_uuid, + effect_type: effect_type.into(), + level, + duration_ms, + show_particles, + })) + .await + } + ///Sends a `RemoveEffect` action to the server. + pub async fn remove_effect( + &self, + player_uuid: String, + effect_type: types::EffectType, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::RemoveEffect( + types::RemoveEffectAction { + player_uuid, + effect_type: effect_type.into(), + }, + )) + .await + } + ///Sends a `SendTitle` action to the server. + pub async fn send_title( + &self, + player_uuid: String, + title: String, + subtitle: impl Into>, + fade_in_ms: impl Into>, + duration_ms: impl Into>, + fade_out_ms: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::SendTitle(types::SendTitleAction { + player_uuid, + title, + subtitle: subtitle.into(), + fade_in_ms: fade_in_ms.into(), + duration_ms: duration_ms.into(), + fade_out_ms: fade_out_ms.into(), + })) + .await + } + ///Sends a `SendPopup` action to the server. + pub async fn send_popup( + &self, + player_uuid: String, + message: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::SendPopup(types::SendPopupAction { + player_uuid, + message, + })) + .await + } + ///Sends a `SendTip` action to the server. + pub async fn send_tip( + &self, + player_uuid: String, + message: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::SendTip(types::SendTipAction { + player_uuid, + message, + })) + .await + } + ///Sends a `PlayerSendToast` action to the server. + pub async fn player_send_toast( + &self, + player_uuid: String, + title: String, + message: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerSendToast( + types::PlayerSendToastAction { + player_uuid, + title, + message, + }, + )) + .await + } + ///Sends a `PlayerSendJukeboxPopup` action to the server. + pub async fn player_send_jukebox_popup( + &self, + player_uuid: String, + message: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerSendJukeboxPopup( + types::PlayerSendJukeboxPopupAction { + player_uuid, + message, + }, + )) + .await + } + ///Sends a `PlayerShowCoordinates` action to the server. + pub async fn player_show_coordinates( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerShowCoordinates( + types::PlayerShowCoordinatesAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerHideCoordinates` action to the server. + pub async fn player_hide_coordinates( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerHideCoordinates( + types::PlayerHideCoordinatesAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerEnableInstantRespawn` action to the server. + pub async fn player_enable_instant_respawn( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerEnableInstantRespawn( + types::PlayerEnableInstantRespawnAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerDisableInstantRespawn` action to the server. + pub async fn player_disable_instant_respawn( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerDisableInstantRespawn( + types::PlayerDisableInstantRespawnAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerSetNameTag` action to the server. + pub async fn player_set_name_tag( + &self, + player_uuid: String, + name_tag: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerSetNameTag( + types::PlayerSetNameTagAction { + player_uuid, + name_tag, + }, + )) + .await + } + ///Sends a `PlayerSetScoreTag` action to the server. + pub async fn player_set_score_tag( + &self, + player_uuid: String, + score_tag: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerSetScoreTag( + types::PlayerSetScoreTagAction { + player_uuid, + score_tag, + }, + )) + .await + } + ///Sends a `PlaySound` action to the server. + pub async fn play_sound( + &self, + player_uuid: String, + sound: types::Sound, + position: impl Into>, + volume: impl Into>, + pitch: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlaySound(types::PlaySoundAction { + player_uuid, + sound: sound.into(), + position: position.into(), + volume: volume.into(), + pitch: pitch.into(), + })) + .await + } + ///Sends a `PlayerShowParticle` action to the server. + pub async fn player_show_particle( + &self, + player_uuid: String, + position: impl Into>, + particle: types::ParticleType, + block: impl Into>, + face: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerShowParticle( + types::PlayerShowParticleAction { + player_uuid, + position: position.into(), + particle: particle.into(), + block: block.into(), + face: face.into(), + }, + )) + .await + } + ///Sends a `PlayerSendScoreboard` action to the server. + pub async fn player_send_scoreboard( + &self, + player_uuid: String, + title: String, + lines: Vec, + padding: impl Into>, + descending: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerSendScoreboard( + types::PlayerSendScoreboardAction { + player_uuid, + title, + lines, + padding: padding.into(), + descending: descending.into(), + }, + )) + .await + } + ///Sends a `PlayerRemoveScoreboard` action to the server. + pub async fn player_remove_scoreboard( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerRemoveScoreboard( + types::PlayerRemoveScoreboardAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerSendMenuForm` action to the server. + pub async fn player_send_menu_form( + &self, + player_uuid: String, + title: String, + body: impl Into>, + buttons: Vec, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerSendMenuForm( + types::PlayerSendMenuFormAction { + player_uuid, + title, + body: body.into(), + buttons, + }, + )) + .await + } + ///Sends a `PlayerSendModalForm` action to the server. + pub async fn player_send_modal_form( + &self, + player_uuid: String, + title: String, + body: String, + yes_text: String, + no_text: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerSendModalForm( + types::PlayerSendModalFormAction { + player_uuid, + title, + body, + yes_text, + no_text, + }, + )) + .await + } + ///Sends a `PlayerSendDialogue` action to the server. + pub async fn player_send_dialogue( + &self, + player_uuid: String, + title: String, + body: impl Into>, + buttons: Vec, + entity: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerSendDialogue( + types::PlayerSendDialogueAction { + player_uuid, + title, + body: body.into(), + buttons, + entity: entity.into(), + }, + )) + .await + } + ///Sends a `PlayerCloseDialogue` action to the server. + pub async fn player_close_dialogue( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerCloseDialogue( + types::PlayerCloseDialogueAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerCloseForm` action to the server. + pub async fn player_close_form( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerCloseForm( + types::PlayerCloseFormAction { player_uuid }, + )) + .await + } + ///Sends a `ExecuteCommand` action to the server. + pub async fn execute_command( + &self, + player_uuid: String, + command: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::ExecuteCommand( + types::ExecuteCommandAction { + player_uuid, + command, + }, + )) + .await + } + ///Sends a `PlayerStartSprinting` action to the server. + pub async fn player_start_sprinting( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerStartSprinting( + types::PlayerStartSprintingAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerStopSprinting` action to the server. + pub async fn player_stop_sprinting( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerStopSprinting( + types::PlayerStopSprintingAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerStartSneaking` action to the server. + pub async fn player_start_sneaking( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerStartSneaking( + types::PlayerStartSneakingAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerStopSneaking` action to the server. + pub async fn player_stop_sneaking( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerStopSneaking( + types::PlayerStopSneakingAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerStartSwimming` action to the server. + pub async fn player_start_swimming( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerStartSwimming( + types::PlayerStartSwimmingAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerStopSwimming` action to the server. + pub async fn player_stop_swimming( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerStopSwimming( + types::PlayerStopSwimmingAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerStartCrawling` action to the server. + pub async fn player_start_crawling( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerStartCrawling( + types::PlayerStartCrawlingAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerStopCrawling` action to the server. + pub async fn player_stop_crawling( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerStopCrawling( + types::PlayerStopCrawlingAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerStartGliding` action to the server. + pub async fn player_start_gliding( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerStartGliding( + types::PlayerStartGlidingAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerStopGliding` action to the server. + pub async fn player_stop_gliding( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerStopGliding( + types::PlayerStopGlidingAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerStartFlying` action to the server. + pub async fn player_start_flying( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerStartFlying( + types::PlayerStartFlyingAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerStopFlying` action to the server. + pub async fn player_stop_flying( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerStopFlying( + types::PlayerStopFlyingAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerSetImmobile` action to the server. + pub async fn player_set_immobile( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerSetImmobile( + types::PlayerSetImmobileAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerSetMobile` action to the server. + pub async fn player_set_mobile( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerSetMobile( + types::PlayerSetMobileAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerSetSpeed` action to the server. + pub async fn player_set_speed( + &self, + player_uuid: String, + speed: f64, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerSetSpeed( + types::PlayerSetSpeedAction { player_uuid, speed }, + )) + .await + } + ///Sends a `PlayerSetFlightSpeed` action to the server. + pub async fn player_set_flight_speed( + &self, + player_uuid: String, + flight_speed: f64, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerSetFlightSpeed( + types::PlayerSetFlightSpeedAction { + player_uuid, + flight_speed, + }, + )) + .await + } + ///Sends a `PlayerSetVerticalFlightSpeed` action to the server. + pub async fn player_set_vertical_flight_speed( + &self, + player_uuid: String, + vertical_flight_speed: f64, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerSetVerticalFlightSpeed( + types::PlayerSetVerticalFlightSpeedAction { + player_uuid, + vertical_flight_speed, + }, + )) + .await + } + ///Sends a `PlayerSetAbsorption` action to the server. + pub async fn player_set_absorption( + &self, + player_uuid: String, + absorption: f64, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerSetAbsorption( + types::PlayerSetAbsorptionAction { + player_uuid, + absorption, + }, + )) + .await + } + ///Sends a `PlayerSetOnFire` action to the server. + pub async fn player_set_on_fire( + &self, + player_uuid: String, + duration_ms: i64, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerSetOnFire( + types::PlayerSetOnFireAction { + player_uuid, + duration_ms, + }, + )) + .await + } + ///Sends a `PlayerExtinguish` action to the server. + pub async fn player_extinguish( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerExtinguish( + types::PlayerExtinguishAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerSetInvisible` action to the server. + pub async fn player_set_invisible( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerSetInvisible( + types::PlayerSetInvisibleAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerSetVisible` action to the server. + pub async fn player_set_visible( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerSetVisible( + types::PlayerSetVisibleAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerSetScale` action to the server. + pub async fn player_set_scale( + &self, + player_uuid: String, + scale: f64, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerSetScale( + types::PlayerSetScaleAction { player_uuid, scale }, + )) + .await + } + ///Sends a `PlayerSetHeldSlot` action to the server. + pub async fn player_set_held_slot( + &self, + player_uuid: String, + slot: i32, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerSetHeldSlot( + types::PlayerSetHeldSlotAction { player_uuid, slot }, + )) + .await + } + ///Sends a `PlayerRespawn` action to the server. + pub async fn player_respawn( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerRespawn( + types::PlayerRespawnAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerTransfer` action to the server. + pub async fn player_transfer( + &self, + player_uuid: String, + address: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerTransfer( + types::PlayerTransferAction { + player_uuid, + address: address.into(), + }, + )) + .await + } + ///Sends a `PlayerKnockBack` action to the server. + pub async fn player_knock_back( + &self, + player_uuid: String, + source: impl Into>, + force: f64, + height: f64, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerKnockBack( + types::PlayerKnockBackAction { + player_uuid, + source: source.into(), + force, + height, + }, + )) + .await + } + ///Sends a `PlayerSwingArm` action to the server. + pub async fn player_swing_arm( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerSwingArm( + types::PlayerSwingArmAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerPunchAir` action to the server. + pub async fn player_punch_air( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerPunchAir( + types::PlayerPunchAirAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerSendBossBar` action to the server. + pub async fn player_send_boss_bar( + &self, + player_uuid: String, + text: String, + health_percentage: impl Into>, + colour: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerSendBossBar( + types::PlayerSendBossBarAction { + player_uuid, + text, + health_percentage: health_percentage.into(), + colour: colour.into().map(|x| x.into()), + }, + )) + .await + } + ///Sends a `PlayerRemoveBossBar` action to the server. + pub async fn player_remove_boss_bar( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerRemoveBossBar( + types::PlayerRemoveBossBarAction { player_uuid }, + )) + .await + } + ///Sends a `PlayerShowHudElement` action to the server. + pub async fn player_show_hud_element( + &self, + player_uuid: String, + element: types::HudElement, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerShowHudElement( + types::PlayerShowHudElementAction { + player_uuid, + element: element.into(), + }, + )) + .await + } + ///Sends a `PlayerHideHudElement` action to the server. + pub async fn player_hide_hud_element( + &self, + player_uuid: String, + element: types::HudElement, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerHideHudElement( + types::PlayerHideHudElementAction { + player_uuid, + element: element.into(), + }, + )) + .await + } + ///Sends a `PlayerOpenSign` action to the server. + pub async fn player_open_sign( + &self, + player_uuid: String, + position: impl Into>, + front_side: bool, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerOpenSign( + types::PlayerOpenSignAction { + player_uuid, + position: position.into(), + front_side, + }, + )) + .await + } + ///Sends a `PlayerEditSign` action to the server. + pub async fn player_edit_sign( + &self, + player_uuid: String, + position: impl Into>, + front_text: String, + back_text: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerEditSign( + types::PlayerEditSignAction { + player_uuid, + position: position.into(), + front_text, + back_text, + }, + )) + .await + } + ///Sends a `PlayerTurnLecternPage` action to the server. + pub async fn player_turn_lectern_page( + &self, + player_uuid: String, + position: impl Into>, + page: i32, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerTurnLecternPage( + types::PlayerTurnLecternPageAction { + player_uuid, + position: position.into(), + page, + }, + )) + .await + } + ///Sends a `PlayerHidePlayer` action to the server. + pub async fn player_hide_player( + &self, + player_uuid: String, + target_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerHidePlayer( + types::PlayerHidePlayerAction { + player_uuid, + target_uuid, + }, + )) + .await + } + ///Sends a `PlayerShowPlayer` action to the server. + pub async fn player_show_player( + &self, + player_uuid: String, + target_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerShowPlayer( + types::PlayerShowPlayerAction { + player_uuid, + target_uuid, + }, + )) + .await + } + ///Sends a `PlayerRemoveAllDebugShapes` action to the server. + pub async fn player_remove_all_debug_shapes( + &self, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::PlayerRemoveAllDebugShapes( + types::PlayerRemoveAllDebugShapesAction { player_uuid }, + )) + .await + } + ///Sends a `WorldSetDefaultGameMode` action to the server. + pub async fn world_set_default_game_mode( + &self, + world: impl Into>, + game_mode: types::GameMode, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldSetDefaultGameMode( + types::WorldSetDefaultGameModeAction { + world: world.into(), + game_mode: game_mode.into(), + }, + )) + .await + } + ///Sends a `WorldSetDifficulty` action to the server. + pub async fn world_set_difficulty( + &self, + world: impl Into>, + difficulty: types::Difficulty, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldSetDifficulty( + types::WorldSetDifficultyAction { + world: world.into(), + difficulty: difficulty.into(), + }, + )) + .await + } + ///Sends a `WorldSetTickRange` action to the server. + pub async fn world_set_tick_range( + &self, + world: impl Into>, + tick_range: i32, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldSetTickRange( + types::WorldSetTickRangeAction { + world: world.into(), + tick_range, + }, + )) + .await + } + ///Sends a `WorldSetBlock` action to the server. + pub async fn world_set_block( + &self, + world: impl Into>, + position: impl Into>, + block: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldSetBlock( + types::WorldSetBlockAction { + world: world.into(), + position: position.into(), + block: block.into(), + }, + )) + .await + } + ///Sends a `WorldPlaySound` action to the server. + pub async fn world_play_sound( + &self, + world: impl Into>, + sound: types::Sound, + position: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldPlaySound( + types::WorldPlaySoundAction { + world: world.into(), + sound: sound.into(), + position: position.into(), + }, + )) + .await + } + ///Sends a `WorldAddParticle` action to the server. + pub async fn world_add_particle( + &self, + world: impl Into>, + position: impl Into>, + particle: types::ParticleType, + block: impl Into>, + face: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldAddParticle( + types::WorldAddParticleAction { + world: world.into(), + position: position.into(), + particle: particle.into(), + block: block.into(), + face: face.into(), + }, + )) + .await + } + ///Sends a `WorldSetTime` action to the server. + pub async fn world_set_time( + &self, + world: impl Into>, + time: i32, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldSetTime( + types::WorldSetTimeAction { + world: world.into(), + time, + }, + )) + .await + } + ///Sends a `WorldStopTime` action to the server. + pub async fn world_stop_time( + &self, + world: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldStopTime( + types::WorldStopTimeAction { + world: world.into(), + }, + )) + .await + } + ///Sends a `WorldStartTime` action to the server. + pub async fn world_start_time( + &self, + world: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldStartTime( + types::WorldStartTimeAction { + world: world.into(), + }, + )) + .await + } + ///Sends a `WorldSetSpawn` action to the server. + pub async fn world_set_spawn( + &self, + world: impl Into>, + spawn: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldSetSpawn( + types::WorldSetSpawnAction { + world: world.into(), + spawn: spawn.into(), + }, + )) + .await + } + ///Sends a `WorldSetBiome` action to the server. + pub async fn world_set_biome( + &self, + world: impl Into>, + position: impl Into>, + biome_id: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldSetBiome( + types::WorldSetBiomeAction { + world: world.into(), + position: position.into(), + biome_id, + }, + )) + .await + } + ///Sends a `WorldSetLiquid` action to the server. + pub async fn world_set_liquid( + &self, + world: impl Into>, + position: impl Into>, + liquid: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldSetLiquid( + types::WorldSetLiquidAction { + world: world.into(), + position: position.into(), + liquid: liquid.into(), + }, + )) + .await + } + ///Sends a `WorldScheduleBlockUpdate` action to the server. + pub async fn world_schedule_block_update( + &self, + world: impl Into>, + position: impl Into>, + block: impl Into>, + delay_ms: i64, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldScheduleBlockUpdate( + types::WorldScheduleBlockUpdateAction { + world: world.into(), + position: position.into(), + block: block.into(), + delay_ms, + }, + )) + .await + } + ///Sends a `WorldBuildStructure` action to the server. + pub async fn world_build_structure( + &self, + world: impl Into>, + origin: impl Into>, + structure: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldBuildStructure( + types::WorldBuildStructureAction { + world: world.into(), + origin: origin.into(), + structure: structure.into(), + }, + )) + .await + } + ///Sends a `WorldQueryEntities` action to the server. + pub async fn world_query_entities( + &self, + world: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldQueryEntities( + types::WorldQueryEntitiesAction { + world: world.into(), + }, + )) + .await + } + ///Sends a `WorldQueryPlayers` action to the server. + pub async fn world_query_players( + &self, + world: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldQueryPlayers( + types::WorldQueryPlayersAction { + world: world.into(), + }, + )) + .await + } + ///Sends a `WorldQueryEntitiesWithin` action to the server. + pub async fn world_query_entities_within( + &self, + world: impl Into>, + r#box: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldQueryEntitiesWithin( + types::WorldQueryEntitiesWithinAction { + world: world.into(), + r#box: r#box.into(), + }, + )) + .await + } + ///Sends a `WorldQueryPlayerSpawn` action to the server. + pub async fn world_query_player_spawn( + &self, + world: impl Into>, + player_uuid: String, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldQueryPlayerSpawn( + types::WorldQueryPlayerSpawnAction { + world: world.into(), + player_uuid, + }, + )) + .await + } + ///Sends a `WorldQueryBlock` action to the server. + pub async fn world_query_block( + &self, + world: impl Into>, + position: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldQueryBlock( + types::WorldQueryBlockAction { + world: world.into(), + position: position.into(), + }, + )) + .await + } + ///Sends a `WorldQueryBiome` action to the server. + pub async fn world_query_biome( + &self, + world: impl Into>, + position: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldQueryBiome( + types::WorldQueryBiomeAction { + world: world.into(), + position: position.into(), + }, + )) + .await + } + ///Sends a `WorldQueryLight` action to the server. + pub async fn world_query_light( + &self, + world: impl Into>, + position: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldQueryLight( + types::WorldQueryLightAction { + world: world.into(), + position: position.into(), + }, + )) + .await + } + ///Sends a `WorldQuerySkyLight` action to the server. + pub async fn world_query_sky_light( + &self, + world: impl Into>, + position: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldQuerySkyLight( + types::WorldQuerySkyLightAction { + world: world.into(), + position: position.into(), + }, + )) + .await + } + ///Sends a `WorldQueryTemperature` action to the server. + pub async fn world_query_temperature( + &self, + world: impl Into>, + position: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldQueryTemperature( + types::WorldQueryTemperatureAction { + world: world.into(), + position: position.into(), + }, + )) + .await + } + ///Sends a `WorldQueryHighestBlock` action to the server. + pub async fn world_query_highest_block( + &self, + world: impl Into>, + x: i32, + z: i32, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldQueryHighestBlock( + types::WorldQueryHighestBlockAction { + world: world.into(), + x, + z, + }, + )) + .await + } + ///Sends a `WorldQueryRainingAt` action to the server. + pub async fn world_query_raining_at( + &self, + world: impl Into>, + position: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldQueryRainingAt( + types::WorldQueryRainingAtAction { + world: world.into(), + position: position.into(), + }, + )) + .await + } + ///Sends a `WorldQuerySnowingAt` action to the server. + pub async fn world_query_snowing_at( + &self, + world: impl Into>, + position: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldQuerySnowingAt( + types::WorldQuerySnowingAtAction { + world: world.into(), + position: position.into(), + }, + )) + .await + } + ///Sends a `WorldQueryThunderingAt` action to the server. + pub async fn world_query_thundering_at( + &self, + world: impl Into>, + position: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldQueryThunderingAt( + types::WorldQueryThunderingAtAction { + world: world.into(), + position: position.into(), + }, + )) + .await + } + ///Sends a `WorldQueryLiquid` action to the server. + pub async fn world_query_liquid( + &self, + world: impl Into>, + position: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldQueryLiquid( + types::WorldQueryLiquidAction { + world: world.into(), + position: position.into(), + }, + )) + .await + } + ///Sends a `WorldQueryDefaultGameMode` action to the server. + pub async fn world_query_default_game_mode( + &self, + world: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::WorldQueryDefaultGameMode( + types::WorldQueryDefaultGameModeAction { + world: world.into(), + }, + )) + .await + } +} diff --git a/packages/rust/src/server/server.rs b/packages/rust/src/server/server.rs new file mode 100644 index 0000000..2a1f90f --- /dev/null +++ b/packages/rust/src/server/server.rs @@ -0,0 +1,66 @@ +//! Lightweight handle for sending actions and subscriptions to the host. +//! +//! Plugin authors receive a [`Server`] reference in every event handler. +//! It can be cloned freely and used to send actions like `send_chat`, +//! `teleport`, or `world_set_block` back to the Dragonfly host. + +use tokio::sync::mpsc; + +use crate::types::{self, PluginToHost}; + +#[derive(Clone)] +pub struct Server { + pub plugin_id: String, + pub sender: mpsc::Sender, +} + +impl Server { + /// Helper to build and send a single action. + pub async fn send_action( + &self, + kind: types::action::Kind, + ) -> Result<(), mpsc::error::SendError> { + let action = types::Action { + correlation_id: None, + kind: Some(kind), + }; + let batch = types::ActionBatch { + actions: vec![action], + }; + let msg = PluginToHost { + plugin_id: self.plugin_id.clone(), + payload: Some(types::PluginPayload::Actions(batch)), + }; + self.sender.send(msg).await + } + + /// Helper to send a batch of actions. + pub async fn send_actions( + &self, + actions: Vec, + ) -> Result<(), mpsc::error::SendError> { + let batch = types::ActionBatch { actions }; + let msg = PluginToHost { + plugin_id: self.plugin_id.clone(), + payload: Some(types::PluginPayload::Actions(batch)), + }; + self.sender.send(msg).await + } + + /// Subscribe to a list of game events. + pub async fn subscribe( + &self, + events: Vec, + ) -> Result<(), mpsc::error::SendError> { + let sub = types::EventSubscribe { + events: events.into_iter().map(|e| e.into()).collect(), + }; + let msg = PluginToHost { + plugin_id: self.plugin_id.clone(), + payload: Some(types::PluginPayload::Subscribe(sub)), + }; + self.sender.send(msg).await + } +} + +mod helpers; diff --git a/packages/rust/tests/command_derive.rs b/packages/rust/tests/command_derive.rs new file mode 100644 index 0000000..6e6c068 --- /dev/null +++ b/packages/rust/tests/command_derive.rs @@ -0,0 +1,137 @@ +use std::convert::TryFrom; + +use dragonfly_plugin::{command::CommandParseError, types, Command}; + +fn make_event(player_uuid: &str, command: &str, args: &[&str]) -> types::CommandEvent { + types::CommandEvent { + player_uuid: player_uuid.to_string(), + name: format!("/{command} {}", args.join(" ")), + raw: format!("/{command} {}", args.join(" ")), + command: command.to_string(), + args: args.iter().map(|s| s.to_string()).collect(), + } +} + +#[derive(Debug, Command)] +#[command(name = "ping", description = "Ping command", aliases("p"))] +struct Ping { + times: i32, +} + +#[derive(Debug, Command)] +#[command(name = "eco", description = "Economy command")] +enum Eco { + #[subcommand(aliases("donate"))] + Pay { + amount: f64, + }, + Bal, +} + +#[derive(Debug, Command)] +#[command(name = "flaggy", description = "Flag-style command")] +enum Flaggy { + Enable { + #[allow(dead_code)] + #[command(name = "value")] + value: bool, + }, +} + +#[test] +fn struct_command_parses_ok() { + let event = make_event("player-uuid", "ping", &["3"]); + let cmd = Ping::try_from(&event).expect("expected ping command to parse"); + assert_eq!(cmd.times, 3); +} + +#[test] +fn struct_command_respects_aliases() { + let event = make_event("player-uuid", "p", &["5"]); + let cmd = Ping::try_from(&event).expect("expected alias to parse"); + assert_eq!(cmd.times, 5); +} + +#[test] +fn struct_command_errors_when_name_does_not_match() { + let event = make_event("player-uuid", "other", &["3"]); + let err = Ping::try_from(&event).unwrap_err(); + assert!(matches!(err, CommandParseError::NoMatch)); +} + +#[test] +fn struct_command_reports_missing_and_invalid_args() { + // Missing required arg. + let event = make_event("player-uuid", "ping", &[]); + let err = Ping::try_from(&event).unwrap_err(); + assert!(matches!(err, CommandParseError::Missing("times"))); + + // Invalid arg type. + let event = make_event("player-uuid", "ping", &["not-a-number"]); + let err = Ping::try_from(&event).unwrap_err(); + assert!(matches!(err, CommandParseError::Invalid("times"))); +} + +#[test] +fn enum_command_parses_subcommands_and_args() { + // canonical subcommand name + let event = make_event("player-uuid", "eco", &["pay", "10.5"]); + let cmd = Eco::try_from(&event).expect("expected eco pay to parse"); + match cmd { + Eco::Pay { amount } => assert!((amount - 10.5).abs() < f64::EPSILON), + other => panic!("expected Pay variant, got {other:?}"), + } + + // alias subcommand + let event = make_event("player-uuid", "eco", &["donate", "2"]); + let cmd = Eco::try_from(&event).expect("expected eco donate to parse"); + match cmd { + Eco::Pay { amount } => assert!((amount - 2.0).abs() < f64::EPSILON), + other => panic!("expected Pay variant, got {other:?}"), + } + + // unit-like subcommand + let event = make_event("player-uuid", "eco", &["bal"]); + let cmd = Eco::try_from(&event).expect("expected eco bal to parse"); + matches!(cmd, Eco::Bal); +} + +#[test] +fn enum_command_reports_missing_or_unknown_subcommand() { + // No args -> missing subcommand. + let event = make_event("player-uuid", "eco", &[]); + let err = Eco::try_from(&event).unwrap_err(); + assert!(matches!(err, CommandParseError::Missing("subcommand"))); + + // Unrecognised subcommand string. + let event = make_event("player-uuid", "eco", &["nope"]); + let err = Eco::try_from(&event).unwrap_err(); + assert!(matches!(err, CommandParseError::UnknownSubcommand)); +} + +#[test] +fn bool_flags_parse_as_expected() { + // NOTE: rust FromStr of bools is case sensitive to literally the word. + // maybe later we add blanket impls for our own trait to parse from commands. + // TODO: this enables a good amount of flexibility so 0.3.1 + // we could add that as its not a BC. + + // true-like values + let event = make_event("player-uuid", "flaggy", &["enable", "true"]); + let cmd = Flaggy::try_from(&event).expect("expected flaggy enable to parse"); + match cmd { + Flaggy::Enable { value } => assert!(value, "expected true to parse as true"), + } + + // false-like values + let event = make_event("player-uuid", "flaggy", &["enable", "false"]); + let cmd = Flaggy::try_from(&event).expect("expected flaggy enable to parse"); + match cmd { + Flaggy::Enable { value } => assert!(!value, "expected false to parse as false"), + } + + // invalid values should surface a parse error + let event = make_event("player-uuid", "flaggy", &["enable", "not-a-bool"]); + let err = Flaggy::try_from(&event).unwrap_err(); + assert!(matches!(err, CommandParseError::Invalid("value"))); +} diff --git a/packages/rust/tests/event_pipeline.rs b/packages/rust/tests/event_pipeline.rs new file mode 100644 index 0000000..abceda9 --- /dev/null +++ b/packages/rust/tests/event_pipeline.rs @@ -0,0 +1,221 @@ +use dragonfly_plugin::{ + command::Ctx, + event::{EventContext, EventHandler}, + event_handler, + server::Server, + types, Command, EventSubscriptions, Plugin, +}; +use tokio::sync::{mpsc, Mutex}; + +#[tokio::test] +async fn event_context_cancel_sends_cancelled_result() { + let (tx, mut rx) = mpsc::channel(1); + + let chat = types::ChatEvent { + player_uuid: "player-uuid".to_string(), + name: "Player".to_string(), + message: "hello".to_string(), + }; + + let mut ctx = EventContext::new("event-1", &chat, tx, "plugin-id".to_string()); + ctx.cancel().await; + + let msg = rx.recv().await.expect("expected event result message"); + assert_eq!(msg.plugin_id, "plugin-id"); + + match msg.payload.expect("missing payload") { + types::PluginPayload::EventResult(result) => { + assert_eq!(result.event_id, "event-1"); + assert_eq!(result.cancel, Some(true)); + assert!(result.update.is_none()); + } + other => panic!("unexpected payload: {:?}", other), + } +} + +#[tokio::test] +async fn event_context_mutation_helper_sets_update() { + let (tx, mut rx) = mpsc::channel(1); + + let chat = types::ChatEvent { + player_uuid: "player-uuid".to_string(), + name: "Player".to_string(), + message: "before".to_string(), + }; + + let mut ctx = EventContext::new("event-mutate", &chat, tx, "plugin-id".to_string()); + ctx.set_message("after".to_string()); + ctx.send().await; + + let msg = rx.recv().await.expect("expected mutation result"); + + match msg.payload.expect("missing payload") { + types::PluginPayload::EventResult(result) => { + assert_eq!(result.event_id, "event-mutate"); + assert!(result.cancel.is_none()); + + let update = result.update.expect("missing update"); + match update { + types::EventResultUpdate::Chat(mutation) => { + assert_eq!(mutation.message.as_deref(), Some("after")); + } + other => panic!("unexpected update variant: {:?}", other), + } + } + other => panic!("unexpected payload: {:?}", other), + } +} + +#[derive(Default)] +struct RecordingPlugin { + calls: Mutex>, +} + +impl EventSubscriptions for RecordingPlugin { + fn get_subscriptions(&self) -> Vec { + vec![types::EventType::Chat] + } +} + +impl dragonfly_plugin::command::CommandRegistry for RecordingPlugin {} + +impl EventHandler for RecordingPlugin { + async fn on_chat(&self, _server: &Server, _event: &mut EventContext<'_, types::ChatEvent>) { + self.calls.lock().await.push("chat"); + } +} + +#[tokio::test] +async fn dispatch_event_routes_chat_to_handler() { + let (tx, mut rx) = mpsc::channel(1); + + let server = Server { + plugin_id: "plugin-id".to_string(), + sender: tx, + }; + + let plugin = RecordingPlugin::default(); + + let chat = types::ChatEvent { + player_uuid: "player-uuid".to_string(), + name: "Player".to_string(), + message: "hello".to_string(), + }; + + let envelope = types::EventEnvelope { + event_id: "chat-event".to_string(), + r#type: types::EventType::Chat as i32, + expects_response: true, + payload: Some(types::EventPayload::Chat(chat)), + }; + + dragonfly_plugin::event::dispatch_event(&server, &plugin, &envelope).await; + + // Handler was called. + let calls = plugin.calls.lock().await; + assert_eq!(calls.as_slice(), &["chat"]); + drop(calls); + + // Ack was sent. + let msg = rx.recv().await.expect("expected ack from dispatch_event"); + assert_eq!(msg.plugin_id, "plugin-id"); +} + +#[tokio::test] +#[should_panic(expected = "Attempted to respond twice to the same event!")] +async fn event_context_double_send_panics_in_debug() { + let (tx, _rx) = mpsc::channel(1); + + let chat = types::ChatEvent { + player_uuid: "player-uuid".to_string(), + name: "Player".to_string(), + message: "hello".to_string(), + }; + + let mut ctx = EventContext::new("event-double", &chat, tx, "plugin-id".to_string()); + + // First send is fine. + ctx.send().await; + // Second send should panic in debug builds. + ctx.send().await; +} + +#[derive(Default, Plugin)] +#[plugin( + id = "test-plugin", + name = "Test Plugin", + version = "0.0.0", + api = "1.0.0", + commands(PingCommand) +)] +struct CommandPlugin { + calls: Mutex>, +} + +#[derive(Debug, Command)] +#[command(name = "ping", description = "Ping command")] +struct PingCommand { + value: i32, +} + +#[event_handler] +impl EventHandler for CommandPlugin { + async fn on_command( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::CommandEvent>, + ) { + self.calls + .lock() + .await + .push("on_command_fallback".to_string()); + } +} + +impl PingCommandHandler for CommandPlugin { + async fn ping_command(&self, ctx: Ctx<'_>, value: i32) { + self.calls + .lock() + .await + .push(format!("handled:{}:{value}", ctx.sender)); + } +} + +#[tokio::test] +async fn dispatch_event_dispatches_command_before_on_command() { + let (tx, mut rx) = mpsc::channel(1); + + let server = Server { + plugin_id: "plugin-id".to_string(), + sender: tx, + }; + + let plugin = CommandPlugin::default(); + + let cmd_event = types::CommandEvent { + player_uuid: "player-uuid".to_string(), + name: "/ping 5".to_string(), + raw: "/ping 5".to_string(), + command: "ping".to_string(), + args: vec!["5".to_string()], + }; + + let envelope = types::EventEnvelope { + event_id: "cmd-event".to_string(), + r#type: types::EventType::Command as i32, + expects_response: true, + payload: Some(types::EventPayload::Command(cmd_event)), + }; + + dragonfly_plugin::event::dispatch_event(&server, &plugin, &envelope).await; + + // Command handler should have run, but on_command fallback should not. + let calls = plugin.calls.lock().await; + assert_eq!(calls.len(), 1); + assert!(calls[0].starts_with("handled:player-uuid:5")); + drop(calls); + + // An EventResult ack should have been sent. + let msg = rx.recv().await.expect("expected command EventResult"); + assert_eq!(msg.plugin_id, "plugin-id"); +} diff --git a/packages/rust/tests/server_helpers.rs b/packages/rust/tests/server_helpers.rs new file mode 100644 index 0000000..3c6f2f5 --- /dev/null +++ b/packages/rust/tests/server_helpers.rs @@ -0,0 +1,99 @@ +use dragonfly_plugin::{server::Server, types}; +use tokio::sync::mpsc; + +#[tokio::test] +async fn send_action_wraps_single_action_in_batch() { + let (tx, mut rx) = mpsc::channel(1); + + let server = Server { + plugin_id: "plugin-id".to_string(), + sender: tx, + }; + + let kind = types::action::Kind::SendChat(types::SendChatAction { + target_uuid: "player-uuid".to_string(), + message: "hello".to_string(), + }); + + server.send_action(kind).await.expect("send_action failed"); + + let msg = rx.recv().await.expect("expected PluginToHost message"); + assert_eq!(msg.plugin_id, "plugin-id"); + + match msg.payload.expect("missing payload") { + types::PluginPayload::Actions(batch) => { + assert_eq!(batch.actions.len(), 1); + let action = &batch.actions[0]; + assert!(action.correlation_id.is_none()); + match action.kind.as_ref().expect("missing action kind") { + types::ActionKind::SendChat(chat) => { + assert_eq!(chat.target_uuid, "player-uuid"); + assert_eq!(chat.message, "hello"); + } + other => panic!("unexpected action kind: {:?}", other), + } + } + other => panic!("unexpected payload: {:?}", other), + } +} + +#[tokio::test] +async fn send_chat_helper_builds_correct_action() { + let (tx, mut rx) = mpsc::channel(1); + + let server = Server { + plugin_id: "plugin-id".to_string(), + sender: tx, + }; + + server + .send_chat("player-uuid".to_string(), "hi there".to_string()) + .await + .expect("send_chat failed"); + + let msg = rx.recv().await.expect("expected PluginToHost message"); + match msg.payload.expect("missing payload") { + types::PluginPayload::Actions(batch) => { + assert_eq!(batch.actions.len(), 1); + let action = &batch.actions[0]; + match action.kind.as_ref().expect("missing action kind") { + types::ActionKind::SendChat(chat) => { + assert_eq!(chat.target_uuid, "player-uuid"); + assert_eq!(chat.message, "hi there"); + } + other => panic!("unexpected action kind: {:?}", other), + } + } + other => panic!("unexpected payload: {:?}", other), + } +} + +#[tokio::test] +async fn subscribe_sends_subscribe_payload() { + let (tx, mut rx) = mpsc::channel(1); + + let server = Server { + plugin_id: "plugin-id".to_string(), + sender: tx, + }; + + server + .subscribe(vec![types::EventType::Chat, types::EventType::Command]) + .await + .expect("subscribe failed"); + + let msg = rx.recv().await.expect("expected PluginToHost message"); + assert_eq!(msg.plugin_id, "plugin-id"); + + match msg.payload.expect("missing payload") { + types::PluginPayload::Subscribe(sub) => { + // Order is preserved from the vec we passed in. + assert_eq!(sub.events.len(), 2); + assert_eq!(sub.events[0], types::EventType::Chat as i32); + assert_eq!(sub.events[1], types::EventType::Command as i32); + } + other => panic!("unexpected payload: {:?}", other), + } +} + + diff --git a/packages/rust/xtask/Cargo.toml b/packages/rust/xtask/Cargo.toml new file mode 100644 index 0000000..5977ef1 --- /dev/null +++ b/packages/rust/xtask/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "xtask" +version = "0.1.0" +edition = "2021" + +publish = false + +[dependencies] +syn = { version = "2.0", features = ["full", "parsing", "visit"] } +quote = "1.0" +heck = "0.5" # For to_snake_case +anyhow = "1.0" +prettyplease = "0.2.37" +proc-macro2 = "1.0.103" + +[dev-dependencies] +insta = { version = "1.44.1" } diff --git a/packages/rust/xtask/assets/mock_prost.rs b/packages/rust/xtask/assets/mock_prost.rs new file mode 100644 index 0000000..e9ea681 --- /dev/null +++ b/packages/rust/xtask/assets/mock_prost.rs @@ -0,0 +1,88 @@ +pub struct Vec3 { + x: f64, + y: f64, + z: f64, +} + +// We need `Clone` for the test helpers +#[derive(Clone)] +pub struct ItemStack { + name: String, + count: i32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum GameMode { + Survival = 0, + Creative = 1, +} + +// A mock action struct +#[derive(Clone, PartialEq)] +pub struct SetGameModeAction { + #[prost(string, tag = "1")] + pub player_uuid: String, + #[prost(enumeration = "GameMode", tag = "2")] + pub game_mode: i32, +} + +// Another mock action struct +#[derive(Clone, PartialEq)] +pub struct GiveItemAction { + #[prost(string, tag = "1")] + pub player_uuid: String, + #[prost(message, optional, tag = "2")] + pub item: ::core::option::Option, +} + +// Mock action enum +#[allow(dead_code)] +mod action { + pub enum Kind { + SetGameMode(super::SetGameModeAction), + GiveItem(super::GiveItemAction), + } +} + +#[derive(Clone, PartialEq)] +pub struct ChatEvent { + #[prost(string, tag = "1")] + pub player_uuid: String, + #[prost(string, tag = "2")] + pub message: String, +} + +#[derive(Clone, PartialEq)] +pub struct BlockBreakEvent { + #[prost(string, tag = "1")] + pub player_uuid: String, +} + +#[allow(dead_code)] +mod event_envelope { + pub enum Payload { + Chat(super::ChatEvent), + BlockBreak(super::BlockBreakEvent), + } +} + +#[derive(Clone, PartialEq)] +pub struct ChatMutation { + #[prost(message, optional, tag = "1")] + pub message: ::core::option::Option, +} + +#[derive(Clone, PartialEq)] +pub struct BlockBreakMutation { + #[prost(message, optional, tag = "1")] + pub drops: ::core::option::Option, +} + +// The other missing enum your test is looking for! +#[allow(dead_code)] +mod event_result { + pub enum Update { + Chat(super::ChatMutation), + BlockBreak(super::BlockBreakMutation), + } +} diff --git a/packages/rust/xtask/src/generate_actions.rs b/packages/rust/xtask/src/generate_actions.rs new file mode 100644 index 0000000..e89f0b8 --- /dev/null +++ b/packages/rust/xtask/src/generate_actions.rs @@ -0,0 +1,151 @@ +//! Generate `Server` helper methods for each `action::Kind` variant. +//! +//! This module inspects the prost-generated `action::Kind` enum from +//! `df.plugin.rs` and produces a single `impl Server { ... }` block in +//! `src/server/helpers.rs`. Each action variant gets a corresponding async +//! helper method that takes ergonomic parameters and forwards them into the +//! raw `types::Action` wire format. + +use anyhow::Result; +use heck::ToSnakeCase; +use quote::{format_ident, quote}; +use std::{collections::HashMap, path::PathBuf}; +use syn::{File, Ident, ItemStruct}; + +use crate::utils::{ + find_nested_enum, get_action_conversion_logic, get_api_type, get_variant_type_path, + prettify_code, ConversionLogic, +}; + +/// Generate the `impl Server { .. }` block with one helper per `action::Kind` +/// variant in the prost-generated API. +pub(crate) fn generate_server_helpers( + ast: &File, + all_structs: &HashMap, + output_path: &PathBuf, +) -> Result<()> { + let code = generate_server_helpers_tokens(ast, all_structs)?; + + let file = prettify_code(code)?; + + std::fs::write(output_path, file)?; + + Ok(()) +} + +fn generate_server_helpers_tokens( + ast: &File, + all_structs: &HashMap, +) -> Result { + let action_kind_enum = find_nested_enum(ast, "action", "Kind")?; + let mut server_helpers = Vec::new(); + + for variant in &action_kind_enum.variants { + let variant_ident = &variant.ident; + let action_struct_path = get_variant_type_path(variant)?; + let action_struct_name = action_struct_path.segments.last().unwrap().ident.clone(); + let fn_name = format_ident!("{}", variant_ident.to_string().to_snake_case()); + let doc_string = format!("Sends a `{}` action to the server.", variant_ident); + + // Find the struct definition + let action_struct_def = all_structs.get(&action_struct_name).ok_or_else(|| { + anyhow::anyhow!("Struct definition not found for {}", action_struct_name) + })?; + + let mut fn_args = Vec::new(); + let mut struct_fields = Vec::new(); + + for field in &action_struct_def.fields { + let field_name = field.ident.as_ref().unwrap(); + let arg_type = get_api_type(field); + let conversion_logic = get_action_conversion_logic(field); + + let struct_field_code = match conversion_logic { + ConversionLogic::Direct => { + quote! { #field_name } + } + // It's an enum or Option. Use the explicit conversion + ConversionLogic::Into => { + quote! { #field_name: #field_name.into() } + } + ConversionLogic::OptionInnerInto => { + quote! { #field_name: #field_name.into().map(|x| x.into()) } + } + }; + + fn_args.push(quote! { #field_name: #arg_type }); + struct_fields.push(quote! { #struct_field_code }); + } + + server_helpers.push(quote! { + #[doc = #doc_string] + pub async fn #fn_name( + &self, + #( #fn_args ),* + ) -> Result<(), mpsc::error::SendError> { + self.send_action(types::action::Kind::#variant_ident( + types::#action_struct_name { + #( #struct_fields ),* + } + )).await + } + }); + } + + Ok(quote! { + //! This file is auto-generated by `xtask`. Do not edit manually. + use crate::{types, Server}; + use tokio::sync::mpsc; + + impl Server { + #( #server_helpers )* + } + } + .to_string()) +} + +#[cfg(test)] +mod tests { + use crate::utils::find_all_structs; + + use super::*; // Import your generator functions + use syn::{parse_file, File}; + + fn setup_test_ast() -> File { + let mock_code = include_str!("../assets/mock_prost.rs"); + + parse_file(mock_code).expect("Failed to parse mock AST") + } + + #[test] + fn snapshot_test_generate_server_actions() { + let ast = setup_test_ast(); + + let all_structs = find_all_structs(&ast); + + let generated_code = + generate_server_helpers_tokens(&ast, &all_structs).expect("Generator function failed"); + + let prettified_code = prettify_code(generated_code).expect("Invalid code being produced."); + + insta::assert_snapshot!("server_actions", prettified_code); + } + + #[test] + fn generate_server_helpers_fails_when_struct_missing() { + // Create a tiny AST with an action::Kind enum referring to a non-existent struct. + let code = r#" + mod action { + pub enum Kind { + Missing(MissingAction), + } + } + "#; + let ast: File = parse_file(code).expect("Failed to parse test AST"); + let all_structs = find_all_structs(&ast); + + let err = generate_server_helpers_tokens(&ast, &all_structs).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("Struct definition not found for MissingAction")); + } +} diff --git a/packages/rust/xtask/src/generate_handlers.rs b/packages/rust/xtask/src/generate_handlers.rs new file mode 100644 index 0000000..f22d313 --- /dev/null +++ b/packages/rust/xtask/src/generate_handlers.rs @@ -0,0 +1,147 @@ +//! Generate the `EventHandler` trait and `dispatch_event` function. +//! +//! This module walks the prost-generated `event_envelope::Payload` enum and +//! produces: +//! - A trait method per event (e.g. `async fn on_chat(...)`). +//! - A single `dispatch_event` function that decodes envelopes and forwards +//! them into the correct handler method. +//! +//! The generated code lives in `src/event/handler.rs` and is considered +//! part of the public SDK surface, so changes here must keep the trait +//! shape and dispatch semantics stable. + +use anyhow::Result; +use heck::ToSnakeCase; +use quote::{format_ident, quote}; +use std::path::PathBuf; +use syn::File; + +use crate::utils::{find_nested_enum, get_variant_type_path, prettify_code}; + +/// Generate the `EventHandler` trait and the central `dispatch_event` +/// function from the prost-generated `EventEnvelope::Payload` enum. +pub fn generate_handler_trait(ast: &File, output_path: &PathBuf) -> Result<()> { + println!( + "Generating Event Handler trait in: {}...", + output_path.to_string_lossy() + ); + + let code = generate_handler_trait_tokens(ast)?; + + // write our generated code. + let file = prettify_code(code)?; + + std::fs::write(output_path, file)?; + + println!( + "Successfully generated Event Handler Trait in: {}.", + output_path.to_string_lossy() + ); + Ok(()) +} + +fn generate_handler_trait_tokens(ast: &File) -> Result { + let event_payload_enum = find_nested_enum(ast, "event_envelope", "Payload")?; + + let mut event_handler_fns = Vec::new(); + let mut dispatch_fn_match_arms = Vec::new(); + + for variant in &event_payload_enum.variants { + let ident = &variant.ident; + let ty_path = get_variant_type_path(variant)?.segments.last(); + let ident_formatted = ident.to_string().to_snake_case(); + let handler_fn_name = format_ident!("on_{}", ident_formatted); + let doc_string = format!("Handler for the `{}` event.", ident); + + event_handler_fns.push(quote! { + #[doc = #doc_string] + async fn #handler_fn_name( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::#ty_path>, + ) { } + }); + + let match_arm = if ident == "Command" { + quote! { + types::event_envelope::Payload::#ident(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + + context.send_ack_if_needed().await; + + // Try registered commands first + let handled = handler.dispatch_commands(server, &mut context).await; + + // Fall back to on_command for unregistered/dynamic commands + if !handled { + handler.#handler_fn_name(server, &mut context).await; + } + }, + } + } else { + quote! { + types::event_envelope::Payload::#ident(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.#handler_fn_name(server, &mut context).await; + context.send_ack_if_needed().await; + }, + } + }; + + dispatch_fn_match_arms.push(match_arm); + } + + Ok(quote! { + //! This file is auto-generated by `xtask`. Do not edit manually. + #![allow(async_fn_in_trait)] + use crate::{event::EventContext, types, Server, EventSubscriptions, command::CommandRegistry}; + + pub trait EventHandler: EventSubscriptions + Send + Sync { + #(#event_handler_fns)* + } + + #[doc(hidden)] + pub async fn dispatch_event(server: &Server, handler: &(impl EventHandler + CommandRegistry), envelope: &types::EventEnvelope) { + let Some(payload) = &envelope.payload else { + return; + }; + match payload { + #(#dispatch_fn_match_arms)* + } + } + }.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; // Import your generator functions + use syn::{parse_file, File}; + + fn setup_test_ast() -> File { + let mock_code = include_str!("../assets/mock_prost.rs"); + + parse_file(mock_code).expect("Failed to parse mock AST") + } + + #[test] + fn snapshot_test_generate_event_handler() { + let ast = setup_test_ast(); + + let generated_code = + generate_handler_trait_tokens(&ast).expect("Generator function failed"); + + let prettified_code = prettify_code(generated_code).expect("Invalid code being produced."); + + insta::assert_snapshot!("event_handler", prettified_code); + } +} diff --git a/packages/rust/xtask/src/generate_mutations.rs b/packages/rust/xtask/src/generate_mutations.rs new file mode 100644 index 0000000..e8c148f --- /dev/null +++ b/packages/rust/xtask/src/generate_mutations.rs @@ -0,0 +1,160 @@ +//! Generate mutation helpers on `EventContext` for event updates. +//! +//! This module inspects the prost-generated `event_result::Update` enum and +//! corresponding mutation structs and generates setter-style helper methods +//! on `EventContext` (e.g. `set_message`, `set_damage`). The resulting +//! code is written to `src/event/mutations.rs` and used directly by plugin +//! authors when mutating events. + +use anyhow::Result; +use quote::{format_ident, quote}; +use std::{collections::HashMap, path::PathBuf}; +use syn::{File, Ident, ItemStruct}; + +use crate::utils::{find_nested_enum, get_api_type, get_variant_type_path, prettify_code}; + +/// Generate mutation helper methods on `EventContext` for each +/// `event_result::Update` variant in the prost-generated API. +pub(crate) fn generate_event_mutations( + ast: &File, + all_structs: &HashMap, + output_path: &PathBuf, +) -> Result<()> { + let code = generate_event_mutations_tokens(ast, all_structs)?; + + let file = prettify_code(code)?; + + std::fs::write(output_path, file)?; + + Ok(()) +} + +fn generate_event_mutations_tokens( + ast: &File, + all_structs: &HashMap, +) -> Result { + let mutation_enum = find_nested_enum(ast, "event_result", "Update")?; + let event_payload_enum = find_nested_enum(ast, "event_envelope", "Payload")?; + let mut mutation_impls = Vec::new(); + + for variant in &mutation_enum.variants { + let mutation_variant_name = &variant.ident; + let mutation_struct_path = get_variant_type_path(variant)?; + let mutation_struct_name = mutation_struct_path.segments.last().unwrap().ident.clone(); + + let event_variant = event_payload_enum + .variants + .iter() + .find(|v| v.ident == *mutation_variant_name) + .ok_or_else(|| { + anyhow::anyhow!("No event payload for mutation {}", mutation_variant_name) + })?; + let event_struct_path = get_variant_type_path(event_variant)?; + let event_struct_name = event_struct_path.segments.last().unwrap().ident.clone(); + + // Find mutation struct definition + let mutation_struct_def = all_structs.get(&mutation_struct_name).ok_or_else(|| { + anyhow::anyhow!("Struct definition not found for {}", mutation_struct_name) + })?; + + let mut helper_methods = Vec::new(); + helper_methods.push(quote! { + fn ensure_mutation_exists(&mut self) { + if !matches!(self.result, EventResult::Mutated(types::EventResultUpdate::#mutation_variant_name(_))) { + self.result = EventResult::Mutated( + types::EventResultUpdate::#mutation_variant_name(types::#mutation_struct_name::default()) + ); + } + } + }); + + for field in &mutation_struct_def.fields { + let field_name = field.ident.as_ref().unwrap(); + let arg_type = get_api_type(field); + let setter_fn_name = format_ident!("set_{}", field_name); + let doc_string = format!("Sets the `{}` for this event.", field_name); + + helper_methods.push(quote! { + #[doc = #doc_string] + pub fn #setter_fn_name(&mut self, #field_name: #arg_type) -> &mut Self { + self.ensure_mutation_exists(); + + if let EventResult::Mutated(types::EventResultUpdate::#mutation_variant_name(ref mut m)) = self.result { + m.#field_name = #field_name.into() + }; + self + } + }); + } + + mutation_impls.push(quote! { + impl<'a> EventContext<'a, types::#event_struct_name> { + #( #helper_methods )* + } + }); + } + + Ok(quote! { + //! This file is auto-generated by `xtask`. Do not edit manually. + #![allow(clippy::all)] + use crate::types; + use crate::event::{EventContext, EventResult}; + + #( #mutation_impls )* + } + .to_string()) +} + +#[cfg(test)] +mod tests { + use crate::utils::find_all_structs; + + use super::*; // Import your generator functions + use syn::{parse_file, File}; + + fn setup_test_ast() -> File { + let mock_code = include_str!("../assets/mock_prost.rs"); + + parse_file(mock_code).expect("Failed to parse mock AST") + } + + #[test] + fn snapshot_test_generate_event_mutations() { + let ast = setup_test_ast(); + + let all_structs = find_all_structs(&ast); + + let generated_code = + generate_event_mutations_tokens(&ast, &all_structs).expect("Generator function failed"); + + let prettified_code = prettify_code(generated_code).expect("Invalid code being produced."); + + insta::assert_snapshot!("event_mutations", prettified_code); + } + + #[test] + fn generate_event_mutations_errors_when_payload_missing() { + // Mutation has a variant with no corresponding payload variant. + let code = r#" + mod event_result { + pub enum Update { + Chat(ChatMutation), + } + } + mod event_envelope { + pub enum Payload { + // Intentionally do not include Chat here. + } + } + pub struct ChatMutation { + pub message: ::prost::alloc::string::String, + } + "#; + let ast: File = parse_file(code).expect("Failed to parse test AST"); + let all_structs = find_all_structs(&ast); + + let err = generate_event_mutations_tokens(&ast, &all_structs).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("No event payload for mutation Chat")); + } +} diff --git a/packages/rust/xtask/src/main.rs b/packages/rust/xtask/src/main.rs new file mode 100644 index 0000000..56061f4 --- /dev/null +++ b/packages/rust/xtask/src/main.rs @@ -0,0 +1,51 @@ +//! Code generation entrypoint for the Rust `dragonfly-plugin` SDK. +//! +//! This `xtask` binary: +//! - Parses the prost-generated `src/generated/df.plugin.rs` file. +//! - Builds an in-memory AST of all events, actions, and result types. +//! - Regenerates three helper files in the main SDK crate: +//! - `src/event/handler.rs` (`generate_handlers`) +//! - `src/event/mutations.rs` (`generate_mutations`) +//! - `src/server/helpers.rs` (`generate_actions`) +//! The public API of the SDK is considered stable; this task should only +//! be changed in ways that preserve the shape of the generated code. + +pub mod generate_actions; +pub mod generate_handlers; +pub mod generate_mutations; +pub mod utils; + +use anyhow::Result; +use std::{fs, path::PathBuf}; +use syn::parse_file; + +use crate::utils::find_all_structs; + +fn main() -> Result<()> { + println!("Starting 'xtask' code generation..."); + let root_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .to_path_buf(); + + let generated_file_path = root_dir.join("src/generated/df.plugin.rs"); + let handler_output_path = root_dir.join("src/event/handler.rs"); + let mutations_output_path = root_dir.join("src/event/mutations.rs"); + let server_output_path = root_dir.join("src/server/helpers.rs"); + + // only read the generated code file once we can just reference it out so we don't waste resources. + println!("Parsing generated proto file..."); + let generated_code = fs::read_to_string(generated_file_path)?; + let ast = parse_file(&generated_code)?; + + // just cache all structs, we can hashmap look them up em. + let all_structs = find_all_structs(&ast); + + // first generate event handlers: + generate_handlers::generate_handler_trait(&ast, &handler_output_path)?; + + generate_mutations::generate_event_mutations(&ast, &all_structs, &mutations_output_path)?; + + generate_actions::generate_server_helpers(&ast, &all_structs, &server_output_path)?; + Ok(()) +} diff --git a/packages/rust/xtask/src/snapshots/xtask__generate_actions__tests__server_actions.snap b/packages/rust/xtask/src/snapshots/xtask__generate_actions__tests__server_actions.snap new file mode 100644 index 0000000..4e23c25 --- /dev/null +++ b/packages/rust/xtask/src/snapshots/xtask__generate_actions__tests__server_actions.snap @@ -0,0 +1,37 @@ +--- +source: xtask/src/generate_actions.rs +expression: prettified_code +--- +//! This file is auto-generated by `xtask`. Do not edit manually. +use crate::{types, Server}; +use tokio::sync::mpsc; +impl Server { + ///Sends a `SetGameMode` action to the server. + pub async fn set_game_mode( + &self, + player_uuid: String, + game_mode: types::GameMode, + ) -> Result<(), mpsc::error::SendError> { + self.send_action( + types::action::Kind::SetGameMode(types::SetGameModeAction { + player_uuid, + game_mode: game_mode.into(), + }), + ) + .await + } + ///Sends a `GiveItem` action to the server. + pub async fn give_item( + &self, + player_uuid: String, + item: impl Into>, + ) -> Result<(), mpsc::error::SendError> { + self.send_action( + types::action::Kind::GiveItem(types::GiveItemAction { + player_uuid, + item: item.into(), + }), + ) + .await + } +} diff --git a/packages/rust/xtask/src/snapshots/xtask__generate_handlers__tests__event_handler.snap b/packages/rust/xtask/src/snapshots/xtask__generate_handlers__tests__event_handler.snap new file mode 100644 index 0000000..239cc28 --- /dev/null +++ b/packages/rust/xtask/src/snapshots/xtask__generate_handlers__tests__event_handler.snap @@ -0,0 +1,55 @@ +--- +source: xtask/src/generate_handlers.rs +expression: prettified_code +--- +//! This file is auto-generated by `xtask`. Do not edit manually. +#![allow(async_fn_in_trait)] +use crate::{ + event::EventContext, types, Server, EventSubscriptions, command::CommandRegistry, +}; +pub trait EventHandler: EventSubscriptions + Send + Sync { + ///Handler for the `Chat` event. + async fn on_chat( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::ChatEvent>, + ) {} + ///Handler for the `BlockBreak` event. + async fn on_block_break( + &self, + _server: &Server, + _event: &mut EventContext<'_, types::BlockBreakEvent>, + ) {} +} +#[doc(hidden)] +pub async fn dispatch_event( + server: &Server, + handler: &(impl EventHandler + CommandRegistry), + envelope: &types::EventEnvelope, +) { + let Some(payload) = &envelope.payload else { + return; + }; + match payload { + types::event_envelope::Payload::Chat(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_chat(server, &mut context).await; + context.send_ack_if_needed().await; + } + types::event_envelope::Payload::BlockBreak(e) => { + let mut context = EventContext::new( + &envelope.event_id, + e, + server.sender.clone(), + server.plugin_id.clone(), + ); + handler.on_block_break(server, &mut context).await; + context.send_ack_if_needed().await; + } + } +} diff --git a/packages/rust/xtask/src/snapshots/xtask__generate_mutations__tests__event_mutations.snap b/packages/rust/xtask/src/snapshots/xtask__generate_mutations__tests__event_mutations.snap new file mode 100644 index 0000000..8caa6eb --- /dev/null +++ b/packages/rust/xtask/src/snapshots/xtask__generate_mutations__tests__event_mutations.snap @@ -0,0 +1,55 @@ +--- +source: xtask/src/generate_mutations.rs +expression: prettified_code +--- +//! This file is auto-generated by `xtask`. Do not edit manually. +#![allow(clippy::all)] +use crate::types; +use crate::event::{EventContext, EventResult}; +impl<'a> EventContext<'a, types::ChatEvent> { + fn ensure_mutation_exists(&mut self) { + if !matches!( + self.result, EventResult::Mutated(types::EventResultUpdate::Chat(_)) + ) { + self.result = EventResult::Mutated( + types::EventResultUpdate::Chat(types::ChatMutation::default()), + ); + } + } + ///Sets the `message` for this event. + pub fn set_message(&mut self, message: impl Into>) -> &mut Self { + self.ensure_mutation_exists(); + if let EventResult::Mutated(types::EventResultUpdate::Chat(ref mut m)) = self + .result + { + m.message = message.into(); + } + self + } +} +impl<'a> EventContext<'a, types::BlockBreakEvent> { + fn ensure_mutation_exists(&mut self) { + if !matches!( + self.result, EventResult::Mutated(types::EventResultUpdate::BlockBreak(_)) + ) { + self.result = EventResult::Mutated( + types::EventResultUpdate::BlockBreak( + types::BlockBreakMutation::default(), + ), + ); + } + } + ///Sets the `drops` for this event. + pub fn set_drops( + &mut self, + drops: impl Into>, + ) -> &mut Self { + self.ensure_mutation_exists(); + if let EventResult::Mutated(types::EventResultUpdate::BlockBreak(ref mut m)) = self + .result + { + m.drops = drops.into(); + } + self + } +} diff --git a/packages/rust/xtask/src/utils.rs b/packages/rust/xtask/src/utils.rs new file mode 100644 index 0000000..404a894 --- /dev/null +++ b/packages/rust/xtask/src/utils.rs @@ -0,0 +1,545 @@ +//! Shared AST helpers used by the Rust SDK `xtask` generators. +//! +//! This module wraps common syn-based utilities for: +//! - Formatting generated Rust code (`prettify_code`). +//! - Scanning the prost-generated AST for structs and nested enums. +//! - Mapping prost field/attribute shapes into public-facing SDK types. +//! - Deciding how to convert helper method arguments into wire types. +//! +//! The functions here are internal to `xtask`, but the behavior they encode +//! (e.g. how `GameMode` enums or `Option` fields are surfaced) is part of +//! the stable shape of the generated SDK API. + +use anyhow::Result; +use quote::quote; +use std::collections::HashMap; +use syn::{ + parse::Parse, parse_file, visit::Visit, Field, File, GenericArgument, Ident, Item, ItemEnum, + ItemMod, ItemStruct, Meta, Path, PathArguments, Type, TypePath, +}; + +/// Pretty-print a Rust source string using `prettyplease`. +/// +/// This is applied to all generated files to keep diffs readable. +pub(crate) fn prettify_code(content: String) -> Result { + let ast = parse_file(&content)?; + Ok(prettyplease::unparse(&ast)) +} + +pub(crate) fn find_all_structs(ast: &File) -> HashMap { + struct StructVisitor<'a> { + structs: HashMap, + } + impl<'a> Visit<'a> for StructVisitor<'a> { + fn visit_item_struct(&mut self, i: &'a ItemStruct) { + self.structs.insert(i.ident.clone(), i); + } + fn visit_item_mod(&mut self, i: &'a ItemMod) { + if let Some((_, items)) = &i.content { + for item in items { + self.visit_item(item); + } + } + } + } + let mut visitor = StructVisitor { + structs: Default::default(), + }; + visitor.visit_file(ast); + visitor.structs +} + +pub(crate) fn find_nested_enum<'a>( + ast: &'a File, + mod_name: &str, + enum_name: &str, +) -> Result<&'a ItemEnum> { + for item in &ast.items { + if let Item::Mod(item_mod) = item { + if item_mod.ident == mod_name { + if let Some((_, items)) = &item_mod.content { + for item in items { + if let Item::Enum(item_enum) = item { + if item_enum.ident == enum_name { + return Ok(item_enum); + } + } + } + } + } + } + } + anyhow::bail!("Enum `{}::{}` not found in AST", mod_name, enum_name) +} + +pub(crate) fn get_variant_type_path(variant: &syn::Variant) -> Result<&Path> { + if let syn::Fields::Unnamed(fields) = &variant.fields { + if let Some(field) = fields.unnamed.first() { + if let Type::Path(type_path) = &field.ty { + return Ok(&type_path.path); + } + } + } + anyhow::bail!("Variant `{}` is not a single-tuple struct", variant.ident) +} + +/// Helper for clean_type to detect `Vec` variants by parsing +/// the syn::TypePath instead of string-matching. +fn is_vec_u8(type_path: &TypePath) -> bool { + // Check if the last segment's identifier is "Vec" + if let Some(segment) = type_path.path.segments.last() { + if segment.ident == "Vec" { + // Check if its type argument is `` + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + if args.args.len() == 1 { + // Check for `Vec` + if let Some(syn::GenericArgument::Type(Type::Path(inner_type_path))) = + args.args.first() + { + // `is_ident` is robust for primitives + if inner_type_path.path.is_ident("u8") { + return true; + } + } + } + } + } + } + false +} + +enum ProstTypeInfo<'a> { + Option(&'a Type), + Vec(&'a Type), + VecU8, + HashMap(&'a Type, &'a Type), + String, + Primitive(&'a Ident), + #[allow(dead_code)] + Model(&'a Ident), // Any other struct/enum like Vec3, ItemStack + Unknown(&'a Type), +} + +fn classify_prost_type(ty: &Type) -> ProstTypeInfo<'_> { + if let Type::Path(type_path) = ty { + let segment = type_path.path.segments.last().unwrap(); + let ident = &segment.ident; + let ident_str = ident.to_string(); + + if ident_str == "Option" { + if let PathArguments::AngleBracketed(args) = &segment.arguments { + if let Some(GenericArgument::Type(inner_ty)) = args.args.first() { + return ProstTypeInfo::Option(inner_ty); + } + } + } + if ident_str == "Vec" { + if is_vec_u8(type_path) { + return ProstTypeInfo::VecU8; + } + if let PathArguments::AngleBracketed(args) = &segment.arguments { + if let Some(GenericArgument::Type(inner_ty)) = args.args.first() { + return ProstTypeInfo::Vec(inner_ty); + } + } + } + + if ident_str == "HashMap" { + if let PathArguments::AngleBracketed(args) = &segment.arguments { + if let (Some(GenericArgument::Type(k_ty)), Some(GenericArgument::Type(v_ty))) = + (args.args.first(), args.args.last()) + { + return ProstTypeInfo::HashMap(k_ty, v_ty); + } + } + } + + if ident_str == "String" { + return ProstTypeInfo::String; + } + if ["i32", "i64", "u32", "u64", "f32", "f64", "bool"].contains(&ident_str.as_str()) { + return ProstTypeInfo::Primitive(ident); + } + + return ProstTypeInfo::Model(ident); + } + + ProstTypeInfo::Unknown(ty) +} + +pub(crate) fn get_prost_enumeration(field: &Field) -> Option { + for attr in &field.attrs { + // We only care about `#[prost(...)]` attributes + if !attr.path().is_ident("prost") { + continue; + } + + // We expect the attribute to be a list, like `prost(string, tag="1")` + if let Ok(list) = attr.meta.require_list() { + // We need to parse the tokens inside the `(...)` + // This is complex, so we parse them as a series of Meta items + let result = list.parse_args_with(|input: syn::parse::ParseStream| { + input.parse_terminated(Meta::parse, syn::Token![,]) + }); + + if let Ok(parsed_metas) = result { + for meta in parsed_metas { + // We are looking for a `NameValue` pair + if let Meta::NameValue(name_value) = meta { + // Specifically, one where the name is `enumeration` + if name_value.path.is_ident("enumeration") { + // The value should be a string literal, e.g., "GameMode" + if let syn::Expr::Lit(expr_lit) = name_value.value { + if let syn::Lit::Str(lit_str) = expr_lit.lit { + return Some(lit_str.parse().unwrap()); + } + } + } + } + } + } + } + } + + // No `enumeration` attribute found + None +} + +pub(crate) fn get_api_type(field: &Field) -> proc_macro2::TokenStream { + let enum_ident = get_prost_enumeration(field); + + match (enum_ident, classify_prost_type(&field.ty)) { + // Enum field (prost represents as i32) + (Some(ident), ProstTypeInfo::Primitive(_)) => { + quote! { types::#ident } + } + // Option field (prost represents as Option) + (Some(ident), ProstTypeInfo::Option(_)) => { + quote! { impl Into> } + } + // Enum with unexpected wrapper - just use the enum type + (Some(ident), _) => { + quote! { types::#ident } + } + // Not an enum, resolve normally + (None, _) => resolve_recursive_api_type(&field.ty), + } +} + +/// Recursive helper for get_api_type. +/// Maps a prost type to its "clean" public-facing type. +fn resolve_recursive_api_type(ty: &Type) -> proc_macro2::TokenStream { + match classify_prost_type(ty) { + ProstTypeInfo::Option(inner) => { + let inner_ty = resolve_recursive_api_type(inner); // Recurse + quote! { impl Into> } + } + ProstTypeInfo::Vec(inner) => { + let inner_ty = resolve_recursive_api_type(inner); // Recurse + quote! { Vec<#inner_ty> } + } + ProstTypeInfo::HashMap(k, v) => { + let k_ty = resolve_recursive_api_type(k); // Recurse + let v_ty = resolve_recursive_api_type(v); // Recurse + quote! { std::collections::HashMap<#k_ty, #v_ty> } + } + ProstTypeInfo::VecU8 => quote! { Vec }, + ProstTypeInfo::String => quote! { String }, + ProstTypeInfo::Primitive(ident) => quote! { #ident }, + + ProstTypeInfo::Model(_) => quote! { types::#ty }, + ProstTypeInfo::Unknown(ty) => quote! { #ty }, + } +} + +pub(crate) enum ConversionLogic { + /// No conversion needed, just use the name. + Direct, + /// Needs `.into()`. + Into, + /// Option: needs `.into().map(|x| x.into())` + OptionInnerInto, +} + +pub(crate) fn get_action_conversion_logic(field: &Field) -> ConversionLogic { + let is_enum = get_prost_enumeration(field).is_some(); + let is_option = matches!(classify_prost_type(&field.ty), ProstTypeInfo::Option(_)); + + match (is_enum, is_option) { + // Option needs: .into().map(|x| x.into()) + (true, true) => ConversionLogic::OptionInnerInto, + // Plain enum or plain Option needs: .into() + (true, false) | (false, true) => ConversionLogic::Into, + // Everything else: direct + (false, false) => ConversionLogic::Direct, + } +} + +#[cfg(test)] +mod tests { + use super::*; // Import all functions from the parent module + use anyhow::Result; + use quote::{format_ident, quote}; + use syn::{parse_file, parse_str, File, ItemEnum, Type}; + + // --- Helper: Parse a string into a syn::File --- + fn parse_test_file(code: &str) -> File { + parse_file(code).expect("Failed to parse test code") + } + + // --- Helper: Parse a string into a syn::Type --- + fn parse_test_type(type_str: &str) -> Type { + parse_str(type_str).expect("Failed to parse test type") + } + + // --- Helper: Get realistic &Field objects from a mock struct --- + fn get_test_fields() -> HashMap { + let code = r#" + struct MockAction { + #[prost(string, tag = "1")] + player_uuid: String, + + #[prost(enumeration = "GameMode", tag = "2")] + game_mode: i32, + + #[prost(message, optional, tag = "3")] + item: ::core::option::Option, + + #[prost(message, tag = "4")] + position: types::Vec3, + } + "#; + let ast = parse_test_file(code); + let structs = find_all_structs(&ast); + let mock_struct = structs + .get(&format_ident!("MockAction")) + .expect("MockAction struct not found"); + + mock_struct + .fields + .iter() + .map(|f| (f.ident.as_ref().unwrap().to_string(), f.clone())) + .collect() + } + + // --- Tests for find_all_structs --- + #[test] + fn test_find_all_structs_nested() { + let code = r#" + struct TopLevel {} + mod my_mod { + struct NestedStruct {} + } + "#; + let ast = parse_test_file(code); + let structs = find_all_structs(&ast); + assert_eq!(structs.len(), 2); + assert!(structs.contains_key(&format_ident!("TopLevel"))); + assert!(structs.contains_key(&format_ident!("NestedStruct"))); + } + + // --- Tests for find_nested_enum --- + #[test] + fn test_find_nested_enum_success() { + let code = r#" + mod event_envelope { + enum Payload { PlayerJoin(String) } + } + "#; + let ast = parse_test_file(code); + let result = find_nested_enum(&ast, "event_envelope", "Payload"); + assert!(result.is_ok()); + } + + #[test] + fn test_find_nested_enum_fail() { + let code = r#" + mod event_envelope { + enum Payload {} + } + "#; + let ast = parse_test_file(code); + let result = find_nested_enum(&ast, "wrong_mod", "Payload"); + assert!(result.is_err()); + } + + // --- Tests for get_variant_type_path --- + #[test] + fn test_get_variant_type_path() -> Result<()> { + let item_enum: ItemEnum = parse_str(r#"enum E { Simple(String), Empty, Struct{f: i32} }"#)?; + let simple_variant = item_enum.variants.first().unwrap(); + assert_eq!(quote!(#simple_variant).to_string(), "Simple (String)"); + let path = get_variant_type_path(simple_variant)?; + assert_eq!(quote!(#path).to_string(), "String"); + + let empty_variant = &item_enum.variants[1]; + assert!(get_variant_type_path(empty_variant).is_err()); + + let struct_variant = &item_enum.variants[2]; + assert!(get_variant_type_path(struct_variant).is_err()); + Ok(()) + } + + // --- Tests for is_vec_u8 --- + // first time ive ever had to actually use ref p some real ball knowledge here. + // AI didn't even get it right + #[test] + fn test_is_vec_u8_helper() { + let ty_vec_u8 = parse_test_type("Vec"); + let ty_prost_vec = parse_test_type("::prost::alloc::vec::Vec"); + let ty_vec_string = parse_test_type("Vec"); + assert!(is_vec_u8(match ty_vec_u8 { + Type::Path(ref p) => p, + _ => panic!(), + })); + assert!(is_vec_u8(match ty_prost_vec { + Type::Path(ref p) => p, + _ => panic!(), + })); + assert!(!is_vec_u8(match ty_vec_string { + Type::Path(ref p) => p, + _ => panic!(), + })); + } + + // --- Tests for classify_prost_type --- + #[test] + fn test_classify_prost_type() { + let ty_opt = parse_test_type("Option"); + let ty_vec = parse_test_type("Vec"); + let ty_bytes = parse_test_type("::prost::alloc::vec::Vec"); + let ty_map = parse_test_type("::std::collections::HashMap"); + let ty_str = parse_test_type("::prost::alloc::string::String"); + let ty_prim = parse_test_type("i32"); + let ty_model = parse_test_type("types::ItemStack"); + + assert!(matches!( + classify_prost_type(&ty_opt), + ProstTypeInfo::Option(_) + )); + assert!(matches!( + classify_prost_type(&ty_vec), + ProstTypeInfo::Vec(_) + )); + assert!(matches!( + classify_prost_type(&ty_bytes), + ProstTypeInfo::VecU8 + )); + assert!(matches!( + classify_prost_type(&ty_map), + ProstTypeInfo::HashMap(_, _) + )); + assert!(matches!( + classify_prost_type(&ty_str), + ProstTypeInfo::String + )); + assert!(matches!( + classify_prost_type(&ty_prim), + ProstTypeInfo::Primitive(_) + )); + assert!(matches!( + classify_prost_type(&ty_model), + ProstTypeInfo::Model(_) + )); + } + + // --- Tests for get_prost_enumeration --- + #[test] + fn test_get_prost_enumeration() { + let fields = get_test_fields(); + let field_game_mode = fields.get("game_mode").unwrap(); + let field_uuid = fields.get("player_uuid").unwrap(); + let field_item = fields.get("item").unwrap(); + + assert_eq!( + get_prost_enumeration(field_game_mode).unwrap().to_string(), + "GameMode" + ); + assert!(get_prost_enumeration(field_uuid).is_none()); + assert!(get_prost_enumeration(field_item).is_none()); + } + + // --- Tests for resolve_recursive_api_type --- + fn assert_resolve_api_type(input_type: &str, expected_output: &str) { + let ty = parse_test_type(input_type); + let resolved = resolve_recursive_api_type(&ty); + assert_eq!( + resolved.to_string().replace(' ', ""), + expected_output.replace(' ', "") + ); + } + + #[test] + fn test_resolve_recursive_api_type() { + assert_resolve_api_type("i32", "i32"); + assert_resolve_api_type("::prost::alloc::string::String", "String"); + assert_resolve_api_type("::prost::alloc::vec::Vec", "Vec"); + assert_resolve_api_type("ItemStack", "types::ItemStack"); + assert_resolve_api_type("Option", "implInto>"); + assert_resolve_api_type("Vec", "Vec"); + assert_resolve_api_type( + "std::collections::HashMap", + "std::collections::HashMap", + ); + } + + // --- Tests for get_api_type --- + fn assert_api_type(field: &Field, expected_output: &str) { + let resolved = get_api_type(field); + assert_eq!( + resolved.to_string().replace(' ', ""), + expected_output.replace(' ', "") + ); + } + + #[test] + fn test_get_api_type() { + let fields = get_test_fields(); + + // Test enum field + assert_api_type(fields.get("game_mode").unwrap(), "types::GameMode"); + + // Test string field + assert_api_type(fields.get("player_uuid").unwrap(), "String"); + + // Test optional model field + assert_api_type( + fields.get("item").unwrap(), + "implInto>", + ); + } + + // --- Tests for get_action_conversion_logic --- + #[test] + fn test_get_action_conversion_logic() { + let fields = get_test_fields(); + let field_game_mode = fields.get("game_mode").unwrap(); + let field_uuid = fields.get("player_uuid").unwrap(); + let field_item = fields.get("item").unwrap(); + let field_pos = fields.get("position").unwrap(); + + // Enum -> Into + assert!(matches!( + get_action_conversion_logic(field_game_mode), + ConversionLogic::Into + )); + + // Option -> Into + assert!(matches!( + get_action_conversion_logic(field_item), + ConversionLogic::Into + )); + + // String -> Direct + assert!(matches!( + get_action_conversion_logic(field_uuid), + ConversionLogic::Direct + )); + + // Model (types::Vec3) -> Direct + assert!(matches!( + get_action_conversion_logic(field_pos), + ConversionLogic::Direct + )); + } +} diff --git a/plugin/adapters/plugin/manager.go b/plugin/adapters/plugin/manager.go index cf049de..5106e88 100644 --- a/plugin/adapters/plugin/manager.go +++ b/plugin/adapters/plugin/manager.go @@ -280,6 +280,7 @@ func (m *Manager) dispatchEvent(envelope *pb.EventEnvelope, expectResult bool) [ Event: envelope, }, } + proc.log.Debug("sending event", "event_id", envelope.EventId, "type", envelope.Type.String()) proc.queue(msg) if !expectResult { @@ -309,6 +310,13 @@ func (m *Manager) dispatchEvent(envelope *pb.EventEnvelope, expectResult bool) [ "event_id", envelope.EventId, "plugin_response_ms", pluginResponseTime.Milliseconds(), "plugin_response_us", pluginResponseTime.Microseconds()) + } else { + // General timing for non-command events + proc.log.Debug("plugin event response received", + "event_id", envelope.EventId, + "type", envelope.Type.String(), + "plugin_response_ms", pluginResponseTime.Milliseconds(), + "plugin_response_us", pluginResponseTime.Microseconds()) } } } @@ -347,6 +355,7 @@ func (m *Manager) dispatchEventParallel(envelope *pb.EventEnvelope, expectResult if expectResult { waitCh = proc.expectEventResult(envelope.EventId) } + proc.log.Debug("sending event", "event_id", envelope.EventId, "type", envelope.Type.String()) proc.queue(&pb.HostToPlugin{ PluginId: proc.id, Payload: &pb.HostToPlugin_Event{ @@ -356,6 +365,7 @@ func (m *Manager) dispatchEventParallel(envelope *pb.EventEnvelope, expectResult if !expectResult { return } + waitStart := time.Now() res, err := proc.waitEventResult(waitCh, eventResponseTimeout) if err != nil { if errors.Is(err, context.DeadlineExceeded) { @@ -364,6 +374,14 @@ func (m *Manager) dispatchEventParallel(envelope *pb.EventEnvelope, expectResult proc.discardEventResult(envelope.EventId) return } + pluginResponseTime := time.Since(waitStart) + if envelope.Type != pb.EventType_COMMAND { + proc.log.Debug("plugin event response received", + "event_id", envelope.EventId, + "type", envelope.Type.String(), + "plugin_response_ms", pluginResponseTime.Milliseconds(), + "plugin_response_us", pluginResponseTime.Microseconds()) + } results[idx] = res }) } @@ -386,8 +404,10 @@ func (m *Manager) emitCancellable(ctx cancelContext, envelope *pb.EventEnvelope) } // If any plugin cancelled, do not apply any mutations. if cancelled { + m.log.Debug("event cancelled by plugin", "event_id", envelope.EventId, "type", envelope.Type.String()) return nil } + m.log.Debug("event completed", "event_id", envelope.EventId, "type", envelope.Type.String(), "responses", len(results)) return results } diff --git a/plugin/adapters/plugin/process.go b/plugin/adapters/plugin/process.go index 6797622..a3051ba 100644 --- a/plugin/adapters/plugin/process.go +++ b/plugin/adapters/plugin/process.go @@ -379,6 +379,7 @@ func (p *pluginProcess) expectEventResult(eventID string) chan *pb.EventResult { p.pendingMu.Lock() p.pending[eventID] = ch p.pendingMu.Unlock() + p.log.Debug("waiting for event result", "event_id", eventID) return ch } @@ -401,6 +402,7 @@ func (p *pluginProcess) discardEventResult(eventID string) { close(ch) } p.pendingMu.Unlock() + p.log.Debug("discarded event result waiter", "event_id", eventID) } func (p *pluginProcess) deliverEventResult(res *pb.EventResult) { @@ -422,4 +424,5 @@ func (p *pluginProcess) deliverEventResult(res *pb.EventResult) { default: } close(ch) + p.log.Debug("delivered event result", "event_id", res.EventId) }