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 read_line(
keys: &ButtonInput<KeyCode>,
typed: &mut MessageReader<bevy::input::keyboard::KeyboardInput>,
open: &mut Typing,
) -> Option<String> {
let said = if open.suggested {
let mut fresh = String::new();
match type_a_line(typed, &mut fresh) {
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)
};
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 = settings.names[0].clone();
if let Some(transport) = state.transport() {
transport.send(NetMsg::chat(&me, &line));
}
state.say(&me, &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 = 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,
) -> 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) if line.chars().count() < crate::transport::CHAT_CHARS => {
line.push(ch)
}
Keystroke::Char(_) => {}
}
}
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::<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.insert_resource(GameSettings::default());
app.init_resource::<crate::app::keycaps::KeyCaps>();
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,
}];
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"
);
}
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::<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.insert_resource(GameSettings::default());
app.init_resource::<crate::app::keycaps::KeyCaps>();
app.add_message::<bevy::input::keyboard::KeyboardInput>();
app.world_mut().spawn(Gamepad::default());
app.add_systems(Update, lobby_input);
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:?}");
}
}