use crate::lobby::{
generate_invite_code, FriendCodeState, LobbyStats, PendingPlayer, PlayerMode, FRIEND_CODE_LEN,
};
use crate::tui::Tui;
use crossterm::event::KeyCode;
use frittura_ssh_core::TerminalEvent;
use sshattrick_core::{AppResult, Game, GameCommand, GameSide, GameState};
fn key_code_to_game_command(code: KeyCode) -> Option<GameCommand> {
match code {
KeyCode::Up => Some(GameCommand::Up),
KeyCode::Down => Some(GameCommand::Down),
KeyCode::Left => Some(GameCommand::Left),
KeyCode::Right => Some(GameCommand::Right),
KeyCode::Char(' ') => Some(GameCommand::Shoot),
_ => None,
}
}
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::mpsc::{self, Sender};
use tokio::task;
use tokio::time::MissedTickBehavior;
use tokio::{select, time};
const UPDATE_TIME_STEP: Duration = Duration::from_millis(1000 / 100);
const DRAW_TIME_STEP: Duration = Duration::from_millis(1000 / 30);
const AFTER_GAME_DELAY: Duration = Duration::from_millis(2000);
pub fn spawn(tui_receiver: mpsc::Receiver<Tui>, lobby_sender: Sender<Tui>) {
let ongoing_games = Arc::new(AtomicUsize::new(0));
task::spawn(matchmaker(tui_receiver, lobby_sender, ongoing_games));
}
async fn matchmaker(
mut tui_receiver: mpsc::Receiver<Tui>,
lobby_sender: Sender<Tui>,
ongoing_games: Arc<AtomicUsize>,
) {
let mut players: Vec<PendingPlayer> = vec![];
let mut update_ticker = time::interval(UPDATE_TIME_STEP);
let mut draw_ticker = time::interval(DRAW_TIME_STEP);
draw_ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut last_stats = LobbyStats::default();
loop {
select! {
new_tui = tui_receiver.recv() => match new_tui {
Some(tui) => {
log::info!("Player joined: {}", tui.username());
players.push(PendingPlayer::new(tui));
}
None => return,
},
_ = update_ticker.tick() => {
if !players.is_empty() {
process_lobby_events(&mut players).await;
for player in players.iter_mut() {
if let PlayerMode::Practicing(g) = &mut player.mode {
if let Err(e) = g.update() {
log::error!("Practice update error: {e}");
player.mode = PlayerMode::Idle;
player.dirty = true;
}
}
}
pair_friend_codes(&mut players, &lobby_sender, &ongoing_games);
pair_auto_queue(&mut players, &lobby_sender, &ongoing_games);
}
}
_ = draw_ticker.tick() => {
let stats = LobbyStats {
connected: players.len(),
ongoing_games: ongoing_games.load(Ordering::Relaxed),
};
if stats.connected != last_stats.connected
|| stats.ongoing_games != last_stats.ongoing_games
{
for player in players.iter_mut() {
player.dirty = true;
}
last_stats = stats;
}
redraw_players(&mut players, &stats).await;
}
}
}
}
async fn process_lobby_events(players: &mut Vec<PendingPlayer>) {
let mut to_remove = vec![];
for (i, player) in players.iter_mut().enumerate() {
while let Some(event) = player.tui.try_next() {
match event {
TerminalEvent::Quit => {
log::info!("Player {} disconnected", player.tui.username());
to_remove.push(i);
break;
}
TerminalEvent::Resize(_, _) => {
player.dirty = true;
}
TerminalEvent::IdleWarning(secs) => {
player.idle_warning = Some(secs);
player.dirty = true;
}
TerminalEvent::Key(key) => {
if player.idle_warning.take().is_some() {
player.dirty = true;
}
let prev_discriminant = std::mem::discriminant(&player.mode);
if matches!(player.mode, PlayerMode::Idle) {
match key.code {
KeyCode::Esc | KeyCode::Backspace => {
log::info!("Player {} left lobby", player.tui.username());
to_remove.push(i);
break;
}
KeyCode::Char('a') => player.mode = PlayerMode::AutoQueue,
KeyCode::Char('p') => {
player.mode =
PlayerMode::Practicing(Box::new(Game::new_practice()));
}
KeyCode::Char('g') => {
player.mode = PlayerMode::ShowingCode(Box::new(
FriendCodeState::new(generate_invite_code()),
));
}
_ => {}
}
} else if let PlayerMode::ShowingCode(state) = &mut player.mode {
match key.code {
KeyCode::Esc => player.mode = PlayerMode::Idle,
KeyCode::Backspace => {
if state.typed.is_empty() {
player.mode = PlayerMode::Idle;
} else {
state.typed.pop();
state.last_attempt_failed = false;
player.dirty = true;
}
}
KeyCode::Char(c) if c.is_ascii_alphanumeric() => {
if state.last_attempt_failed {
state.typed.clear();
state.last_attempt_failed = false;
}
if state.typed.len() < FRIEND_CODE_LEN {
state.typed.push(c.to_ascii_uppercase());
player.dirty = true;
}
}
_ => {}
}
} else if matches!(key.code, KeyCode::Esc | KeyCode::Backspace) {
player.mode = PlayerMode::Idle;
} else if let PlayerMode::Practicing(g) = &mut player.mode {
if let Some(cmd) = key_code_to_game_command(key.code) {
g.handle_command(GameSide::Red, cmd);
}
}
if prev_discriminant != std::mem::discriminant(&player.mode) {
player.dirty = true;
}
}
_ => {}
}
}
}
for i in to_remove.into_iter().rev() {
let player = players.remove(i);
player.tui.close().await;
}
}
fn pair_friend_codes(
players: &mut Vec<PendingPlayer>,
lobby_sender: &Sender<Tui>,
ongoing_games: &Arc<AtomicUsize>,
) {
if !players
.iter()
.any(|p| matches!(&p.mode, PlayerMode::ShowingCode(s) if s.typed.len() == FRIEND_CODE_LEN))
{
return;
}
loop {
let host_map: HashMap<&str, usize> = players
.iter()
.enumerate()
.filter_map(|(i, p)| match &p.mode {
PlayerMode::ShowingCode(s) => Some((s.code.as_str(), i)),
_ => None,
})
.collect();
let pair = players.iter().enumerate().find_map(|(i, typer)| {
let s = match &typer.mode {
PlayerMode::ShowingCode(s) if s.typed.len() == FRIEND_CODE_LEN => s,
_ => return None,
};
let &j = host_map.get(s.typed.as_str())?;
if i == j {
return None;
}
Some((i.min(j), i.max(j)))
});
let Some((lo, hi)) = pair else { break };
let high_player = players.remove(hi);
let low_player = players.remove(lo);
log::info!(
"Friend-code match: {} and {}",
low_player.tui.username(),
high_player.tui.username()
);
spawn_game(
low_player.tui,
high_player.tui,
lobby_sender.clone(),
ongoing_games.clone(),
);
}
for player in players.iter_mut() {
if let PlayerMode::ShowingCode(state) = &mut player.mode {
if state.typed.len() == FRIEND_CODE_LEN && !state.last_attempt_failed {
state.last_attempt_failed = true;
player.dirty = true;
}
}
}
}
fn pair_auto_queue(
players: &mut Vec<PendingPlayer>,
lobby_sender: &Sender<Tui>,
ongoing_games: &Arc<AtomicUsize>,
) {
loop {
let auto_indices: Vec<usize> = players
.iter()
.enumerate()
.filter(|(_, p)| matches!(p.mode, PlayerMode::AutoQueue))
.map(|(i, _)| i)
.take(2)
.collect();
let [i1, i2] = auto_indices.as_slice() else {
return;
};
let p2 = players.remove(*i2);
let p1 = players.remove(*i1);
log::info!(
"Auto-pairing {} and {}",
p1.tui.username(),
p2.tui.username()
);
spawn_game(p1.tui, p2.tui, lobby_sender.clone(), ongoing_games.clone());
}
}
async fn redraw_players(players: &mut [PendingPlayer], stats: &LobbyStats) {
for player in players.iter_mut() {
let PendingPlayer {
tui,
mode,
dirty,
idle_warning,
} = player;
let mut wrote = false;
match mode {
PlayerMode::Practicing(g) => {
if let Ok(img) = g.draw() {
let lines = crate::img_lines::img_to_lines(&img);
let _ = tui.draw(g, &lines, GameSide::Red, *idle_warning);
wrote = true;
}
}
lobby_mode if *dirty => {
let view = match lobby_mode {
PlayerMode::Idle => crate::lobby::LobbyView::Idle,
PlayerMode::AutoQueue => crate::lobby::LobbyView::AutoQueue,
PlayerMode::ShowingCode(state) => crate::lobby::LobbyView::ShowingCode {
code: state.code.as_str(),
typed: state.typed.as_str(),
last_attempt_failed: state.last_attempt_failed,
},
PlayerMode::Practicing(_) => unreachable!(),
};
let _ = tui.draw_lobby(stats, view, *idle_warning);
*dirty = false;
wrote = true;
}
_ => {}
}
if wrote {
let _ = tui.push_data().await;
}
}
}
fn spawn_game(
red_tui: Tui,
blue_tui: Tui,
lobby_sender: Sender<Tui>,
ongoing_games: Arc<AtomicUsize>,
) {
ongoing_games.fetch_add(1, Ordering::Relaxed);
task::spawn(async move {
let mut red = Some(red_tui);
let mut blue = Some(blue_tui);
let mut red_warn: Option<u32> = None;
let mut blue_warn: Option<u32> = None;
let mut game = Game::new();
log::info!("Game {} spawned", game.id);
let mut update_ticker = time::interval(UPDATE_TIME_STEP);
let mut draw_ticker = time::interval(DRAW_TIME_STEP);
draw_ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop {
if let GameState::Ending { time, .. } = game.state {
if Instant::now() - time > AFTER_GAME_DELAY {
break;
}
}
if red.is_none() && blue.is_none() {
break;
}
select! {
_ = update_ticker.tick() => {
if let Err(e) = game.update() {
log::error!("Error updating game: {e}");
break;
}
}
_ = draw_ticker.tick() => {
if let Err(e) = draw_and_push(&game, &mut red, &mut blue, red_warn, blue_warn).await {
log::error!("Error rendering game: {e}");
break;
}
}
event = next_or_pending(&mut red) => {
handle_event(&mut game, GameSide::Red, event, &mut red, &mut red_warn).await;
}
event = next_or_pending(&mut blue) => {
handle_event(&mut game, GameSide::Blue, event, &mut blue, &mut blue_warn).await;
}
}
}
let winner = game.winner();
for (side, slot) in [(GameSide::Red, red), (GameSide::Blue, blue)] {
if let Some(mut tui) = slot {
tui.record_game(winner == Some(side));
if let Err(err) = lobby_sender.send(tui).await {
err.0.close().await;
}
}
}
ongoing_games.fetch_sub(1, Ordering::Relaxed);
});
}
async fn draw_and_push(
game: &Game,
red: &mut Option<Tui>,
blue: &mut Option<Tui>,
red_warn: Option<u32>,
blue_warn: Option<u32>,
) -> AppResult<()> {
if red.is_none() && blue.is_none() {
return Ok(());
}
let image = game.draw()?;
let image_lines = crate::img_lines::img_to_lines(&image);
for (slot, side, warn) in [
(red.as_mut(), GameSide::Red, red_warn),
(blue.as_mut(), GameSide::Blue, blue_warn),
] {
if let Some(t) = slot {
t.draw(game, &image_lines, side, warn)?;
}
}
match (red.as_mut(), blue.as_mut()) {
(Some(r), Some(b)) => {
let (a, c) = tokio::join!(r.push_data(), b.push_data());
a?;
c?;
}
(Some(t), None) | (None, Some(t)) => t.push_data().await?,
(None, None) => {}
}
Ok(())
}
async fn handle_event(
game: &mut Game,
side: GameSide,
event: TerminalEvent,
own_tui: &mut Option<Tui>,
own_warn: &mut Option<u32>,
) {
match event {
TerminalEvent::Quit => {
if let Some(tui) = own_tui.take() {
tui.close().await;
}
*own_warn = None;
if !matches!(game.state, GameState::Ending { .. }) {
game.end_with_winner(Some(side.opposite()), true);
}
}
TerminalEvent::IdleWarning(secs) => {
*own_warn = Some(secs);
}
TerminalEvent::Key(crossterm::event::KeyEvent {
code: KeyCode::Esc, ..
}) => {
*own_warn = None;
if !matches!(game.state, GameState::Ending { .. }) {
game.end_with_winner(Some(side.opposite()), true);
}
}
TerminalEvent::Key(key) => {
*own_warn = None;
if let Some(cmd) = key_code_to_game_command(key.code) {
game.handle_command(side, cmd);
}
}
_ => {}
}
}
async fn next_or_pending(tui: &mut Option<Tui>) -> TerminalEvent {
match tui {
Some(t) => t.next().await,
None => std::future::pending().await,
}
}