rift_plugin/lib.rs
1//! Guest SDK for Rift WebAssembly plugins.
2//!
3//! A Rift plugin is a WebAssembly component built for `wasm32-wasip2` against
4//! the `plugin` world of `rift:plugin@0.2.0`. This crate ships that WIT,
5//! generates the bindings and wraps them, so a plugin is an ordinary
6//! `cdylib` crate:
7//!
8//! ```toml
9//! [lib]
10//! crate-type = ["cdylib"]
11//!
12//! [dependencies]
13//! rift-plugin = "0.2"
14//! ```
15//!
16//! ```no_run
17//! use rift_plugin::{Action, CommandSpec, Context, Hook, Plugin, Registration, SuggestKind};
18//!
19//! #[derive(Default)]
20//! struct Greeter;
21//!
22//! impl Plugin for Greeter {
23//! fn init(&mut self, _config: &str) -> Result<Registration, String> {
24//! Ok(Registration::new()
25//! .command(
26//! CommandSpec::new("greet")
27//! .alias("hi")
28//! .permission("greeter.use")
29//! .suggest(SuggestKind::Players),
30//! )
31//! .hooks([Hook::Command]))
32//! }
33//!
34//! fn command(&mut self, ctx: Context) -> Action {
35//! Action::message(format!("Hi, {}!", ctx.name))
36//! }
37//! }
38//!
39//! rift_plugin::export!(Greeter);
40//! ```
41//!
42//! Build it with `cargo build --release --target wasm32-wasip2`. Rust's
43//! linker for that target emits a component directly; no `cargo component`
44//! or `wasm-tools component new` step is needed.
45//!
46//! # State
47//!
48//! The component model calls exports as free functions, so the SDK keeps the
49//! single plugin instance for you: [`export!`] stores one value of your type
50//! in a thread-local, created with [`Default`] right before
51//! [`Plugin::init`], and every hook receives it as `&mut self`. Keep plugin
52//! state in your struct's fields. This is sound because guests are
53//! single-threaded and the host never calls one instance concurrently or
54//! re-entrantly. [`Plugin::shutdown`] runs last and the instance is dropped
55//! after it returns.
56//!
57//! # Hooks
58//!
59//! Every [`Plugin`] method has a default that does nothing (`continue`,
60//! `default`, `none` or an empty list). List the hooks you implement in
61//! [`Registration::hooks`] so the host skips the rest. An empty list delivers
62//! every hook except [`Hook::Chat`] and [`Hook::Kick`], which are only ever
63//! delivered when listed. [`Plugin::dialog`] and [`Plugin::job_done`] need no
64//! entry.
65//!
66//! The decision hooks answer with an [`Action`]; each accepts a subset:
67//!
68//! | Hook | Accepts |
69//! | --- | --- |
70//! | `login`, `before_transfer`, `chat` | `Continue`, `deny` |
71//! | `initial_server`, `kick` | `Continue`, `deny`, `server` |
72//! | `command` | `Continue`, `message`, `server`, `queue`, `LeaveQueue` |
73//!
74//! # Host API
75//!
76//! [`players`], [`store`], [`permissions`], [`bus`], [`servers`], [`timers`],
77//! [`background`], [`log`] and [`meta`] mirror the WIT interfaces of the same
78//! names. Errors are the host's message as a `String`. [`log`] also installs
79//! a [`::log`] logger before `init`, so `log::info!` and friends reach Rift's
80//! log. Plugin configuration arrives as JSON; decode it with [`config()`].
81//!
82//! WASI 0.2 (`std::fs` on the granted directories, clocks, random) works
83//! through the standard library. A `wasi:http` helper is not provided.
84//!
85//! # Text
86//!
87//! Everything a player reads is a [`Text`]: [`Text::Plain`], shown exactly
88//! as written, or [`Text::Json`], a JSON text component with colours, line
89//! breaks, hover text and click events. Every function and constructor that
90//! shows text takes `impl Into<Text>`:
91//!
92//! - `&str` and `String` become plain text;
93//! - the [`text`] builders ([`text::mini`] for Rift markup, [`text::legacy`]
94//! for `&c` codes, [`text::Component`] field by field) and a
95//! [`serde_json::Value`] become JSON text.
96//!
97//! ```no_run
98//! use rift_plugin::{Action, TitleSpec, players, text};
99//! use serde_json::json;
100//!
101//! players::message("Steve", "Shown <exactly> as written").ok();
102//!
103//! let welcome = text::mini("<gold>Welcome, <player>!</gold> <gray><click:run_command:/spawn>[Spawn]")
104//! .arg("player", "Steve");
105//! players::message("Steve", welcome).ok();
106//! players::title(
107//! "Steve",
108//! &TitleSpec::new(text::mini("<green>Lobby")).subtitle(text::legacy("&7Have fun")),
109//! )
110//! .ok();
111//!
112//! let reply = Action::message(
113//! text::Component::text("[Accept]")
114//! .color("green")
115//! .bold()
116//! .run_command("/party accept Alex")
117//! .hover_text("Join Alex's party"),
118//! );
119//! let banned = Action::deny(json!({
120//! "text": "You are banned\n", "color": "red",
121//! "extra": [{"text": "Reason: spam", "color": "gray"}],
122//! }));
123//! ```
124//!
125//! Rift spells hover and click events the way each player's client reads
126//! them, so either the 1.21.5+ form (`click_event`, `hover_event`) or the
127//! older one (`clickEvent`, `hoverEvent`) works for every client. Markup is
128//! only ever read from an explicit `{"mini": ...}` object, which
129//! [`text::mini`] writes; a string is always shown as written, so text from
130//! players can be passed safely as plain text or as a markup argument.
131//!
132//! # Chat and kicks
133//!
134//! [`Plugin::chat`] sees every chat message, and every command that reaches
135//! the backend, before the backend does, and may drop it. [`Plugin::kick`]
136//! decides what happens to a player a backend kicked. Both are delivered
137//! only to a registration that lists them:
138//!
139//! ```no_run
140//! use rift_plugin::{Action, ChatAttempt, ChatKind, Context, Hook, Plugin, Registration};
141//!
142//! #[derive(Default)]
143//! struct Mute {
144//! muted: Vec<String>,
145//! }
146//!
147//! impl Plugin for Mute {
148//! fn init(&mut self, _config: &str) -> Result<Registration, String> {
149//! Ok(Registration::new().hooks([Hook::Chat, Hook::Kick]))
150//! }
151//!
152//! fn chat(&mut self, attempt: ChatAttempt) -> Action {
153//! if attempt.kind == ChatKind::Message && self.muted.contains(&attempt.ctx.uuid) {
154//! return Action::deny("You are muted.");
155//! }
156//! Action::Continue
157//! }
158//!
159//! fn kick(&mut self, ctx: Context) -> Action {
160//! match ctx.reason.as_deref() {
161//! Some(reason) if reason.contains("restarting") => Action::server("lobby"),
162//! _ => Action::Continue,
163//! }
164//! }
165//! }
166//!
167//! rift_plugin::export!(Mute);
168//! ```
169//!
170//! Rift asks a plugin that lists [`Hook::Chat`] about every message of every
171//! player. Dropping one signed message breaks the player's signature chain,
172//! so unless the proxy sets `authentication.signed_chat = false`, the first
173//! deny costs that player their chat signing until they reconnect.
174//!
175//! # Background work
176//!
177//! A plugin runs one call at a time, so a slow lookup in `login` would keep
178//! every other hook waiting. The [`background`] module moves such work to
179//! worker instances (manifest `[background] workers = N`): the hook defers
180//! its answer, queues a job and returns; a worker runs [`Plugin::work`];
181//! [`Plugin::job_done`] receives the result and gives the answer.
182//!
183//! ```no_run
184//! use rift_plugin::{Action, Context, Hook, Plugin, Registration, background};
185//! use std::collections::HashMap;
186//!
187//! #[derive(Default)]
188//! struct Bans {
189//! /// Job handle to the ticket of the login that waits for it.
190//! waiting: HashMap<u64, u64>,
191//! }
192//!
193//! impl Plugin for Bans {
194//! fn init(&mut self, _config: &str) -> Result<Registration, String> {
195//! Ok(Registration::new().hooks([Hook::Login]))
196//! }
197//!
198//! fn login(&mut self, ctx: Context) -> Action {
199//! let Ok(ticket) = background::defer() else {
200//! return Action::deny("Try again in a moment.");
201//! };
202//! match background::run(ctx.uuid.as_bytes()) {
203//! Ok(job) => {
204//! self.waiting.insert(job, ticket);
205//! }
206//! Err(_) => {
207//! let _ = background::resolve(ticket, Action::deny("Try again in a moment."));
208//! }
209//! }
210//! // Ignored: the answer is the one given to `resolve`.
211//! Action::Continue
212//! }
213//!
214//! fn job_done(&mut self, job: u64, outcome: Result<Vec<u8>, String>) {
215//! let Some(ticket) = self.waiting.remove(&job) else { return };
216//! let action = match outcome {
217//! Ok(reason) if reason.is_empty() => Action::Continue,
218//! Ok(reason) => Action::deny(String::from_utf8_lossy(&reason).into_owned()),
219//! Err(_) => Action::deny("Try again in a moment."),
220//! };
221//! let _ = background::resolve(ticket, action);
222//! }
223//!
224//! // Runs on a worker: another instance, with its own state.
225//! fn work(&mut self, uuid: Vec<u8>) -> Result<Vec<u8>, String> {
226//! let uuid = String::from_utf8(uuid).map_err(|error| error.to_string())?;
227//! // The slow part; an empty answer means "not banned".
228//! Ok(std::fs::read(format!("/data/bans/{uuid}")).unwrap_or_default())
229//! }
230//! }
231//!
232//! rift_plugin::export!(Bans);
233//! ```
234//!
235//! A worker is a separate instance of the same component with its own
236//! memory: its state is its own `Default` value, [`Plugin::init`] never runs
237//! there, and it receives no hooks. [`Plugin::work_init`] runs once per
238//! worker before its first job, with the plugin's configuration. The
239//! deferred hook's deadline keeps running while the job does.
240//!
241//! # Dialogs
242//!
243//! [`players::show_dialog`] shows a dialog (Minecraft 1.21.6+) in the
244//! vanilla JSON format. A button whose `custom` or `dynamic/custom` action
245//! has an `id` without a namespace comes back to [`Plugin::dialog`]:
246//!
247//! ```no_run
248//! use rift_plugin::{Context, DialogResponse, Plugin, players};
249//! use serde_json::json;
250//!
251//! #[derive(Default)]
252//! struct Rules;
253//!
254//! impl Plugin for Rules {
255//! fn join(&mut self, ctx: Context) {
256//! let _ = players::show_dialog(&ctx.uuid, &json!({
257//! "type": "confirmation",
258//! "title": "Server rules",
259//! "body": "Be nice.",
260//! "yes": {"label": "Accept", "action": {"type": "custom", "id": "accept"}},
261//! "no": {"label": "Leave", "action": {"type": "custom", "id": "leave"}},
262//! }));
263//! }
264//!
265//! fn dialog(&mut self, response: DialogResponse) {
266//! if response.action == "leave" {
267//! let _ = players::kick(&response.ctx.uuid, "You declined the rules.");
268//! }
269//! }
270//! }
271//! # rift_plugin::export!(Rules);
272//! ```
273
274#![warn(missing_docs)]
275
276/// The raw generated bindings for the `plugin` world, including WASI.
277///
278/// Prefer the wrappers at the crate root; this is an escape hatch.
279#[allow(missing_docs, clippy::all)]
280pub mod bindings {
281 wit_bindgen::generate!({
282 path: "wit",
283 world: "rift:plugin/plugin@0.2.0",
284 pub_export_macro: true,
285 export_macro_name: "__export_rift_plugin",
286 default_bindings_module: "rift_plugin::bindings",
287 generate_all,
288 });
289}
290
291pub use bindings::rift::plugin::types::{
292 BossBarSpec, BusMessage, ChatAttempt, ChatKind, CommandSpec, Connection, Context,
293 DialogResponse, Hook, HttpRequest, HttpResponse, Level, Ping, PlayerInfo, PlayerRecord,
294 ProfileProperty, Registration, RiftEvent, RouteDecision, StatusOverride, SuggestKind,
295 Suggestion, Text, TitleSpec,
296};
297
298/// The answer to a decision hook: the contract's `decision` variant.
299///
300/// Each hook accepts a subset; an answer outside it counts as a failed call:
301/// - `login`, `before_transfer`, `chat`: `Continue`, [`deny`](Action::deny)
302/// - `initial_server`, `kick`: `Continue`, [`deny`](Action::deny),
303/// [`server`](Action::server)
304/// - `command`: `Continue`, [`message`](Action::message),
305/// [`server`](Action::server), [`queue`](Action::queue), `LeaveQueue`
306pub type Action = bindings::rift::plugin::types::Decision;
307
308use serde::de::DeserializeOwned;
309
310/// A Rift plugin. Implement the hooks you need and pass the type to
311/// [`export!`].
312///
313/// The instance is created with [`Default`] before [`Plugin::init`]; see the
314/// [crate documentation](crate#state) for how state is kept.
315#[allow(unused_variables)]
316pub trait Plugin: Default + 'static {
317 /// Called once per instance with the plugin's configuration as JSON
318 /// (`{}` without one). Create timers and bus subscriptions here.
319 fn init(&mut self, config: &str) -> Result<Registration, String> {
320 Ok(Registration::new())
321 }
322
323 /// The configuration changed and the manifest sets `hot_reload = true`.
324 fn reload(&mut self, config: &str) -> Result<(), String> {
325 Ok(())
326 }
327
328 /// The instance is about to be dropped.
329 fn shutdown(&mut self, reason: &str) {}
330
331 /// A TCP connection was accepted, before any bytes are read.
332 fn route(&mut self, connection: Connection) -> RouteDecision {
333 RouteDecision::Default
334 }
335
336 /// A player authenticated. Accepts `continue` and `deny`.
337 fn login(&mut self, ctx: Context) -> Action {
338 Action::Continue
339 }
340
341 /// Choose the first backend. Accepts `continue`, `deny` and `server`.
342 fn initial_server(&mut self, ctx: Context) -> Action {
343 Action::Continue
344 }
345
346 /// A transfer is about to happen. Accepts `continue` and `deny`.
347 fn before_transfer(&mut self, ctx: Context) -> Action {
348 Action::Continue
349 }
350
351 /// A registered command ran; `ctx.command` is its name, also when an
352 /// alias was typed, and `ctx.args` the text after it. Accepts
353 /// `continue`, `message`, `server`, `queue` and `leave-queue`.
354 ///
355 /// For a command registered with [`CommandSpec::console`], `ctx.console`
356 /// tells that the operator console runs it: there is no player then
357 /// (`ctx.uuid` is empty and `ctx.name` is `CONSOLE`), and a `message`
358 /// answer is shown to the operator.
359 fn command(&mut self, ctx: Context) -> Action {
360 Action::Continue
361 }
362
363 /// Complete an argument of a command registered with
364 /// [`SuggestKind::Dynamic`].
365 fn suggest(&mut self, ctx: Context) -> Vec<Suggestion> {
366 Vec::new()
367 }
368
369 /// A server-list ping; return the fields to replace.
370 fn status(&mut self, ping: Ping) -> Option<StatusOverride> {
371 None
372 }
373
374 /// An authenticated request to `/ext` or `/ext/*` on the web server.
375 fn http(&mut self, request: HttpRequest) -> Option<HttpResponse> {
376 None
377 }
378
379 /// A player sent a chat message, or a command that neither Rift nor a
380 /// plugin owns, and the backend has not seen it yet. Accepts `continue`,
381 /// which lets it through unchanged, and `deny`, which drops it and shows
382 /// the player the text.
383 ///
384 /// Delivered only when [`Registration::hooks`] lists [`Hook::Chat`]; see
385 /// the [crate documentation](crate#chat-and-kicks) for what that
386 /// requires of the proxy. For a command, `attempt.ctx.command` is the
387 /// root name and `attempt.ctx.args` the rest.
388 fn chat(&mut self, attempt: ChatAttempt) -> Action {
389 Action::Continue
390 }
391
392 /// A backend kicked a player in play: `ctx.server` is the backend and
393 /// `ctx.reason` its reason without formatting. `continue` leaves it to
394 /// `network.on_kick`, `deny` disconnects the player with the text and
395 /// `server` moves them there.
396 ///
397 /// Delivered only when [`Registration::hooks`] lists [`Hook::Kick`].
398 fn kick(&mut self, ctx: Context) -> Action {
399 Action::Continue
400 }
401
402 /// A player joined.
403 fn join(&mut self, ctx: Context) {}
404
405 /// A transfer finished (see `ctx.success`).
406 fn after_transfer(&mut self, ctx: Context) {}
407
408 /// A session closed (see `ctx.reason`).
409 fn disconnect(&mut self, ctx: Context) {}
410
411 /// A message arrived on a subscription made with [`bus::subscribe`].
412 fn message(&mut self, message: BusMessage) {}
413
414 /// A timer made with [`timers::every`] or [`timers::after`] fired.
415 fn timer(&mut self, handle: u64) {}
416
417 /// A Rift event (`rift.events.*`).
418 fn event(&mut self, event: RiftEvent) {}
419
420 /// A player clicked a plugin action of a dialog this plugin showed with
421 /// [`players::show_dialog`]. Delivered without being listed in
422 /// [`Registration::hooks`].
423 fn dialog(&mut self, response: DialogResponse) {}
424
425 /// A job started with [`background::run`] finished: `handle` is the one
426 /// `run` returned and `outcome` what [`Plugin::work`] returned on the
427 /// worker, or why the job failed. Delivered without being listed in
428 /// [`Registration::hooks`].
429 fn job_done(&mut self, handle: u64, outcome: Result<Vec<u8>, String>) {}
430
431 /// Background workers only: called once per worker before its first
432 /// job, with the plugin's configuration as JSON.
433 ///
434 /// A worker is a separate instance of the component. `self` is a fresh
435 /// [`Default`] value that only `work_init` and [`Plugin::work`] ever
436 /// see; [`Plugin::init`] is never called on it.
437 fn work_init(&mut self, config: &str) -> Result<(), String> {
438 Ok(())
439 }
440
441 /// Background workers only: one job queued with [`background::run`].
442 /// It may block; the plugin's hooks keep running meanwhile. The result
443 /// reaches [`Plugin::job_done`] on the plugin's main instance.
444 fn work(&mut self, payload: Vec<u8>) -> Result<Vec<u8>, String> {
445 Err("this plugin has no background work".into())
446 }
447}
448
449/// Exports a [`Plugin`] implementation as the component's
450/// `rift:plugin/handler` interface. Use it exactly once per plugin crate.
451///
452/// ```ignore
453/// rift_plugin::export!(MyPlugin);
454/// ```
455#[macro_export]
456macro_rules! export {
457 ($plugin:ty) => {
458 #[doc(hidden)]
459 type __RiftPluginExport = $crate::__private::Adapter<$plugin>;
460 $crate::bindings::__export_rift_plugin!(__RiftPluginExport with_types_in $crate::bindings);
461 };
462}
463
464/// Decodes the JSON configuration `init`, `reload` and `work_init` receive.
465///
466/// The host passes `{}` when the plugin has no configuration, so mark
467/// optional fields with `#[serde(default)]`.
468pub fn config<T: DeserializeOwned>(json: &str) -> Result<T, String> {
469 serde_json::from_str(json).map_err(|error| format!("invalid plugin configuration: {error}"))
470}
471
472impl Text {
473 /// Plain text of 1..=1024 bytes, shown exactly as written. The same as
474 /// converting a `&str` or `String`.
475 pub fn plain(text: impl Into<String>) -> Self {
476 Self::Plain(text.into())
477 }
478
479 /// A JSON text component of at most 32 KiB that is already JSON text.
480 /// The [`text`] builders and [`serde_json::Value`] convert into this
481 /// with `into()`.
482 pub fn json(json: impl Into<String>) -> Self {
483 Self::Json(json.into())
484 }
485}
486
487impl From<&str> for Text {
488 /// [`Text::Plain`].
489 fn from(text: &str) -> Self {
490 Self::Plain(text.to_owned())
491 }
492}
493
494impl From<String> for Text {
495 /// [`Text::Plain`].
496 fn from(text: String) -> Self {
497 Self::Plain(text)
498 }
499}
500
501impl From<&String> for Text {
502 /// [`Text::Plain`].
503 fn from(text: &String) -> Self {
504 Self::Plain(text.clone())
505 }
506}
507
508impl From<serde_json::Value> for Text {
509 /// [`Text::Json`]: the value is a JSON text component.
510 fn from(component: serde_json::Value) -> Self {
511 Self::Json(component.to_string())
512 }
513}
514
515impl From<&serde_json::Value> for Text {
516 /// [`Text::Json`]: the value is a JSON text component.
517 fn from(component: &serde_json::Value) -> Self {
518 Self::Json(component.to_string())
519 }
520}
521
522impl PartialEq for Text {
523 fn eq(&self, other: &Self) -> bool {
524 match (self, other) {
525 (Self::Plain(a), Self::Plain(b)) | (Self::Json(a), Self::Json(b)) => a == b,
526 _ => false,
527 }
528 }
529}
530
531impl Registration {
532 /// No commands and no hooks, which delivers every hook except
533 /// [`Hook::Chat`] and [`Hook::Kick`].
534 pub fn new() -> Self {
535 Self {
536 commands: Vec::new(),
537 hooks: Vec::new(),
538 }
539 }
540
541 /// Registers a command.
542 pub fn command(mut self, command: CommandSpec) -> Self {
543 self.commands.push(command);
544 self
545 }
546
547 /// Asks for one more hook to be delivered.
548 pub fn hook(mut self, hook: Hook) -> Self {
549 self.hooks.push(hook);
550 self
551 }
552
553 /// Asks for these hooks to be delivered.
554 pub fn hooks(mut self, hooks: impl IntoIterator<Item = Hook>) -> Self {
555 self.hooks.extend(hooks);
556 self
557 }
558}
559
560impl Default for Registration {
561 fn default() -> Self {
562 Self::new()
563 }
564}
565
566impl CommandSpec {
567 /// A command without aliases, permission node or suggestions, for
568 /// players only. Without a permission every player may see and run it.
569 ///
570 /// Names are 1 to 64 bytes of `a-z0-9_-`; `server` and `hub` are
571 /// reserved.
572 pub fn new(name: impl Into<String>) -> Self {
573 Self {
574 name: name.into(),
575 aliases: Vec::new(),
576 permission: String::new(),
577 suggest: SuggestKind::None,
578 console: false,
579 }
580 }
581
582 /// Another name for the command, written like the name. A command has
583 /// at most 16; the hooks always see the name in `ctx.command`.
584 pub fn alias(mut self, alias: impl Into<String>) -> Self {
585 self.aliases.push(alias.into());
586 self
587 }
588
589 /// More names for the command; see [`CommandSpec::alias`].
590 pub fn aliases<S: Into<String>>(mut self, aliases: impl IntoIterator<Item = S>) -> Self {
591 self.aliases.extend(aliases.into_iter().map(Into::into));
592 self
593 }
594
595 /// The permission node a player needs to see and run the command. An
596 /// empty node opens the command to every player.
597 pub fn permission(mut self, node: impl Into<String>) -> Self {
598 self.permission = node.into();
599 self
600 }
601
602 /// How the command's arguments are completed.
603 pub fn suggest(mut self, kind: SuggestKind) -> Self {
604 self.suggest = kind;
605 self
606 }
607
608 /// Operators may run the command from the console (`rift admin plugins
609 /// run`); [`Plugin::command`] then sees `ctx.console`.
610 pub fn console(mut self) -> Self {
611 self.console = true;
612 self
613 }
614}
615
616impl Action {
617 /// `deny(reason)`: refuse, and show the player the reason.
618 pub fn deny(reason: impl Into<Text>) -> Self {
619 Self::Deny(reason.into())
620 }
621
622 /// `message(text)`: reply to whoever ran a command.
623 pub fn message(text: impl Into<Text>) -> Self {
624 Self::Message(text.into())
625 }
626
627 /// `server(name)`: send the player to a backend.
628 pub fn server(name: impl Into<String>) -> Self {
629 Self::Server(name.into())
630 }
631
632 /// `queue(name)`: put the player into the queue for a backend.
633 pub fn queue(name: impl Into<String>) -> Self {
634 Self::Queue(name.into())
635 }
636}
637
638impl PartialEq for Action {
639 fn eq(&self, other: &Self) -> bool {
640 match (self, other) {
641 (Self::Continue, Self::Continue) | (Self::LeaveQueue, Self::LeaveQueue) => true,
642 (Self::Deny(a), Self::Deny(b)) | (Self::Message(a), Self::Message(b)) => a == b,
643 (Self::Server(a), Self::Server(b)) | (Self::Queue(a), Self::Queue(b)) => a == b,
644 _ => false,
645 }
646 }
647}
648
649impl DialogResponse {
650 /// The player's input values, decoded from `values_json`. They come from
651 /// the client: validate them like any player input.
652 pub fn values<T: DeserializeOwned>(&self) -> Result<T, String> {
653 serde_json::from_str(&self.values_json)
654 .map_err(|error| format!("invalid dialog values: {error}"))
655 }
656
657 /// The action's `payload` or `additions`, as the plugin wrote it; `None`
658 /// when it had none.
659 pub fn payload<T: DeserializeOwned>(&self) -> Result<Option<T>, String> {
660 self.payload_json
661 .as_deref()
662 .map(serde_json::from_str)
663 .transpose()
664 .map_err(|error| format!("invalid dialog payload: {error}"))
665 }
666}
667
668impl Suggestion {
669 /// A suggestion without a tooltip.
670 pub fn new(text: impl Into<String>) -> Self {
671 Self {
672 text: text.into(),
673 tooltip: None,
674 }
675 }
676
677 /// The tooltip shown next to the suggestion.
678 pub fn tooltip(mut self, tooltip: impl Into<Text>) -> Self {
679 self.tooltip = Some(tooltip.into());
680 self
681 }
682}
683
684impl Default for StatusOverride {
685 /// Replaces nothing. Set the fields to replace:
686 ///
687 /// ```
688 /// use rift_plugin::{StatusOverride, text};
689 ///
690 /// let status = StatusOverride {
691 /// motd: Some(text::mini("<gold>Rift</gold>\n<gray>Now with plugins").into()),
692 /// max_players: Some(500),
693 /// ..Default::default()
694 /// };
695 /// ```
696 fn default() -> Self {
697 Self {
698 motd: None,
699 version: None,
700 protocol: None,
701 online: None,
702 max_players: None,
703 sample: None,
704 favicon: None,
705 }
706 }
707}
708
709impl TitleSpec {
710 /// A title with the vanilla times (10 ticks fade in, 70 stay, 20 fade
711 /// out).
712 pub fn new(title: impl Into<Text>) -> Self {
713 Self {
714 title: Some(title.into()),
715 subtitle: None,
716 fade_in: 10,
717 stay: 70,
718 fade_out: 20,
719 }
720 }
721
722 /// Only a subtitle, with the vanilla times: the title above it stays
723 /// empty.
724 pub fn subtitle_only(subtitle: impl Into<Text>) -> Self {
725 Self {
726 title: None,
727 subtitle: Some(subtitle.into()),
728 fade_in: 10,
729 stay: 70,
730 fade_out: 20,
731 }
732 }
733
734 /// The subtitle.
735 pub fn subtitle(mut self, subtitle: impl Into<Text>) -> Self {
736 self.subtitle = Some(subtitle.into());
737 self
738 }
739
740 /// Fade-in, stay and fade-out times in ticks.
741 pub fn times(mut self, fade_in: i32, stay: i32, fade_out: i32) -> Self {
742 self.fade_in = fade_in;
743 self.stay = stay;
744 self.fade_out = fade_out;
745 self
746 }
747}
748
749/// The joined players, and what Rift remembers about those who left.
750/// `target` is a name, a UUID or a connection id.
751///
752/// The functions that show text take anything that converts into a
753/// [`Text`]: a string for plain text, a [`text`]
754/// builder or a [`serde_json::Value`] for a JSON text component. Each
755/// returns whether the player was online.
756///
757/// [`PlayerInfo`] carries what Rift knows about an online
758/// player, including `ip`, the profile `properties`, `visible_to`, `locale`
759/// (the language the client reported, `None` until the client has sent it:
760/// never in [`Plugin::login`], and in
761/// [`Plugin::join`] only for 1.20.2 and newer clients)
762/// and `language` (the one set with [`set_language`](players::set_language), else `locale`).
763///
764/// [`lookup`](players::lookup) and [`search`](players::search) answer with a
765/// [`PlayerRecord`] for every player who joined at
766/// least once, online or not.
767pub mod players {
768 use crate::Text;
769 use crate::bindings::rift::plugin::players as host;
770 pub use host::{
771 boss_bar, clear_dialog, dialog, get, list, lookup, names, search, set_language, title,
772 transfer,
773 };
774
775 /// Send a chat message to a player.
776 pub fn message(target: &str, text: impl Into<Text>) -> Result<bool, String> {
777 host::message(target, &text.into())
778 }
779
780 /// Show text above a player's hotbar.
781 pub fn action_bar(target: &str, text: impl Into<Text>) -> Result<bool, String> {
782 host::action_bar(target, &text.into())
783 }
784
785 /// Disconnect a player with a reason.
786 pub fn kick(target: &str, reason: impl Into<Text>) -> Result<bool, String> {
787 host::kick(target, &reason.into())
788 }
789
790 /// Send a chat message to every player. Returns how many received it.
791 pub fn broadcast(text: impl Into<Text>) -> Result<u32, String> {
792 host::broadcast(&text.into())
793 }
794
795 /// Replace a player's tab-list header and footer. Both `None` restores
796 /// the configured tab list.
797 ///
798 /// ```no_run
799 /// use rift_plugin::{players, text};
800 ///
801 /// players::tab_list("Steve", Some(text::mini("<gold>Rift").into()), Some("Have fun".into())).ok();
802 /// players::tab_list("Steve", None, None).ok();
803 /// ```
804 pub fn tab_list(
805 target: &str,
806 header: Option<Text>,
807 footer: Option<Text>,
808 ) -> Result<bool, String> {
809 host::tab_list(target, header.as_ref(), footer.as_ref())
810 }
811
812 /// Show a dialog in the vanilla JSON format (Minecraft 1.21.6+). Returns
813 /// false when the player is offline, older than 1.21.6 or has too many
814 /// undelivered actions; an invalid dialog is an error.
815 pub fn show_dialog(target: &str, dialog: &serde_json::Value) -> Result<bool, String> {
816 self::dialog(target, &dialog.to_string())
817 }
818}
819
820/// The durable key-value store. Namespaces are prefixed with
821/// `plugin.<name>.` by the host.
822pub mod store {
823 pub use crate::bindings::rift::plugin::store::{delete, get, increment, list, set};
824 use serde::Serialize;
825 use serde::de::DeserializeOwned;
826
827 /// Reads a JSON-encoded entry.
828 pub fn get_json<T: DeserializeOwned>(namespace: &str, key: &str) -> Result<Option<T>, String> {
829 match get(namespace, key)? {
830 Some(bytes) => serde_json::from_slice(&bytes)
831 .map(Some)
832 .map_err(|error| format!("invalid JSON in {namespace}/{key}: {error}")),
833 None => Ok(None),
834 }
835 }
836
837 /// Writes an entry as JSON.
838 pub fn set_json<T: Serialize + ?Sized>(
839 namespace: &str,
840 key: &str,
841 value: &T,
842 ) -> Result<(), String> {
843 let bytes = serde_json::to_vec(value).map_err(|error| error.to_string())?;
844 set(namespace, key, &bytes)
845 }
846}
847
848/// Rift's permission store: user nodes and group memberships.
849///
850/// `add_group(uuid, group, expires_at)` adds the user to a group, until
851/// `expires_at` Unix seconds when given (see [`meta::time`]
852/// for the current time).
853pub mod permissions {
854 pub use crate::bindings::rift::plugin::permissions::{
855 add_group, groups, has, list_groups, meta, remove_group, set, set_meta,
856 };
857
858 /// The meta key that makes a player visible only to viewers holding the
859 /// node in its value (see docs/permissions.md, "Visibility").
860 pub const VISIBLE_TO: &str = "rift.visible-to";
861}
862
863/// The message bus. Subscriptions deliver to [`Plugin::message`].
864pub mod bus {
865 use crate::BusMessage;
866 pub use crate::bindings::rift::plugin::bus::{
867 publish, request, stream_publish, subscribe, unsubscribe,
868 };
869
870 /// Publishes `payload` to the message's reply subject. Returns `None`
871 /// when the message has none, otherwise how many subscribers received it.
872 pub fn reply(message: &BusMessage, payload: &[u8]) -> Result<Option<u32>, String> {
873 match &message.reply {
874 Some(subject) => publish(subject, payload, None).map(Some),
875 None => Ok(None),
876 }
877 }
878}
879
880/// Managed servers, through the `rift.control.*` operations. Results are
881/// the control service's JSON replies.
882pub mod servers {
883 pub use crate::bindings::rift::plugin::servers::{
884 control, create, download, remove, start, stop,
885 };
886}
887
888/// Timers fire through [`Plugin::timer`] with the
889/// returned handle.
890pub mod timers {
891 pub use crate::bindings::rift::plugin::timers::{after, cancel, every};
892}
893
894/// Work that must not hold up the plugin's hooks.
895///
896/// A plugin runs one call at a time, so a hook that waits for a database or
897/// a web service keeps every other hook waiting. Background workers take
898/// that work: each is a separate instance of the component, started when the
899/// manifest sets `[background] workers`. The pattern has three steps (the
900/// [crate documentation](crate#background-work) has a complete example):
901///
902/// 1. In a decision hook (`login`, `initial_server`, `before_transfer`,
903/// `command`, `chat` or `kick`), call [`defer`](background::defer) for a ticket and [`run`](background::run)
904/// with the job's payload, remember the ticket under the job's handle and
905/// return. The hook's own return value is ignored.
906/// 2. A worker calls [`Plugin::work`] with the payload.
907/// It may block.
908/// 3. [`Plugin::job_done`] receives the handle and
909/// the result on the main instance; call [`resolve`](background::resolve) with the ticket and
910/// the answer.
911///
912/// The deferred hook's deadline keeps running: an answer that comes too late
913/// counts as a missed deadline, and [`resolve`](background::resolve) then returns `false`. [`run`](background::run)
914/// also works without [`defer`](background::defer), from any hook, for work nobody waits for.
915pub mod background {
916 use crate::Action;
917 use crate::bindings::rift::plugin::background as host;
918 pub use host::{defer, is_worker, run};
919
920 /// Answer a deferred hook with the ticket [`defer`] returned. Returns
921 /// `false` when nobody waits for the ticket any more: the deadline
922 /// passed, or it was answered already.
923 pub fn resolve(ticket: u64, action: Action) -> Result<bool, String> {
924 host::resolve(ticket, &action)
925 }
926}
927
928/// Plugin metadata and clocks. `api` is the `rift:plugin` version the host
929/// implements, such as `0.2.0`.
930pub mod meta {
931 pub use crate::bindings::rift::plugin::meta::{
932 api, clock, config_dir, data_dir, name, time, version,
933 };
934}
935
936/// Rift's log stream, prefixed with the plugin name by the host.
937///
938/// The SDK installs a [`::log`] logger before [`Plugin::init`]
939/// (and before [`Plugin::work_init`] on a worker),
940/// so the `log` crate's macros work as usual. They are re-exported here for
941/// plugins that do not depend on `log` themselves:
942/// `rift_plugin::log::info!("ready")`.
943pub mod log {
944 pub use crate::bindings::rift::plugin::log::log;
945 #[doc(no_inline)]
946 pub use ::log::{debug, error, info, trace, warn};
947
948 use crate::Level;
949
950 struct HostLogger;
951
952 static LOGGER: HostLogger = HostLogger;
953
954 impl ::log::Log for HostLogger {
955 fn enabled(&self, _metadata: &::log::Metadata) -> bool {
956 true
957 }
958
959 fn log(&self, record: &::log::Record) {
960 let level = match record.level() {
961 ::log::Level::Trace => Level::Trace,
962 ::log::Level::Debug => Level::Debug,
963 ::log::Level::Info => Level::Info,
964 ::log::Level::Warn => Level::Warn,
965 ::log::Level::Error => Level::Error,
966 };
967 match record.args().as_str() {
968 Some(message) => log(level, message),
969 None => log(level, &record.args().to_string()),
970 }
971 }
972
973 fn flush(&self) {}
974 }
975
976 /// Installs the host logger once. Later calls do nothing, so a plugin may
977 /// lower the level with `log::set_max_level` in `init`.
978 pub(crate) fn install() {
979 if ::log::set_logger(&LOGGER).is_ok() {
980 ::log::set_max_level(::log::LevelFilter::Trace);
981 }
982 }
983}
984
985/// Builders for JSON text: each converts into a [`Text::Json`](crate::Text),
986/// so it can be passed wherever text is shown.
987///
988/// - [`text::mini`] writes Rift markup as `{"mini": "...", "args": {...}}`; Rift
989/// turns it into a component. See `docs/text.md` for the tags.
990/// - [`text::legacy`] writes `&c` codes as `{"legacy": "...", "marker": "&"}`.
991/// - [`text::Component`] builds a JSON text component field by field, with the
992/// 1.21.5 spellings `click_event` and `hover_event` (Rift rewrites them
993/// for older clients).
994///
995/// Every builder converts into [`Text`] and into a
996/// [`serde_json::Value`] for use inside other components, and implements
997/// [`Display`](std::fmt::Display) (its JSON text). A bare string is never
998/// read as markup, so player input passed as an argument or as text cannot
999/// add tags or click events.
1000pub mod text {
1001 use serde_json::{Map, Value};
1002 use std::fmt;
1003
1004 /// Rift markup, such as `<gold>Hi <player>!`. Add arguments with
1005 /// [`Mini::arg`] and [`Mini::arg_component`].
1006 pub fn mini(markup: impl Into<String>) -> Mini {
1007 Mini {
1008 markup: markup.into(),
1009 args: Map::new(),
1010 }
1011 }
1012
1013 /// Markup with named arguments; see [`mini`].
1014 #[derive(Debug, Clone, PartialEq)]
1015 pub struct Mini {
1016 markup: String,
1017 args: Map<String, Value>,
1018 }
1019
1020 impl Mini {
1021 /// `<name>` shows `text` exactly as written: it is never parsed, so
1022 /// a player name cannot add tags.
1023 pub fn arg(mut self, name: impl Into<String>, text: impl Into<String>) -> Self {
1024 self.args.insert(name.into(), Value::String(text.into()));
1025 self
1026 }
1027
1028 /// `<name>` shows a component with its own formatting, such as a
1029 /// [`Component`], a [`legacy`] object or a [`serde_json::Value`].
1030 pub fn arg_component(
1031 mut self,
1032 name: impl Into<String>,
1033 component: impl Into<Value>,
1034 ) -> Self {
1035 self.args.insert(name.into(), component.into());
1036 self
1037 }
1038
1039 /// The authoring object as JSON.
1040 pub fn to_value(&self) -> Value {
1041 let mut object = Map::new();
1042 object.insert("mini".into(), Value::String(self.markup.clone()));
1043 if !self.args.is_empty() {
1044 object.insert("args".into(), Value::Object(self.args.clone()));
1045 }
1046 Value::Object(object)
1047 }
1048 }
1049
1050 /// Legacy formatting codes with the `&` marker, such as `&cRed &lbold`;
1051 /// `&#rrggbb` writes a hex colour. Use [`Legacy::marker`] for another
1052 /// marker.
1053 pub fn legacy(text: impl Into<String>) -> Legacy {
1054 Legacy {
1055 text: text.into(),
1056 marker: '&',
1057 }
1058 }
1059
1060 /// Legacy codes; see [`legacy`].
1061 #[derive(Debug, Clone, PartialEq)]
1062 pub struct Legacy {
1063 text: String,
1064 marker: char,
1065 }
1066
1067 impl Legacy {
1068 /// The character that starts a code, such as `§`; one symbol, not a
1069 /// letter, digit or space.
1070 pub fn marker(mut self, marker: char) -> Self {
1071 self.marker = marker;
1072 self
1073 }
1074
1075 /// The authoring object as JSON.
1076 pub fn to_value(&self) -> Value {
1077 let mut object = Map::new();
1078 object.insert("legacy".into(), Value::String(self.text.clone()));
1079 object.insert("marker".into(), Value::String(self.marker.to_string()));
1080 Value::Object(object)
1081 }
1082 }
1083
1084 /// A JSON text component, built field by field.
1085 ///
1086 /// ```
1087 /// use rift_plugin::text::Component;
1088 ///
1089 /// let invite = Component::text("Alex invited you. ")
1090 /// .extra(Component::text("[Accept]").color("green").bold().run_command("/party accept Alex"));
1091 /// assert_eq!(
1092 /// serde_json::Value::from(invite),
1093 /// serde_json::json!({
1094 /// "text": "Alex invited you. ",
1095 /// "extra": [{
1096 /// "text": "[Accept]", "color": "green", "bold": true,
1097 /// "click_event": {"action": "run_command", "command": "/party accept Alex"},
1098 /// }],
1099 /// })
1100 /// );
1101 /// ```
1102 #[derive(Debug, Clone, PartialEq)]
1103 pub struct Component(Map<String, Value>);
1104
1105 impl Component {
1106 /// A component showing `text`.
1107 pub fn text(text: impl Into<String>) -> Self {
1108 let mut fields = Map::new();
1109 fields.insert("text".into(), Value::String(text.into()));
1110 Self(fields)
1111 }
1112
1113 /// A colour name such as `red` or `#ff8800`.
1114 pub fn color(self, color: impl Into<String>) -> Self {
1115 self.field("color", color.into())
1116 }
1117
1118 /// Bold text.
1119 pub fn bold(self) -> Self {
1120 self.field("bold", true)
1121 }
1122
1123 /// Italic text.
1124 pub fn italic(self) -> Self {
1125 self.field("italic", true)
1126 }
1127
1128 /// Underlined text.
1129 pub fn underlined(self) -> Self {
1130 self.field("underlined", true)
1131 }
1132
1133 /// Struck-through text.
1134 pub fn strikethrough(self) -> Self {
1135 self.field("strikethrough", true)
1136 }
1137
1138 /// Obfuscated (scrambling) text.
1139 pub fn obfuscated(self) -> Self {
1140 self.field("obfuscated", true)
1141 }
1142
1143 fn click(self, action: &str, key: &str, value: impl Into<String>) -> Self {
1144 let mut click = Map::new();
1145 click.insert("action".into(), Value::String(action.into()));
1146 click.insert(key.into(), Value::String(value.into()));
1147 self.field("click_event", Value::Object(click))
1148 }
1149
1150 /// A click runs `command` (with its leading `/`) as the player.
1151 pub fn run_command(self, command: impl Into<String>) -> Self {
1152 self.click("run_command", "command", command)
1153 }
1154
1155 /// A click puts `command` into the player's chat box.
1156 pub fn suggest_command(self, command: impl Into<String>) -> Self {
1157 self.click("suggest_command", "command", command)
1158 }
1159
1160 /// A click opens `url` (`http` or `https`) after a confirmation.
1161 pub fn open_url(self, url: impl Into<String>) -> Self {
1162 self.click("open_url", "url", url)
1163 }
1164
1165 /// A click copies `text` to the clipboard.
1166 pub fn copy_to_clipboard(self, text: impl Into<String>) -> Self {
1167 self.click("copy_to_clipboard", "value", text)
1168 }
1169
1170 /// Hover text shown as written.
1171 pub fn hover_text(self, text: impl Into<String>) -> Self {
1172 self.hover(Value::String(text.into()))
1173 }
1174
1175 /// Hover text as a component, such as another [`Component`] or a
1176 /// [`mini`] object.
1177 pub fn hover(self, component: impl Into<Value>) -> Self {
1178 let mut hover = Map::new();
1179 hover.insert("action".into(), Value::String("show_text".into()));
1180 hover.insert("value".into(), component.into());
1181 self.field("hover_event", Value::Object(hover))
1182 }
1183
1184 /// Text inserted into the chat box on shift-click.
1185 pub fn insertion(self, text: impl Into<String>) -> Self {
1186 self.field("insertion", text.into())
1187 }
1188
1189 /// Appends a child: a [`Component`], an authoring object or a string
1190 /// shown as written. Children inherit this component's style.
1191 pub fn extra(mut self, child: impl Into<Value>) -> Self {
1192 let child = child.into();
1193 match self.0.get_mut("extra") {
1194 Some(Value::Array(children)) => children.push(child),
1195 _ => {
1196 self.0.insert("extra".into(), Value::Array(vec![child]));
1197 }
1198 }
1199 self
1200 }
1201
1202 /// Sets any other field, such as `font`, `shadow_color` or
1203 /// `"italic": false`.
1204 pub fn field(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
1205 self.0.insert(key.into(), value.into());
1206 self
1207 }
1208
1209 /// The component as JSON.
1210 pub fn to_value(&self) -> Value {
1211 Value::Object(self.0.clone())
1212 }
1213 }
1214
1215 macro_rules! json_text {
1216 ($($builder:ty),*) => {$(
1217 impl fmt::Display for $builder {
1218 /// The JSON text.
1219 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1220 fmt::Display::fmt(&self.to_value(), f)
1221 }
1222 }
1223
1224 impl From<$builder> for Value {
1225 fn from(builder: $builder) -> Value {
1226 builder.to_value()
1227 }
1228 }
1229
1230 impl From<$builder> for crate::Text {
1231 /// [`Text::Json`](crate::Text::Json) with the builder's JSON.
1232 fn from(builder: $builder) -> crate::Text {
1233 crate::Text::Json(builder.to_string())
1234 }
1235 }
1236
1237 impl From<&$builder> for crate::Text {
1238 /// [`Text::Json`](crate::Text::Json) with the builder's JSON.
1239 fn from(builder: &$builder) -> crate::Text {
1240 crate::Text::Json(builder.to_string())
1241 }
1242 }
1243 )*};
1244 }
1245
1246 json_text!(Mini, Legacy, Component);
1247}
1248
1249#[doc(hidden)]
1250pub mod __private {
1251 use crate::bindings::exports::rift::plugin::handler::Guest;
1252 use crate::*;
1253 use std::any::Any;
1254 use std::cell::RefCell;
1255 use std::marker::PhantomData;
1256
1257 thread_local! {
1258 static INSTANCE: RefCell<Option<Box<dyn Any>>> = const { RefCell::new(None) };
1259 }
1260
1261 /// Runs `f` on the instance, creating it first when there is none: a
1262 /// background worker never sees `init`.
1263 fn with<P: Plugin, R>(f: impl FnOnce(&mut P) -> R) -> R {
1264 INSTANCE.with(|slot| {
1265 let mut slot = slot.borrow_mut();
1266 let plugin = slot
1267 .get_or_insert_with(|| Box::new(P::default()))
1268 .downcast_mut::<P>()
1269 .expect("one plugin type per component");
1270 f(plugin)
1271 })
1272 }
1273
1274 /// Connects a [`Plugin`] to the generated `Guest` trait.
1275 pub struct Adapter<P>(PhantomData<P>);
1276
1277 impl<P: Plugin> Guest for Adapter<P> {
1278 fn init(config_json: String) -> Result<Registration, String> {
1279 crate::log::install();
1280 INSTANCE.with(|slot| *slot.borrow_mut() = Some(Box::new(P::default())));
1281 with(|p: &mut P| p.init(&config_json))
1282 }
1283
1284 fn reload(config_json: String) -> Result<(), String> {
1285 with(|p: &mut P| p.reload(&config_json))
1286 }
1287
1288 fn shutdown(reason: String) {
1289 with(|p: &mut P| p.shutdown(&reason));
1290 let instance = INSTANCE.with(|slot| slot.borrow_mut().take());
1291 drop(instance);
1292 }
1293
1294 fn route(connection: Connection) -> RouteDecision {
1295 with(|p: &mut P| p.route(connection))
1296 }
1297
1298 fn login(ctx: Context) -> Action {
1299 with(|p: &mut P| p.login(ctx))
1300 }
1301
1302 fn initial_server(ctx: Context) -> Action {
1303 with(|p: &mut P| p.initial_server(ctx))
1304 }
1305
1306 fn before_transfer(ctx: Context) -> Action {
1307 with(|p: &mut P| p.before_transfer(ctx))
1308 }
1309
1310 fn command(ctx: Context) -> Action {
1311 with(|p: &mut P| p.command(ctx))
1312 }
1313
1314 fn suggest(ctx: Context) -> Vec<Suggestion> {
1315 with(|p: &mut P| p.suggest(ctx))
1316 }
1317
1318 fn status(ping: Ping) -> Option<StatusOverride> {
1319 with(|p: &mut P| p.status(ping))
1320 }
1321
1322 fn http(request: HttpRequest) -> Option<HttpResponse> {
1323 with(|p: &mut P| p.http(request))
1324 }
1325
1326 fn chat(attempt: ChatAttempt) -> Action {
1327 with(|p: &mut P| p.chat(attempt))
1328 }
1329
1330 fn kick(ctx: Context) -> Action {
1331 with(|p: &mut P| p.kick(ctx))
1332 }
1333
1334 fn join(ctx: Context) {
1335 with(|p: &mut P| p.join(ctx))
1336 }
1337
1338 fn after_transfer(ctx: Context) {
1339 with(|p: &mut P| p.after_transfer(ctx))
1340 }
1341
1342 fn disconnect(ctx: Context) {
1343 with(|p: &mut P| p.disconnect(ctx))
1344 }
1345
1346 fn message(message: BusMessage) {
1347 with(|p: &mut P| p.message(message))
1348 }
1349
1350 fn timer(handle: u64) {
1351 with(|p: &mut P| p.timer(handle))
1352 }
1353
1354 fn event(event: RiftEvent) {
1355 with(|p: &mut P| p.event(event))
1356 }
1357
1358 fn dialog(response: DialogResponse) {
1359 with(|p: &mut P| p.dialog(response))
1360 }
1361
1362 fn job_done(handle: u64, outcome: Result<Vec<u8>, String>) {
1363 with(|p: &mut P| p.job_done(handle, outcome))
1364 }
1365
1366 // A worker instance: `init` never ran here, so the logger is
1367 // installed and the instance created on the first of these calls.
1368 fn work_init(config_json: String) -> Result<(), String> {
1369 crate::log::install();
1370 with(|p: &mut P| p.work_init(&config_json))
1371 }
1372
1373 fn work(payload: Vec<u8>) -> Result<Vec<u8>, String> {
1374 crate::log::install();
1375 with(|p: &mut P| p.work(payload))
1376 }
1377 }
1378}
1379
1380#[cfg(test)]
1381mod tests {
1382 use super::*;
1383 use serde_json::json;
1384
1385 #[test]
1386 fn builders_fill_the_wit_records() {
1387 let registration = Registration::new()
1388 .command(
1389 CommandSpec::new("hello")
1390 .alias("hi")
1391 .aliases(["hey", "yo"])
1392 .permission("hello.use")
1393 .suggest(SuggestKind::Players)
1394 .console(),
1395 )
1396 .command(CommandSpec::new("plain"))
1397 .hooks([Hook::Login, Hook::Command])
1398 .hook(Hook::Chat);
1399 let hello = ®istration.commands[0];
1400 assert_eq!(hello.name, "hello");
1401 assert_eq!(hello.aliases, ["hi", "hey", "yo"]);
1402 assert_eq!(hello.permission, "hello.use");
1403 assert!(matches!(hello.suggest, SuggestKind::Players));
1404 assert!(hello.console);
1405 let plain = ®istration.commands[1];
1406 assert!(plain.aliases.is_empty() && plain.permission.is_empty() && !plain.console);
1407 assert!(matches!(plain.suggest, SuggestKind::None));
1408 assert_eq!(
1409 registration.hooks,
1410 vec![Hook::Login, Hook::Command, Hook::Chat]
1411 );
1412 assert!(Registration::default().commands.is_empty());
1413 assert!(Registration::default().hooks.is_empty());
1414 }
1415
1416 #[test]
1417 fn strings_are_plain_and_builders_are_json() {
1418 assert_eq!(Text::from("<red>hi"), Text::Plain("<red>hi".into()));
1419 assert_eq!(Text::from(String::from("a")), Text::plain("a"));
1420 assert_eq!(Text::from(&String::from("a")), Text::plain("a"));
1421 assert_eq!(
1422 Text::from(text::mini("<red>x")),
1423 Text::Json(r#"{"mini":"<red>x"}"#.into())
1424 );
1425 assert_eq!(
1426 Text::from(text::legacy("&cx")),
1427 Text::json(r#"{"legacy":"&cx","marker":"&"}"#)
1428 );
1429 assert_eq!(
1430 Text::from(&text::Component::text("x").bold()),
1431 Text::json(r#"{"bold":true,"text":"x"}"#)
1432 );
1433 assert_eq!(
1434 Text::from(json!({"text": "x"})),
1435 Text::json(r#"{"text":"x"}"#)
1436 );
1437 // A JSON string is still JSON text: shown as written by the host.
1438 assert_eq!(Text::from(&json!("a\"b")), Text::json(r#""a\"b""#));
1439 assert_ne!(Text::plain("x"), Text::json("x"));
1440 }
1441
1442 #[test]
1443 fn actions_take_any_text() {
1444 assert_eq!(Action::deny("no"), Action::Deny(Text::Plain("no".into())));
1445 assert_eq!(
1446 Action::deny(json!({"text": "Banned", "color": "red"})),
1447 Action::Deny(Text::Json(r#"{"color":"red","text":"Banned"}"#.into()))
1448 );
1449 assert_eq!(
1450 Action::message(format!("Hi, {}!", "Alex")),
1451 Action::Message(Text::Plain("Hi, Alex!".into()))
1452 );
1453 assert_eq!(
1454 Action::message(text::mini("<gold>Hi")),
1455 Action::Message(Text::Json(r#"{"mini":"<gold>Hi"}"#.into()))
1456 );
1457 assert_eq!(Action::server("lobby"), Action::Server("lobby".into()));
1458 assert_eq!(Action::queue("game"), Action::Queue("game".into()));
1459 assert_ne!(Action::server("a"), Action::queue("a"));
1460 assert_ne!(Action::deny("a"), Action::message("a"));
1461 assert_ne!(Action::Continue, Action::LeaveQueue);
1462 }
1463
1464 #[test]
1465 fn titles_suggestions_and_status_take_any_text() {
1466 let title = TitleSpec::new(text::mini("<red>T"))
1467 .subtitle("sub")
1468 .times(1, 2, 3);
1469 assert_eq!(title.title, Some(Text::json(r#"{"mini":"<red>T"}"#)));
1470 assert_eq!(title.subtitle, Some(Text::plain("sub")));
1471 assert_eq!((title.fade_in, title.stay, title.fade_out), (1, 2, 3));
1472 let plain = TitleSpec::new("T");
1473 assert_eq!(plain.subtitle, None);
1474 assert_eq!((plain.fade_in, plain.stay, plain.fade_out), (10, 70, 20));
1475 let subtitle = TitleSpec::subtitle_only("only").times(0, 20, 0);
1476 assert_eq!(subtitle.title, None);
1477 assert_eq!(subtitle.subtitle, Some(Text::plain("only")));
1478 assert_eq!(subtitle.stay, 20);
1479
1480 assert_eq!(Suggestion::new("a").tooltip, None);
1481 assert_eq!(
1482 Suggestion::new("a").tooltip("tip").tooltip,
1483 Some(Text::plain("tip"))
1484 );
1485 assert_eq!(
1486 Suggestion::new("a").tooltip(text::legacy("&7tip")).tooltip,
1487 Some(Text::json(r#"{"legacy":"&7tip","marker":"&"}"#))
1488 );
1489
1490 let status = StatusOverride {
1491 version: Some("Rift".into()),
1492 sample: Some(vec!["one".into(), text::mini("<green>two").into()]),
1493 ..Default::default()
1494 };
1495 assert_eq!(status.version, Some(Text::plain("Rift")));
1496 assert_eq!(status.sample.map(|lines| lines.len()), Some(2));
1497 let nothing = StatusOverride::default();
1498 assert!(nothing.motd.is_none() && nothing.version.is_none() && nothing.sample.is_none());
1499 assert!(nothing.protocol.is_none() && nothing.online.is_none());
1500 assert!(nothing.max_players.is_none() && nothing.favicon.is_none());
1501 }
1502
1503 #[test]
1504 fn hooks_have_defaults() {
1505 #[derive(Default)]
1506 struct Nothing;
1507 impl Plugin for Nothing {}
1508 let mut plugin = Nothing;
1509 let registration = plugin.init("{}").unwrap();
1510 assert!(registration.commands.is_empty() && registration.hooks.is_empty());
1511 assert_eq!(plugin.work_init("{}"), Ok(()));
1512 assert_eq!(
1513 plugin.work(b"job".to_vec()),
1514 Err("this plugin has no background work".to_owned())
1515 );
1516 plugin.job_done(1, Ok(Vec::new()));
1517 plugin.job_done(2, Err("failed".into()));
1518 }
1519
1520 #[test]
1521 fn text_builders_write_escaped_json() {
1522 let welcome = text::mini("<gold>Hi \"<player>\"</gold>")
1523 .arg("player", "St<b>eve\n")
1524 .arg_component("rank", text::legacy("&cAdmin"));
1525 let value: serde_json::Value = serde_json::from_str(&welcome.to_string()).unwrap();
1526 assert_eq!(
1527 value,
1528 json!({
1529 "mini": "<gold>Hi \"<player>\"</gold>",
1530 "args": {"player": "St<b>eve\n", "rank": {"legacy": "&cAdmin", "marker": "&"}},
1531 })
1532 );
1533 assert_eq!(text::mini("<red>x").to_string(), r#"{"mini":"<red>x"}"#);
1534 assert_eq!(
1535 text::legacy("§aHi").marker('§').to_string(),
1536 r#"{"legacy":"§aHi","marker":"§"}"#
1537 );
1538 let component = text::Component::text("[a\"b]")
1539 .color("#ff8800")
1540 .bold()
1541 .italic()
1542 .underlined()
1543 .strikethrough()
1544 .obfuscated()
1545 .suggest_command("/msg ")
1546 .hover(text::mini("<green>Hover"))
1547 .insertion("ins")
1548 .extra("plain")
1549 .extra(text::Component::text("child").open_url("https://example.org"))
1550 .field("font", "minecraft:uniform");
1551 let value: serde_json::Value = serde_json::from_str(&component.to_string()).unwrap();
1552 assert_eq!(
1553 value,
1554 json!({
1555 "text": "[a\"b]", "color": "#ff8800", "bold": true, "italic": true,
1556 "underlined": true, "strikethrough": true, "obfuscated": true,
1557 "click_event": {"action": "suggest_command", "command": "/msg "},
1558 "hover_event": {"action": "show_text", "value": {"mini": "<green>Hover"}},
1559 "insertion": "ins",
1560 "extra": ["plain", {"text": "child", "click_event": {"action": "open_url", "url": "https://example.org"}}],
1561 "font": "minecraft:uniform",
1562 })
1563 );
1564 let value: serde_json::Value = text::Component::text("c")
1565 .copy_to_clipboard("v")
1566 .run_command("/x")
1567 .hover_text("t")
1568 .into();
1569 assert_eq!(
1570 value["click_event"],
1571 json!({"action": "run_command", "command": "/x"})
1572 );
1573 assert_eq!(
1574 value["hover_event"],
1575 json!({"action": "show_text", "value": "t"})
1576 );
1577 }
1578
1579 #[test]
1580 fn config_decodes_json() {
1581 #[derive(serde::Deserialize)]
1582 struct Config {
1583 #[serde(default)]
1584 deny: Option<String>,
1585 }
1586 let config: Config = config(r#"{"deny":"Mallory"}"#).unwrap();
1587 assert_eq!(config.deny.as_deref(), Some("Mallory"));
1588 let empty: Config = super::config("{}").unwrap();
1589 assert_eq!(empty.deny, None);
1590 assert!(super::config::<Config>("nope").is_err());
1591 }
1592}