Skip to main content

eldiron_shared/
text_game.rs

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}