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}