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>,
}
#[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,
}
}
pub fn game_name(was: &str) -> Typing {
Typing {
what: Entry::GameName,
text: was.to_string(),
then: None,
}
}
pub fn address(was: &str) -> Typing {
Typing {
what: Entry::Address,
text: was.to_string(),
then: None,
}
}
pub fn player_name(then: Intent, was: &str) -> Typing {
Typing {
what: Entry::PlayerName,
text: was.to_string(),
then: Some(then),
}
}
}
#[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>,
settings: &mut GameSettings,
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;
};
{
let finished = type_a_line(typed, &mut open.text);
let Some(said) = finished else {
if !keys.just_pressed(KeyCode::Escape) {
state.typing = Some(open);
}
return Typed::Taken;
};
match answer(&open, said) {
Answered::AskAgain => {
state.typing = Some(Typing {
text: String::new(),
..open
});
state.feedback = tr.lobby_needs_name.to_string();
}
Answered::Chat(line) if line.is_empty() => {}
Answered::Chat(line) => {
let me = settings.names[0].clone();
let msg = NetMsg::Chat {
name: crate::transport::wire_name(&me),
text: crate::transport::wire_chat(&line),
};
if let Some((_, transport)) = &state.hosting {
transport.send(msg);
} else if let Some(transport) = &state.joining {
transport.send(msg);
}
state.say(&me, &line);
}
Answered::PlayerThenGame(name) => {
name_myself(settings, name);
let was = state.game_name.clone();
state.typing = Some(Typing::game_name(&was));
}
Answered::PlayerThen(name, then) => {
name_myself(settings, 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();
state.typing = Some(Typing::player_name(Intent::Dial(addr), &settings.names[0]));
}
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, name: String) {
settings.names[0] = name;
settings.tidy_name(0);
settings.save();
}
pub(super) fn type_a_line(
typed: &mut MessageReader<bevy::input::keyboard::KeyboardInput>,
line: &mut String,
) -> Option<String> {
let mut sent = None;
let mut dropped = false;
for event in typed.read() {
if !event.state.is_pressed() {
continue;
}
if matches!(event.key_code, KeyCode::Enter | KeyCode::NumpadEnter) {
sent = Some(std::mem::take(line));
} else if event.key_code == KeyCode::Escape {
dropped = true;
} else if matches!(event.key_code, KeyCode::Backspace | KeyCode::Delete) {
line.pop();
} else {
for ch in event.text.iter().flat_map(|text| text.chars()) {
if !ch.is_control() && line.chars().count() < crate::transport::CHAT_CHARS {
line.push(ch);
}
}
}
}
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())
);
}
#[test]
fn joining_asks_the_name_and_then_joins() {
let asked = Typing::player_name(Intent::Join(3), "");
assert_eq!(
answer(&asked, "Cy".into()),
Answered::PlayerThen("Cy".into(), Some(Intent::Join(3))),
"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(0), ""),
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::match_setup::CustomBeaches>();
app.insert_resource(GameSettings::default());
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,
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.joining(), "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.joining(), "and the cursor's beach is not taken");
}
}