veloren_common/
cmd.rs

1use crate::{
2    assets::{self, AssetCombined, Concatenate},
3    combat::GroupTarget,
4    comp::{
5        self, AdminRole as Role, Skill, aura::AuraKindVariant, buff::BuffKind,
6        inventory::item::try_all_item_defs,
7    },
8    generation::try_all_entity_configs,
9    npc, outcome,
10    recipe::RecipeBookManifest,
11    terrain,
12};
13use common_i18n::Content;
14use hashbrown::HashMap;
15use lazy_static::lazy_static;
16use serde::{Deserialize, Serialize};
17use std::{
18    fmt::{self, Display},
19    str::FromStr,
20};
21use strum::{AsRefStr, EnumIter, EnumString, IntoEnumIterator, VariantNames};
22use tracing::warn;
23
24/// Struct representing a command that a user can run from server chat.
25pub struct ChatCommandData {
26    /// A list of arguments useful for both tab completion and parsing
27    pub args: Vec<ArgumentSpec>,
28    /// The i18n content for the description of the command
29    pub description: Content,
30    /// Whether the command requires administrator permissions.
31    pub needs_role: Option<Role>,
32}
33
34impl ChatCommandData {
35    pub fn new(args: Vec<ArgumentSpec>, description: Content, needs_role: Option<Role>) -> Self {
36        Self {
37            args,
38            description,
39            needs_role,
40        }
41    }
42}
43
44#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)]
45pub enum KitSpec {
46    Item(String),
47    ModularWeapon {
48        tool: comp::tool::ToolKind,
49        material: comp::item::Material,
50    },
51}
52#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)]
53pub struct KitManifest(pub HashMap<String, Vec<(KitSpec, u32)>>);
54impl assets::Asset for KitManifest {
55    type Loader = assets::RonLoader;
56
57    const EXTENSION: &'static str = "ron";
58}
59impl Concatenate for KitManifest {
60    fn concatenate(self, b: Self) -> Self { KitManifest(self.0.concatenate(b.0)) }
61}
62
63#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)]
64pub struct SkillPresetManifest(pub HashMap<String, Vec<(Skill, u8)>>);
65impl assets::Asset for SkillPresetManifest {
66    type Loader = assets::RonLoader;
67
68    const EXTENSION: &'static str = "ron";
69}
70impl Concatenate for SkillPresetManifest {
71    fn concatenate(self, b: Self) -> Self { SkillPresetManifest(self.0.concatenate(b.0)) }
72}
73
74pub const KIT_MANIFEST_PATH: &str = "server.manifests.kits";
75pub const PRESET_MANIFEST_PATH: &str = "server.manifests.presets";
76
77/// Enum for all possible area types
78#[derive(Debug, Clone, EnumIter, EnumString, AsRefStr)]
79pub enum AreaKind {
80    #[strum(serialize = "build")]
81    Build,
82    #[strum(serialize = "no_durability")]
83    NoDurability,
84}
85
86lazy_static! {
87    static ref ALIGNMENTS: Vec<String> = ["wild", "enemy", "npc", "pet"]
88        .iter()
89        .map(|s| s.to_string())
90        .collect();
91    static ref SKILL_TREES: Vec<String> = ["general", "sword", "axe", "hammer", "bow", "staff", "sceptre", "mining"]
92        .iter()
93        .map(|s| s.to_string())
94        .collect();
95    /// TODO: Make this use hot-reloading
96    static ref ENTITIES: Vec<String> = {
97        let npc_names = &*npc::NPC_NAMES.read();
98        let mut souls = Vec::new();
99        macro_rules! push_souls {
100            ($species:tt) => {
101                for s in comp::$species::ALL_SPECIES.iter() {
102                    souls.push(npc_names.$species.species[s].keyword.clone())
103                }
104            };
105            ($base:tt, $($species:tt),+ $(,)?) => {
106                push_souls!($base);
107                push_souls!($($species),+);
108            }
109        }
110        for npc in npc::ALL_NPCS.iter() {
111            souls.push(npc_names[*npc].keyword.clone())
112        }
113
114        // See `[AllBodies](crate::comp::body::AllBodies)`
115        push_souls!(
116            humanoid,
117            quadruped_small,
118            quadruped_medium,
119            quadruped_low,
120            bird_medium,
121            bird_large,
122            fish_small,
123            fish_medium,
124            biped_small,
125            biped_large,
126            theropod,
127            dragon,
128            golem,
129            arthropod,
130            crustacean,
131        );
132
133        souls
134    };
135    static ref AREA_KINDS: Vec<String> = AreaKind::iter().map(|kind| kind.as_ref().to_string()).collect();
136    static ref OBJECTS: Vec<String> = comp::object::ALL_OBJECTS
137        .iter()
138        .map(|o| o.to_string().to_string())
139        .collect();
140    static ref RECIPES: Vec<String> = {
141        let rbm = RecipeBookManifest::load().cloned();
142        rbm.keys().cloned().collect::<Vec<String>>()
143    };
144    static ref TIMES: Vec<String> = [
145        "midnight", "night", "dawn", "morning", "day", "noon", "dusk"
146    ]
147    .iter()
148    .map(|s| s.to_string())
149    .collect();
150
151    static ref WEATHERS: Vec<String> = [
152        "clear", "cloudy", "rain", "wind", "storm"
153    ]
154    .iter()
155    .map(|s| s.to_string())
156    .collect();
157
158    pub static ref BUFF_PARSER: HashMap<String, BuffKind> = {
159        let string_from_buff = |kind| match kind {
160            BuffKind::Burning => "burning",
161            BuffKind::Regeneration => "regeneration",
162            BuffKind::Saturation => "saturation",
163            BuffKind::Bleeding => "bleeding",
164            BuffKind::Cursed => "cursed",
165            BuffKind::Potion => "potion",
166            BuffKind::Agility => "agility",
167            BuffKind::CampfireHeal => "campfire_heal",
168            BuffKind::EnergyRegen => "energy_regen",
169            BuffKind::IncreaseMaxEnergy => "increase_max_energy",
170            BuffKind::IncreaseMaxHealth => "increase_max_health",
171            BuffKind::Invulnerability => "invulnerability",
172            BuffKind::ProtectingWard => "protecting_ward",
173            BuffKind::Frenzied => "frenzied",
174            BuffKind::Crippled => "crippled",
175            BuffKind::Frozen => "frozen",
176            BuffKind::Wet => "wet",
177            BuffKind::Ensnared => "ensnared",
178            BuffKind::Poisoned => "poisoned",
179            BuffKind::Hastened => "hastened",
180            BuffKind::Fortitude => "fortitude",
181            BuffKind::Parried => "parried",
182            BuffKind::PotionSickness => "potion_sickness",
183            BuffKind::Reckless => "reckless",
184            BuffKind::Polymorphed => "polymorphed",
185            BuffKind::Flame => "flame",
186            BuffKind::Frigid => "frigid",
187            BuffKind::Lifesteal => "lifesteal",
188            // BuffKind::SalamanderAspect => "salamander_aspect",
189            BuffKind::ImminentCritical => "imminent_critical",
190            BuffKind::Fury => "fury",
191            BuffKind::Sunderer => "sunderer",
192            BuffKind::Defiance => "defiance",
193            BuffKind::Bloodfeast => "bloodfeast",
194            BuffKind::Berserk => "berserk",
195            BuffKind::Heatstroke => "heatstroke",
196            BuffKind::ScornfulTaunt => "scornful_taunt",
197            BuffKind::Rooted => "rooted",
198            BuffKind::Winded => "winded",
199            BuffKind::Concussion => "concussion",
200            BuffKind::Staggered => "staggered",
201            BuffKind::Tenacity => "tenacity",
202            BuffKind::Resilience => "resilience",
203        };
204        let mut buff_parser = HashMap::new();
205        for kind in BuffKind::iter() {
206            buff_parser.insert(string_from_buff(kind).to_string(), kind);
207        }
208        buff_parser
209    };
210
211    pub static ref BUFF_PACK: Vec<String> = {
212        let mut buff_pack: Vec<_> = BUFF_PARSER.keys().cloned().collect();
213        // Remove invulnerability as it removes debuffs
214        buff_pack.retain(|kind| kind != "invulnerability");
215        buff_pack
216    };
217
218    static ref BUFFS: Vec<String> = {
219        let mut buff_pack: Vec<String> = BUFF_PARSER.keys().cloned().collect();
220
221        // Add `all` as valid command
222        buff_pack.push("all".to_owned());
223        buff_pack
224    };
225
226    static ref BLOCK_KINDS: Vec<String> = terrain::block::BlockKind::iter()
227        .map(|bk| bk.to_string())
228        .collect();
229
230    static ref SPRITE_KINDS: Vec<String> = terrain::sprite::SPRITE_KINDS
231        .keys()
232        .cloned()
233        .collect();
234
235    static ref OUTCOME_KINDS: Vec<String> = outcome::Outcome::VARIANTS
236        .iter()
237        .map(|s| s.to_string())
238        .collect();
239
240    static ref ROLES: Vec<String> = ["admin", "moderator"].iter().copied().map(Into::into).collect();
241
242    /// List of item's asset specifiers. Useful for tab completing.
243    /// Doesn't cover all items (like modulars), includes "fake" items like
244    /// TagExamples.
245    pub static ref ITEM_SPECS: Vec<String> = {
246        let mut items = try_all_item_defs()
247            .unwrap_or_else(|e| {
248                warn!(?e, "Failed to load item specifiers");
249                Vec::new()
250            });
251        items.sort();
252        items
253    };
254
255    /// List of all entity configs. Useful for tab completing
256    pub static ref ENTITY_CONFIGS: Vec<String> = {
257        try_all_entity_configs()
258            .unwrap_or_else(|e| {
259                warn!(?e, "Failed to load entity configs");
260                Vec::new()
261            })
262    };
263
264    pub static ref KITS: Vec<String> = {
265        let mut kits = if let Ok(kits) = KitManifest::load_and_combine_static(KIT_MANIFEST_PATH) {
266            let mut kits = kits.read().0.keys().cloned().collect::<Vec<String>>();
267            kits.sort();
268            kits
269        } else {
270            Vec::new()
271        };
272        kits.push("all".to_owned());
273
274        kits
275    };
276
277    static ref PRESETS: HashMap<String, Vec<(Skill, u8)>> = {
278        if let Ok(presets) = SkillPresetManifest::load_and_combine_static(PRESET_MANIFEST_PATH) {
279            presets.read().0.clone()
280        } else {
281            warn!("Error while loading presets");
282            HashMap::new()
283        }
284    };
285
286    static ref PRESET_LIST: Vec<String> = {
287        let mut preset_list: Vec<String> = PRESETS.keys().cloned().collect();
288        preset_list.push("clear".to_owned());
289
290        preset_list
291    };
292}
293
294pub enum EntityTarget {
295    Player(String),
296    RtsimNpc(u64),
297    Uid(crate::uid::Uid),
298}
299
300impl FromStr for EntityTarget {
301    type Err = String;
302
303    fn from_str(s: &str) -> Result<Self, Self::Err> {
304        // NOTE: `@` is an invalid character in usernames, so we can use it here.
305        if let Some((spec, data)) = s.split_once('@') {
306            match spec {
307                "rtsim" => Ok(EntityTarget::RtsimNpc(u64::from_str(data).map_err(
308                    |_| format!("Expected a valid number after 'rtsim@' but found {data}."),
309                )?)),
310                "uid" => Ok(EntityTarget::Uid(
311                    u64::from_str(data)
312                        .map_err(|_| {
313                            format!("Expected a valid number after 'uid@' but found {data}.")
314                        })?
315                        .into(),
316                )),
317                _ => Err(format!(
318                    "Expected either 'rtsim' or 'uid' before '@' but found '{spec}'"
319                )),
320            }
321        } else {
322            Ok(EntityTarget::Player(s.to_string()))
323        }
324    }
325}
326
327// Please keep this sorted alphabetically :-)
328#[derive(Copy, Clone, strum::EnumIter)]
329pub enum ServerChatCommand {
330    Adminify,
331    Airship,
332    Alias,
333    AreaAdd,
334    AreaList,
335    AreaRemove,
336    Aura,
337    Ban,
338    BanIp,
339    BattleMode,
340    BattleModeForce,
341    Body,
342    Buff,
343    Build,
344    Campfire,
345    ClearPersistedTerrain,
346    CreateLocation,
347    DeathEffect,
348    DebugColumn,
349    DebugWays,
350    DeleteLocation,
351    DestroyTethers,
352    DisconnectAllPlayers,
353    Dismount,
354    DropAll,
355    Dummy,
356    Explosion,
357    Faction,
358    GiveItem,
359    Goto,
360    Group,
361    GroupInvite,
362    GroupKick,
363    GroupLeave,
364    GroupPromote,
365    Health,
366    IntoNpc,
367    JoinFaction,
368    Jump,
369    Kick,
370    Kill,
371    KillNpcs,
372    Kit,
373    Lantern,
374    Light,
375    Lightning,
376    Location,
377    MakeBlock,
378    MakeNpc,
379    MakeSprite,
380    MakeVolume,
381    Motd,
382    Mount,
383    Object,
384    Outcome,
385    PermitBuild,
386    Players,
387    Portal,
388    Region,
389    ReloadChunks,
390    RemoveLights,
391    RepairEquipment,
392    ResetRecipes,
393    Respawn,
394    RevokeBuild,
395    RevokeBuildAll,
396    RtsimChunk,
397    RtsimInfo,
398    RtsimNpc,
399    RtsimPurge,
400    RtsimTp,
401    Safezone,
402    Say,
403    Scale,
404    ServerPhysics,
405    SetMotd,
406    Ship,
407    Site,
408    SkillPoint,
409    SkillPreset,
410    Spawn,
411    Spot,
412    Sudo,
413    Tell,
414    Tether,
415    Time,
416    TimeScale,
417    Tp,
418    Unban,
419    UnbanIp,
420    Version,
421    Waypoint,
422    WeatherZone,
423    Whitelist,
424    Wiring,
425    World,
426}
427
428impl ServerChatCommand {
429    pub fn data(&self) -> ChatCommandData {
430        use ArgumentSpec::*;
431        use Requirement::*;
432        use Role::*;
433        let cmd = ChatCommandData::new;
434        match self {
435            ServerChatCommand::Adminify => cmd(
436                vec![PlayerName(Required), Enum("role", ROLES.clone(), Optional)],
437                Content::localized("command-adminify-desc"),
438                Some(Admin),
439            ),
440            ServerChatCommand::Airship => cmd(
441                vec![
442                    Enum(
443                        "kind",
444                        comp::ship::ALL_AIRSHIPS
445                            .iter()
446                            .map(|b| format!("{b:?}"))
447                            .collect(),
448                        Optional,
449                    ),
450                    Float("destination_degrees_ccw_of_east", 90.0, Optional),
451                ],
452                Content::localized("command-airship-desc"),
453                Some(Admin),
454            ),
455            ServerChatCommand::Alias => cmd(
456                vec![Any("name", Required)],
457                Content::localized("command-alias-desc"),
458                Some(Moderator),
459            ),
460            ServerChatCommand::Aura => cmd(
461                vec![
462                    Float("aura_radius", 10.0, Required),
463                    Float("aura_duration", 10.0, Optional),
464                    Boolean("new_entity", "true".to_string(), Optional),
465                    Enum("aura_target", GroupTarget::all_options(), Optional),
466                    Enum("aura_kind", AuraKindVariant::all_options(), Required),
467                    Any("aura spec", Optional),
468                ],
469                Content::localized("command-aura-desc"),
470                Some(Admin),
471            ),
472            ServerChatCommand::Buff => cmd(
473                vec![
474                    Enum("buff", BUFFS.clone(), Required),
475                    Float("strength", 0.01, Optional),
476                    Float("duration", 10.0, Optional),
477                    Any("buff data spec", Optional),
478                ],
479                Content::localized("command-buff-desc"),
480                Some(Admin),
481            ),
482            ServerChatCommand::Ban => cmd(
483                vec![
484                    PlayerName(Required),
485                    Boolean("overwrite", "true".to_string(), Optional),
486                    Any("ban duration", Optional),
487                    Message(Optional),
488                ],
489                Content::localized("command-ban-desc"),
490                Some(Moderator),
491            ),
492            ServerChatCommand::BanIp => cmd(
493                vec![
494                    PlayerName(Required),
495                    Boolean("overwrite", "true".to_string(), Optional),
496                    Any("ban duration", Optional),
497                    Message(Optional),
498                ],
499                Content::localized("command-ban-ip-desc"),
500                Some(Moderator),
501            ),
502            #[rustfmt::skip]
503            ServerChatCommand::BattleMode => cmd(
504                vec![Enum(
505                    "battle mode",
506                    vec!["pvp".to_owned(), "pve".to_owned()],
507                    Optional,
508                )],
509                Content::localized("command-battlemode-desc"),
510                None,
511
512            ),
513            ServerChatCommand::IntoNpc => cmd(
514                vec![AssetPath(
515                    "entity_config",
516                    "common.entity.",
517                    ENTITY_CONFIGS.clone(),
518                    Required,
519                )],
520                Content::localized("command-into_npc-desc"),
521                Some(Admin),
522            ),
523            ServerChatCommand::Body => cmd(
524                vec![Enum("body", ENTITIES.clone(), Required)],
525                Content::localized("command-body-desc"),
526                Some(Admin),
527            ),
528            ServerChatCommand::BattleModeForce => cmd(
529                vec![Enum(
530                    "battle mode",
531                    vec!["pvp".to_owned(), "pve".to_owned()],
532                    Required,
533                )],
534                Content::localized("command-battlemode_force-desc"),
535                Some(Admin),
536            ),
537            ServerChatCommand::Build => cmd(vec![], Content::localized("command-build-desc"), None),
538            ServerChatCommand::AreaAdd => cmd(
539                vec![
540                    Any("name", Required),
541                    Enum("kind", AREA_KINDS.clone(), Required),
542                    Integer("xlo", 0, Required),
543                    Integer("xhi", 10, Required),
544                    Integer("ylo", 0, Required),
545                    Integer("yhi", 10, Required),
546                    Integer("zlo", 0, Required),
547                    Integer("zhi", 10, Required),
548                ],
549                Content::localized("command-area_add-desc"),
550                Some(Admin),
551            ),
552            ServerChatCommand::AreaList => cmd(
553                vec![],
554                Content::localized("command-area_list-desc"),
555                Some(Admin),
556            ),
557            ServerChatCommand::AreaRemove => cmd(
558                vec![
559                    Any("name", Required),
560                    Enum("kind", AREA_KINDS.clone(), Required),
561                ],
562                Content::localized("command-area_remove-desc"),
563                Some(Admin),
564            ),
565            ServerChatCommand::Campfire => cmd(
566                vec![],
567                Content::localized("command-campfire-desc"),
568                Some(Admin),
569            ),
570            ServerChatCommand::ClearPersistedTerrain => cmd(
571                vec![Integer("chunk_radius", 6, Required)],
572                Content::localized("command-clear_persisted_terrain-desc"),
573                Some(Admin),
574            ),
575            ServerChatCommand::DeathEffect => cmd(
576                vec![
577                    Enum("death_effect", vec!["transform".to_string()], Required),
578                    // NOTE: I added this for QoL as transform is currently the only death effect
579                    // and takes an asset path, when more on-death effects are added to the command
580                    // remove this.
581                    AssetPath(
582                        "entity_config",
583                        "common.entity.",
584                        ENTITY_CONFIGS.clone(),
585                        Required,
586                    ),
587                ],
588                Content::localized("command-death_effect-dest"),
589                Some(Admin),
590            ),
591            ServerChatCommand::DebugColumn => cmd(
592                vec![Integer("x", 15000, Required), Integer("y", 15000, Required)],
593                Content::localized("command-debug_column-desc"),
594                Some(Admin),
595            ),
596            ServerChatCommand::DebugWays => cmd(
597                vec![Integer("x", 15000, Required), Integer("y", 15000, Required)],
598                Content::localized("command-debug_ways-desc"),
599                Some(Admin),
600            ),
601            ServerChatCommand::DisconnectAllPlayers => cmd(
602                vec![Any("confirm", Required)],
603                Content::localized("command-disconnect_all_players-desc"),
604                Some(Admin),
605            ),
606            ServerChatCommand::DropAll => cmd(
607                vec![],
608                Content::localized("command-dropall-desc"),
609                Some(Moderator),
610            ),
611            ServerChatCommand::Dummy => cmd(
612                vec![],
613                Content::localized("command-dummy-desc"),
614                Some(Admin),
615            ),
616            ServerChatCommand::Explosion => cmd(
617                vec![Float("radius", 5.0, Required)],
618                Content::localized("command-explosion-desc"),
619                Some(Admin),
620            ),
621            ServerChatCommand::Faction => cmd(
622                vec![Message(Optional)],
623                Content::localized("command-faction-desc"),
624                None,
625            ),
626            ServerChatCommand::GiveItem => cmd(
627                vec![
628                    AssetPath("item", "common.items.", ITEM_SPECS.clone(), Required),
629                    Integer("num", 1, Optional),
630                ],
631                Content::localized("command-give_item-desc"),
632                Some(Admin),
633            ),
634            ServerChatCommand::Goto => cmd(
635                vec![
636                    Float("x", 0.0, Required),
637                    Float("y", 0.0, Required),
638                    Float("z", 0.0, Required),
639                    Boolean("Dismount from ship", "true".to_string(), Optional),
640                ],
641                Content::localized("command-goto-desc"),
642                Some(Admin),
643            ),
644            ServerChatCommand::Group => cmd(
645                vec![Message(Optional)],
646                Content::localized("command-group-desc"),
647                None,
648            ),
649            ServerChatCommand::GroupInvite => cmd(
650                vec![PlayerName(Required)],
651                Content::localized("command-group_invite-desc"),
652                None,
653            ),
654            ServerChatCommand::GroupKick => cmd(
655                vec![PlayerName(Required)],
656                Content::localized("command-group_kick-desc"),
657                None,
658            ),
659            ServerChatCommand::GroupLeave => {
660                cmd(vec![], Content::localized("command-group_leave-desc"), None)
661            },
662            ServerChatCommand::GroupPromote => cmd(
663                vec![PlayerName(Required)],
664                Content::localized("command-group_promote-desc"),
665                None,
666            ),
667            ServerChatCommand::Health => cmd(
668                vec![Integer("hp", 100, Required)],
669                Content::localized("command-health-desc"),
670                Some(Admin),
671            ),
672            ServerChatCommand::Respawn => cmd(
673                vec![],
674                Content::localized("command-respawn-desc"),
675                Some(Moderator),
676            ),
677            ServerChatCommand::JoinFaction => cmd(
678                vec![Any("faction", Optional)],
679                Content::localized("command-join_faction-desc"),
680                None,
681            ),
682            ServerChatCommand::Jump => cmd(
683                vec![
684                    Float("x", 0.0, Required),
685                    Float("y", 0.0, Required),
686                    Float("z", 0.0, Required),
687                    Boolean("Dismount from ship", "true".to_string(), Optional),
688                ],
689                Content::localized("command-jump-desc"),
690                Some(Admin),
691            ),
692            ServerChatCommand::Kick => cmd(
693                vec![PlayerName(Required), Message(Optional)],
694                Content::localized("command-kick-desc"),
695                Some(Moderator),
696            ),
697            ServerChatCommand::Kill => cmd(vec![], Content::localized("command-kill-desc"), None),
698            ServerChatCommand::KillNpcs => cmd(
699                vec![Float("radius", 100.0, Optional), Flag("--also-pets")],
700                Content::localized("command-kill_npcs-desc"),
701                Some(Admin),
702            ),
703            ServerChatCommand::Kit => cmd(
704                vec![Enum("kit_name", KITS.to_vec(), Required)],
705                Content::localized("command-kit-desc"),
706                Some(Admin),
707            ),
708            ServerChatCommand::Lantern => cmd(
709                vec![
710                    Float("strength", 5.0, Required),
711                    Float("r", 1.0, Optional),
712                    Float("g", 1.0, Optional),
713                    Float("b", 1.0, Optional),
714                ],
715                Content::localized("command-lantern-desc"),
716                Some(Admin),
717            ),
718            ServerChatCommand::Light => cmd(
719                vec![
720                    Float("r", 1.0, Optional),
721                    Float("g", 1.0, Optional),
722                    Float("b", 1.0, Optional),
723                    Float("x", 0.0, Optional),
724                    Float("y", 0.0, Optional),
725                    Float("z", 0.0, Optional),
726                    Float("strength", 5.0, Optional),
727                ],
728                Content::localized("command-light-desc"),
729                Some(Admin),
730            ),
731            ServerChatCommand::MakeBlock => cmd(
732                vec![
733                    Enum("block", BLOCK_KINDS.clone(), Required),
734                    Integer("r", 255, Optional),
735                    Integer("g", 255, Optional),
736                    Integer("b", 255, Optional),
737                ],
738                Content::localized("command-make_block-desc"),
739                Some(Admin),
740            ),
741            ServerChatCommand::MakeNpc => cmd(
742                vec![
743                    AssetPath(
744                        "entity_config",
745                        "common.entity.",
746                        ENTITY_CONFIGS.clone(),
747                        Required,
748                    ),
749                    Integer("num", 1, Optional),
750                ],
751                Content::localized("command-make_npc-desc"),
752                Some(Admin),
753            ),
754            ServerChatCommand::MakeSprite => cmd(
755                vec![Enum("sprite", SPRITE_KINDS.clone(), Required)],
756                Content::localized("command-make_sprite-desc"),
757                Some(Admin),
758            ),
759            ServerChatCommand::Motd => cmd(vec![], Content::localized("command-motd-desc"), None),
760            ServerChatCommand::Object => cmd(
761                vec![Enum("object", OBJECTS.clone(), Required)],
762                Content::localized("command-object-desc"),
763                Some(Admin),
764            ),
765            ServerChatCommand::Outcome => cmd(
766                vec![Enum("outcome", OUTCOME_KINDS.clone(), Required)],
767                Content::localized("command-outcome-desc"),
768                Some(Admin),
769            ),
770            ServerChatCommand::PermitBuild => cmd(
771                vec![Any("area_name", Required)],
772                Content::localized("command-permit_build-desc"),
773                Some(Admin),
774            ),
775            ServerChatCommand::Players => {
776                cmd(vec![], Content::localized("command-players-desc"), None)
777            },
778            ServerChatCommand::Portal => cmd(
779                vec![
780                    Float("x", 0., Required),
781                    Float("y", 0., Required),
782                    Float("z", 0., Required),
783                    Boolean("requires_no_aggro", "true".to_string(), Optional),
784                    Float("buildup_time", 5., Optional),
785                ],
786                Content::localized("command-portal-desc"),
787                Some(Admin),
788            ),
789            ServerChatCommand::ReloadChunks => cmd(
790                vec![Integer("chunk_radius", 6, Optional)],
791                Content::localized("command-reload_chunks-desc"),
792                Some(Admin),
793            ),
794            ServerChatCommand::ResetRecipes => cmd(
795                vec![],
796                Content::localized("command-reset_recipes-desc"),
797                Some(Admin),
798            ),
799            ServerChatCommand::RemoveLights => cmd(
800                vec![Float("radius", 20.0, Optional)],
801                Content::localized("command-remove_lights-desc"),
802                Some(Admin),
803            ),
804            ServerChatCommand::RevokeBuild => cmd(
805                vec![Any("area_name", Required)],
806                Content::localized("command-revoke_build-desc"),
807                Some(Admin),
808            ),
809            ServerChatCommand::RevokeBuildAll => cmd(
810                vec![],
811                Content::localized("command-revoke_build_all-desc"),
812                Some(Admin),
813            ),
814            ServerChatCommand::Region => cmd(
815                vec![Message(Optional)],
816                Content::localized("command-region-desc"),
817                None,
818            ),
819            ServerChatCommand::Safezone => cmd(
820                vec![Float("range", 100.0, Optional)],
821                Content::localized("command-safezone-desc"),
822                Some(Moderator),
823            ),
824            ServerChatCommand::Say => cmd(
825                vec![Message(Optional)],
826                Content::localized("command-say-desc"),
827                None,
828            ),
829            ServerChatCommand::ServerPhysics => cmd(
830                vec![
831                    PlayerName(Required),
832                    Boolean("enabled", "true".to_string(), Optional),
833                    Message(Optional),
834                ],
835                Content::localized("command-server_physics-desc"),
836                Some(Moderator),
837            ),
838            ServerChatCommand::SetMotd => cmd(
839                vec![Any("locale", Optional), Message(Optional)],
840                Content::localized("command-set_motd-desc"),
841                Some(Admin),
842            ),
843            ServerChatCommand::Ship => cmd(
844                vec![
845                    Enum(
846                        "kind",
847                        comp::ship::ALL_SHIPS
848                            .iter()
849                            .map(|b| format!("{b:?}"))
850                            .collect(),
851                        Optional,
852                    ),
853                    Boolean(
854                        "Whether the ship should be tethered to the target (or its mount)",
855                        "false".to_string(),
856                        Optional,
857                    ),
858                    Float("destination_degrees_ccw_of_east", 90.0, Optional),
859                ],
860                Content::localized("command-ship-desc"),
861                Some(Admin),
862            ),
863            // Uses Message because site names can contain spaces,
864            // which would be assumed to be separators otherwise
865            ServerChatCommand::Site => cmd(
866                vec![
867                    SiteName(Required),
868                    Boolean("Dismount from ship", "true".to_string(), Optional),
869                ],
870                Content::localized("command-site-desc"),
871                Some(Moderator),
872            ),
873            ServerChatCommand::SkillPoint => cmd(
874                vec![
875                    Enum("skill tree", SKILL_TREES.clone(), Required),
876                    Integer("amount", 1, Optional),
877                ],
878                Content::localized("command-skill_point-desc"),
879                Some(Admin),
880            ),
881            ServerChatCommand::SkillPreset => cmd(
882                vec![Enum("preset_name", PRESET_LIST.to_vec(), Required)],
883                Content::localized("command-skill_preset-desc"),
884                Some(Admin),
885            ),
886            ServerChatCommand::Spawn => cmd(
887                vec![
888                    Enum("alignment", ALIGNMENTS.clone(), Required),
889                    Enum("entity", ENTITIES.clone(), Required),
890                    Integer("amount", 1, Optional),
891                    Boolean("ai", "true".to_string(), Optional),
892                    Float("scale", 1.0, Optional),
893                    Boolean("tethered", "false".to_string(), Optional),
894                ],
895                Content::localized("command-spawn-desc"),
896                Some(Admin),
897            ),
898            ServerChatCommand::Spot => cmd(
899                vec![Enum(
900                    "Spot kind to find",
901                    crate::spot::RON_SPOT_PROPERTIES
902                        .0
903                        .iter()
904                        .map(|s| s.base_structures.clone())
905                        .collect(),
906                    Required,
907                )],
908                Content::localized("command-spot-desc"),
909                Some(Admin),
910            ),
911            ServerChatCommand::Sudo => cmd(
912                vec![EntityTarget(Required), SubCommand],
913                Content::localized("command-sudo-desc"),
914                Some(Moderator),
915            ),
916            ServerChatCommand::Tell => cmd(
917                vec![PlayerName(Required), Message(Optional)],
918                Content::localized("command-tell-desc"),
919                None,
920            ),
921            ServerChatCommand::Time => cmd(
922                vec![Enum("time", TIMES.clone(), Optional)],
923                Content::localized("command-time-desc"),
924                Some(Admin),
925            ),
926            ServerChatCommand::TimeScale => cmd(
927                vec![Float("time scale", 1.0, Optional)],
928                Content::localized("command-time_scale-desc"),
929                Some(Admin),
930            ),
931            ServerChatCommand::Tp => cmd(
932                vec![
933                    EntityTarget(Optional),
934                    Boolean("Dismount from ship", "true".to_string(), Optional),
935                ],
936                Content::localized("command-tp-desc"),
937                Some(Moderator),
938            ),
939            ServerChatCommand::RtsimTp => cmd(
940                vec![
941                    Integer("npc index", 0, Required),
942                    Boolean("Dismount from ship", "true".to_string(), Optional),
943                ],
944                Content::localized("command-rtsim_tp-desc"),
945                Some(Admin),
946            ),
947            ServerChatCommand::RtsimInfo => cmd(
948                vec![Integer("npc index", 0, Required)],
949                Content::localized("command-rtsim_info-desc"),
950                Some(Admin),
951            ),
952            ServerChatCommand::RtsimNpc => cmd(
953                vec![Any("query", Required), Integer("max number", 20, Optional)],
954                Content::localized("command-rtsim_npc-desc"),
955                Some(Admin),
956            ),
957            ServerChatCommand::RtsimPurge => cmd(
958                vec![Boolean(
959                    "whether purging of rtsim data should occur on next startup",
960                    true.to_string(),
961                    Required,
962                )],
963                Content::localized("command-rtsim_purge-desc"),
964                Some(Admin),
965            ),
966            ServerChatCommand::RtsimChunk => cmd(
967                vec![],
968                Content::localized("command-rtsim_chunk-desc"),
969                Some(Admin),
970            ),
971            ServerChatCommand::Unban => cmd(
972                vec![PlayerName(Required)],
973                Content::localized("command-unban-desc"),
974                Some(Moderator),
975            ),
976            ServerChatCommand::UnbanIp => cmd(
977                vec![PlayerName(Required)],
978                Content::localized("command-unban-ip-desc"),
979                Some(Moderator),
980            ),
981            ServerChatCommand::Version => {
982                cmd(vec![], Content::localized("command-version-desc"), None)
983            },
984            ServerChatCommand::Waypoint => cmd(
985                vec![],
986                Content::localized("command-waypoint-desc"),
987                Some(Admin),
988            ),
989            ServerChatCommand::Wiring => cmd(
990                vec![],
991                Content::localized("command-wiring-desc"),
992                Some(Admin),
993            ),
994            ServerChatCommand::Whitelist => cmd(
995                vec![Any("add/remove", Required), PlayerName(Required)],
996                Content::localized("command-whitelist-desc"),
997                Some(Moderator),
998            ),
999            ServerChatCommand::World => cmd(
1000                vec![Message(Optional)],
1001                Content::localized("command-world-desc"),
1002                None,
1003            ),
1004            ServerChatCommand::MakeVolume => cmd(
1005                vec![Integer("size", 15, Optional)],
1006                Content::localized("command-make_volume-desc"),
1007                Some(Admin),
1008            ),
1009            ServerChatCommand::Location => cmd(
1010                vec![Any("name", Required)],
1011                Content::localized("command-location-desc"),
1012                None,
1013            ),
1014            ServerChatCommand::CreateLocation => cmd(
1015                vec![Any("name", Required)],
1016                Content::localized("command-create_location-desc"),
1017                Some(Moderator),
1018            ),
1019            ServerChatCommand::DeleteLocation => cmd(
1020                vec![Any("name", Required)],
1021                Content::localized("command-delete_location-desc"),
1022                Some(Moderator),
1023            ),
1024            ServerChatCommand::WeatherZone => cmd(
1025                vec![
1026                    Enum("weather kind", WEATHERS.clone(), Required),
1027                    Float("radius", 500.0, Optional),
1028                    Float("time", 300.0, Optional),
1029                ],
1030                Content::localized("command-weather_zone-desc"),
1031                Some(Admin),
1032            ),
1033            ServerChatCommand::Lightning => cmd(
1034                vec![],
1035                Content::localized("command-lightning-desc"),
1036                Some(Admin),
1037            ),
1038            ServerChatCommand::Scale => cmd(
1039                vec![
1040                    Float("factor", 1.0, Required),
1041                    Boolean("reset_mass", true.to_string(), Optional),
1042                ],
1043                Content::localized("command-scale-desc"),
1044                Some(Admin),
1045            ),
1046            ServerChatCommand::RepairEquipment => cmd(
1047                vec![],
1048                Content::localized("command-repair_equipment-desc"),
1049                Some(Admin),
1050            ),
1051            ServerChatCommand::Tether => cmd(
1052                vec![
1053                    EntityTarget(Required),
1054                    Boolean("automatic length", "true".to_string(), Optional),
1055                ],
1056                Content::localized("command-tether-desc"),
1057                Some(Admin),
1058            ),
1059            ServerChatCommand::DestroyTethers => cmd(
1060                vec![],
1061                Content::localized("command-destroy_tethers-desc"),
1062                Some(Admin),
1063            ),
1064            ServerChatCommand::Mount => cmd(
1065                vec![EntityTarget(Required)],
1066                Content::localized("command-mount-desc"),
1067                Some(Admin),
1068            ),
1069            ServerChatCommand::Dismount => cmd(
1070                vec![EntityTarget(Required)],
1071                Content::localized("command-dismount-desc"),
1072                Some(Admin),
1073            ),
1074        }
1075    }
1076
1077    /// The keyword used to invoke the command, omitting the prefix.
1078    pub fn keyword(&self) -> &'static str {
1079        match self {
1080            ServerChatCommand::Adminify => "adminify",
1081            ServerChatCommand::Airship => "airship",
1082            ServerChatCommand::Alias => "alias",
1083            ServerChatCommand::AreaAdd => "area_add",
1084            ServerChatCommand::AreaList => "area_list",
1085            ServerChatCommand::AreaRemove => "area_remove",
1086            ServerChatCommand::Aura => "aura",
1087            ServerChatCommand::Ban => "ban",
1088            ServerChatCommand::BanIp => "ban_ip",
1089            ServerChatCommand::BattleMode => "battlemode",
1090            ServerChatCommand::BattleModeForce => "battlemode_force",
1091            ServerChatCommand::Body => "body",
1092            ServerChatCommand::Buff => "buff",
1093            ServerChatCommand::Build => "build",
1094            ServerChatCommand::Campfire => "campfire",
1095            ServerChatCommand::ClearPersistedTerrain => "clear_persisted_terrain",
1096            ServerChatCommand::DeathEffect => "death_effect",
1097            ServerChatCommand::DebugColumn => "debug_column",
1098            ServerChatCommand::DebugWays => "debug_ways",
1099            ServerChatCommand::DisconnectAllPlayers => "disconnect_all_players",
1100            ServerChatCommand::DropAll => "dropall",
1101            ServerChatCommand::Dummy => "dummy",
1102            ServerChatCommand::Explosion => "explosion",
1103            ServerChatCommand::Faction => "faction",
1104            ServerChatCommand::GiveItem => "give_item",
1105            ServerChatCommand::Goto => "goto",
1106            ServerChatCommand::Group => "group",
1107            ServerChatCommand::GroupInvite => "group_invite",
1108            ServerChatCommand::GroupKick => "group_kick",
1109            ServerChatCommand::GroupLeave => "group_leave",
1110            ServerChatCommand::GroupPromote => "group_promote",
1111            ServerChatCommand::Health => "health",
1112            ServerChatCommand::IntoNpc => "into_npc",
1113            ServerChatCommand::JoinFaction => "join_faction",
1114            ServerChatCommand::Jump => "jump",
1115            ServerChatCommand::Kick => "kick",
1116            ServerChatCommand::Kill => "kill",
1117            ServerChatCommand::KillNpcs => "kill_npcs",
1118            ServerChatCommand::Kit => "kit",
1119            ServerChatCommand::Lantern => "lantern",
1120            ServerChatCommand::Respawn => "respawn",
1121            ServerChatCommand::Light => "light",
1122            ServerChatCommand::MakeBlock => "make_block",
1123            ServerChatCommand::MakeNpc => "make_npc",
1124            ServerChatCommand::MakeSprite => "make_sprite",
1125            ServerChatCommand::Motd => "motd",
1126            ServerChatCommand::Object => "object",
1127            ServerChatCommand::Outcome => "outcome",
1128            ServerChatCommand::PermitBuild => "permit_build",
1129            ServerChatCommand::Players => "players",
1130            ServerChatCommand::Portal => "portal",
1131            ServerChatCommand::ResetRecipes => "reset_recipes",
1132            ServerChatCommand::Region => "region",
1133            ServerChatCommand::ReloadChunks => "reload_chunks",
1134            ServerChatCommand::RemoveLights => "remove_lights",
1135            ServerChatCommand::RevokeBuild => "revoke_build",
1136            ServerChatCommand::RevokeBuildAll => "revoke_build_all",
1137            ServerChatCommand::Safezone => "safezone",
1138            ServerChatCommand::Say => "say",
1139            ServerChatCommand::ServerPhysics => "server_physics",
1140            ServerChatCommand::SetMotd => "set_motd",
1141            ServerChatCommand::Ship => "ship",
1142            ServerChatCommand::Site => "site",
1143            ServerChatCommand::SkillPoint => "skill_point",
1144            ServerChatCommand::SkillPreset => "skill_preset",
1145            ServerChatCommand::Spawn => "spawn",
1146            ServerChatCommand::Spot => "spot",
1147            ServerChatCommand::Sudo => "sudo",
1148            ServerChatCommand::Tell => "tell",
1149            ServerChatCommand::Time => "time",
1150            ServerChatCommand::TimeScale => "time_scale",
1151            ServerChatCommand::Tp => "tp",
1152            ServerChatCommand::RtsimTp => "rtsim_tp",
1153            ServerChatCommand::RtsimInfo => "rtsim_info",
1154            ServerChatCommand::RtsimNpc => "rtsim_npc",
1155            ServerChatCommand::RtsimPurge => "rtsim_purge",
1156            ServerChatCommand::RtsimChunk => "rtsim_chunk",
1157            ServerChatCommand::Unban => "unban",
1158            ServerChatCommand::UnbanIp => "unban_ip",
1159            ServerChatCommand::Version => "version",
1160            ServerChatCommand::Waypoint => "waypoint",
1161            ServerChatCommand::Wiring => "wiring",
1162            ServerChatCommand::Whitelist => "whitelist",
1163            ServerChatCommand::World => "world",
1164            ServerChatCommand::MakeVolume => "make_volume",
1165            ServerChatCommand::Location => "location",
1166            ServerChatCommand::CreateLocation => "create_location",
1167            ServerChatCommand::DeleteLocation => "delete_location",
1168            ServerChatCommand::WeatherZone => "weather_zone",
1169            ServerChatCommand::Lightning => "lightning",
1170            ServerChatCommand::Scale => "scale",
1171            ServerChatCommand::RepairEquipment => "repair_equipment",
1172            ServerChatCommand::Tether => "tether",
1173            ServerChatCommand::DestroyTethers => "destroy_tethers",
1174            ServerChatCommand::Mount => "mount",
1175            ServerChatCommand::Dismount => "dismount",
1176        }
1177    }
1178
1179    /// The short keyword used to invoke the command, omitting the leading '/'.
1180    /// Returns None if the command doesn't have a short keyword
1181    pub fn short_keyword(&self) -> Option<&'static str> {
1182        Some(match self {
1183            ServerChatCommand::Faction => "f",
1184            ServerChatCommand::Group => "g",
1185            ServerChatCommand::Region => "r",
1186            ServerChatCommand::Say => "s",
1187            ServerChatCommand::Tell => "t",
1188            ServerChatCommand::World => "w",
1189            _ => return None,
1190        })
1191    }
1192
1193    /// Produce an iterator over all the available commands
1194    pub fn iter() -> impl Iterator<Item = Self> + Clone { <Self as IntoEnumIterator>::iter() }
1195
1196    /// A message that explains what the command does
1197    pub fn help_content(&self) -> Content {
1198        let data = self.data();
1199
1200        let usage = std::iter::once(format!("/{}", self.keyword()))
1201            .chain(data.args.iter().map(|arg| arg.usage_string()))
1202            .collect::<Vec<_>>()
1203            .join(" ");
1204
1205        Content::localized_with_args("command-help-template", [
1206            ("usage", Content::Plain(usage)),
1207            ("description", data.description),
1208        ])
1209    }
1210
1211    /// Produce an iterator that first goes over all the short keywords
1212    /// and their associated commands and then iterates over all the normal
1213    /// keywords with their associated commands
1214    pub fn iter_with_keywords() -> impl Iterator<Item = (&'static str, Self)> {
1215        Self::iter()
1216        // Go through all the shortcuts first
1217        .filter_map(|c| c.short_keyword().map(|s| (s, c)))
1218        .chain(Self::iter().map(|c| (c.keyword(), c)))
1219    }
1220
1221    pub fn needs_role(&self) -> Option<comp::AdminRole> { self.data().needs_role }
1222
1223    /// Returns a format string for parsing arguments with scan_fmt
1224    pub fn arg_fmt(&self) -> String {
1225        self.data()
1226            .args
1227            .iter()
1228            .map(|arg| match arg {
1229                ArgumentSpec::PlayerName(_) => "{}",
1230                ArgumentSpec::EntityTarget(_) => "{}",
1231                ArgumentSpec::SiteName(_) => "{/.*/}",
1232                ArgumentSpec::Float(_, _, _) => "{}",
1233                ArgumentSpec::Integer(_, _, _) => "{d}",
1234                ArgumentSpec::Any(_, _) => "{}",
1235                ArgumentSpec::Command(_) => "{}",
1236                ArgumentSpec::Message(_) => "{/.*/}",
1237                ArgumentSpec::SubCommand => "{} {/.*/}",
1238                ArgumentSpec::Enum(_, _, _) => "{}",
1239                ArgumentSpec::AssetPath(_, _, _, _) => "{}",
1240                ArgumentSpec::Boolean(_, _, _) => "{}",
1241                ArgumentSpec::Flag(_) => "{}",
1242            })
1243            .collect::<Vec<_>>()
1244            .join(" ")
1245    }
1246}
1247
1248impl Display for ServerChatCommand {
1249    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
1250        write!(f, "{}", self.keyword())
1251    }
1252}
1253
1254impl FromStr for ServerChatCommand {
1255    type Err = ();
1256
1257    fn from_str(keyword: &str) -> Result<ServerChatCommand, ()> {
1258        Self::iter()
1259        // Go through all the shortcuts first
1260        .filter_map(|c| c.short_keyword().map(|s| (s, c)))
1261        .chain(Self::iter().map(|c| (c.keyword(), c)))
1262            // Find command with matching string as keyword
1263            .find_map(|(kwd, command)| (kwd == keyword).then_some(command))
1264            // Return error if not found
1265            .ok_or(())
1266    }
1267}
1268
1269#[derive(Eq, PartialEq, Debug, Clone, Copy)]
1270pub enum Requirement {
1271    Required,
1272    Optional,
1273}
1274
1275/// Representation for chat command arguments
1276pub enum ArgumentSpec {
1277    /// The argument refers to a player by alias
1278    PlayerName(Requirement),
1279    /// The arguments refers to an entity in some way.
1280    EntityTarget(Requirement),
1281    // The argument refers to a site, by name.
1282    SiteName(Requirement),
1283    /// The argument is a float. The associated values are
1284    /// * label
1285    /// * suggested tab-completion
1286    /// * whether it's optional
1287    Float(&'static str, f32, Requirement),
1288    /// The argument is an integer. The associated values are
1289    /// * label
1290    /// * suggested tab-completion
1291    /// * whether it's optional
1292    Integer(&'static str, i32, Requirement),
1293    /// The argument is any string that doesn't contain spaces
1294    Any(&'static str, Requirement),
1295    /// The argument is a command name (such as in /help)
1296    Command(Requirement),
1297    /// This is the final argument, consuming all characters until the end of
1298    /// input.
1299    Message(Requirement),
1300    /// This command is followed by another command (such as in /sudo)
1301    SubCommand,
1302    /// The argument is likely an enum. The associated values are
1303    /// * label
1304    /// * Predefined string completions
1305    /// * whether it's optional
1306    Enum(&'static str, Vec<String>, Requirement),
1307    /// The argument is an asset path. The associated values are
1308    /// * label
1309    /// * Path prefix shared by all assets
1310    /// * List of all asset paths as strings for completion
1311    /// * whether it's optional
1312    AssetPath(&'static str, &'static str, Vec<String>, Requirement),
1313    /// The argument is likely a boolean. The associated values are
1314    /// * label
1315    /// * suggested tab-completion
1316    /// * whether it's optional
1317    Boolean(&'static str, String, Requirement),
1318    /// The argument is a flag that enables or disables a feature.
1319    Flag(&'static str),
1320}
1321
1322impl ArgumentSpec {
1323    pub fn usage_string(&self) -> String {
1324        match self {
1325            ArgumentSpec::PlayerName(req) => {
1326                if &Requirement::Required == req {
1327                    "<player>".to_string()
1328                } else {
1329                    "[player]".to_string()
1330                }
1331            },
1332            ArgumentSpec::EntityTarget(req) => {
1333                if &Requirement::Required == req {
1334                    "<entity>".to_string()
1335                } else {
1336                    "[entity]".to_string()
1337                }
1338            },
1339            ArgumentSpec::SiteName(req) => {
1340                if &Requirement::Required == req {
1341                    "<site>".to_string()
1342                } else {
1343                    "[site]".to_string()
1344                }
1345            },
1346            ArgumentSpec::Float(label, _, req) => {
1347                if &Requirement::Required == req {
1348                    format!("<{}>", label)
1349                } else {
1350                    format!("[{}]", label)
1351                }
1352            },
1353            ArgumentSpec::Integer(label, _, req) => {
1354                if &Requirement::Required == req {
1355                    format!("<{}>", label)
1356                } else {
1357                    format!("[{}]", label)
1358                }
1359            },
1360            ArgumentSpec::Any(label, req) => {
1361                if &Requirement::Required == req {
1362                    format!("<{}>", label)
1363                } else {
1364                    format!("[{}]", label)
1365                }
1366            },
1367            ArgumentSpec::Command(req) => {
1368                if &Requirement::Required == req {
1369                    "<[/]command>".to_string()
1370                } else {
1371                    "[[/]command]".to_string()
1372                }
1373            },
1374            ArgumentSpec::Message(req) => {
1375                if &Requirement::Required == req {
1376                    "<message>".to_string()
1377                } else {
1378                    "[message]".to_string()
1379                }
1380            },
1381            ArgumentSpec::SubCommand => "<[/]command> [args...]".to_string(),
1382            ArgumentSpec::Enum(label, _, req) => {
1383                if &Requirement::Required == req {
1384                    format!("<{}>", label)
1385                } else {
1386                    format!("[{}]", label)
1387                }
1388            },
1389            ArgumentSpec::AssetPath(label, _, _, req) => {
1390                if &Requirement::Required == req {
1391                    format!("<{}>", label)
1392                } else {
1393                    format!("[{}]", label)
1394                }
1395            },
1396            ArgumentSpec::Boolean(label, _, req) => {
1397                if &Requirement::Required == req {
1398                    format!("<{}>", label)
1399                } else {
1400                    format!("[{}]", label)
1401                }
1402            },
1403            ArgumentSpec::Flag(label) => {
1404                format!("[{}]", label)
1405            },
1406        }
1407    }
1408
1409    pub fn requirement(&self) -> Requirement {
1410        match self {
1411            ArgumentSpec::PlayerName(r)
1412            | ArgumentSpec::EntityTarget(r)
1413            | ArgumentSpec::SiteName(r)
1414            | ArgumentSpec::Float(_, _, r)
1415            | ArgumentSpec::Integer(_, _, r)
1416            | ArgumentSpec::Any(_, r)
1417            | ArgumentSpec::Command(r)
1418            | ArgumentSpec::Message(r)
1419            | ArgumentSpec::Enum(_, _, r)
1420            | ArgumentSpec::AssetPath(_, _, _, r)
1421            | ArgumentSpec::Boolean(_, _, r) => *r,
1422            ArgumentSpec::Flag(_) => Requirement::Optional,
1423            ArgumentSpec::SubCommand => Requirement::Required,
1424        }
1425    }
1426}
1427
1428pub trait CommandEnumArg: FromStr {
1429    fn all_options() -> Vec<String>;
1430}
1431
1432macro_rules! impl_from_to_str_cmd {
1433    ($enum:ident, ($($attribute:ident => $str:expr),*)) => {
1434        impl std::str::FromStr for $enum {
1435            type Err = String;
1436
1437            fn from_str(s: &str) -> Result<Self, Self::Err> {
1438                match s {
1439                    $(
1440                        $str => Ok($enum::$attribute),
1441                    )*
1442                    s => Err(format!("Invalid variant: {s}")),
1443                }
1444            }
1445        }
1446
1447        impl $crate::cmd::CommandEnumArg for $enum {
1448            fn all_options() -> Vec<String> {
1449                vec![$($str.to_string()),*]
1450            }
1451        }
1452    }
1453}
1454
1455impl_from_to_str_cmd!(AuraKindVariant, (
1456    Buff => "buff",
1457    FriendlyFire => "friendly_fire",
1458    ForcePvP => "force_pvp"
1459));
1460
1461impl_from_to_str_cmd!(GroupTarget, (
1462    InGroup => "in_group",
1463    OutOfGroup => "out_of_group"
1464));
1465
1466/// Parse a series of command arguments into values, including collecting all
1467/// trailing arguments.
1468#[macro_export]
1469macro_rules! parse_cmd_args {
1470    ($args:expr, $($t:ty),* $(, ..$tail:ty)? $(,)?) => {
1471        {
1472            let mut args = $args.into_iter().peekable();
1473            (
1474                // We only consume the input argument when parsing is successful. If this fails, we
1475                // will then attempt to parse it as the next argument type. This is done regardless
1476                // of whether the argument is optional because that information is not available
1477                // here. Nevertheless, if the caller only precedes to use the parsed arguments when
1478                // all required arguments parse successfully to `Some(val)` this should not create
1479                // any unexpected behavior.
1480                //
1481                // This does mean that optional arguments will be included in the trailing args or
1482                // that one optional arg could be interpreted as another, if the user makes a
1483                // mistake that causes an optional arg to fail to parse. But there is no way to
1484                // discern this in the current model with the optional args and trailing arg being
1485                // solely position based.
1486                $({
1487                    let parsed = args.peek().and_then(|s| s.parse::<$t>().ok());
1488                    // Consume successfully parsed arg.
1489                    if parsed.is_some() { args.next(); }
1490                    parsed
1491                }),*
1492                $(, args.map(|s| s.to_string()).collect::<$tail>())?
1493            )
1494        }
1495    };
1496}
1497
1498#[cfg(test)]
1499mod tests {
1500    use super::*;
1501    use crate::comp::Item;
1502
1503    #[test]
1504    fn verify_cmd_list_sorted() {
1505        let mut list = ServerChatCommand::iter()
1506            .map(|c| c.keyword())
1507            .collect::<Vec<_>>();
1508
1509        // Vec::is_sorted is unstable, so we do it the hard way
1510        let list2 = list.clone();
1511        list.sort_unstable();
1512        assert_eq!(list, list2);
1513    }
1514
1515    #[test]
1516    fn test_loading_skill_presets() {
1517        SkillPresetManifest::load_expect_combined_static(PRESET_MANIFEST_PATH);
1518    }
1519
1520    #[test]
1521    fn test_load_kits() {
1522        let kits = KitManifest::load_expect_combined_static(KIT_MANIFEST_PATH).read();
1523        let mut rng = rand::thread_rng();
1524        for kit in kits.0.values() {
1525            for (item_id, _) in kit.iter() {
1526                match item_id {
1527                    KitSpec::Item(item_id) => {
1528                        Item::new_from_asset_expect(item_id);
1529                    },
1530                    KitSpec::ModularWeapon { tool, material } => {
1531                        comp::item::modular::random_weapon(*tool, *material, None, &mut rng)
1532                            .unwrap_or_else(|_| {
1533                                panic!(
1534                                    "Failed to synthesize a modular {tool:?} made of {material:?}."
1535                                )
1536                            });
1537                    },
1538                }
1539            }
1540        }
1541    }
1542}