use super::*;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Entry {
PlayerName,
GameName,
Address,
Chat,
}
#[derive(Clone, Debug)]
pub struct Typing {
pub what: Entry,
pub text: String,
pub then: Option<Intent>,
pub suggested: bool,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum Answered {
AskAgain,
Chat(String),
PlayerThenGame(String),
PlayerThen(String, Option<Intent>),
GameNamed(String),
AddressGiven(SocketAddr),
BadAddress(String),
}
pub(super) fn answer(open: &Typing, said: String) -> Answered {
if said.is_empty() && matches!(open.what, Entry::PlayerName | Entry::GameName) {
return Answered::AskAgain;
}
match (open.what, open.then) {
(Entry::Chat, _) => Answered::Chat(said),
(Entry::PlayerName, Some(Intent::Host)) => Answered::PlayerThenGame(said),
(Entry::PlayerName, then) => Answered::PlayerThen(said, then),
(Entry::GameName, _) => Answered::GameNamed(said),
(Entry::Address, _) => match said.parse::<SocketAddr>() {
Ok(addr) => Answered::AddressGiven(addr),
Err(_) => Answered::BadAddress(said),
},
}
}
impl Typing {
pub fn chat() -> Typing {
Typing {
what: Entry::Chat,
text: String::new(),
then: None,
suggested: false,
}
}
pub fn game_name(was: &str) -> Typing {
Typing {
what: Entry::GameName,
text: was.to_string(),
then: None,
suggested: false,
}
}
pub fn address(was: &str) -> Typing {
Typing {
what: Entry::Address,
text: was.to_string(),
then: None,
suggested: false,
}
}
pub fn player_name(then: Intent, was: &str) -> Typing {
Typing {
what: Entry::PlayerName,
text: was.to_string(),
then: Some(then),
suggested: false,
}
}
}
impl Typing {
pub fn chat_saying(line: &str) -> Typing {
Typing {
text: line.to_string(),
..Typing::chat()
}
}
pub fn or_suggest(mut self, suggestion: impl FnOnce() -> String) -> Typing {
if self.text.trim().is_empty() {
self.text = suggestion();
self.suggested = true;
}
self
}
}
pub(super) fn suggested_name(tr: &crate::app::i18n::Tr) -> String {
let n = 10 + crate::app::clock::fresh_seed() % 90;
fill(tr.lobby_suggest_name, &[("n", &n.to_string())])
}
pub(super) fn suggested_beach(tr: &crate::app::i18n::Tr, host: &str) -> String {
fill(tr.lobby_suggest_beach, &[("p", host)])
}
pub(super) fn next_quick_line(tr: &crate::app::i18n::Tr, said: &str) -> &'static str {
let lines = &tr.quick_chat;
let next = lines
.iter()
.position(|&line| line == said)
.map_or(0, |at| (at + 1) % lines.len());
lines[next]
}
fn fits_for(what: Entry) -> fn(&str) -> bool {
match what {
Entry::GameName => crate::transport::beach_name_fits,
Entry::PlayerName => crate::transport::name_fits,
Entry::Address | Entry::Chat => crate::transport::chat_fits,
}
}
fn read_line(
keys: &ButtonInput<KeyCode>,
typed: &mut MessageReader<bevy::input::keyboard::KeyboardInput>,
open: &mut Typing,
) -> Option<String> {
let fits = fits_for(open.what);
let said = if open.suggested {
let mut fresh = String::new();
match type_a_line(typed, &mut fresh, fits) {
Some(said) if said.is_empty() => Some(open.text.trim().to_string()),
Some(said) => Some(said),
None => {
if !fresh.is_empty() || keys.just_pressed(KeyCode::Backspace) {
open.text = fresh;
open.suggested = false;
}
None
}
}
} else {
type_a_line(typed, &mut open.text, fits)
};
said.or_else(|| {
(keys.just_pressed(KeyCode::Enter) && !keys.just_pressed(KeyCode::Escape))
.then(|| open.text.trim().to_string())
})
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum Typed {
Nothing,
Taken,
Unblocked(Intent),
}
pub(super) fn drive_typing(
keys: &ButtonInput<KeyCode>,
typed: &mut MessageReader<bevy::input::keyboard::KeyboardInput>,
quick: bool,
settings: &mut GameSettings,
caps: &crate::app::keycaps::KeyCaps,
state: &mut LobbyState,
tr: &'static crate::app::i18n::Tr,
) -> Typed {
let mut intent: Option<Intent> = None;
let Some(mut open) = state.typing.take() else {
return Typed::Nothing;
};
if quick && open.what == Entry::Chat {
open.text = next_quick_line(tr, &open.text).to_string();
}
{
let finished = read_line(keys, typed, &mut open);
let Some(said) = finished else {
if !keys.just_pressed(KeyCode::Escape) {
state.typing = Some(open);
}
return Typed::Taken;
};
match answer(&open, said) {
Answered::AskAgain => {
let again = Typing {
text: String::new(),
suggested: false,
..open
};
state.typing = Some(match again.what {
Entry::GameName => {
let host = settings.names[0].clone();
again.or_suggest(|| suggested_beach(tr, &host))
}
Entry::PlayerName => again.or_suggest(|| suggested_name(tr)),
Entry::Address | Entry::Chat => again,
});
state.feedback = tr.lobby_needs_name.to_string();
}
Answered::Chat(line) if line.is_empty() => {}
Answered::Chat(line) => {
let me = state.joined().map_or_else(
|| settings.names[0].clone(),
|joined| speaking_as(joined.bot.as_deref(), &settings.names[0]).to_string(),
);
if let Some(transport) = state.transport() {
transport.send(NetMsg::chat(&me, &line));
}
state.say(
&crate::transport::name_as_sent(&me),
&crate::transport::chat_as_sent(&line),
);
}
Answered::PlayerThenGame(name) => {
name_myself(settings, caps, name);
let host = settings.names[0].clone();
let was = state.game_name.clone();
state.typing =
Some(Typing::game_name(&was).or_suggest(|| suggested_beach(tr, &host)));
}
Answered::PlayerThen(name, then) => {
name_myself(settings, caps, name);
intent = then;
}
Answered::GameNamed(name) => {
state.game_name = crate::transport::beach_name_as_sent(&name);
intent = Some(Intent::Host);
}
Answered::AddressGiven(addr) => {
settings.last_beach = addr.to_string();
settings.save(caps);
state.typing = Some(
Typing::player_name(Intent::Dial(addr), &settings.names[0])
.or_suggest(|| suggested_name(tr)),
);
}
Answered::BadAddress(text) => {
state.typing = Some(Typing { text, ..open });
state.feedback = tr.lobby_bad_address.to_string();
}
}
}
match intent {
Some(intent) => Typed::Unblocked(intent),
None => Typed::Taken,
}
}
pub(super) fn name_myself(
settings: &mut GameSettings,
caps: &crate::app::keycaps::KeyCaps,
name: String,
) {
settings.set_name(0, &name);
settings.tidy_name(0);
settings.save(caps);
}
pub(crate) fn type_a_line(
typed: &mut MessageReader<bevy::input::keyboard::KeyboardInput>,
line: &mut String,
fits: fn(&str) -> bool,
) -> Option<String> {
use crate::app::typing::{Keystroke, keystrokes};
let mut sent = None;
let mut dropped = false;
let ends = [KeyCode::Enter, KeyCode::NumpadEnter, KeyCode::Escape];
for stroke in keystrokes(typed, &ends) {
match stroke {
Keystroke::Done(KeyCode::Escape) => dropped = true,
Keystroke::Done(_) => sent = Some(std::mem::take(line)),
Keystroke::Erase => {
line.pop();
}
Keystroke::Char(ch) => {
line.push(ch);
if !fits(line) {
line.pop();
}
}
}
}
if dropped {
line.clear();
return None;
}
sent.map(|line| line.trim().to_string())
}
#[cfg(test)]
mod door_tests {
use super::*;
#[test]
fn h_leads_to_a_beach_on_the_air() {
assert_eq!(host_step(idle()), HostStep::Nothing);
assert_eq!(
host_step(HostAsk {
pressed_h: true,
..idle()
}),
HostStep::Ask
);
let asked = Typing::player_name(Intent::Host, "");
assert_eq!(asked.what, Entry::PlayerName);
assert_eq!(asked.then, Some(Intent::Host));
let next = answer(&asked, "Bob".into());
assert_eq!(next, Answered::PlayerThenGame("Bob".into()));
let asked = Typing::game_name("");
assert_eq!(asked.what, Entry::GameName);
assert_eq!(
answer(&asked, "Room 3".into()),
Answered::GameNamed("Room 3".into())
);
assert_eq!(
host_step(HostAsk {
answered: true,
..idle()
}),
HostStep::Go,
"answered, and nothing else needed pressing"
);
}
#[test]
fn a_player_who_has_a_name_is_still_asked() {
let asked = Typing::player_name(Intent::Host, "Bob");
assert_eq!(asked.text, "Bob", "pre-filled, so Enter accepts it");
assert_eq!(
answer(&asked, "Bob".into()),
Answered::PlayerThenGame("Bob".into())
);
}
fn beach() -> SocketAddr {
"192.168.1.7:47710".parse().expect("an address")
}
#[test]
fn joining_asks_the_name_and_then_joins() {
let asked = Typing::player_name(Intent::Join(beach()), "");
assert_eq!(
answer(&asked, "Cy".into()),
Answered::PlayerThen("Cy".into(), Some(Intent::Join(beach()))),
"and remembers which beach, not merely that there was one"
);
}
#[test]
fn an_address_is_asked_first_and_the_name_second() {
let asked = Typing::address("");
assert_eq!(asked.what, Entry::Address);
let there: SocketAddr = "192.168.1.5:47777".parse().expect("addr");
assert_eq!(
answer(&asked, "192.168.1.5:47777".into()),
Answered::AddressGiven(there)
);
let asked = Typing::player_name(Intent::Dial(there), "");
assert_eq!(
answer(&asked, "Cy".into()),
Answered::PlayerThen("Cy".into(), Some(Intent::Dial(there))),
"and remembers where it was going"
);
}
#[test]
fn a_mistyped_address_is_handed_back() {
let asked = Typing::address("");
for typo in ["192.168.1.5", "192.168.1.5:", "over there", ""] {
assert_eq!(
answer(&asked, typo.into()),
Answered::BadAddress(typo.into()),
"{typo:?}"
);
}
assert_eq!(
answer(&asked, "47777".into()),
Answered::BadAddress("47777".into())
);
assert_eq!(
answer(&asked, "[::1]:47777".into()),
Answered::AddressGiven("[::1]:47777".parse().expect("addr"))
);
}
#[test]
fn an_empty_name_is_refused_at_both_doors() {
for open in [
Typing::player_name(Intent::Host, ""),
Typing::player_name(Intent::Join(beach()), ""),
Typing::game_name(""),
] {
assert_eq!(answer(&open, String::new()), Answered::AskAgain, "{open:?}");
assert_eq!(answer(&open, " ".trim().to_string()), Answered::AskAgain);
}
assert_eq!(
answer(&Typing::chat(), String::new()),
Answered::Chat(String::new())
);
}
#[test]
fn the_prompt_is_not_on_a_face_that_can_be_hidden() {
let mut app = App::new();
app.insert_resource(GameSettings::default());
app.init_resource::<LobbyState>();
app.add_systems(
Startup,
|mut commands: Commands, settings: Res<GameSettings>| {
spawn_lobby_ui(&mut commands, settings.tr(), &LobbyArt::default());
},
);
app.add_systems(Update, update_lobby_view);
app.update();
let world = app.world_mut();
assert!(!world.resource::<LobbyState>().can_chat());
let mut rows = world.query::<(Entity, &ChatRow)>();
let prompt = rows
.iter(world)
.find(|(_, row)| row.0 == CHAT_LINES)
.map(|(entity, _)| entity)
.expect("the row the question is asked on");
let mut at = prompt;
let mut hops = 0;
while let Some(parent) = world.get::<ChildOf>(at) {
at = parent.parent();
hops += 1;
assert!(hops < 16, "walked too far up; is the tree a loop?");
assert!(
world.get::<TableView>(at).is_none(),
"the prompt hangs off the face that is hidden while browsing"
);
assert!(
world.get::<BrowseView>(at).is_none(),
"nor may it hang off the other one, hidden the rest of the time"
);
if let Some(node) = world.get::<Node>(at) {
assert_ne!(node.display, Display::None, "an ancestor is hidden");
}
}
}
#[test]
fn finishing_one_question_does_not_join_the_beach_under_the_cursor() {
use bevy::input::ButtonState;
use bevy::input::keyboard::{Key, KeyboardInput};
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.init_state::<Screen>();
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<crate::app::gamepad::PadFace>();
app.init_resource::<crate::app::gamepad::LastDevice>();
app.init_resource::<LobbyState>();
app.init_resource::<MatchConfig>();
app.init_resource::<crate::app::bot_seats::BotSeats>();
app.init_resource::<Online>();
app.init_resource::<crate::app::match_setup::CustomBeaches>();
app.init_resource::<crate::app::match_setup::MatchMenu>();
app.insert_resource(GameSettings::default());
app.init_resource::<crate::app::keycaps::KeyCaps>();
app.init_resource::<crate::app::cursor::LiveSeatKeys>();
app.add_message::<KeyboardInput>();
app.add_systems(Update, lobby_input);
let there: SocketAddr = "10.0.0.7:47777".parse().expect("addr");
{
let mut state = app.world_mut().resource_mut::<LobbyState>();
state.hosts = vec![HostEntry {
addr: there,
id: 7,
name: "somebody else's".into(),
host: "Sam".into(),
taken: 1,
seats: 6,
running: false,
bots: false,
full: false,
age: 0.0,
heard: 1,
}];
state.selected = Some(there);
}
let press_enter = |app: &mut App| {
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::Enter);
app.world_mut().write_message(KeyboardInput {
key_code: KeyCode::Enter,
logical_key: Key::Enter,
state: ButtonState::Pressed,
text: None,
repeat: false,
window: Entity::PLACEHOLDER,
});
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.reset_all();
};
app.world_mut().resource_mut::<LobbyState>().typing =
Some(Typing::player_name(Intent::Host, "Bob"));
press_enter(&mut app);
let state = app.world().resource::<LobbyState>();
assert_eq!(
state.typing.as_ref().map(|open| open.what),
Some(Entry::GameName),
"the next question is up"
);
assert!(
state.joined().is_none(),
"and nothing was dialled behind it"
);
app.world_mut().resource_mut::<LobbyState>().typing =
Some(Typing::address("10.0.0.9:47777"));
press_enter(&mut app);
let state = app.world().resource::<LobbyState>();
assert_eq!(
state.typing.as_ref().map(|open| open.what),
Some(Entry::PlayerName),
"who is dialling is asked next"
);
assert!(
state.joined().is_none(),
"and the cursor's beach is not taken"
);
}
#[test]
fn a_line_of_chat_is_typed_and_echoed_as_the_wire_carries_it() {
use bevy::input::ButtonState;
use bevy::input::keyboard::{Key, KeyboardInput};
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.init_state::<Screen>();
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<crate::app::gamepad::PadFace>();
app.init_resource::<crate::app::gamepad::LastDevice>();
app.init_resource::<LobbyState>();
app.init_resource::<MatchConfig>();
app.init_resource::<crate::app::bot_seats::BotSeats>();
app.init_resource::<Online>();
app.init_resource::<crate::app::match_setup::CustomBeaches>();
app.init_resource::<crate::app::match_setup::MatchMenu>();
let mut settings = GameSettings::default();
settings.names[0] = "あ".repeat(12);
app.insert_resource(settings);
app.init_resource::<crate::app::keycaps::KeyCaps>();
app.init_resource::<crate::app::cursor::LiveSeatKeys>();
app.add_message::<KeyboardInput>();
app.add_systems(Update, lobby_input);
app.world_mut().resource_mut::<LobbyState>().typing = Some(Typing::chat());
for _ in 0..48 {
app.world_mut().write_message(KeyboardInput {
key_code: KeyCode::KeyA,
logical_key: Key::Character("い".into()),
state: ButtonState::Pressed,
text: Some("い".into()),
repeat: false,
window: Entity::PLACEHOLDER,
});
}
app.update();
let typed = app
.world()
.resource::<LobbyState>()
.typing
.as_ref()
.map(|open| open.text.clone());
assert_eq!(typed, Some("い".repeat(32)), "the box stops at the wire");
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::Enter);
app.world_mut().write_message(KeyboardInput {
key_code: KeyCode::Enter,
logical_key: Key::Enter,
state: ButtonState::Pressed,
text: None,
repeat: false,
window: Entity::PLACEHOLDER,
});
app.update();
let state = app.world().resource::<LobbyState>();
let said = state.chat.last().expect("the line was echoed");
assert_eq!(said.who, "あ".repeat(8), "the name as the wire keeps it");
assert_eq!(said.line, "い".repeat(32));
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.reset_all();
app.world_mut().resource_mut::<LobbyState>().typing = Some(Typing {
text: "x".repeat(40),
suggested: true,
..Typing::game_name("")
});
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::Enter);
app.update();
assert_eq!(
app.world().resource::<LobbyState>().game_name,
"x".repeat(24),
"the host's own screen names the beach as the hall reads it"
);
}
fn pad_lobby() -> App {
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.init_state::<Screen>();
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<crate::app::gamepad::PadFace>();
app.init_resource::<crate::app::gamepad::LastDevice>();
app.init_resource::<LobbyState>();
app.init_resource::<MatchConfig>();
app.init_resource::<crate::app::bot_seats::BotSeats>();
app.init_resource::<Online>();
app.init_resource::<crate::app::match_setup::CustomBeaches>();
app.init_resource::<crate::app::match_setup::MatchMenu>();
app.insert_resource(GameSettings::default());
app.init_resource::<crate::app::keycaps::KeyCaps>();
app.init_resource::<crate::app::cursor::LiveSeatKeys>();
app.add_message::<bevy::input::keyboard::KeyboardInput>();
app.world_mut().spawn(Gamepad::default());
let face = |lobby: Res<LobbyState>,
bots: Res<crate::app::bot_seats::BotSeats>,
settings: Res<GameSettings>,
mut face: ResMut<crate::app::gamepad::PadFace>| {
face.0 = crate::app::gamepad::face::lobby_face(&lobby, &bots, settings.tr(), true);
};
app.add_systems(Update, (face, lobby_input).chain());
app
}
fn south(app: &mut App) {
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::Enter);
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.reset_all();
}
fn north(app: &mut App) {
let pad = app
.world_mut()
.query_filtered::<Entity, With<Gamepad>>()
.single(app.world())
.expect("one pad");
let mut gamepad = app.world_mut().get_mut::<Gamepad>(pad).expect("the pad");
gamepad.digital_mut().press(GamepadButton::North);
app.update();
let mut gamepad = app.world_mut().get_mut::<Gamepad>(pad).expect("the pad");
gamepad.digital_mut().release(GamepadButton::North);
gamepad.digital_mut().clear();
}
fn typing(app: &App) -> Option<Typing> {
app.world().resource::<LobbyState>().typing.clone()
}
#[test]
fn a_pad_takes_the_suggested_names() {
let mut app = pad_lobby();
app.world_mut().resource_mut::<LobbyState>().typing =
Some(Typing::player_name(Intent::Host, "").or_suggest(|| "Crab 42".into()));
south(&mut app);
assert_eq!(app.world().resource::<GameSettings>().names[0], "Crab 42");
let next = typing(&app).expect("the beach's name is asked next");
assert_eq!(next.what, Entry::GameName);
assert_eq!(next.text, "Crab 42's game");
assert!(next.suggested);
}
#[test]
fn a_pad_takes_the_name_on_file() {
let asked = Typing::player_name(Intent::Host, "Maya").or_suggest(|| "Crab 42".into());
assert_eq!(asked.text, "Maya");
assert!(!asked.suggested);
let mut app = pad_lobby();
app.world_mut().resource_mut::<LobbyState>().typing = Some(asked);
south(&mut app);
assert_eq!(app.world().resource::<GameSettings>().names[0], "Maya");
}
#[test]
fn typing_over_a_suggestion_replaces_it() {
use bevy::input::ButtonState;
use bevy::input::keyboard::{Key, KeyboardInput};
let mut app = pad_lobby();
app.world_mut().resource_mut::<LobbyState>().typing =
Some(Typing::player_name(Intent::Host, "").or_suggest(|| "Crab 42".into()));
app.world_mut().write_message(KeyboardInput {
key_code: KeyCode::KeyB,
logical_key: Key::Character("B".into()),
state: ButtonState::Pressed,
text: Some("B".into()),
repeat: false,
window: Entity::PLACEHOLDER,
});
app.update();
let open = typing(&app).expect("still being typed");
assert_eq!(open.text, "B");
assert!(!open.suggested);
}
#[test]
fn north_walks_the_quick_chat_and_south_says_it() {
let tr = GameSettings::default().tr();
let mut app = pad_lobby();
app.world_mut().resource_mut::<LobbyState>().typing = Some(Typing::chat());
north(&mut app);
assert_eq!(typing(&app).expect("open").text, tr.quick_chat[0]);
north(&mut app);
assert_eq!(typing(&app).expect("open").text, tr.quick_chat[1]);
for _ in 2..tr.quick_chat.len() {
north(&mut app);
}
north(&mut app);
assert_eq!(
typing(&app).expect("open").text,
tr.quick_chat[0],
"round again"
);
north(&mut app);
south(&mut app);
let state = app.world().resource::<LobbyState>();
assert!(state.typing.is_none(), "said and closed");
assert_eq!(
state.chat.last().map(|said| said.line.as_str()),
Some(tr.quick_chat[1])
);
}
#[test]
fn a_refused_name_is_asked_again_with_a_suggestion() {
let mut app = pad_lobby();
app.world_mut().resource_mut::<LobbyState>().typing =
Some(Typing::player_name(Intent::Host, ""));
south(&mut app);
let again = typing(&app).expect("asked again");
assert_eq!(again.what, Entry::PlayerName);
assert!(again.suggested && !again.text.is_empty(), "{again:?}");
}
#[test]
fn the_name_box_holds_what_a_name_keeps() {
assert_eq!(
crate::transport::NAME_CHARS,
crate::app::settings::NAME_MAX,
"the wire and the settings agree on the cap"
);
let fits = fits_for(Entry::PlayerName);
for (base, past) in [("M", 'M'), ("あ", 'あ'), ("Zoë", 'ë')] {
let mut name = String::new();
while fits(&format!("{name}{base}")) {
name.push_str(base);
}
assert!(fits(&name));
assert!(!fits(&format!("{name}{past}")), "{name:?} is full");
let mut settings = GameSettings::default();
settings.set_name(0, &name);
assert_eq!(settings.names[0], name, "saved whole");
assert_eq!(crate::transport::name_as_sent(&name), name, "sent whole");
}
assert!(
!fits(&"あ".repeat(9)),
"nine Japanese characters are 27 bytes"
);
assert!(
!fits(&"M".repeat(13)),
"and thirteen of anything is past the cap"
);
assert!(
fits_for(Entry::Chat)(&"M".repeat(13)),
"a chat line is longer"
);
}
}