Skip to main content

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