1use crate::prelude::*;
2use rusterix::{Entity, Item, Linedef, Map, Sector, Value};
3use std::collections::BTreeSet;
4use toml::Table;
5use vek::Vec2;
6
7const TEXT_ROOM_FALLBACK_DISTANCE: f32 = 2.0;
8
9#[derive(Clone, Copy, PartialEq, Eq)]
10pub enum StartupDisplay {
11 Description,
12 Room,
13 None,
14}
15
16#[derive(Clone, Copy, PartialEq, Eq)]
17pub enum ExitPresentation {
18 List,
19 Sentence,
20}
21
22#[derive(Clone)]
23pub struct TextExit {
24 pub direction: String,
25 pub title: String,
26 pub target_title: String,
27 pub target_sector_id: u32,
28 pub target_center: Vec2<f32>,
29}
30
31#[derive(Clone)]
32pub enum TextTarget {
33 Entity { id: u32, distance: f32 },
34 Item { id: u32, distance: f32 },
35}
36
37#[derive(Clone, Default)]
38pub struct TextRoom {
39 pub title: String,
40 pub description: String,
41 pub exits: Vec<TextExit>,
42 pub live_entities: Vec<String>,
43 pub nearby_attackers: Vec<String>,
44 pub dead_entities: Vec<String>,
45 pub items: Vec<String>,
46}
47
48pub fn config_table(src: &str) -> Option<Table> {
49 src.parse::<Table>().ok()
50}
51
52pub fn authoring_startup_display(src: &str) -> StartupDisplay {
53 config_table(src)
54 .and_then(|table| {
55 table
56 .get("startup")
57 .and_then(toml::Value::as_table)
58 .and_then(|table| table.get("show"))
59 .and_then(toml::Value::as_str)
60 .map(str::to_string)
61 })
62 .map(|value| match value.trim().to_ascii_lowercase().as_str() {
63 "none" => StartupDisplay::None,
64 "room" => StartupDisplay::Room,
65 _ => StartupDisplay::Description,
66 })
67 .unwrap_or(StartupDisplay::Description)
68}
69
70pub fn authoring_startup_welcome(src: &str) -> Option<String> {
71 config_table(src)
72 .and_then(|table| {
73 table
74 .get("startup")
75 .and_then(toml::Value::as_table)
76 .and_then(|table| table.get("welcome"))
77 .and_then(toml::Value::as_str)
78 .map(str::to_string)
79 })
80 .map(|value| value.trim().to_string())
81 .filter(|value| !value.is_empty())
82}
83
84pub fn authoring_connection_probe_distance(src: &str) -> f32 {
85 config_table(src)
86 .and_then(|table| {
87 table
88 .get("connections")
89 .and_then(toml::Value::as_table)
90 .and_then(|table| table.get("probe_distance"))
91 .and_then(|value| {
92 value
93 .as_float()
94 .or_else(|| value.as_integer().map(|v| v as f64))
95 })
96 })
97 .map(|value| value as f32)
98 .unwrap_or(1.5)
99}
100
101pub fn authoring_exit_presentation(src: &str) -> ExitPresentation {
102 config_table(src)
103 .and_then(|table| {
104 table
105 .get("exits")
106 .and_then(toml::Value::as_table)
107 .and_then(|table| table.get("style"))
108 .and_then(toml::Value::as_str)
109 .map(str::to_string)
110 })
111 .map(|value| match value.trim().to_ascii_lowercase().as_str() {
112 "sentence" => ExitPresentation::Sentence,
113 _ => ExitPresentation::List,
114 })
115 .unwrap_or(ExitPresentation::List)
116}
117
118pub fn current_player_and_sector(map: &Map) -> Option<(&Entity, &Sector)> {
119 let player = map.entities.iter().find(|entity| entity.is_player())?;
120 let player_pos = player.get_pos_xz();
121 let current_sector_id = player
122 .attributes
123 .get("sector_id")
124 .and_then(|value| match value {
125 Value::Int64(v) if *v >= 0 => Some(*v as u32),
126 Value::Int(v) if *v >= 0 => Some(*v as u32),
127 _ => None,
128 });
129 let current_sector_name = player
130 .get_attr_string("sector")
131 .filter(|s| !s.is_empty())
132 .or_else(|| map.find_sector_at(player_pos).map(|s| s.name.clone()))
133 .unwrap_or_default();
134
135 let sector = if let Some(current_sector_id) = current_sector_id {
136 map.sectors
137 .iter()
138 .find(|sector| sector.id == current_sector_id)
139 .or_else(|| map.find_sector_at(player_pos))
140 .or_else(|| probe_nearest_sector(map, player_pos, TEXT_ROOM_FALLBACK_DISTANCE))
141 } else if !current_sector_name.is_empty() {
142 map.sectors
143 .iter()
144 .find(|sector| sector.name == current_sector_name)
145 .or_else(|| map.find_sector_at(player_pos))
146 .or_else(|| probe_nearest_sector(map, player_pos, TEXT_ROOM_FALLBACK_DISTANCE))
147 } else {
148 map.find_sector_at(player_pos)
149 .or_else(|| probe_nearest_sector(map, player_pos, TEXT_ROOM_FALLBACK_DISTANCE))
150 }?;
151
152 Some((player, sector))
153}
154
155pub fn display_name_for_entity(entity: &Entity) -> String {
156 entity
157 .get_attr_string("name")
158 .or_else(|| entity.get_attr_string("class_name"))
159 .unwrap_or_else(|| format!("Entity {}", entity.id))
160}
161
162fn entity_target_labels(entity: &Entity) -> Vec<String> {
163 let mut labels = Vec::new();
164
165 if let Some(name) = entity
166 .get_attr_string("name")
167 .filter(|s| !s.trim().is_empty())
168 {
169 labels.push(name);
170 }
171 if let Some(race) = entity
172 .get_attr_string("race")
173 .filter(|s| !s.trim().is_empty())
174 {
175 labels.push(race);
176 }
177 if let Some(class) = entity
178 .get_attr_string("class")
179 .filter(|s| !s.trim().is_empty())
180 {
181 labels.push(class);
182 }
183 if let Some(class_name) = entity
184 .get_attr_string("class_name")
185 .filter(|s| !s.trim().is_empty())
186 {
187 labels.push(class_name);
188 }
189
190 if labels.is_empty() {
191 labels.push(format!("Entity {}", entity.id));
192 }
193
194 labels
195}
196
197pub fn display_name_for_item(item: &Item) -> String {
198 item.get_attr_string("name")
199 .or_else(|| item.get_attr_string("class_name"))
200 .unwrap_or_else(|| format!("Item {}", item.id))
201}
202
203pub fn entity_is_dead(entity: &Entity) -> bool {
204 entity
205 .get_attr_string("mode")
206 .map(|mode| mode.eq_ignore_ascii_case("dead"))
207 .unwrap_or(false)
208}
209
210pub fn corpse_name_for_entity(entity: &Entity) -> String {
211 let name = display_name_for_entity(entity);
212 if name.trim().is_empty() {
213 String::new()
214 } else {
215 format!("corpse of {}", sentence_case_exit_title(&name))
216 }
217}
218
219pub fn entity_sector_matches(map: &Map, entity: &Entity, sector: &Sector) -> bool {
220 entity
221 .attributes
222 .get("sector_id")
223 .and_then(|value| match value {
224 Value::Int64(v) if *v >= 0 => Some(*v as u32),
225 Value::Int(v) if *v >= 0 => Some(*v as u32),
226 _ => None,
227 })
228 .map(|id| id == sector.id)
229 .or_else(|| {
230 entity
231 .get_attr_string("sector")
232 .filter(|s| !s.is_empty())
233 .map(|s| s == sector.name)
234 })
235 .unwrap_or_else(|| {
236 let pos = entity.get_pos_xz();
237 map.find_sector_at(pos)
238 .or_else(|| probe_nearest_sector(map, pos, TEXT_ROOM_FALLBACK_DISTANCE))
239 .map(|s| s.id)
240 == Some(sector.id)
241 })
242}
243
244fn entity_target_matches_player(entity: &Entity, player_id: u32) -> bool {
245 entity
246 .attributes
247 .get("target")
248 .map(|value| match value {
249 Value::UInt(v) => *v == player_id,
250 Value::Int(v) if *v >= 0 => *v as u32 == player_id,
251 Value::Int64(v) if *v >= 0 => *v as u32 == player_id,
252 Value::Str(v) => v.trim().parse::<u32>().ok() == Some(player_id),
253 _ => false,
254 })
255 .unwrap_or(false)
256}
257
258pub fn item_sector_matches(map: &Map, item: &Item, sector: &Sector) -> bool {
259 item.attributes
260 .get("sector_id")
261 .and_then(|value| match value {
262 Value::Int64(v) if *v >= 0 => Some(*v as u32),
263 Value::Int(v) if *v >= 0 => Some(*v as u32),
264 _ => None,
265 })
266 .map(|id| id == sector.id)
267 .or_else(|| {
268 item.get_attr_string("sector")
269 .filter(|s| !s.is_empty())
270 .map(|s| s == sector.name)
271 })
272 .unwrap_or_else(|| {
273 let pos = item.get_pos_xz();
274 map.find_sector_at(pos)
275 .or_else(|| probe_nearest_sector(map, pos, TEXT_ROOM_FALLBACK_DISTANCE))
276 .map(|s| s.id)
277 == Some(sector.id)
278 })
279}
280
281pub fn sector_text_metadata(sector: &Sector) -> (String, String) {
282 let mut title = sector.name.clone();
283 let mut description = String::new();
284
285 if let Some(Value::Str(data)) = sector.properties.get("data")
286 && let Ok(table) = data.parse::<toml::Table>()
287 {
288 if let Some(value) = table.get("title").and_then(toml::Value::as_str)
289 && !value.trim().is_empty()
290 {
291 title = value.to_string();
292 }
293 if let Some(value) = table.get("description").and_then(toml::Value::as_str)
294 && !value.trim().is_empty()
295 {
296 description = value.to_string();
297 }
298 }
299
300 (title.trim().to_string(), description.trim_end().to_string())
301}
302
303pub fn linedef_text_metadata(linedef: &Linedef) -> (String, String) {
304 let mut title = linedef.name.clone();
305 let mut description = String::new();
306
307 if let Some(Value::Str(data)) = linedef.properties.get("data")
308 && let Ok(table) = data.parse::<toml::Table>()
309 {
310 if let Some(value) = table.get("title").and_then(toml::Value::as_str)
311 && !value.trim().is_empty()
312 {
313 title = value.to_string();
314 }
315 if let Some(value) = table.get("description").and_then(toml::Value::as_str)
316 && !value.trim().is_empty()
317 {
318 description = value.to_string();
319 }
320 }
321
322 (title.trim().to_string(), description.trim_end().to_string())
323}
324
325pub fn cardinal_direction(from: Vec2<f32>, to: Vec2<f32>) -> String {
326 let delta = to - from;
327 if delta.x.abs() > delta.y.abs() {
328 if delta.x >= 0.0 { "east" } else { "west" }
329 } else if delta.y >= 0.0 {
330 "south"
331 } else {
332 "north"
333 }
334 .to_string()
335}
336
337pub fn probe_nearest_sector(map: &Map, origin: Vec2<f32>, max_distance: f32) -> Option<&Sector> {
338 let mut best: Option<(&Sector, f32)> = None;
339
340 for sector in &map.sectors {
341 if sector.layer.is_some() {
342 continue;
343 }
344 if let Some(distance) = sector.signed_distance(map, origin) {
345 let distance = distance.abs();
346 if distance <= max_distance {
347 match best {
348 Some((_, best_distance)) if distance >= best_distance => {}
349 _ => best = Some((sector, distance)),
350 }
351 }
352 }
353 }
354
355 best.map(|(sector, _)| sector)
356}
357
358pub fn resolve_text_exits(map: &Map, sector: &Sector, probe_distance: f32) -> Vec<TextExit> {
359 let mut exits = Vec::new();
360 let Some(current_center) = sector.center(map) else {
361 return exits;
362 };
363
364 for linedef in &map.linedefs {
365 let (line_title, line_description) = linedef_text_metadata(linedef);
366 if line_title.is_empty() && line_description.is_empty() {
367 continue;
368 }
369
370 let Some(v0) = map.get_vertex(linedef.start_vertex) else {
371 continue;
372 };
373 let Some(v1) = map.get_vertex(linedef.end_vertex) else {
374 continue;
375 };
376
377 let edge = v1 - v0;
378 if edge.magnitude_squared() <= f32::EPSILON {
379 continue;
380 }
381
382 let endpoint_a = probe_nearest_sector(map, v0, probe_distance);
383 let endpoint_b = probe_nearest_sector(map, v1, probe_distance);
384
385 let side_a;
386 let side_b;
387 let (front_sector, back_sector) = if let (Some(a), Some(b)) = (endpoint_a, endpoint_b) {
388 (a, b)
389 } else {
390 let midpoint = (v0 + v1) * 0.5;
391 let normal = Vec2::new(-edge.y, edge.x).normalized();
392 side_a = probe_nearest_sector(
393 map,
394 midpoint - normal * (probe_distance * 0.5),
395 probe_distance,
396 );
397 side_b = probe_nearest_sector(
398 map,
399 midpoint + normal * (probe_distance * 0.5),
400 probe_distance,
401 );
402 let (Some(a), Some(b)) = (side_a, side_b) else {
403 continue;
404 };
405 (a, b)
406 };
407
408 if front_sector.id == back_sector.id {
409 continue;
410 }
411
412 let target_sector = if front_sector.id == sector.id {
413 back_sector
414 } else if back_sector.id == sector.id {
415 front_sector
416 } else {
417 continue;
418 };
419
420 let Some(target_center) = target_sector.center(map) else {
421 continue;
422 };
423 let direction = cardinal_direction(current_center, target_center);
424 let (target_title, _) = sector_text_metadata(target_sector);
425 let title = if !line_title.is_empty() {
426 line_title.clone()
427 } else if !target_title.is_empty() {
428 target_title.clone()
429 } else {
430 target_sector.name.clone()
431 };
432
433 exits.push(TextExit {
434 direction,
435 title,
436 target_title,
437 target_sector_id: target_sector.id,
438 target_center,
439 });
440 }
441
442 exits.sort_by_key(|exit| match exit.direction.as_str() {
443 "north" => 0,
444 "east" => 1,
445 "south" => 2,
446 "west" => 3,
447 _ => 4,
448 });
449 exits.dedup_by(|a, b| a.direction == b.direction && a.target_sector_id == b.target_sector_id);
450 exits
451}
452
453pub fn build_text_room(map: &Map, authoring: &str) -> Option<TextRoom> {
454 let (player, sector) = current_player_and_sector(map)?;
455 let probe_distance = authoring_connection_probe_distance(authoring);
456 let (title, description) = sector_text_metadata(sector);
457 let exits = resolve_text_exits(map, sector, probe_distance);
458 let live_entities = map
459 .entities
460 .iter()
461 .filter(|entity| !entity.is_player() && !entity_is_dead(entity))
462 .filter(|entity| entity_sector_matches(map, entity, sector))
463 .map(display_name_for_entity)
464 .filter(|name| !name.trim().is_empty())
465 .collect();
466 let nearby_attackers = map
467 .entities
468 .iter()
469 .filter(|entity| !entity.is_player() && !entity_is_dead(entity))
470 .filter(|entity| !entity_sector_matches(map, entity, sector))
471 .filter(|entity| entity_target_matches_player(entity, player.id))
472 .map(display_name_for_entity)
473 .filter(|name| !name.trim().is_empty())
474 .collect();
475 let dead_entities = map
476 .entities
477 .iter()
478 .filter(|entity| !entity.is_player() && entity_is_dead(entity))
479 .filter(|entity| entity_sector_matches(map, entity, sector))
480 .map(corpse_name_for_entity)
481 .filter(|name| !name.trim().is_empty())
482 .collect();
483 let items = map
484 .items
485 .iter()
486 .filter(|item| item_sector_matches(map, item, sector))
487 .map(display_name_for_item)
488 .filter(|name| !name.trim().is_empty())
489 .collect();
490
491 Some(TextRoom {
492 title,
493 description,
494 exits,
495 live_entities,
496 nearby_attackers,
497 dead_entities,
498 items,
499 })
500}
501
502pub fn render_current_sector_description(map: &Map) -> Option<String> {
503 let (_, sector) = current_player_and_sector(map)?;
504 let (_, description) = sector_text_metadata(sector);
505 if description.trim().is_empty() {
506 None
507 } else {
508 Some(description.trim_end().to_string())
509 }
510}
511
512pub fn render_player_inventory(map: &Map) -> Option<String> {
513 let (player, _) = current_player_and_sector(map)?;
514
515 let mut lines = vec!["Inventory:".to_string()];
516 let configured_slots = player
517 .attributes
518 .get("inventory_slots")
519 .and_then(|value| match value {
520 Value::Int(v) if *v >= 0 => Some(*v as usize),
521 Value::Int64(v) if *v >= 0 => Some(*v as usize),
522 Value::UInt(v) => Some(*v as usize),
523 _ => None,
524 })
525 .unwrap_or(0);
526
527 let slot_count = player.inventory.len().max(configured_slots);
528 if slot_count == 0 {
529 lines.push(" <empty>".to_string());
530 } else {
531 for index in 0..slot_count {
532 let label = match player.inventory.get(index).and_then(|slot| slot.as_ref()) {
533 Some(item) => display_name_for_item(item),
534 None => "<empty>".to_string(),
535 };
536 lines.push(format!(" {}. {}", index + 1, label));
537 }
538 }
539
540 Some(lines.join("\n"))
541}
542
543fn configured_attr_name(config_src: &str, key: &str, default: &str) -> String {
544 config_table(config_src)
545 .and_then(|config| {
546 config
547 .get("game")
548 .and_then(toml::Value::as_table)
549 .and_then(|game| game.get(key))
550 .and_then(toml::Value::as_str)
551 .map(str::to_string)
552 })
553 .unwrap_or_else(|| default.to_string())
554}
555
556fn configured_slot_names(config_src: &str, key: &str) -> Vec<String> {
557 config_table(config_src)
558 .and_then(|config| {
559 config
560 .get("game")
561 .and_then(toml::Value::as_table)
562 .and_then(|game| game.get(key))
563 .and_then(toml::Value::as_array)
564 .map(|slots| {
565 slots
566 .iter()
567 .filter_map(toml::Value::as_str)
568 .map(|slot| slot.trim().to_ascii_lowercase())
569 .filter(|slot| !slot.is_empty())
570 .collect::<Vec<_>>()
571 })
572 })
573 .unwrap_or_default()
574}
575
576fn is_weapon_slot(config_src: &str, slot: &str) -> bool {
577 let normalized = slot.trim().to_ascii_lowercase();
578 let configured = configured_slot_names(config_src, "weapon_slots");
579 if !configured.is_empty() {
580 return configured
581 .iter()
582 .any(|configured| configured == &normalized);
583 }
584
585 matches!(
586 normalized.as_str(),
587 "main_hand" | "mainhand" | "weapon" | "hand_main" | "off_hand" | "offhand" | "hand_off"
588 )
589}
590
591fn is_gear_slot(config_src: &str, slot: &str) -> bool {
592 let normalized = slot.trim().to_ascii_lowercase();
593 let configured = configured_slot_names(config_src, "gear_slots");
594 if !configured.is_empty() {
595 return configured
596 .iter()
597 .any(|configured| configured == &normalized);
598 }
599
600 !is_weapon_slot(config_src, slot)
601}
602
603fn fmt_scalar(value: f32) -> String {
604 if (value - value.round()).abs() <= 0.0001 {
605 (value.round() as i32).to_string()
606 } else {
607 format!("{:.2}", value)
608 .trim_end_matches('0')
609 .trim_end_matches('.')
610 .to_string()
611 }
612}
613
614fn sum_equipped_attr(
615 entity: &Entity,
616 config_src: &str,
617 attr: &str,
618 filter: fn(&str, &str) -> bool,
619) -> f32 {
620 entity
621 .equipped
622 .iter()
623 .filter(|(slot, _)| filter(config_src, slot))
624 .map(|(_, item)| item.attributes.get_float_default(attr, 0.0))
625 .sum()
626}
627
628fn sum_all_equipped_attr(entity: &Entity, attr: &str) -> f32 {
629 entity
630 .equipped
631 .values()
632 .map(|item| item.attributes.get_float_default(attr, 0.0))
633 .sum()
634}
635
636fn resolve_player_attr(entity: &Entity, attr: &str, config_src: &str) -> String {
637 if let Some(inner) = attr.strip_prefix("WEAPON.") {
638 return fmt_scalar(sum_equipped_attr(entity, config_src, inner, is_weapon_slot));
639 }
640 if let Some(inner) = attr.strip_prefix("EQUIPPED.") {
641 return fmt_scalar(sum_all_equipped_attr(entity, inner));
642 }
643 if let Some(inner) = attr.strip_prefix("ARMOR.") {
644 return fmt_scalar(sum_equipped_attr(entity, config_src, inner, is_gear_slot));
645 }
646 if attr.eq_ignore_ascii_case("ATTACK") {
647 return fmt_scalar(sum_equipped_attr(entity, config_src, "DMG", is_weapon_slot));
648 }
649 if attr.eq_ignore_ascii_case("ARMOR") {
650 return fmt_scalar(sum_equipped_attr(entity, config_src, "ARMOR", is_gear_slot));
651 }
652 if attr.eq_ignore_ascii_case("LEVEL") {
653 let level_attr = configured_attr_name(config_src, "level", "LEVEL");
654 return format!(
655 "{}",
656 entity
657 .attributes
658 .get_float_default(&level_attr, 1.0)
659 .round() as i32
660 );
661 }
662 if attr.eq_ignore_ascii_case("EXPERIENCE") || attr.eq_ignore_ascii_case("EXP") {
663 let exp_attr = configured_attr_name(config_src, "experience", "EXP");
664 return format!(
665 "{}",
666 entity.attributes.get_float_default(&exp_attr, 0.0).round() as i32
667 );
668 }
669 if attr.eq_ignore_ascii_case("FUNDS") {
670 return format!(
671 "{}",
672 entity.attributes.get_float_default("FUNDS", 0.0).round() as i32
673 );
674 }
675
676 entity
677 .attributes
678 .get(attr)
679 .map(|value| format!("{}", value))
680 .unwrap_or_else(|| format!("PLAYER.{}", attr))
681}
682
683fn resolve_player_stats_template(authoring_src: &str) -> Option<String> {
684 config_table(authoring_src)
685 .and_then(|authoring| {
686 authoring
687 .get("text")
688 .and_then(toml::Value::as_table)
689 .and_then(|text| text.get("stats"))
690 .and_then(toml::Value::as_table)
691 .and_then(|stats| {
692 stats
693 .get("text")
694 .and_then(toml::Value::as_str)
695 .map(str::to_string)
696 .or_else(|| {
697 stats
698 .get("lines")
699 .and_then(toml::Value::as_array)
700 .map(|lines| {
701 lines
702 .iter()
703 .filter_map(toml::Value::as_str)
704 .collect::<Vec<_>>()
705 .join("\n")
706 })
707 })
708 })
709 })
710 .filter(|text| !text.trim().is_empty())
711}
712
713fn render_player_template(template: &str, entity: &Entity, config_src: &str) -> String {
714 let mut out = String::new();
715 let mut rest = template;
716
717 while let Some(start) = rest.find('{') {
718 out.push_str(&rest[..start]);
719 let after_start = &rest[start + 1..];
720 if let Some(end) = after_start.find('}') {
721 let token = &after_start[..end];
722 if let Some(attr) = token.strip_prefix("PLAYER.") {
723 out.push_str(&resolve_player_attr(entity, attr, config_src));
724 } else {
725 out.push('{');
726 out.push_str(token);
727 out.push('}');
728 }
729 rest = &after_start[end + 1..];
730 } else {
731 out.push_str(&rest[start..]);
732 rest = "";
733 }
734 }
735
736 out.push_str(rest);
737 out
738}
739
740pub fn render_player_stats(map: &Map, authoring_src: &str, config_src: &str) -> Option<String> {
741 let (player, _) = current_player_and_sector(map)?;
742 let template = resolve_player_stats_template(authoring_src).unwrap_or_else(|| {
743 [
744 "STR:\t{PLAYER.STR}\tDEX:\t{PLAYER.DEX}",
745 "EXP:\t{PLAYER.EXP}\tLEVEL:\t{PLAYER.LEVEL}",
746 "HP:\t{PLAYER.HP}\tG:\t{PLAYER.FUNDS}",
747 "ATK:\t{PLAYER.ATTACK}\tDEF:\t{PLAYER.ARMOR}",
748 ]
749 .join("\n")
750 });
751
752 Some(render_player_template(&template, player, config_src))
753}
754
755pub fn normalize_target_name(text: &str) -> String {
756 text.trim()
757 .to_ascii_lowercase()
758 .chars()
759 .map(|c| {
760 if c.is_ascii_alphanumeric() || c.is_ascii_whitespace() {
761 c
762 } else {
763 ' '
764 }
765 })
766 .collect::<String>()
767 .split_whitespace()
768 .collect::<Vec<_>>()
769 .join(" ")
770}
771
772pub fn target_matches(name: &str, query: &str) -> bool {
773 let name_norm = normalize_target_name(name);
774 let query_norm = normalize_target_name(query);
775 !query_norm.is_empty()
776 && (name_norm == query_norm
777 || name_norm.starts_with(&(query_norm.clone() + " "))
778 || name_norm.contains(&format!(" {}", query_norm)))
779}
780
781pub fn resolve_text_target(map: &Map, sector: &Sector, query: &str) -> Result<TextTarget, String> {
782 let Some((player, _)) = current_player_and_sector(map) else {
783 return Err("No local player found.".into());
784 };
785 let player_pos = player.get_pos_xz();
786 let mut matches: Vec<(String, TextTarget)> = Vec::new();
787
788 for entity in &map.entities {
789 if entity.is_player() || entity_is_dead(entity) {
790 continue;
791 }
792 let in_room = entity_sector_matches(map, entity, sector);
793 let attacking_player = entity_target_matches_player(entity, player.id);
794 if !in_room && !attacking_player {
795 continue;
796 }
797 let name = display_name_for_entity(entity);
798 if entity_target_labels(entity)
799 .iter()
800 .any(|label| target_matches(label, query))
801 {
802 matches.push((
803 name,
804 TextTarget::Entity {
805 id: entity.id,
806 distance: player_pos.distance(entity.get_pos_xz()),
807 },
808 ));
809 }
810 }
811
812 for item in &map.items {
813 if !item_sector_matches(map, item, sector) {
814 continue;
815 }
816 let name = display_name_for_item(item);
817 if target_matches(&name, query) {
818 matches.push((
819 name,
820 TextTarget::Item {
821 id: item.id,
822 distance: player_pos.distance(item.get_pos_xz()),
823 },
824 ));
825 }
826 }
827
828 if matches.is_empty() {
829 return Err(format!("You do not see '{}' here.", query.trim()));
830 }
831 if matches.len() > 1 {
832 matches.sort_by(|a, b| a.0.cmp(&b.0));
833 let names = matches
834 .into_iter()
835 .map(|m| m.0)
836 .collect::<Vec<_>>()
837 .join(", ");
838 return Err(format!("Be more specific: {}", names));
839 }
840
841 Ok(matches.remove(0).1)
842}
843
844fn authored_description_from_entry(value: &toml::Value) -> Option<String> {
845 if let Some(text) = value.as_str() {
846 let text = text.trim();
847 if !text.is_empty() {
848 return Some(text.to_string());
849 }
850 }
851
852 if let Some(table) = value.as_table()
853 && let Some(text) = table.get("description").and_then(toml::Value::as_str)
854 {
855 let text = text.trim();
856 if !text.is_empty() {
857 return Some(text.to_string());
858 }
859 }
860
861 None
862}
863
864fn authored_description_from_data(
865 data: &str,
866 mode: Option<&str>,
867 state: Option<&str>,
868) -> Option<String> {
869 let table = data.parse::<toml::Table>().ok()?;
870
871 if let Some(mode) = mode
872 && let Some(entries) = table.get("mode").and_then(toml::Value::as_table)
873 && let Some(value) = entries.get(mode)
874 && let Some(description) = authored_description_from_entry(value)
875 {
876 return Some(description);
877 }
878
879 if let Some(state) = state
880 && let Some(entries) = table.get("state").and_then(toml::Value::as_table)
881 && let Some(value) = entries.get(state)
882 && let Some(description) = authored_description_from_entry(value)
883 {
884 return Some(description);
885 }
886
887 table
888 .get("description")
889 .and_then(toml::Value::as_str)
890 .map(str::trim)
891 .filter(|text| !text.is_empty())
892 .map(ToString::to_string)
893}
894
895pub fn text_target_look_description(
896 project: &Project,
897 map: &Map,
898 sector: &Sector,
899 query: &str,
900) -> Result<String, String> {
901 let target = resolve_text_target(map, sector, query)?;
902
903 match target {
904 TextTarget::Entity { id, .. } => {
905 let Some(entity) = map.entities.iter().find(|entity| entity.id == id) else {
906 return Err(format!("You do not see '{}' here.", query.trim()));
907 };
908
909 if let Some(msg) = entity.attributes.get_str("on_look") {
910 let msg = msg.trim();
911 if !msg.is_empty() {
912 return Ok(msg.to_string());
913 }
914 }
915
916 let Some(class_name) = entity.get_attr_string("class_name") else {
917 return Err(format!("You see nothing special about {}.", query.trim()));
918 };
919 let Some(character) = project.characters.values().find(|c| c.name == class_name) else {
920 return Err(format!("You see nothing special about {}.", query.trim()));
921 };
922 let mode = entity.get_attr_string("mode");
923 authored_description_from_data(&character.authoring, mode.as_deref(), None)
924 .ok_or_else(|| format!("You see nothing special about {}.", query.trim()))
925 }
926 TextTarget::Item { id, .. } => {
927 let Some(item) = map.items.iter().find(|item| item.id == id) else {
928 return Err(format!("You do not see '{}' here.", query.trim()));
929 };
930
931 if let Some(msg) = item.attributes.get_str("on_look") {
932 let msg = msg.trim();
933 if !msg.is_empty() {
934 return Ok(msg.to_string());
935 }
936 }
937
938 let Some(class_name) = item.get_attr_string("class_name") else {
939 return Err(format!("You see nothing special about {}.", query.trim()));
940 };
941 let Some(item_template) = project.items.values().find(|i| i.name == class_name) else {
942 return Err(format!("You see nothing special about {}.", query.trim()));
943 };
944 let state = item
945 .get_attr_string("state")
946 .filter(|value| !value.trim().is_empty())
947 .or_else(|| {
948 if item.attributes.get_bool_default("active", false) {
949 Some("on".to_string())
950 } else {
951 Some("off".to_string())
952 }
953 });
954 authored_description_from_data(&item_template.authoring, None, state.as_deref())
955 .ok_or_else(|| format!("You see nothing special about {}.", query.trim()))
956 }
957 }
958}
959
960pub fn current_player_supported_intents(project: &Project, map: &Map) -> BTreeSet<String> {
961 let mut intents = BTreeSet::new();
962 let Some((player, _)) = current_player_and_sector(map) else {
963 return intents;
964 };
965 let Some(class_name) = player.get_attr_string("class_name") else {
966 return intents;
967 };
968 let Some(character) = project.characters.values().find(|c| c.name == class_name) else {
969 return intents;
970 };
971 let Ok(table) = character.data.parse::<Table>() else {
972 return intents;
973 };
974 let Some(input) = table.get("input").and_then(toml::Value::as_table) else {
975 return intents;
976 };
977
978 for value in input.values().filter_map(toml::Value::as_str) {
979 let trimmed = value.trim();
980 let lower = trimmed.to_ascii_lowercase();
981 if let Some(inner) = lower
982 .strip_prefix("intent(")
983 .and_then(|v| v.strip_suffix(')'))
984 .map(str::trim)
985 {
986 let inner = inner.trim_matches('"').trim_matches('\'').trim();
987 if !inner.is_empty() {
988 intents.insert(inner.to_string());
989 }
990 }
991 }
992
993 intents
994}
995
996pub fn with_indefinite_article(text: &str) -> String {
997 let trimmed = text.trim_start();
998 let lower = trimmed.to_ascii_lowercase();
999 if lower.starts_with("your ")
1000 || lower.starts_with("my ")
1001 || lower.starts_with("a ")
1002 || lower.starts_with("an ")
1003 || lower.starts_with("the ")
1004 || lower.starts_with("this ")
1005 || lower.starts_with("that ")
1006 || lower.starts_with("in ")
1007 || lower.starts_with("on ")
1008 || lower.starts_with("at ")
1009 || lower.starts_with("under ")
1010 || lower.starts_with("inside ")
1011 || lower.starts_with("outside ")
1012 || lower.starts_with("near ")
1013 || lower.starts_with("behind ")
1014 || lower.starts_with("before ")
1015 || lower.starts_with("after ")
1016 {
1017 return text.to_string();
1018 }
1019
1020 if !trimmed.contains(char::is_whitespace)
1021 && trimmed
1022 .chars()
1023 .next()
1024 .map(|c| c.is_ascii_uppercase())
1025 .unwrap_or(false)
1026 {
1027 return text.to_string();
1028 }
1029
1030 let first = trimmed.chars().next().map(|c| c.to_ascii_lowercase());
1031 let article = match first {
1032 Some('a' | 'e' | 'i' | 'o' | 'u') => "an",
1033 _ => "a",
1034 };
1035 format!("{} {}", article, text)
1036}
1037
1038pub fn sentence_case_exit_title(title: &str) -> String {
1039 let trimmed = title.trim();
1040 if trimmed.is_empty() {
1041 return String::new();
1042 }
1043
1044 let mut chars = trimmed.chars();
1045 let first = chars.next().unwrap();
1046 let mut out = first.to_ascii_lowercase().to_string();
1047 out.push_str(chars.as_str());
1048 out
1049}
1050
1051pub fn render_exit_sentence(exits: &[TextExit]) -> String {
1052 let parts: Vec<String> = exits
1053 .iter()
1054 .map(|exit| {
1055 let subject = if !exit.target_title.trim().is_empty() {
1056 sentence_case_exit_title(&exit.target_title)
1057 } else {
1058 sentence_case_exit_title(&exit.title)
1059 };
1060 format!("{} to the {}", subject, exit.direction)
1061 })
1062 .collect();
1063
1064 match parts.len() {
1065 0 => String::new(),
1066 1 => format!("You see {}.", with_indefinite_article(&parts[0])),
1067 2 => format!(
1068 "You see {} and {}.",
1069 with_indefinite_article(&parts[0]),
1070 with_indefinite_article(&parts[1])
1071 ),
1072 _ => {
1073 let mut sentence = String::from("You see ");
1074 for (index, part) in parts.iter().enumerate() {
1075 let article_part = with_indefinite_article(part);
1076 if index == parts.len() - 1 {
1077 sentence.push_str("and ");
1078 sentence.push_str(&article_part);
1079 } else {
1080 sentence.push_str(&article_part);
1081 sentence.push_str(", ");
1082 }
1083 }
1084 sentence.push('.');
1085 sentence
1086 }
1087 }
1088}
1089
1090pub fn render_presence_sentence(prefix: &str, names: &[String]) -> String {
1091 match names.len() {
1092 0 => String::new(),
1093 1 => format!("{} {} here.", prefix, with_indefinite_article(&names[0])),
1094 2 => format!(
1095 "{} {} and {} here.",
1096 prefix,
1097 with_indefinite_article(&names[0]),
1098 with_indefinite_article(&names[1])
1099 ),
1100 _ => {
1101 let mut sentence = format!("{} ", prefix);
1102 for (index, part) in names.iter().enumerate() {
1103 let part = with_indefinite_article(part);
1104 if index == names.len() - 1 {
1105 sentence.push_str("and ");
1106 sentence.push_str(&part);
1107 } else {
1108 sentence.push_str(&part);
1109 sentence.push_str(", ");
1110 }
1111 }
1112 sentence.push_str(" here.");
1113 sentence
1114 }
1115 }
1116}
1117
1118pub fn render_nearby_attackers_sentence(names: &[String]) -> String {
1119 match names.len() {
1120 0 => String::new(),
1121 1 => format!(
1122 "{} is attacking you from nearby.",
1123 with_indefinite_article(&names[0])
1124 ),
1125 2 => format!(
1126 "{} and {} are attacking you from nearby.",
1127 with_indefinite_article(&names[0]),
1128 with_indefinite_article(&names[1])
1129 ),
1130 _ => {
1131 let mut sentence = String::new();
1132 for (index, part) in names.iter().enumerate() {
1133 let part = with_indefinite_article(part);
1134 if index == names.len() - 1 {
1135 sentence.push_str("and ");
1136 sentence.push_str(&part);
1137 } else {
1138 sentence.push_str(&part);
1139 sentence.push_str(", ");
1140 }
1141 }
1142 sentence.push_str(" are attacking you from nearby.");
1143 sentence
1144 }
1145 }
1146}
1147
1148pub fn render_nearby_attacker_appearance_sentence(names: &[String]) -> String {
1149 match names.len() {
1150 0 => String::new(),
1151 1 => format!("{} appears nearby.", names[0].trim()),
1152 2 => format!("{} and {} appear nearby.", names[0].trim(), names[1].trim()),
1153 _ => {
1154 let mut sentence = String::new();
1155 for (index, part) in names.iter().enumerate() {
1156 if index == names.len() - 1 {
1157 sentence.push_str("and ");
1158 sentence.push_str(part.trim());
1159 } else {
1160 sentence.push_str(part.trim());
1161 sentence.push_str(", ");
1162 }
1163 }
1164 sentence.push_str(" appear nearby.");
1165 sentence
1166 }
1167 }
1168}