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(_))) => {} 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 (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 ("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 ("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}