initiative_core/world/command/
mod.rs

1use crate::app::{
2    AppMeta, Autocomplete, AutocompleteSuggestion, CommandAlias, CommandMatches, ContextAwareParse,
3    Runnable,
4};
5use crate::storage::{Change, Record, RepositoryError, StorageCommand};
6use crate::utils::{quoted_words, CaseInsensitiveStr};
7use crate::world::npc::NpcData;
8use crate::world::place::PlaceData;
9use crate::world::thing::{Thing, ThingData};
10use crate::world::Field;
11use async_trait::async_trait;
12use futures::join;
13use std::fmt;
14use std::ops::Range;
15
16mod autocomplete;
17mod parse;
18
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub enum WorldCommand {
21    Create {
22        parsed_thing_data: ParsedThing<ThingData>,
23    },
24    CreateMultiple {
25        thing_data: ThingData,
26    },
27    Edit {
28        name: String,
29        parsed_diff: ParsedThing<ThingData>,
30    },
31}
32
33#[derive(Clone, Debug, Eq, PartialEq)]
34pub struct ParsedThing<T> {
35    pub thing_data: T,
36    pub unknown_words: Vec<Range<usize>>,
37    pub word_count: usize,
38}
39
40#[async_trait(?Send)]
41impl Runnable for WorldCommand {
42    async fn run(self, input: &str, app_meta: &mut AppMeta) -> Result<String, String> {
43        match self {
44            Self::Create { parsed_thing_data } => {
45                let original_thing_data = parsed_thing_data.thing_data;
46                let unknown_words = parsed_thing_data.unknown_words.to_owned();
47                let mut output = None;
48
49                for _ in 0..10 {
50                    let mut thing_data = original_thing_data.clone();
51                    thing_data.regenerate(&mut app_meta.rng, &app_meta.demographics);
52                    let mut command_alias = None;
53
54                    let (message, change) = match thing_data.name() {
55                        Field::Locked(Some(name)) => {
56                            (
57                                Some(format!(
58                                    "\n\n_Because you specified a name, {name} has been automatically added to your `journal`. Use `undo` to remove {them}._",
59                                    name = name,
60                                    them = thing_data.gender().them(),
61                                )),
62                                Change::CreateAndSave { thing_data, uuid: None },
63                            )
64                        }
65                        Field::Unlocked(Some(name)) => {
66                            command_alias = Some(CommandAlias::literal(
67                                "save",
68                                format!("save {}", name),
69                                StorageCommand::Save {
70                                    name: name.to_string(),
71                                }
72                                .into(),
73                            ));
74
75                            app_meta.command_aliases.insert(CommandAlias::literal(
76                                "more",
77                                format!("create {}", original_thing_data.display_description()),
78                                WorldCommand::CreateMultiple {
79                                    thing_data: original_thing_data.clone(),
80                                }
81                                .into(),
82                            ));
83
84                            (
85                                Some(format!(
86                                    "\n\n_{name} has not yet been saved. Use ~save~ to save {them} to your `journal`. For more suggestions, type ~more~._",
87                                    name = name,
88                                    them = thing_data.gender().them(),
89                                )),
90                                Change::Create { thing_data, uuid: None },
91                            )
92                        }
93                        _ => (None, Change::Create { thing_data, uuid: None }),
94                    };
95
96                    match app_meta.repository.modify(change).await {
97                        Ok(Some(Record { thing, .. })) => {
98                            output = Some(format!(
99                                "{}{}",
100                                thing.display_details(
101                                    app_meta
102                                        .repository
103                                        .load_relations(&thing)
104                                        .await
105                                        .unwrap_or_default(),
106                                ),
107                                message.as_ref().map_or("", String::as_str),
108                            ));
109
110                            if let Some(alias) = command_alias {
111                                app_meta.command_aliases.insert(alias);
112                            }
113
114                            break;
115                        }
116
117                        Err((
118                            Change::Create { thing_data, .. } | Change::CreateAndSave { thing_data, .. },
119                            RepositoryError::NameAlreadyExists(other_thing),
120                        )) => if thing_data.name().is_locked() {
121                            return Err(format!(
122                                "That name is already in use by {}.",
123                                other_thing.display_summary(),
124                            ));
125                        },
126
127                        Err((Change::Create { thing_data, .. }, RepositoryError::MissingName)) => return Err(format!("There is no name generator implemented for that type. You must specify your own name using `{} named [name]`.", thing_data.display_description())),
128
129                        Ok(None) | Err(_) => return Err("An error occurred.".to_string()),
130                    }
131                }
132
133                if let Some(output) = output {
134                    Ok(append_unknown_words_notice(output, input, unknown_words))
135                } else {
136                    Err(format!(
137                        "Couldn't create a unique {} name.",
138                        original_thing_data.display_description(),
139                    ))
140                }
141            }
142            Self::CreateMultiple { thing_data } => {
143                let mut output = format!(
144                    "# Alternative suggestions for \"{}\"",
145                    thing_data.display_description(),
146                );
147
148                for i in 1..=10 {
149                    let mut thing_output = None;
150
151                    for _ in 0..10 {
152                        let mut thing_data = thing_data.clone();
153                        thing_data.regenerate(&mut app_meta.rng, &app_meta.demographics);
154
155                        match app_meta
156                            .repository
157                            .modify(Change::Create {
158                                thing_data,
159                                uuid: None,
160                            })
161                            .await
162                        {
163                            Ok(Some(Record { thing, .. })) => {
164                                app_meta.command_aliases.insert(CommandAlias::literal(
165                                    (i % 10).to_string(),
166                                    format!("load {}", thing.name()),
167                                    StorageCommand::Load {
168                                        name: thing.name().to_string(),
169                                    }
170                                    .into(),
171                                ));
172                                thing_output = Some(format!(
173                                    "{}~{}~ {}",
174                                    if i == 1 { "\n\n" } else { "\\\n" },
175                                    i % 10,
176                                    thing.display_summary(),
177                                ));
178                                break;
179                            }
180                            Ok(None) | Err((_, RepositoryError::NameAlreadyExists(_))) => {} // silently retry
181                            Err(_) => return Err("An error occurred.".to_string()),
182                        }
183                    }
184
185                    if let Some(thing_output) = thing_output {
186                        output.push_str(&thing_output);
187                    } else {
188                        output.push_str("\n\n! An error occurred generating additional results.");
189                        break;
190                    }
191                }
192
193                app_meta.command_aliases.insert(CommandAlias::literal(
194                    "more",
195                    format!("create {}", thing_data.display_description()),
196                    Self::CreateMultiple { thing_data }.into(),
197                ));
198
199                output.push_str("\n\n_For even more suggestions, type ~more~._");
200
201                Ok(output)
202            }
203            Self::Edit { name, parsed_diff } => {
204                let ParsedThing {
205                    thing_data: thing_diff,
206                    unknown_words,
207                    word_count: _,
208                } = parsed_diff;
209
210                let thing_type = thing_diff.as_str();
211
212                match app_meta.repository.modify(Change::Edit {
213                        name: name.clone(),
214                        uuid: None,
215                        diff: thing_diff,
216                    }).await {
217                    Ok(Some(Record { thing, .. })) => Ok(
218                        if matches!(app_meta.repository.undo_history().next(), Some(Change::EditAndUnsave { .. })) {
219                            format!(
220                                "{}\n\n_{} was successfully edited and automatically saved to your `journal`. Use `undo` to reverse this._",
221                                thing.display_details(app_meta.repository.load_relations(&thing).await.unwrap_or_default()),
222                                name,
223                            )
224                        } else {
225                            format!(
226                                "{}\n\n_{} was successfully edited. Use `undo` to reverse this._",
227                                thing.display_details(app_meta.repository.load_relations(&thing).await.unwrap_or_default()),
228                                name,
229                            )
230                        }
231                    ),
232                    Err((_, RepositoryError::NotFound)) => Err(format!(r#"There is no {} named "{}"."#, thing_type, name)),
233                    _ => Err(format!("Couldn't edit `{}`.", name)),
234                }
235                .map(|s| append_unknown_words_notice(s, input, unknown_words))
236            }
237        }
238    }
239}
240
241#[async_trait(?Send)]
242impl ContextAwareParse for WorldCommand {
243    async fn parse_input(input: &str, app_meta: &AppMeta) -> CommandMatches<Self> {
244        let mut matches = CommandMatches::default();
245
246        if let Some(Ok(parsed_thing_data)) = input
247            .strip_prefix_ci("create ")
248            .map(|s| s.parse::<ParsedThing<ThingData>>())
249        {
250            if parsed_thing_data.unknown_words.is_empty() {
251                matches.push_canonical(Self::Create { parsed_thing_data });
252            } else {
253                matches.push_fuzzy(Self::Create { parsed_thing_data });
254            }
255        } else if let Ok(parsed_thing_data) = input.parse::<ParsedThing<ThingData>>() {
256            matches.push_fuzzy(Self::Create { parsed_thing_data });
257        }
258
259        if let Some(word) = quoted_words(input)
260            .skip(1)
261            .find(|word| word.as_str().eq_ci("is"))
262        {
263            let (name, description) = (
264                input[..word.range().start].trim(),
265                input[word.range().end..].trim(),
266            );
267
268            let (diff, thing): (Result<ParsedThing<ThingData>, ()>, Option<Thing>) =
269                if let Ok(Record { thing, .. }) = app_meta.repository.get_by_name(name).await {
270                    (
271                        match thing.data {
272                            ThingData::Npc(_) => description
273                                .parse::<ParsedThing<NpcData>>()
274                                .map(|t| t.into_thing_data()),
275                            ThingData::Place(_) => description
276                                .parse::<ParsedThing<PlaceData>>()
277                                .map(|t| t.into_thing_data()),
278                        }
279                        .or_else(|_| description.parse()),
280                        Some(thing),
281                    )
282                } else {
283                    // This will be an error when we try to run the command, but for now we'll pretend
284                    // it's valid so that we can provide a more coherent message.
285                    (description.parse(), None)
286                };
287
288            if let Ok(mut diff) = diff {
289                let name = thing
290                    .map(|t| t.name().to_string())
291                    .unwrap_or_else(|| name.to_string());
292
293                diff.unknown_words.iter_mut().for_each(|range| {
294                    *range = range.start + word.range().end + 1..range.end + word.range().end + 1
295                });
296
297                matches.push_fuzzy(Self::Edit {
298                    name,
299                    parsed_diff: diff,
300                });
301            }
302        }
303
304        matches
305    }
306}
307
308#[async_trait(?Send)]
309impl Autocomplete for WorldCommand {
310    async fn autocomplete(input: &str, app_meta: &AppMeta) -> Vec<AutocompleteSuggestion> {
311        let mut suggestions = Vec::new();
312
313        let (mut place_suggestions, mut npc_suggestions) = join!(
314            PlaceData::autocomplete(input, app_meta),
315            NpcData::autocomplete(input, app_meta),
316        );
317
318        suggestions.append(&mut place_suggestions);
319        suggestions.append(&mut npc_suggestions);
320
321        let mut input_words = quoted_words(input).skip(1);
322
323        if let Some((is_word, next_word)) = input_words
324            .find(|word| word.as_str().eq_ci("is"))
325            .and_then(|word| input_words.next().map(|next_word| (word, next_word)))
326        {
327            if let Ok(Record { thing, .. }) = app_meta
328                .repository
329                .get_by_name(input[..is_word.range().start].trim())
330                .await
331            {
332                let split_pos = input.len() - input[is_word.range().end..].trim_start().len();
333
334                let edit_suggestions = match thing.data {
335                    ThingData::Npc(_) => {
336                        NpcData::autocomplete(input[split_pos..].trim_start(), app_meta)
337                    }
338                    ThingData::Place(_) => {
339                        PlaceData::autocomplete(input[split_pos..].trim_start(), app_meta)
340                    }
341                }
342                .await;
343
344                suggestions.extend(edit_suggestions.into_iter().map(|suggestion| {
345                    AutocompleteSuggestion::new(
346                        format!("{}{}", &input[..split_pos], suggestion.term),
347                        format!("edit {}", thing.as_str()),
348                    )
349                }));
350
351                if next_word.as_str().in_ci(&["named", "called"]) && input_words.next().is_some() {
352                    suggestions.push(AutocompleteSuggestion::new(
353                        input.to_string(),
354                        format!("rename {}", thing.as_str()),
355                    ));
356                }
357            }
358        }
359
360        if let Ok(Record { thing, .. }) = app_meta.repository.get_by_name(input.trim_end()).await {
361            suggestions.push(AutocompleteSuggestion::new(
362                if input.ends_with(char::is_whitespace) {
363                    format!("{}is [{} description]", input, thing.as_str())
364                } else {
365                    format!("{} is [{} description]", input, thing.as_str())
366                },
367                format!("edit {}", thing.as_str()),
368            ));
369        } else if let Some((last_word_index, last_word)) =
370            quoted_words(input).enumerate().skip(1).last()
371        {
372            if "is".starts_with_ci(last_word.as_str()) {
373                if let Ok(Record { thing, .. }) = app_meta
374                    .repository
375                    .get_by_name(input[..last_word.range().start].trim())
376                    .await
377                {
378                    suggestions.push(AutocompleteSuggestion::new(
379                        if last_word.range().end == input.len() {
380                            format!(
381                                "{}is [{} description]",
382                                &input[..last_word.range().start],
383                                thing.as_str(),
384                            )
385                        } else {
386                            format!("{}[{} description]", &input, thing.as_str())
387                        },
388                        format!("edit {}", thing.as_str()),
389                    ))
390                }
391            } else if let Some(suggestion) = ["named", "called"]
392                .iter()
393                .find(|s| s.starts_with_ci(last_word.as_str()))
394            {
395                let second_last_word = quoted_words(input).nth(last_word_index - 1).unwrap();
396
397                if second_last_word.as_str().eq_ci("is") {
398                    if let Ok(Record { thing, .. }) = app_meta
399                        .repository
400                        .get_by_name(input[..second_last_word.range().start].trim())
401                        .await
402                    {
403                        suggestions.push(AutocompleteSuggestion::new(
404                            if last_word.range().end == input.len() {
405                                format!(
406                                    "{}{} [name]",
407                                    &input[..last_word.range().start],
408                                    suggestion,
409                                )
410                            } else {
411                                format!("{}[name]", input)
412                            },
413                            format!("rename {}", thing.as_str()),
414                        ));
415                    }
416                }
417            }
418        }
419
420        suggestions
421    }
422}
423
424impl fmt::Display for WorldCommand {
425    fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
426        match self {
427            Self::Create { parsed_thing_data } => write!(
428                f,
429                "create {}",
430                parsed_thing_data.thing_data.display_description()
431            ),
432            Self::CreateMultiple { thing_data } => {
433                write!(f, "create  multiple {}", thing_data.display_description())
434            }
435            Self::Edit { name, parsed_diff } => {
436                write!(
437                    f,
438                    "{} is {}",
439                    name,
440                    parsed_diff.thing_data.display_description()
441                )
442            }
443        }
444    }
445}
446
447impl<T: Into<ThingData>> ParsedThing<T> {
448    pub fn into_thing_data(self) -> ParsedThing<ThingData> {
449        ParsedThing {
450            thing_data: self.thing_data.into(),
451            unknown_words: self.unknown_words,
452            word_count: self.word_count,
453        }
454    }
455}
456
457impl<T: Default> Default for ParsedThing<T> {
458    fn default() -> Self {
459        Self {
460            thing_data: T::default(),
461            unknown_words: Vec::default(),
462            word_count: 0,
463        }
464    }
465}
466
467impl<T: Into<ThingData>> From<ParsedThing<T>> for ThingData {
468    fn from(input: ParsedThing<T>) -> Self {
469        input.thing_data.into()
470    }
471}
472
473fn append_unknown_words_notice(
474    mut output: String,
475    input: &str,
476    unknown_words: Vec<Range<usize>>,
477) -> String {
478    if !unknown_words.is_empty() {
479        output.push_str(
480            "\n\n! initiative.sh doesn't know some of those words, but it did its best.\n\n\\> ",
481        );
482
483        {
484            let mut pos = 0;
485            for word_range in unknown_words.iter() {
486                output.push_str(&input[pos..word_range.start]);
487                pos = word_range.end;
488                output.push_str("**");
489                output.push_str(&input[word_range.clone()]);
490                output.push_str("**");
491            }
492            output.push_str(&input[pos..]);
493        }
494
495        output.push_str("\\\n\u{a0}\u{a0}");
496
497        {
498            let mut words = unknown_words.into_iter();
499            let mut unknown_word = words.next();
500            for (i, _) in input.char_indices() {
501                if unknown_word.as_ref().is_some_and(|word| i >= word.end) {
502                    unknown_word = words.next();
503                }
504
505                if let Some(word) = &unknown_word {
506                    output.push(if i >= word.start { '^' } else { '\u{a0}' });
507                } else {
508                    break;
509                }
510            }
511        }
512
513        output.push_str("\\\nWant to help improve its vocabulary? Join us [on Discord](https://discord.gg/ZrqJPpxXVZ) and suggest your new words!");
514    }
515    output
516}
517
518#[cfg(test)]
519mod test {
520    use super::*;
521    use crate::test_utils as test;
522    use crate::world::npc::{Age, Gender, NpcData, Species};
523    use crate::world::place::{PlaceData, PlaceType};
524
525    #[tokio::test]
526    async fn parse_input_test() {
527        let mut app_meta = test::app_meta();
528
529        assert_eq!(
530            CommandMatches::new_fuzzy(create(NpcData::default())),
531            WorldCommand::parse_input("npc", &app_meta).await,
532        );
533
534        assert_eq!(
535            CommandMatches::new_canonical(create(NpcData::default())),
536            WorldCommand::parse_input("create npc", &app_meta).await,
537        );
538
539        assert_eq!(
540            CommandMatches::new_fuzzy(create(NpcData {
541                species: Species::Elf.into(),
542                ..Default::default()
543            })),
544            WorldCommand::parse_input("elf", &app_meta).await,
545        );
546
547        assert_eq!(
548            CommandMatches::default(),
549            WorldCommand::parse_input("potato", &app_meta).await,
550        );
551
552        {
553            app_meta
554                .repository
555                .modify(Change::Create {
556                    thing_data: NpcData {
557                        name: "Spot".into(),
558                        ..Default::default()
559                    }
560                    .into(),
561                    uuid: None,
562                })
563                .await
564                .unwrap();
565
566            assert_eq!(
567                CommandMatches::new_fuzzy(WorldCommand::Edit {
568                    name: "Spot".into(),
569                    parsed_diff: ParsedThing {
570                        thing_data: NpcData {
571                            age: Age::Child.into(),
572                            gender: Gender::Masculine.into(),
573                            ..Default::default()
574                        }
575                        .into(),
576                        #[expect(clippy::single_range_in_vec_init)]
577                        unknown_words: vec![10..14],
578                        word_count: 2,
579                    },
580                }),
581                WorldCommand::parse_input("Spot is a good boy", &app_meta).await,
582            );
583        }
584    }
585
586    #[tokio::test]
587    async fn autocomplete_test() {
588        let app_meta = test::app_meta::with_test_data().await;
589
590        for (word, summary) in [
591            ("npc", "create person"),
592            // Species
593            ("dragonborn", "create dragonborn"),
594            ("dwarf", "create dwarf"),
595            ("elf", "create elf"),
596            ("gnome", "create gnome"),
597            ("half-elf", "create half-elf"),
598            ("half-orc", "create half-orc"),
599            ("halfling", "create halfling"),
600            ("human", "create human"),
601            ("tiefling", "create tiefling"),
602            // PlaceType
603            ("inn", "create inn"),
604        ] {
605            test::assert_autocomplete_eq!(
606                [(word, summary)],
607                WorldCommand::autocomplete(word, &app_meta).await,
608            );
609
610            test::assert_autocomplete_eq!(
611                [(word, summary)],
612                WorldCommand::autocomplete(&word.to_uppercase(), &app_meta).await,
613            );
614        }
615
616        test::assert_autocomplete_eq!(
617            [
618                ("baby", "create infant"),
619                ("bakery", "create bakery"),
620                ("bank", "create bank"),
621                ("bar", "create bar"),
622                ("barony", "create barony"),
623                ("barracks", "create barracks"),
624                ("barrens", "create barrens"),
625                ("base", "create base"),
626                ("bathhouse", "create bathhouse"),
627                ("beach", "create beach"),
628                ("blacksmith", "create blacksmith"),
629                ("boy", "create child, he/him"),
630                ("brewery", "create brewery"),
631                ("bridge", "create bridge"),
632                ("building", "create building"),
633                ("business", "create business"),
634            ],
635            WorldCommand::autocomplete("b", &app_meta).await,
636        );
637
638        test::assert_autocomplete_eq!(
639            [("penelope is [character description]", "edit character")],
640            WorldCommand::autocomplete("penelope", &app_meta).await,
641        );
642
643        test::assert_autocomplete_eq!(
644            [("PENELOPE is a [character description]", "edit character")],
645            WorldCommand::autocomplete("PENELOPE is a ", &app_meta).await,
646        );
647
648        test::assert_autocomplete_eq!(
649            [
650                ("penelope is an elderly", "edit character"),
651                ("penelope is an elf", "edit character"),
652                ("penelope is an elvish", "edit character"),
653                ("penelope is an enby", "edit character"),
654            ],
655            WorldCommand::autocomplete("penelope is an e", &app_meta).await,
656        );
657    }
658
659    #[tokio::test]
660    async fn display_test() {
661        let app_meta = test::app_meta();
662
663        for command in [
664            create(PlaceData {
665                subtype: "inn".parse::<PlaceType>().ok().into(),
666                ..Default::default()
667            }),
668            create(NpcData::default()),
669            create(test::npc().species(Species::Elf).build()),
670        ] {
671            let command_string = command.to_string();
672            assert_ne!("", command_string);
673
674            assert_eq!(
675                CommandMatches::new_canonical(command.clone()),
676                WorldCommand::parse_input(&command_string, &app_meta).await,
677                "{}",
678                command_string,
679            );
680
681            assert_eq!(
682                CommandMatches::new_canonical(command),
683                WorldCommand::parse_input(&command_string.to_uppercase(), &app_meta).await,
684                "{}",
685                command_string.to_uppercase(),
686            );
687        }
688    }
689
690    fn parsed_thing(thing_data: impl Into<ThingData>) -> ParsedThing<ThingData> {
691        ParsedThing {
692            thing_data: thing_data.into(),
693            unknown_words: Vec::new(),
694            word_count: 1,
695        }
696    }
697
698    fn create(thing_data: impl Into<ThingData>) -> WorldCommand {
699        WorldCommand::Create {
700            parsed_thing_data: parsed_thing(thing_data),
701        }
702    }
703}