use std::collections::HashMap;
use std::collections::VecDeque;
use go_fish::HookResult;
use go_fish_web::LobbyInfo;
use go_fish_web::LobbyLeftReason;
use go_fish_web::LobbyPlayer;
use go_fish_web::ServerMessage;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
pub const MAX_NOTIFICATION_HISTORY: usize = 3;
pub use crate::network::NetworkEvent;
#[derive(Debug, Clone, PartialEq)]
pub struct ConnectingState {
pub status: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PreLobbyState {
pub player_name: String,
pub input_state: PreLobbyInputState,
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub enum PreLobbyInputState {
#[default] None,
}
#[derive(Debug, Clone, PartialEq)]
pub struct LobbyState {
pub player_name: String,
pub lobby_id: String,
pub leader: String,
pub players: Vec<LobbyPlayer>,
pub max_players: usize,
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum GameInputState {
Idle,
SelectingTarget { cursor: usize },
SelectingRank { target: String, cursor: usize },
}
#[derive(Debug, Clone)]
pub struct GameState {
pub player_name: String,
pub players: Vec<String>,
pub hand: go_fish::Hand,
pub completed_books: Vec<go_fish::CompleteBook>,
pub opponent_card_counts: HashMap<String, usize>,
pub opponent_book_counts: HashMap<String, usize>,
pub active_player: String,
pub notifications: VecDeque<Line<'static>>,
pub hook_error: Option<go_fish_web::HookError>,
pub game_result: Option<go_fish_web::GameResult>,
pub input_state: GameInputState,
has_received_snapshot: bool,
}
impl GameState {
pub fn new(player_name: String, players: Vec<String>) -> Self {
let opponents: HashMap<String, usize> = players.iter()
.filter(|p| *p != &player_name)
.map(|p| (p.clone(), 0))
.collect();
let mut notifications: VecDeque<Line<'static>> = VecDeque::new();
notifications.push_front(Line::from("Game started!"));
GameState {
player_name,
players,
hand: go_fish::Hand::empty(),
completed_books: vec![],
opponent_card_counts: opponents.clone(),
opponent_book_counts: opponents.keys().map(|k| (k.clone(), 0)).collect(),
active_player: String::new(),
notifications,
hook_error: None,
has_received_snapshot: false,
game_result: None,
input_state: GameInputState::Idle,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum BrowsingStatus {
Loading,
Loaded(Vec<LobbyInfo>),
Creating,
EnteringId { input: String, error: Option<String> },
Error(String),
}
#[derive(Debug, Clone, PartialEq)]
pub struct BrowsingLobbiesState {
pub player_name: String,
pub status: BrowsingStatus,
pub selected_index: usize,
pub frame_index: usize,
}
#[derive(Debug, Clone)]
pub enum Screen {
Connecting(ConnectingState),
PreLobby(PreLobbyState),
BrowsingLobbies(BrowsingLobbiesState),
Lobby(LobbyState),
Game(GameState),
}
#[derive(Debug, Clone)]
pub struct AppState {
pub screen: Screen,
}
impl AppState {
pub fn new() -> AppState {
AppState {
screen: Screen::Connecting(ConnectingState {
status: "Connecting…".to_string(),
}),
}
}
}
pub fn apply_network_event(state: &mut AppState, event: &NetworkEvent) {
match event {
NetworkEvent::Message(msg) => apply_server_message(state, msg),
NetworkEvent::Closed => apply_connection_closed(state),
NetworkEvent::Error(err) => apply_connection_error(state, err),
}
}
fn apply_server_message(state: &mut AppState, msg: &ServerMessage) {
match msg {
ServerMessage::PlayerIdentity(name) => {
if let Screen::Connecting(_) = &state.screen {
state.screen = Screen::PreLobby(PreLobbyState {
player_name: name.clone(),
input_state: PreLobbyInputState::None,
error: None,
});
}
}
ServerMessage::LobbyJoined {
lobby_id,
leader,
players,
max_players,
} => {
let player_name = match &state.screen {
Screen::PreLobby(s) => Some(s.player_name.clone()),
Screen::BrowsingLobbies(s) => Some(s.player_name.clone()),
_ => None,
};
if let Some(player_name) = player_name {
state.screen = Screen::Lobby(LobbyState {
player_name,
lobby_id: lobby_id.clone(),
leader: leader.clone(),
players: players.clone(),
max_players: *max_players,
error: None,
});
}
}
ServerMessage::LobbyUpdated { leader, players } => {
if let Screen::Lobby(lobby) = &mut state.screen {
let player_name = lobby.player_name.clone();
if !players.iter().any(|p| p.name() == player_name) {
state.screen = Screen::PreLobby(PreLobbyState {
player_name,
input_state: PreLobbyInputState::None,
error: None,
});
} else {
lobby.leader = leader.clone();
lobby.players = players.clone();
}
}
}
ServerMessage::LobbyLeft(reason) => {
match reason {
LobbyLeftReason::RequestedByPlayer => {
if let Screen::Lobby(lobby) = &state.screen {
let player_name = lobby.player_name.clone();
state.screen = Screen::PreLobby(PreLobbyState {
player_name,
input_state: PreLobbyInputState::None,
error: None,
});
}
}
}
}
ServerMessage::GameStarted => {
if let Screen::Lobby(lobby) = &state.screen {
let player_name = lobby.player_name.clone();
let players: Vec<String> = lobby.players.iter().map(|p| p.name().to_string()).collect();
state.screen = Screen::Game(GameState::new(player_name, players));
}
}
ServerMessage::GameSnapshot(snapshot) => {
if let Screen::Game(game) = &mut state.screen {
let prev_rank_counts: HashMap<go_fish::Rank, usize> = game.hand.books.iter()
.map(|b| (b.rank, b.cards.len()))
.collect();
let prev_book_count = game.completed_books.len();
let prev_opponent_books: HashMap<String, usize> = game.opponent_book_counts.clone();
game.hand = snapshot.hand_state.hand.clone();
game.hand.books.sort_by(|a, b| a.rank.cmp(&b.rank));
game.completed_books = snapshot.hand_state.completed_books.clone();
for opp in &snapshot.opponents {
game.opponent_card_counts.insert(opp.name.clone(), opp.card_count);
game.opponent_book_counts.insert(opp.name.clone(), opp.completed_books.len());
}
game.active_player = snapshot.active_player.clone();
if snapshot.active_player == game.player_name {
game.hook_error = None;
}
game.input_state = GameInputState::Idle;
if game.has_received_snapshot {
process_snapshot_notifications(
game,
&prev_rank_counts,
prev_book_count,
&prev_opponent_books,
snapshot,
);
} else {
if let Some(ref outcome) = snapshot.last_hook_outcome {
let player_name = game.player_name.clone();
push_notification(game, format_hook_outcome(outcome, &player_name));
}
}
game.has_received_snapshot = true;
}
}
ServerMessage::HookError(err) => {
if let Screen::Game(game) = &mut state.screen {
game.hook_error = Some(err.clone());
}
}
ServerMessage::GameResult(result) => {
if let Screen::Game(game) = &mut state.screen {
game.game_result = Some(result.clone());
}
}
ServerMessage::GameAborted => {
if let Screen::Game(game) = &state.screen {
let player_name = game.player_name.clone();
state.screen = Screen::PreLobby(PreLobbyState {
player_name,
input_state: PreLobbyInputState::None,
error: Some("Game aborted: a player disconnected.".to_string()),
});
}
}
ServerMessage::Error(msg) => {
match &mut state.screen {
Screen::Connecting(s) => {
s.status = msg.clone();
}
Screen::PreLobby(s) => {
s.error = Some(msg.clone());
}
Screen::BrowsingLobbies(s) => {
match &mut s.status {
BrowsingStatus::EnteringId { input, error } => {
input.clear();
*error = Some(msg.clone());
}
_ => {
s.status = BrowsingStatus::Error(msg.clone());
}
}
}
Screen::Lobby(s) => {
s.error = Some(msg.clone());
}
Screen::Game(_s) => {}
}
}
ServerMessage::LobbyList(lobbies) => {
if let Screen::BrowsingLobbies(s) = &mut state.screen {
s.selected_index = if lobbies.is_empty() {
0
} else {
s.selected_index.min(lobbies.len() - 1)
};
s.status = BrowsingStatus::Loaded(lobbies.clone());
}
}
_ => {}
}
}
fn apply_connection_closed(state: &mut AppState) {
let msg = "Server closed connection.".to_string();
if let Screen::Game(game) = &state.screen {
let player_name = game.player_name.clone();
state.screen = Screen::PreLobby(PreLobbyState {
player_name,
input_state: PreLobbyInputState::None,
error: None,
});
return;
}
if let Screen::BrowsingLobbies(b) = &state.screen {
let player_name = b.player_name.clone();
state.screen = Screen::PreLobby(PreLobbyState {
player_name,
input_state: PreLobbyInputState::None,
error: Some(msg),
});
return;
}
match &mut state.screen {
Screen::Connecting(s) => s.status = msg,
Screen::PreLobby(s) => s.error = Some(msg),
Screen::Lobby(s) => s.error = Some(msg),
Screen::Game(_) | Screen::BrowsingLobbies(_) => unreachable!(),
}
}
fn apply_connection_error(state: &mut AppState, err: &str) {
let msg = format!("Connection error: {}", err);
if let Screen::Game(game) = &state.screen {
let player_name = game.player_name.clone();
state.screen = Screen::PreLobby(PreLobbyState {
player_name,
input_state: PreLobbyInputState::None,
error: None,
});
return;
}
if let Screen::BrowsingLobbies(b) = &state.screen {
let player_name = b.player_name.clone();
state.screen = Screen::PreLobby(PreLobbyState {
player_name,
input_state: PreLobbyInputState::None,
error: Some(msg),
});
return;
}
match &mut state.screen {
Screen::Connecting(s) => s.status = msg,
Screen::PreLobby(s) => s.error = Some(msg),
Screen::Lobby(s) => s.error = Some(msg),
Screen::Game(_) | Screen::BrowsingLobbies(_) => unreachable!(),
}
}
fn push_notification(game: &mut GameState, line: Line<'static>) {
game.notifications.push_front(line);
game.notifications.truncate(MAX_NOTIFICATION_HISTORY);
}
fn process_snapshot_notifications(
game: &mut GameState,
prev_rank_counts: &HashMap<go_fish::Rank, usize>,
prev_book_count: usize,
prev_opponent_books: &HashMap<String, usize>,
snapshot: &go_fish_web::GameSnapshot,
) {
let green = Style::default().fg(Color::Green);
for opp in &snapshot.opponents {
let prev = prev_opponent_books.get(&opp.name).copied().unwrap_or(0);
if opp.completed_books.len() > prev {
for book in &opp.completed_books[prev..] {
push_notification(game, Line::from(format!("{} completed a book of {}s!", opp.name, book.rank)));
}
}
}
if let Some(ref outcome) = snapshot.last_hook_outcome {
let player_name = game.player_name.clone();
push_notification(game, format_hook_outcome(outcome, &player_name));
}
if let Some(drawn_rank) = detect_deck_draw(game, prev_rank_counts, prev_book_count, snapshot) {
push_notification(game, Line::from(vec![
Span::styled("You", green),
Span::raw(format!(" drew a {} from the deck", drawn_rank)),
]));
}
let local_book_lines: Vec<Line<'static>> = if game.completed_books.len() > prev_book_count {
game.completed_books[prev_book_count..].iter()
.map(|b| Line::from(vec![
Span::styled("You", green),
Span::raw(format!(" completed a book of {}s!", b.rank)),
]))
.collect()
} else {
vec![]
};
for line in local_book_lines {
push_notification(game, line);
}
}
fn detect_deck_draw(
game: &GameState,
prev_rank_counts: &HashMap<go_fish::Rank, usize>,
prev_book_count: usize,
snapshot: &go_fish_web::GameSnapshot,
) -> Option<go_fish::Rank> {
let new_rank_counts: HashMap<go_fish::Rank, usize> = game.hand.books.iter()
.map(|b| (b.rank, b.cards.len()))
.collect();
let hook_catch_rank: Option<go_fish::Rank> = snapshot.last_hook_outcome.as_ref()
.filter(|o| o.fisher_name == game.player_name)
.and_then(|o| match &o.result {
HookResult::Catch(_) => Some(o.rank),
HookResult::GoFish => None,
});
for (rank, &new_count) in &new_rank_counts {
let old_count = prev_rank_counts.get(rank).copied().unwrap_or(0);
if new_count > old_count && hook_catch_rank != Some(*rank) {
return Some(*rank);
}
}
let new_book_count = game.completed_books.len();
if new_book_count > prev_book_count {
for book in &game.completed_books[prev_book_count..] {
if hook_catch_rank != Some(book.rank) && !new_rank_counts.contains_key(&book.rank) {
let old_count = prev_rank_counts.get(&book.rank).copied().unwrap_or(0);
if old_count == 3 {
return Some(book.rank);
}
}
}
}
None
}
fn format_hook_outcome(outcome: &go_fish_web::HookOutcome, player_name: &str) -> Line<'static> {
let green = Style::default().fg(Color::Green);
let fisher_span: Span<'static> = if outcome.fisher_name == player_name {
Span::styled("You", green)
} else {
Span::raw(outcome.fisher_name.clone())
};
let target_span: Span<'static> = if outcome.target_name == player_name {
Span::styled("you", green)
} else {
Span::raw(outcome.target_name.clone())
};
match &outcome.result {
HookResult::Catch(book) => {
let n = book.cards.len();
let s = if n == 1 { "" } else { "s" };
Line::from(vec![
fisher_span,
Span::raw(" asked "),
target_span,
Span::raw(format!(" for {}s — Caught {} card{}!", outcome.rank, n, s)),
])
}
HookResult::GoFish => Line::from(vec![
fisher_span,
Span::raw(" asked "),
target_span,
Span::raw(format!(" for {}s — Go Fish!", outcome.rank)),
]),
}
}
#[cfg(test)]
#[path = "state_tests.rs"]
mod state_tests;