diff --git a/ansi/src/lib.rs b/ansi/src/lib.rs index 9b7a9f34..c2ab9525 100644 --- a/ansi/src/lib.rs +++ b/ansi/src/lib.rs @@ -20,6 +20,24 @@ struct AnsiState { strike: bool, } +impl AnsiState { + fn restore_ansi(self: &Self) -> String { + let mut buf = String::new(); + if !(self.bold && self.underline && self.strike && + self.background != 0 && self.foreground != 0) { + buf.push_str(ansi!("")); + } + if self.bold { buf.push_str(ansi!("")); } + if self.underline { buf.push_str(ansi!("")); } + if self.strike { buf.push_str(ansi!("")); } + if self.background != 0 { + buf.push_str(&format!("\x1b[{}m", 39 + self.background)); } + if self.foreground != 0 { + buf.push_str(&format!("\x1b[{}m", 29 + self.foreground)); } + buf + } +} + #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] struct AnsiEvent<'l> ( AnsiParseToken<'l>, @@ -187,9 +205,113 @@ pub fn limit_special_characters(input: &str) -> String { /// Flows a second column around a first column, limiting the width of both /// columns as specified, and adding a gutter. -pub fn flow_around(col1: &str, col1_width: u64, gutter: &str, - col2: &str, col2_width: u64) -> String { - "not yet".to_owned() +pub fn flow_around(col1: &str, col1_width: usize, gutter: &str, + col2: &str, col2_width: usize) -> String { + let mut it1 = AnsiIterator::new(col1).peekable(); + let mut it2 = AnsiIterator::new(col2).peekable(); + + let mut buf = String::new(); + + // Phase 1: col1 still has data, so flow col2 around col1. + 'around_rows: loop { + match it1.peek() { + None => break 'around_rows, + Some(AnsiEvent(_, st)) => buf.push_str(&st.restore_ansi()) + } + let mut fill_needed: usize = 0; + let mut skip_nl = true; + 'col_data: for i in 0..col1_width { + 'until_move_forward: loop { + match it1.next() { + None | Some(AnsiEvent(AnsiParseToken::Newline, _)) => { + fill_needed = col1_width - i; + skip_nl = false; + break 'col_data; + } + Some(AnsiEvent(AnsiParseToken::Character(c), _)) => { + buf.push(c); + break 'until_move_forward; + } + Some(AnsiEvent(AnsiParseToken::ControlSeq(s), _)) => { + buf.push_str(s); + } + } + } + } + // If there is a newline (optionally preceded by 1+ control characters), + // and we didn't just read one, we should skip it, since we broke to a + // new line anyway. It is safe to eat any control characters since we will + // restore_ansi() anyway. + if skip_nl { + loop { + match it1.peek() { + None => break, + Some(AnsiEvent(AnsiParseToken::Character(_), _)) => break, + Some(AnsiEvent(AnsiParseToken::ControlSeq(s), _)) => { + if fill_needed > 0 { buf.push_str(s); } + it1.next(); + } + Some(AnsiEvent(AnsiParseToken::Newline, _)) => { + it1.next(); + break; + } + } + } + } + for _ in 0..fill_needed { buf.push(' '); } + + buf.push_str(gutter); + + if let Some(AnsiEvent(_, st)) = it2.peek() { + buf.push_str(&st.restore_ansi()) + } + skip_nl = true; + 'col_data: for _ in 0..col2_width { + 'until_move_forward: loop { + match it2.next() { + None | Some(AnsiEvent(AnsiParseToken::Newline, _)) => { + skip_nl = false; + break 'col_data; + } + Some(AnsiEvent(AnsiParseToken::Character(c), _)) => { + buf.push(c); + break 'until_move_forward; + } + Some(AnsiEvent(AnsiParseToken::ControlSeq(s), _)) => { + buf.push_str(s); + } + } + } + } + if skip_nl { + loop { + match it2.peek() { + None => break, + Some(AnsiEvent(AnsiParseToken::Character(_), _)) => break, + Some(AnsiEvent(AnsiParseToken::ControlSeq(s), _)) => { + if fill_needed > 0 { buf.push_str(s); } + it2.next(); + } + Some(AnsiEvent(AnsiParseToken::Newline, _)) => { + it2.next(); + break; + } + } + } + } + buf.push('\n'); + } + + // Now just copy anything left in it2 over. + for AnsiEvent(e, _) in it2 { + match e { + AnsiParseToken::Character(c) => buf.push(c), + AnsiParseToken::Newline => buf.push('\n'), + AnsiParseToken::ControlSeq(t) => buf.push_str(t) + } + } + + buf } #[cfg(test)] @@ -225,5 +347,37 @@ mod test { assert_eq!(limit_special_characters("Test\x1b[5;5fing"), "Test5fing"); } + + #[test] + fn flow_around_works_for_plain_text() { + let str1 = " /\\ /\\\n\ + /--------\\\n\ + | () () |\n\ + | |\n\ + | /\\ |\n\ + | \\ / |\n\ + | -(--)- |\n\ + | / \\ |\n\ + \\--------/\n\ + A very poor rendition of a cat! Meow."; + let str2 = "Hello world, this is the second column for this test. It starts with a rather long line that will wrap.\n\ + And here is a shorter line.\n\ + All of this should by nicely wrapped, even if it is exactly the len\n\ + gth of column 2!\n\ + \n\ + But double newlines should come up as blank lines.\n\ + Blah\n\ + Blah\n\ + Blah\n\ + Blah\n\ + Blah\n\ + Blah\n\ + Blah\n\ + And once we get to the bottom of column 1, column 2 should just get written\n\ + out normally, not in the previous column."; + // This has a lot of unnecessary resets, but that is expected with the algorithm right now. + let expected = "\u{1b}[0m /\\ /\\ | \u{1b}[0mHello world, this is the second column for this test. It starts wit\n\u{1b}[0m/--------\\ | \u{1b}[0mh a rather long line that will wrap.\n\u{1b}[0m| () () | | \u{1b}[0mAnd here is a shorter line.\n\u{1b}[0m| | | \u{1b}[0mAll of this should by nicely wrapped, even if it is exactly the len\n\u{1b}[0m| /\\ | | \u{1b}[0mgth of column 2!\n\u{1b}[0m| \\ / | | \u{1b}[0m\n\u{1b}[0m| -(--)- | | \u{1b}[0mBut double newlines should come up as blank lines.\n\u{1b}[0m| / \\ | | \u{1b}[0mBlah\n\u{1b}[0m\\--------/ | \u{1b}[0mBlah\n\u{1b}[0mA very poo | \u{1b}[0mBlah\n\u{1b}[0mr renditio | \u{1b}[0mBlah\n\u{1b}[0mn of a cat | \u{1b}[0mBlah\n\u{1b}[0m! Meow. | \u{1b}[0mBlah\nBlah\nAnd once we get to the bottom of column 1, column 2 should just get written\nout normally, not in the previous column."; + assert_eq!(flow_around(str1, 10, " | ", str2, 67), expected); + } } diff --git a/blastmud_game/src/db.rs b/blastmud_game/src/db.rs index 3fa317b8..438de5eb 100644 --- a/blastmud_game/src/db.rs +++ b/blastmud_game/src/db.rs @@ -284,6 +284,17 @@ impl DBTrans { &[&item_type, &item_code]).await?; Ok(()) } + + pub async fn find_item_by_type_code(self: &Self, item_type: &str, item_code: &str) -> + DResult> { + if let Some(item) = self.pg_trans()?.query_opt( + "SELECT details FROM items WHERE \ + details->>'item_type' = $1 AND \ + details->>'item_code' = $2", &[&item_type, &item_code]).await? { + return Ok(serde_json::from_value(item.get("details"))?); + } + Ok(None) + } pub async fn commit(mut self: Self) -> DResult<()> { let trans_opt = self.with_trans_mut(|t| std::mem::replace(t, None)); diff --git a/blastmud_game/src/message_handler/user_commands.rs b/blastmud_game/src/message_handler/user_commands.rs index 27b4a9c5..d67defb6 100644 --- a/blastmud_game/src/message_handler/user_commands.rs +++ b/blastmud_game/src/message_handler/user_commands.rs @@ -72,6 +72,14 @@ static REGISTERED_COMMANDS: UserVerbRegistry = phf_map! { "look" => look::VERB, }; +pub fn explicit_if_allowed<'l>(ctx: &VerbContext, explicit: &'l str, non_explicit: Option<&'l str>) -> &'l str { + if ctx.session_dat.less_explicit_mode { + non_explicit.unwrap_or(explicit) + } else { + explicit + } +} + fn resolve_handler(ctx: &VerbContext, cmd: &str) -> Option<&'static UserVerbRef> { let mut result = ALWAYS_AVAILABLE_COMMANDS.get(cmd); diff --git a/blastmud_game/src/message_handler/user_commands/look.rs b/blastmud_game/src/message_handler/user_commands/look.rs index fd5239b1..cc9c54b3 100644 --- a/blastmud_game/src/message_handler/user_commands/look.rs +++ b/blastmud_game/src/message_handler/user_commands/look.rs @@ -1,12 +1,81 @@ -use super::{VerbContext, UserVerb, UserVerbRef, UResult, UserError}; +use super::{VerbContext, UserVerb, UserVerbRef, UResult, UserError, explicit_if_allowed}; use async_trait::async_trait; +use ansi::{ansi, flow_around}; +use crate::models::{user::User, item::Item}; +use crate::static_content::room; + +pub fn get_user_or_fail<'l>(ctx: &'l VerbContext) -> UResult<&'l User> { + ctx.user_dat.as_ref() + .ok_or_else(|| UserError("Not logged in".to_owned())) +} + +pub async fn get_player_item_or_fail(ctx: &VerbContext<'_>) -> UResult { + Ok(ctx.trans.find_item_by_type_code( + "player", &get_user_or_fail(ctx)?.username.to_lowercase()).await? + .ok_or_else(|| UserError("Your player is gone, you'll need to re-register or ask an admin".to_owned()))?) +} + +pub fn render_map(room: &room::Room, width: usize, height: usize) -> String { + let mut buf = String::new(); + let my_loc = &room.grid_coords; + let min_x = my_loc.x - (width as i64) / 2; + let max_x = min_x + (width as i64); + let min_y = my_loc.y - (height as i64) / 2; + let max_y = min_y + (height as i64); + for x in min_x..max_x { + for y in min_y..max_y { + if my_loc.x == x && my_loc.y == y { + buf.push_str(ansi!("()")) + } else { + buf.push_str(room::room_map_by_loc() + .get(&room::GridCoords { x, y, z: my_loc.z }) + .map(|r| r.short) + .unwrap_or(" ")); + } + } + buf.push('\n'); + } + buf +} + +pub async fn describe_normal_item(ctx: &VerbContext<'_>, item: &Item) -> UResult<()> { + ctx.trans.queue_for_session( + ctx.session, + Some(explicit_if_allowed( + ctx, + &item.display, + item.display_less_explicit.as_ref().map(|s|&**s))) + ).await?; + Ok(()) +} + +pub async fn describe_room(ctx: &VerbContext<'_>, room: &room::Room) -> UResult<()> { + let zone = room::zone_details().get(room.zone).map(|z|z.display).unwrap_or("Outside of time"); + ctx.trans.queue_for_session( + ctx.session, + Some(&flow_around(&render_map(room, 5, 5), 10, " ", + &format!("{} ({})\n{}\n", room.name, zone, + explicit_if_allowed(ctx, room.description, + room.description_less_explicit)), 68)) + ).await?; + Ok(()) +} pub struct Verb; #[async_trait] impl UserVerb for Verb { async fn handle(self: &Self, ctx: &mut VerbContext, _verb: &str, _remaining: &str) -> UResult<()> { - let user = ctx.user_dat.as_ref() - .ok_or_else(|| UserError("Not logged in".to_owned()))?; + let player_item = get_player_item_or_fail(ctx).await?; + let (loctype, loccode) = player_item.location.split_once("/").unwrap_or(("room", "repro_xv_chargen")); + if loctype != "room" { + let item = ctx.trans.find_item_by_type_code(loctype, loccode).await? + .ok_or_else(|| UserError("Sorry, that no longer exists".to_owned()))?; + describe_normal_item(ctx, &item).await?; + } else { + let room = + room::room_map_by_code().get(loccode).ok_or_else(|| UserError("Sorry, that room no longer exists".to_owned()))?; + describe_room(ctx, &room).await?; + } Ok(()) } } diff --git a/blastmud_game/src/static_content.rs b/blastmud_game/src/static_content.rs index 82262e54..7719aa59 100644 --- a/blastmud_game/src/static_content.rs +++ b/blastmud_game/src/static_content.rs @@ -4,7 +4,7 @@ use crate::models::item::Item; use std::collections::{BTreeSet, BTreeMap}; use log::info; -mod room; +pub mod room; pub struct StaticItem { pub item_code: &'static str, diff --git a/blastmud_game/src/static_content/room.rs b/blastmud_game/src/static_content/room.rs index 077545e8..25e2c0e3 100644 --- a/blastmud_game/src/static_content/room.rs +++ b/blastmud_game/src/static_content/room.rs @@ -23,10 +23,11 @@ pub fn zone_details() -> &'static BTreeMap<&'static str, Zone> { ).into_iter().map(|x|(x.code, x)).collect()) } +#[derive(Eq, PartialEq, Ord, PartialOrd, Clone, Debug)] pub struct GridCoords { - x: i64, - y: i64, - z: i64 + pub x: i64, + pub y: i64, + pub z: i64 } pub enum ExitType { @@ -66,6 +67,7 @@ pub struct Room { pub short: &'static str, pub grid_coords: GridCoords, pub description: &'static str, + pub description_less_explicit: Option<&'static str>, pub exits: Vec } @@ -76,19 +78,22 @@ pub fn room_list() -> &'static Vec { Room { zone: "repro_xv", code: "repro_xv_chargen", - name: "Choice Room", + name: ansi!("Choice Room"), short: ansi!("CR"), - description: "A room brightly lit in unnaturally white light, covered in sparkling \ - white tiles from floor to \ - ceiling. A loudspeaker plays a message on loop:\r\n\ - \t\"Citizen, you are here because your memory has been wiped and you \ - are ready to start a fresh life. As a being enhanced by Gazos-Murlison \ - Co technology, the emperor has granted you the power to choose 14 points \ - of upgrades to yourself. Choose wisely, as it will impact who you end up \ - being, and you would need to completely wipe your brain again to change \ - them. Talk to Statbot to spend your 14 points and create your body.\"\r\n\ - [Try \"statbot hi, to send hi to statbot - the \" means to \ - whisper to a particular person in the room]", + description: ansi!( + "A room brightly lit in unnaturally white light, covered in sparkling\n\ + white tiles from floor to ceiling. A loudspeaker plays a message on\n\ + loop:\r\n\ + \t\"Citizen, you are here because your memory has been wiped and\n\ + you are ready to start a fresh life. As a being enhanced by \ + Gazos-Murlison Co technology, the emperor has granted you the power \ + to choose 14 points of upgrades to yourself. Choose wisely, as it \ + will impact who you end up being, and you would need to completely \ + wipe your brain again to change them. Talk to Statbot to spend your \ + 14 points and create your body.\"\n\ + [Try \"statbot hi, to send hi to statbot - the \" means \ + to whisper to a particular person in the room]"), + description_less_explicit: None, grid_coords: GridCoords { x: 0, y: 0, z: 2 }, exits: vec!() }, @@ -101,6 +106,12 @@ pub fn room_map_by_code() -> &'static BTreeMap<&'static str, &'static Room> { || room_list().iter().map(|r| (r.code, r)).collect()) } +static STATIC_ROOM_MAP_BY_LOC: OnceCell> = OnceCell::new(); +pub fn room_map_by_loc() -> &'static BTreeMap<&'static GridCoords, &'static Room> { + STATIC_ROOM_MAP_BY_LOC.get_or_init( + || room_list().iter().map(|r| (&r.grid_coords, r)).collect()) +} + pub fn room_static_items() -> Box> { Box::new(room_list().iter().map(|r| StaticItem { item_code: r.code, @@ -108,7 +119,7 @@ pub fn room_static_items() -> Box> { item_code: r.code.to_owned(), item_type: "room".to_owned(), display: r.description.to_owned(), - location: r.code.to_owned(), + location: format!("room/{}", r.code), is_static: true, ..Item::default() })