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    pub 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    GotoRand,
361    Group,
362    GroupInvite,
363    GroupKick,
364    GroupLeave,
365    GroupPromote,
366    Health,
367    IntoNpc,
368    JoinFaction,
369    Jump,
370    Kick,
371    Kill,
372    KillNpcs,
373    Kit,
374    Lantern,
375    Light,
376    Lightning,
377    Location,
378    MakeBlock,
379    MakeNpc,
380    MakeSprite,
381    MakeVolume,
382    Motd,
383    Mount,
384    Object,
385    Outcome,
386    PermitBuild,
387    Players,
388    Portal,
389    Region,
390    ReloadChunks,
391    RemoveLights,
392    RepairEquipment,
393    ResetRecipes,
394    Respawn,
395    RevokeBuild,
396    RevokeBuildAll,
397    RtsimChunk,
398    RtsimInfo,
399    RtsimNpc,
400    RtsimPurge,
401    RtsimTp,
402    Safezone,
403    Say,
404    Scale,
405    ServerPhysics,
406    SetMotd,
407    Ship,
408    Site,
409    SkillPoint,
410    SkillPreset,
411    Spawn,
412    Spot,
413    Sudo,
414    Tell,
415    Tether,
416    Time,
417    TimeScale,
418    Tp,
419    Unban,
420    UnbanIp,
421    Version,
422    Waypoint,
423    WeatherZone,
424    Whitelist,
425    Wiring,
426    World,
427}
428
429impl ServerChatCommand {
430    pub fn data(&self) -> ChatCommandData {
431        use ArgumentSpec::*;
432        use Requirement::*;
433        use Role::*;
434        let cmd = ChatCommandData::new;
435        match self {
436            ServerChatCommand::Adminify => cmd(
437                vec![PlayerName(Required), Enum("role", ROLES.clone(), Optional)],
438                Content::localized("command-adminify-desc"),
439                Some(Admin),
440            ),
441            ServerChatCommand::Airship => cmd(
442                vec![
443                    Enum(
444                        "kind",
445                        comp::ship::ALL_AIRSHIPS
446                            .iter()
447                            .map(|b| format!("{b:?}"))
448                            .collect(),
449                        Optional,
450                    ),
451                    Float("destination_degrees_ccw_of_east", 90.0, Optional),
452                ],
453                Content::localized("command-airship-desc"),
454                Some(Admin),
455            ),
456            ServerChatCommand::Alias => cmd(
457                vec![Any("name", Required)],
458                Content::localized("command-alias-desc"),
459                Some(Moderator),
460            ),
461            ServerChatCommand::Aura => cmd(
462                vec![
463                    Float("aura_radius", 10.0, Required),
464                    Float("aura_duration", 10.0, Optional),
465                    Boolean("new_entity", "true".to_string(), Optional),
466                    Enum("aura_target", GroupTarget::all_options(), Optional),
467                    Enum("aura_kind", AuraKindVariant::all_options(), Required),
468                    Any("aura spec", Optional),
469                ],
470                Content::localized("command-aura-desc"),
471                Some(Admin),
472            ),
473            ServerChatCommand::Buff => cmd(
474                vec![
475                    Enum("buff", BUFFS.clone(), Required),
476                    Float("strength", 0.01, Optional),
477                    Float("duration", 10.0, Optional),
478                    Any("buff data spec", Optional),
479                ],
480                Content::localized("command-buff-desc"),
481                Some(Admin),
482            ),
483            ServerChatCommand::Ban => cmd(
484                vec![
485                    PlayerName(Required),
486                    Boolean("overwrite", "true".to_string(), Optional),
487                    Any("ban duration", Optional),
488                    Message(Optional),
489                ],
490                Content::localized("command-ban-desc"),
491                Some(Moderator),
492            ),
493            ServerChatCommand::BanIp => cmd(
494                vec![
495                    PlayerName(Required),
496                    Boolean("overwrite", "true".to_string(), Optional),
497                    Any("ban duration", Optional),
498                    Message(Optional),
499                ],
500                Content::localized("command-ban-ip-desc"),
501                Some(Moderator),
502            ),
503            #[rustfmt::skip]
504            ServerChatCommand::BattleMode => cmd(
505                vec![Enum(
506                    "battle mode",
507                    vec!["pvp".to_owned(), "pve".to_owned()],
508                    Optional,
509                )],
510                Content::localized("command-battlemode-desc"),
511                None,
512
513            ),
514            ServerChatCommand::IntoNpc => cmd(
515                vec![AssetPath(
516                    "entity_config",
517                    "common.entity.",
518                    ENTITY_CONFIGS.clone(),
519                    Required,
520                )],
521                Content::localized("command-into_npc-desc"),
522                Some(Admin),
523            ),
524            ServerChatCommand::Body => cmd(
525                vec![Enum("body", ENTITIES.clone(), Required)],
526                Content::localized("command-body-desc"),
527                Some(Admin),
528            ),
529            ServerChatCommand::BattleModeForce => cmd(
530                vec![Enum(
531                    "battle mode",
532                    vec!["pvp".to_owned(), "pve".to_owned()],
533                    Required,
534                )],
535                Content::localized("command-battlemode_force-desc"),
536                Some(Admin),
537            ),
538            ServerChatCommand::Build => cmd(vec![], Content::localized("command-build-desc"), None),
539            ServerChatCommand::AreaAdd => cmd(
540                vec![
541                    Any("name", Required),
542                    Enum("kind", AREA_KINDS.clone(), Required),
543                    Integer("xlo", 0, Required),
544                    Integer("xhi", 10, Required),
545                    Integer("ylo", 0, Required),
546                    Integer("yhi", 10, Required),
547                    Integer("zlo", 0, Required),
548                    Integer("zhi", 10, Required),
549                ],
550                Content::localized("command-area_add-desc"),
551                Some(Admin),
552            ),
553            ServerChatCommand::AreaList => cmd(
554                vec![],
555                Content::localized("command-area_list-desc"),
556                Some(Admin),
557            ),
558            ServerChatCommand::AreaRemove => cmd(
559                vec![
560                    Any("name", Required),
561                    Enum("kind", AREA_KINDS.clone(), Required),
562                ],
563                Content::localized("command-area_remove-desc"),
564                Some(Admin),
565            ),
566            ServerChatCommand::Campfire => cmd(
567                vec![],
568                Content::localized("command-campfire-desc"),
569                Some(Admin),
570            ),
571            ServerChatCommand::ClearPersistedTerrain => cmd(
572                vec![Integer("chunk_radius", 6, Required)],
573                Content::localized("command-clear_persisted_terrain-desc"),
574                Some(Admin),
575            ),
576            ServerChatCommand::DeathEffect => cmd(
577                vec![
578                    Enum("death_effect", vec!["transform".to_string()], Required),
579                    // NOTE: I added this for QoL as transform is currently the only death effect
580                    // and takes an asset path, when more on-death effects are added to the command
581                    // remove this.
582                    AssetPath(
583                        "entity_config",
584                        "common.entity.",
585                        ENTITY_CONFIGS.clone(),
586                        Required,
587                    ),
588                ],
589                Content::localized("command-death_effect-dest"),
590                Some(Admin),
591            ),
592            ServerChatCommand::DebugColumn => cmd(
593                vec![Integer("x", 15000, Required), Integer("y", 15000, Required)],
594                Content::localized("command-debug_column-desc"),
595                Some(Admin),
596            ),
597            ServerChatCommand::DebugWays => cmd(
598                vec![Integer("x", 15000, Required), Integer("y", 15000, Required)],
599                Content::localized("command-debug_ways-desc"),
600                Some(Admin),
601            ),
602            ServerChatCommand::DisconnectAllPlayers => cmd(
603                vec![Any("confirm", Required)],
604                Content::localized("command-disconnect_all_players-desc"),
605                Some(Admin),
606            ),
607            ServerChatCommand::DropAll => cmd(
608                vec![],
609                Content::localized("command-dropall-desc"),
610                Some(Moderator),
611            ),
612            ServerChatCommand::Dummy => cmd(
613                vec![],
614                Content::localized("command-dummy-desc"),
615                Some(Admin),
616            ),
617            ServerChatCommand::Explosion => cmd(
618                vec![Float("radius", 5.0, Required)],
619                Content::localized("command-explosion-desc"),
620                Some(Admin),
621            ),
622            ServerChatCommand::Faction => cmd(
623                vec![Message(Optional)],
624                Content::localized("command-faction-desc"),
625                None,
626            ),
627            ServerChatCommand::GiveItem => cmd(
628                vec![
629                    AssetPath("item", "common.items.", ITEM_SPECS.clone(), Required),
630                    Integer("num", 1, Optional),
631                ],
632                Content::localized("command-give_item-desc"),
633                Some(Admin),
634            ),
635            ServerChatCommand::Goto => cmd(
636                vec![
637                    Float("x", 0.0, Required),
638                    Float("y", 0.0, Required),
639                    Float("z", 0.0, Required),
640                    Boolean("Dismount from ship", "true".to_string(), Optional),
641                ],
642                Content::localized("command-goto-desc"),
643                Some(Admin),
644            ),
645            ServerChatCommand::GotoRand => cmd(
646                vec![Boolean("Dismount from ship", "true".to_string(), Optional)],
647                Content::localized("command-goto-rand"),
648                Some(Admin),
649            ),
650            ServerChatCommand::Group => cmd(
651                vec![Message(Optional)],
652                Content::localized("command-group-desc"),
653                None,
654            ),
655            ServerChatCommand::GroupInvite => cmd(
656                vec![PlayerName(Required)],
657                Content::localized("command-group_invite-desc"),
658                None,
659            ),
660            ServerChatCommand::GroupKick => cmd(
661                vec![PlayerName(Required)],
662                Content::localized("command-group_kick-desc"),
663                None,
664            ),
665            ServerChatCommand::GroupLeave => {
666                cmd(vec![], Content::localized("command-group_leave-desc"), None)
667            },
668            ServerChatCommand::GroupPromote => cmd(
669                vec![PlayerName(Required)],
670                Content::localized("command-group_promote-desc"),
671                None,
672            ),
673            ServerChatCommand::Health => cmd(
674                vec![Integer("hp", 100, Required)],
675                Content::localized("command-health-desc"),
676                Some(Admin),
677            ),
678            ServerChatCommand::Respawn => cmd(
679                vec![],
680                Content::localized("command-respawn-desc"),
681                Some(Moderator),
682            ),
683            ServerChatCommand::JoinFaction => cmd(
684                vec![Any("faction", Optional)],
685                Content::localized("command-join_faction-desc"),
686                None,
687            ),
688            ServerChatCommand::Jump => cmd(
689                vec![
690                    Float("x", 0.0, Required),
691                    Float("y", 0.0, Required),
692                    Float("z", 0.0, Required),
693                    Boolean("Dismount from ship", "true".to_string(), Optional),
694                ],
695                Content::localized("command-jump-desc"),
696                Some(Admin),
697            ),
698            ServerChatCommand::Kick => cmd(
699                vec![PlayerName(Required), Message(Optional)],
700                Content::localized("command-kick-desc"),
701                Some(Moderator),
702            ),
703            ServerChatCommand::Kill => cmd(vec![], Content::localized("command-kill-desc"), None),
704            ServerChatCommand::KillNpcs => cmd(
705                vec![Float("radius", 100.0, Optional), Flag("--also-pets")],
706                Content::localized("command-kill_npcs-desc"),
707                Some(Admin),
708            ),
709            ServerChatCommand::Kit => cmd(
710                vec![Enum("kit_name", KITS.to_vec(), Required)],
711                Content::localized("command-kit-desc"),
712                Some(Admin),
713            ),
714            ServerChatCommand::Lantern => cmd(
715                vec![
716                    Float("strength", 5.0, Required),
717                    Float("r", 1.0, Optional),
718                    Float("g", 1.0, Optional),
719                    Float("b", 1.0, Optional),
720                ],
721                Content::localized("command-lantern-desc"),
722                Some(Admin),
723            ),
724            ServerChatCommand::Light => cmd(
725                vec![
726                    Float("r", 1.0, Optional),
727                    Float("g", 1.0, Optional),
728                    Float("b", 1.0, Optional),
729                    Float("x", 0.0, Optional),
730                    Float("y", 0.0, Optional),
731                    Float("z", 0.0, Optional),
732                    Float("strength", 5.0, Optional),
733                ],
734                Content::localized("command-light-desc"),
735                Some(Admin),
736            ),
737            ServerChatCommand::MakeBlock => cmd(
738                vec![
739                    Enum("block", BLOCK_KINDS.clone(), Required),
740                    Integer("r", 255, Optional),
741                    Integer("g", 255, Optional),
742                    Integer("b", 255, Optional),
743                ],
744                Content::localized("command-make_block-desc"),
745                Some(Admin),
746            ),
747            ServerChatCommand::MakeNpc => cmd(
748                vec![
749                    AssetPath(
750                        "entity_config",
751                        "common.entity.",
752                        ENTITY_CONFIGS.clone(),
753                        Required,
754                    ),
755                    Integer("num", 1, Optional),
756                ],
757                Content::localized("command-make_npc-desc"),
758                Some(Admin),
759            ),
760            ServerChatCommand::MakeSprite => cmd(
761                vec![Enum("sprite", SPRITE_KINDS.clone(), Required)],
762                Content::localized("command-make_sprite-desc"),
763                Some(Admin),
764            ),
765            ServerChatCommand::Motd => cmd(vec![], Content::localized("command-motd-desc"), None),
766            ServerChatCommand::Object => cmd(
767                vec![Enum("object", OBJECTS.clone(), Required)],
768                Content::localized("command-object-desc"),
769                Some(Admin),
770            ),
771            ServerChatCommand::Outcome => cmd(
772                vec![Enum("outcome", OUTCOME_KINDS.clone(), Required)],
773                Content::localized("command-outcome-desc"),
774                Some(Admin),
775            ),
776            ServerChatCommand::PermitBuild => cmd(
777                vec![Any("area_name", Required)],
778                Content::localized("command-permit_build-desc"),
779                Some(Admin),
780            ),
781            ServerChatCommand::Players => {
782                cmd(vec![], Content::localized("command-players-desc"), None)
783            },
784            ServerChatCommand::Portal => cmd(
785                vec![
786                    Float("x", 0., Required),
787                    Float("y", 0., Required),
788                    Float("z", 0., Required),
789                    Boolean("requires_no_aggro", "true".to_string(), Optional),
790                    Float("buildup_time", 5., Optional),
791                ],
792                Content::localized("command-portal-desc"),
793                Some(Admin),
794            ),
795            ServerChatCommand::ReloadChunks => cmd(
796                vec![Integer("chunk_radius", 6, Optional)],
797                Content::localized("command-reload_chunks-desc"),
798                Some(Admin),
799            ),
800            ServerChatCommand::ResetRecipes => cmd(
801                vec![],
802                Content::localized("command-reset_recipes-desc"),
803                Some(Admin),
804            ),
805            ServerChatCommand::RemoveLights => cmd(
806                vec![Float("radius", 20.0, Optional)],
807                Content::localized("command-remove_lights-desc"),
808                Some(Admin),
809            ),
810            ServerChatCommand::RevokeBuild => cmd(
811                vec![Any("area_name", Required)],
812                Content::localized("command-revoke_build-desc"),
813                Some(Admin),
814            ),
815            ServerChatCommand::RevokeBuildAll => cmd(
816                vec![],
817                Content::localized("command-revoke_build_all-desc"),
818                Some(Admin),
819            ),
820            ServerChatCommand::Region => cmd(
821                vec![Message(Optional)],
822                Content::localized("command-region-desc"),
823                None,
824            ),
825            ServerChatCommand::Safezone => cmd(
826                vec![Float("range", 100.0, Optional)],
827                Content::localized("command-safezone-desc"),
828                Some(Moderator),
829            ),
830            ServerChatCommand::Say => cmd(
831                vec![Message(Optional)],
832                Content::localized("command-say-desc"),
833                None,
834            ),
835            ServerChatCommand::ServerPhysics => cmd(
836                vec![
837                    PlayerName(Required),
838                    Boolean("enabled", "true".to_string(), Optional),
839                    Message(Optional),
840                ],
841                Content::localized("command-server_physics-desc"),
842                Some(Moderator),
843            ),
844            ServerChatCommand::SetMotd => cmd(
845                vec![Any("locale", Optional), Message(Optional)],
846                Content::localized("command-set_motd-desc"),
847                Some(Admin),
848            ),
849            ServerChatCommand::Ship => cmd(
850                vec![
851                    Enum(
852                        "kind",
853                        comp::ship::ALL_SHIPS
854                            .iter()
855                            .map(|b| format!("{b:?}"))
856                            .collect(),
857                        Optional,
858                    ),
859                    Boolean(
860                        "Whether the ship should be tethered to the target (or its mount)",
861                        "false".to_string(),
862                        Optional,
863                    ),
864                    Float("destination_degrees_ccw_of_east", 90.0, Optional),
865                ],
866                Content::localized("command-ship-desc"),
867                Some(Admin),
868            ),
869            // Uses Message because site names can contain spaces,
870            // which would be assumed to be separators otherwise
871            ServerChatCommand::Site => cmd(
872                vec![
873                    SiteName(Required),
874                    Boolean("Dismount from ship", "true".to_string(), Optional),
875                ],
876                Content::localized("command-site-desc"),
877                Some(Moderator),
878            ),
879            ServerChatCommand::SkillPoint => cmd(
880                vec![
881                    Enum("skill tree", SKILL_TREES.clone(), Required),
882                    Integer("amount", 1, Optional),
883                ],
884                Content::localized("command-skill_point-desc"),
885                Some(Admin),
886            ),
887            ServerChatCommand::SkillPreset => cmd(
888                vec![Enum("preset_name", PRESET_LIST.to_vec(), Required)],
889                Content::localized("command-skill_preset-desc"),
890                Some(Admin),
891            ),
892            ServerChatCommand::Spawn => cmd(
893                vec![
894                    Enum("alignment", ALIGNMENTS.clone(), Required),
895                    Enum("entity", ENTITIES.clone(), Required),
896                    Integer("amount", 1, Optional),
897                    Boolean("ai", "true".to_string(), Optional),
898                    Float("scale", 1.0, Optional),
899                    Boolean("tethered", "false".to_string(), Optional),
900                ],
901                Content::localized("command-spawn-desc"),
902                Some(Admin),
903            ),
904            ServerChatCommand::Spot => cmd(
905                vec![Enum(
906                    "Spot kind to find",
907                    crate::spot::RON_SPOT_PROPERTIES
908                        .0
909                        .iter()
910                        .map(|s| s.base_structures.clone())
911                        .collect(),
912                    Required,
913                )],
914                Content::localized("command-spot-desc"),
915                Some(Admin),
916            ),
917            ServerChatCommand::Sudo => cmd(
918                vec![EntityTarget(Required), SubCommand],
919                Content::localized("command-sudo-desc"),
920                Some(Moderator),
921            ),
922            ServerChatCommand::Tell => cmd(
923                vec![PlayerName(Required), Message(Optional)],
924                Content::localized("command-tell-desc"),
925                None,
926            ),
927            ServerChatCommand::Time => cmd(
928                vec![Enum("time", TIMES.clone(), Optional)],
929                Content::localized("command-time-desc"),
930                Some(Admin),
931            ),
932            ServerChatCommand::TimeScale => cmd(
933                vec![Float("time scale", 1.0, Optional)],
934                Content::localized("command-time_scale-desc"),
935                Some(Admin),
936            ),
937            ServerChatCommand::Tp => cmd(
938                vec![
939                    EntityTarget(Optional),
940                    Boolean("Dismount from ship", "true".to_string(), Optional),
941                ],
942                Content::localized("command-tp-desc"),
943                Some(Moderator),
944            ),
945            ServerChatCommand::RtsimTp => cmd(
946                vec![
947                    Integer("npc index", 0, Required),
948                    Boolean("Dismount from ship", "true".to_string(), Optional),
949                ],
950                Content::localized("command-rtsim_tp-desc"),
951                Some(Admin),
952            ),
953            ServerChatCommand::RtsimInfo => cmd(
954                vec![Integer("npc index", 0, Required)],
955                Content::localized("command-rtsim_info-desc"),
956                Some(Admin),
957            ),
958            ServerChatCommand::RtsimNpc => cmd(
959                vec![Any("query", Required), Integer("max number", 20, Optional)],
960                Content::localized("command-rtsim_npc-desc"),
961                Some(Admin),
962            ),
963            ServerChatCommand::RtsimPurge => cmd(
964                vec![Boolean(
965                    "whether purging of rtsim data should occur on next startup",
966                    true.to_string(),
967                    Required,
968                )],
969                Content::localized("command-rtsim_purge-desc"),
970                Some(Admin),
971            ),
972            ServerChatCommand::RtsimChunk => cmd(
973                vec![],
974                Content::localized("command-rtsim_chunk-desc"),
975                Some(Admin),
976            ),
977            ServerChatCommand::Unban => cmd(
978                vec![PlayerName(Required)],
979                Content::localized("command-unban-desc"),
980                Some(Moderator),
981            ),
982            ServerChatCommand::UnbanIp => cmd(
983                vec![PlayerName(Required)],
984                Content::localized("command-unban-ip-desc"),
985                Some(Moderator),
986            ),
987            ServerChatCommand::Version => {
988                cmd(vec![], Content::localized("command-version-desc"), None)
989            },
990            ServerChatCommand::Waypoint => cmd(
991                vec![],
992                Content::localized("command-waypoint-desc"),
993                Some(Admin),
994            ),
995            ServerChatCommand::Wiring => cmd(
996                vec![],
997                Content::localized("command-wiring-desc"),
998                Some(Admin),
999            ),
1000            ServerChatCommand::Whitelist => cmd(
1001                vec![Any("add/remove", Required), PlayerName(Required)],
1002                Content::localized("command-whitelist-desc"),
1003                Some(Moderator),
1004            ),
1005            ServerChatCommand::World => cmd(
1006                vec![Message(Optional)],
1007                Content::localized("command-world-desc"),
1008                None,
1009            ),
1010            ServerChatCommand::MakeVolume => cmd(
1011                vec![Integer("size", 15, Optional)],
1012                Content::localized("command-make_volume-desc"),
1013                Some(Admin),
1014            ),
1015            ServerChatCommand::Location => cmd(
1016                vec![Any("name", Required)],
1017                Content::localized("command-location-desc"),
1018                None,
1019            ),
1020            ServerChatCommand::CreateLocation => cmd(
1021                vec![Any("name", Required)],
1022                Content::localized("command-create_location-desc"),
1023                Some(Moderator),
1024            ),
1025            ServerChatCommand::DeleteLocation => cmd(
1026                vec![Any("name", Required)],
1027                Content::localized("command-delete_location-desc"),
1028                Some(Moderator),
1029            ),
1030            ServerChatCommand::WeatherZone => cmd(
1031                vec![
1032                    Enum("weather kind", WEATHERS.clone(), Required),
1033                    Float("radius", 500.0, Optional),
1034                    Float("time", 300.0, Optional),
1035                ],
1036                Content::localized("command-weather_zone-desc"),
1037                Some(Admin),
1038            ),
1039            ServerChatCommand::Lightning => cmd(
1040                vec![],
1041                Content::localized("command-lightning-desc"),
1042                Some(Admin),
1043            ),
1044            ServerChatCommand::Scale => cmd(
1045                vec![
1046                    Float("factor", 1.0, Required),
1047                    Boolean("reset_mass", true.to_string(), Optional),
1048                ],
1049                Content::localized("command-scale-desc"),
1050                Some(Admin),
1051            ),
1052            ServerChatCommand::RepairEquipment => cmd(
1053                vec![],
1054                Content::localized("command-repair_equipment-desc"),
1055                Some(Admin),
1056            ),
1057            ServerChatCommand::Tether => cmd(
1058                vec![
1059                    EntityTarget(Required),
1060                    Boolean("automatic length", "true".to_string(), Optional),
1061                ],
1062                Content::localized("command-tether-desc"),
1063                Some(Admin),
1064            ),
1065            ServerChatCommand::DestroyTethers => cmd(
1066                vec![],
1067                Content::localized("command-destroy_tethers-desc"),
1068                Some(Admin),
1069            ),
1070            ServerChatCommand::Mount => cmd(
1071                vec![EntityTarget(Required)],
1072                Content::localized("command-mount-desc"),
1073                Some(Admin),
1074            ),
1075            ServerChatCommand::Dismount => cmd(
1076                vec![EntityTarget(Required)],
1077                Content::localized("command-dismount-desc"),
1078                Some(Admin),
1079            ),
1080        }
1081    }
1082
1083    /// The keyword used to invoke the command, omitting the prefix.
1084    pub fn keyword(&self) -> &'static str {
1085        match self {
1086            ServerChatCommand::Adminify => "adminify",
1087            ServerChatCommand::Airship => "airship",
1088            ServerChatCommand::Alias => "alias",
1089            ServerChatCommand::AreaAdd => "area_add",
1090            ServerChatCommand::AreaList => "area_list",
1091            ServerChatCommand::AreaRemove => "area_remove",
1092            ServerChatCommand::Aura => "aura",
1093            ServerChatCommand::Ban => "ban",
1094            ServerChatCommand::BanIp => "ban_ip",
1095            ServerChatCommand::BattleMode => "battlemode",
1096            ServerChatCommand::BattleModeForce => "battlemode_force",
1097            ServerChatCommand::Body => "body",
1098            ServerChatCommand::Buff => "buff",
1099            ServerChatCommand::Build => "build",
1100            ServerChatCommand::Campfire => "campfire",
1101            ServerChatCommand::ClearPersistedTerrain => "clear_persisted_terrain",
1102            ServerChatCommand::DeathEffect => "death_effect",
1103            ServerChatCommand::DebugColumn => "debug_column",
1104            ServerChatCommand::DebugWays => "debug_ways",
1105            ServerChatCommand::DisconnectAllPlayers => "disconnect_all_players",
1106            ServerChatCommand::DropAll => "dropall",
1107            ServerChatCommand::Dummy => "dummy",
1108            ServerChatCommand::Explosion => "explosion",
1109            ServerChatCommand::Faction => "faction",
1110            ServerChatCommand::GiveItem => "give_item",
1111            ServerChatCommand::Goto => "goto",
1112            ServerChatCommand::GotoRand => "goto_rand",
1113            ServerChatCommand::Group => "group",
1114            ServerChatCommand::GroupInvite => "group_invite",
1115            ServerChatCommand::GroupKick => "group_kick",
1116            ServerChatCommand::GroupLeave => "group_leave",
1117            ServerChatCommand::GroupPromote => "group_promote",
1118            ServerChatCommand::Health => "health",
1119            ServerChatCommand::IntoNpc => "into_npc",
1120            ServerChatCommand::JoinFaction => "join_faction",
1121            ServerChatCommand::Jump => "jump",
1122            ServerChatCommand::Kick => "kick",
1123            ServerChatCommand::Kill => "kill",
1124            ServerChatCommand::KillNpcs => "kill_npcs",
1125            ServerChatCommand::Kit => "kit",
1126            ServerChatCommand::Lantern => "lantern",
1127            ServerChatCommand::Respawn => "respawn",
1128            ServerChatCommand::Light => "light",
1129            ServerChatCommand::MakeBlock => "make_block",
1130            ServerChatCommand::MakeNpc => "make_npc",
1131            ServerChatCommand::MakeSprite => "make_sprite",
1132            ServerChatCommand::Motd => "motd",
1133            ServerChatCommand::Object => "object",
1134            ServerChatCommand::Outcome => "outcome",
1135            ServerChatCommand::PermitBuild => "permit_build",
1136            ServerChatCommand::Players => "players",
1137            ServerChatCommand::Portal => "portal",
1138            ServerChatCommand::ResetRecipes => "reset_recipes",
1139            ServerChatCommand::Region => "region",
1140            ServerChatCommand::ReloadChunks => "reload_chunks",
1141            ServerChatCommand::RemoveLights => "remove_lights",
1142            ServerChatCommand::RevokeBuild => "revoke_build",
1143            ServerChatCommand::RevokeBuildAll => "revoke_build_all",
1144            ServerChatCommand::Safezone => "safezone",
1145            ServerChatCommand::Say => "say",
1146            ServerChatCommand::ServerPhysics => "server_physics",
1147            ServerChatCommand::SetMotd => "set_motd",
1148            ServerChatCommand::Ship => "ship",
1149            ServerChatCommand::Site => "site",
1150            ServerChatCommand::SkillPoint => "skill_point",
1151            ServerChatCommand::SkillPreset => "skill_preset",
1152            ServerChatCommand::Spawn => "spawn",
1153            ServerChatCommand::Spot => "spot",
1154            ServerChatCommand::Sudo => "sudo",
1155            ServerChatCommand::Tell => "tell",
1156            ServerChatCommand::Time => "time",
1157            ServerChatCommand::TimeScale => "time_scale",
1158            ServerChatCommand::Tp => "tp",
1159            ServerChatCommand::RtsimTp => "rtsim_tp",
1160            ServerChatCommand::RtsimInfo => "rtsim_info",
1161            ServerChatCommand::RtsimNpc => "rtsim_npc",
1162            ServerChatCommand::RtsimPurge => "rtsim_purge",
1163            ServerChatCommand::RtsimChunk => "rtsim_chunk",
1164            ServerChatCommand::Unban => "unban",
1165            ServerChatCommand::UnbanIp => "unban_ip",
1166            ServerChatCommand::Version => "version",
1167            ServerChatCommand::Waypoint => "waypoint",
1168            ServerChatCommand::Wiring => "wiring",
1169            ServerChatCommand::Whitelist => "whitelist",
1170            ServerChatCommand::World => "world",
1171            ServerChatCommand::MakeVolume => "make_volume",
1172            ServerChatCommand::Location => "location",
1173            ServerChatCommand::CreateLocation => "create_location",
1174            ServerChatCommand::DeleteLocation => "delete_location",
1175            ServerChatCommand::WeatherZone => "weather_zone",
1176            ServerChatCommand::Lightning => "lightning",
1177            ServerChatCommand::Scale => "scale",
1178            ServerChatCommand::RepairEquipment => "repair_equipment",
1179            ServerChatCommand::Tether => "tether",
1180            ServerChatCommand::DestroyTethers => "destroy_tethers",
1181            ServerChatCommand::Mount => "mount",
1182            ServerChatCommand::Dismount => "dismount",
1183        }
1184    }
1185
1186    /// The short keyword used to invoke the command, omitting the leading '/'.
1187    /// Returns None if the command doesn't have a short keyword
1188    pub fn short_keyword(&self) -> Option<&'static str> {
1189        Some(match self {
1190            ServerChatCommand::Faction => "f",
1191            ServerChatCommand::Group => "g",
1192            ServerChatCommand::Region => "r",
1193            ServerChatCommand::Say => "s",
1194            ServerChatCommand::Tell => "t",
1195            ServerChatCommand::World => "w",
1196            _ => return None,
1197        })
1198    }
1199
1200    /// Produce an iterator over all the available commands
1201    pub fn iter() -> impl Iterator<Item = Self> + Clone { <Self as IntoEnumIterator>::iter() }
1202
1203    /// A message that explains what the command does
1204    pub fn help_content(&self) -> Content {
1205        let data = self.data();
1206
1207        let usage = std::iter::once(format!("/{}", self.keyword()))
1208            .chain(data.args.iter().map(|arg| arg.usage_string()))
1209            .collect::<Vec<_>>()
1210            .join(" ");
1211
1212        Content::localized_with_args("command-help-template", [
1213            ("usage", Content::Plain(usage)),
1214            ("description", data.description),
1215        ])
1216    }
1217
1218    /// Produce an iterator that first goes over all the short keywords
1219    /// and their associated commands and then iterates over all the normal
1220    /// keywords with their associated commands
1221    pub fn iter_with_keywords() -> impl Iterator<Item = (&'static str, Self)> {
1222        Self::iter()
1223        // Go through all the shortcuts first
1224        .filter_map(|c| c.short_keyword().map(|s| (s, c)))
1225        .chain(Self::iter().map(|c| (c.keyword(), c)))
1226    }
1227
1228    pub fn needs_role(&self) -> Option<comp::AdminRole> { self.data().needs_role }
1229
1230    /// Returns a format string for parsing arguments with scan_fmt
1231    pub fn arg_fmt(&self) -> String {
1232        self.data()
1233            .args
1234            .iter()
1235            .map(|arg| match arg {
1236                ArgumentSpec::PlayerName(_) => "{}",
1237                ArgumentSpec::EntityTarget(_) => "{}",
1238                ArgumentSpec::SiteName(_) => "{/.*/}",
1239                ArgumentSpec::Float(_, _, _) => "{}",
1240                ArgumentSpec::Integer(_, _, _) => "{d}",
1241                ArgumentSpec::Any(_, _) => "{}",
1242                ArgumentSpec::Command(_) => "{}",
1243                ArgumentSpec::Message(_) => "{/.*/}",
1244                ArgumentSpec::SubCommand => "{} {/.*/}",
1245                ArgumentSpec::Enum(_, _, _) => "{}",
1246                ArgumentSpec::AssetPath(_, _, _, _) => "{}",
1247                ArgumentSpec::Boolean(_, _, _) => "{}",
1248                ArgumentSpec::Flag(_) => "{}",
1249            })
1250            .collect::<Vec<_>>()
1251            .join(" ")
1252    }
1253}
1254
1255impl Display for ServerChatCommand {
1256    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
1257        write!(f, "{}", self.keyword())
1258    }
1259}
1260
1261impl FromStr for ServerChatCommand {
1262    type Err = ();
1263
1264    fn from_str(keyword: &str) -> Result<ServerChatCommand, ()> {
1265        Self::iter()
1266        // Go through all the shortcuts first
1267        .filter_map(|c| c.short_keyword().map(|s| (s, c)))
1268        .chain(Self::iter().map(|c| (c.keyword(), c)))
1269            // Find command with matching string as keyword
1270            .find_map(|(kwd, command)| (kwd == keyword).then_some(command))
1271            // Return error if not found
1272            .ok_or(())
1273    }
1274}
1275
1276#[derive(Eq, PartialEq, Debug, Clone, Copy)]
1277pub enum Requirement {
1278    Required,
1279    Optional,
1280}
1281
1282/// Representation for chat command arguments
1283pub enum ArgumentSpec {
1284    /// The argument refers to a player by alias
1285    PlayerName(Requirement),
1286    /// The arguments refers to an entity in some way.
1287    EntityTarget(Requirement),
1288    // The argument refers to a site, by name.
1289    SiteName(Requirement),
1290    /// The argument is a float. The associated values are
1291    /// * label
1292    /// * suggested tab-completion
1293    /// * whether it's optional
1294    Float(&'static str, f32, Requirement),
1295    /// The argument is an integer. The associated values are
1296    /// * label
1297    /// * suggested tab-completion
1298    /// * whether it's optional
1299    Integer(&'static str, i32, Requirement),
1300    /// The argument is any string that doesn't contain spaces
1301    Any(&'static str, Requirement),
1302    /// The argument is a command name (such as in /help)
1303    Command(Requirement),
1304    /// This is the final argument, consuming all characters until the end of
1305    /// input.
1306    Message(Requirement),
1307    /// This command is followed by another command (such as in /sudo)
1308    SubCommand,
1309    /// The argument is likely an enum. The associated values are
1310    /// * label
1311    /// * Predefined string completions
1312    /// * whether it's optional
1313    Enum(&'static str, Vec<String>, Requirement),
1314    /// The argument is an asset path. The associated values are
1315    /// * label
1316    /// * Path prefix shared by all assets
1317    /// * List of all asset paths as strings for completion
1318    /// * whether it's optional
1319    AssetPath(&'static str, &'static str, Vec<String>, Requirement),
1320    /// The argument is likely a boolean. The associated values are
1321    /// * label
1322    /// * suggested tab-completion
1323    /// * whether it's optional
1324    Boolean(&'static str, String, Requirement),
1325    /// The argument is a flag that enables or disables a feature.
1326    Flag(&'static str),
1327}
1328
1329impl ArgumentSpec {
1330    pub fn usage_string(&self) -> String {
1331        match self {
1332            ArgumentSpec::PlayerName(req) => {
1333                if &Requirement::Required == req {
1334                    "<player>".to_string()
1335                } else {
1336                    "[player]".to_string()
1337                }
1338            },
1339            ArgumentSpec::EntityTarget(req) => {
1340                if &Requirement::Required == req {
1341                    "<entity>".to_string()
1342                } else {
1343                    "[entity]".to_string()
1344                }
1345            },
1346            ArgumentSpec::SiteName(req) => {
1347                if &Requirement::Required == req {
1348                    "<site>".to_string()
1349                } else {
1350                    "[site]".to_string()
1351                }
1352            },
1353            ArgumentSpec::Float(label, _, req) => {
1354                if &Requirement::Required == req {
1355                    format!("<{}>", label)
1356                } else {
1357                    format!("[{}]", label)
1358                }
1359            },
1360            ArgumentSpec::Integer(label, _, req) => {
1361                if &Requirement::Required == req {
1362                    format!("<{}>", label)
1363                } else {
1364                    format!("[{}]", label)
1365                }
1366            },
1367            ArgumentSpec::Any(label, req) => {
1368                if &Requirement::Required == req {
1369                    format!("<{}>", label)
1370                } else {
1371                    format!("[{}]", label)
1372                }
1373            },
1374            ArgumentSpec::Command(req) => {
1375                if &Requirement::Required == req {
1376                    "<[/]command>".to_string()
1377                } else {
1378                    "[[/]command]".to_string()
1379                }
1380            },
1381            ArgumentSpec::Message(req) => {
1382                if &Requirement::Required == req {
1383                    "<message>".to_string()
1384                } else {
1385                    "[message]".to_string()
1386                }
1387            },
1388            ArgumentSpec::SubCommand => "<[/]command> [args...]".to_string(),
1389            ArgumentSpec::Enum(label, _, req) => {
1390                if &Requirement::Required == req {
1391                    format!("<{}>", label)
1392                } else {
1393                    format!("[{}]", label)
1394                }
1395            },
1396            ArgumentSpec::AssetPath(label, _, _, req) => {
1397                if &Requirement::Required == req {
1398                    format!("<{}>", label)
1399                } else {
1400                    format!("[{}]", label)
1401                }
1402            },
1403            ArgumentSpec::Boolean(label, _, req) => {
1404                if &Requirement::Required == req {
1405                    format!("<{}>", label)
1406                } else {
1407                    format!("[{}]", label)
1408                }
1409            },
1410            ArgumentSpec::Flag(label) => {
1411                format!("[{}]", label)
1412            },
1413        }
1414    }
1415
1416    pub fn requirement(&self) -> Requirement {
1417        match self {
1418            ArgumentSpec::PlayerName(r)
1419            | ArgumentSpec::EntityTarget(r)
1420            | ArgumentSpec::SiteName(r)
1421            | ArgumentSpec::Float(_, _, r)
1422            | ArgumentSpec::Integer(_, _, r)
1423            | ArgumentSpec::Any(_, r)
1424            | ArgumentSpec::Command(r)
1425            | ArgumentSpec::Message(r)
1426            | ArgumentSpec::Enum(_, _, r)
1427            | ArgumentSpec::AssetPath(_, _, _, r)
1428            | ArgumentSpec::Boolean(_, _, r) => *r,
1429            ArgumentSpec::Flag(_) => Requirement::Optional,
1430            ArgumentSpec::SubCommand => Requirement::Required,
1431        }
1432    }
1433}
1434
1435pub trait CommandEnumArg: FromStr {
1436    fn all_options() -> Vec<String>;
1437}
1438
1439macro_rules! impl_from_to_str_cmd {
1440    ($enum:ident, ($($attribute:ident => $str:expr),*)) => {
1441        impl std::str::FromStr for $enum {
1442            type Err = String;
1443
1444            fn from_str(s: &str) -> Result<Self, Self::Err> {
1445                match s {
1446                    $(
1447                        $str => Ok($enum::$attribute),
1448                    )*
1449                    s => Err(format!("Invalid variant: {s}")),
1450                }
1451            }
1452        }
1453
1454        impl $crate::cmd::CommandEnumArg for $enum {
1455            fn all_options() -> Vec<String> {
1456                vec![$($str.to_string()),*]
1457            }
1458        }
1459    }
1460}
1461
1462impl_from_to_str_cmd!(AuraKindVariant, (
1463    Buff => "buff",
1464    FriendlyFire => "friendly_fire",
1465    ForcePvP => "force_pvp"
1466));
1467
1468impl_from_to_str_cmd!(GroupTarget, (
1469    InGroup => "in_group",
1470    OutOfGroup => "out_of_group",
1471    All => "all"
1472));
1473
1474/// Parse a series of command arguments into values, including collecting all
1475/// trailing arguments.
1476#[macro_export]
1477macro_rules! parse_cmd_args {
1478    ($args:expr, $($t:ty),* $(, ..$tail:ty)? $(,)?) => {
1479        {
1480            let mut args = $args.into_iter().peekable();
1481            (
1482                // We only consume the input argument when parsing is successful. If this fails, we
1483                // will then attempt to parse it as the next argument type. This is done regardless
1484                // of whether the argument is optional because that information is not available
1485                // here. Nevertheless, if the caller only precedes to use the parsed arguments when
1486                // all required arguments parse successfully to `Some(val)` this should not create
1487                // any unexpected behavior.
1488                //
1489                // This does mean that optional arguments will be included in the trailing args or
1490                // that one optional arg could be interpreted as another, if the user makes a
1491                // mistake that causes an optional arg to fail to parse. But there is no way to
1492                // discern this in the current model with the optional args and trailing arg being
1493                // solely position based.
1494                $({
1495                    let parsed = args.peek().and_then(|s| s.parse::<$t>().ok());
1496                    // Consume successfully parsed arg.
1497                    if parsed.is_some() { args.next(); }
1498                    parsed
1499                }),*
1500                $(, args.map(|s| s.to_string()).collect::<$tail>())?
1501            )
1502        }
1503    };
1504}
1505
1506#[cfg(test)]
1507mod tests {
1508    use super::*;
1509    use crate::comp::Item;
1510
1511    #[test]
1512    fn verify_cmd_list_sorted() {
1513        let mut list = ServerChatCommand::iter()
1514            .map(|c| c.keyword())
1515            .collect::<Vec<_>>();
1516
1517        // Vec::is_sorted is unstable, so we do it the hard way
1518        let list2 = list.clone();
1519        list.sort_unstable();
1520        assert_eq!(list, list2);
1521    }
1522
1523    #[test]
1524    fn test_loading_skill_presets() {
1525        SkillPresetManifest::load_expect_combined_static(PRESET_MANIFEST_PATH);
1526    }
1527
1528    #[test]
1529    fn test_load_kits() {
1530        let kits = KitManifest::load_expect_combined_static(KIT_MANIFEST_PATH).read();
1531        let mut rng = rand::thread_rng();
1532        for kit in kits.0.values() {
1533            for (item_id, _) in kit.iter() {
1534                match item_id {
1535                    KitSpec::Item(item_id) => {
1536                        Item::new_from_asset_expect(item_id);
1537                    },
1538                    KitSpec::ModularWeapon { tool, material } => {
1539                        comp::item::modular::random_weapon(*tool, *material, None, &mut rng)
1540                            .unwrap_or_else(|_| {
1541                                panic!(
1542                                    "Failed to synthesize a modular {tool:?} made of {material:?}."
1543                                )
1544                            });
1545                    },
1546                }
1547            }
1548        }
1549    }
1550}