aboutsummaryrefslogtreecommitdiff
use std::{io, time::Duration};

use ratatui::{
	crossterm::{event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind}, execute},
	layout::{Constraint, Layout, Margin, Rect},
	style::{Color, Modifier, Style},
	symbols::merge::MergeStrategy,
	text::{Line, Span},
	widgets::{Block, Paragraph},
	DefaultTerminal, Frame,
};

use crate::{board::{repetitions, GameBoard, Move, Outcome, Turn, REPETITION_DRAW}, board_view::{pos_at, BoardView}, player::{Input, Player}};

/// Runs a game in the terminal: draws the board, the move history and the current player's panel,
/// and passes mouse clicks on the board to the player whose turn it is.
#[derive(Default)]
pub struct GameHandler
{
	start: GameBoard,
	/// Every move played so far, with the board after it. Sheep play the even-numbered moves.
	history: Vec<(Move, GameBoard)>,
	/// Where the last draw put the board, for turning clicks into points.
	board_area: Rect,
	/// Where the last draw put each visible history entry, with its index in `history`.
	history_rows: Vec<(Rect, usize)>,
}

/// What to do after an event.
enum Action
{
	None,
	Quit,
	Play(Move),
	Revert(usize),
}

/// A bordered pane whose borders join up with its neighbours' where they overlap.
fn panel() -> Block<'static>
{
	Block::bordered().merge_borders(MergeStrategy::Exact)
}

fn side_name(turn: Turn) -> &'static str
{
	match turn
	{
		Turn::Sheep => "Sheep",
		Turn::Tiger => "Tigers",
	}
}

impl GameHandler
{
	/// The current board.
	pub fn board(&self) -> GameBoard
	{
		self.history.last().map_or(self.start, |&(_, board)| board)
	}

	/// The side to move. Sheep move first.
	pub fn turn(&self) -> Turn
	{
		if self.history.len().is_multiple_of(2) { Turn::Sheep } else { Turn::Tiger }
	}

	/// The boards before the current one, oldest first.
	pub fn previous(&self) -> Vec<GameBoard>
	{
		std::iter::once(self.start).chain(self.history.iter().map(|&(_, board)| board))
			.take(self.history.len()).collect()
	}

	/// How the game ended, if it is over: a win, or a draw once a position comes up a third time.
	pub fn outcome(&self) -> Option<Outcome>
	{
		let (board, turn) = (self.board(), self.turn());
		// check_win runs before `turn`'s move, so whoever moved last has won.
		if board.check_win(turn)
		{
			return Some(Outcome::win(turn.next_turn()));
		}
		(repetitions(&self.previous(), &board) >= REPETITION_DRAW).then_some(Outcome::Draw)
	}

	/// Play `m` for the side to move. Panics if it is illegal.
	pub fn play(&mut self, m: Move)
	{
		let mut board = self.board();
		board.make_move(m).unwrap();
		self.history.push((m, board));
	}

	/// Go back to the board before move `i` (0-based), dropping it and every move after it.
	pub fn revert(&mut self, i: usize)
	{
		self.history.truncate(i);
	}

	/// Play in the terminal with `p1` as the sheep and `p2` as the tigers, until someone wins and
	/// the user quits. Returns how it ended, or None if the user quit before the game was over.
	pub fn play_game(&mut self, p1: &mut dyn Player, p2: &mut dyn Player) -> io::Result<Option<Outcome>>
	{
		let mut terminal = ratatui::init();
		let result = execute!(io::stdout(), EnableMouseCapture)
			.and_then(|()| self.run(&mut terminal, [p1, p2]));
		let _ = execute!(io::stdout(), DisableMouseCapture);
		ratatui::restore();
		result
	}

	fn run(&mut self, terminal: &mut DefaultTerminal, mut players: [&mut dyn Player; 2]) -> io::Result<Option<Outcome>>
	{
		let idx = |turn: Turn| match turn { Turn::Sheep => 0, Turn::Tiger => 1 };
		loop
		{
			let (board, turn) = (self.board(), self.turn());
			let over = self.outcome().is_some();
			players[idx(turn)].update(board, turn);
			// Drawn before `pick_move`, so a computer's "thinking" panel is on screen while it searches
			terminal.draw(|frame| self.draw(frame, &players))?;

			let player = &mut *players[idx(turn)];
			let action = match (!over).then(|| player.pick_move(board, turn, &self.previous())).flatten()
			{
				// Input that came in during the search goes first, so a quit or a revert isn't
				// lost when both players are computers and nothing ever waits for input
				Some(m) => self.pending_action(player, board, turn)?.unwrap_or(Action::Play(m)),
				None => self.handle_event(event::read()?, player, board, turn, over),
			};

			match action
			{
				Action::None => (),
				Action::Quit => return Ok(self.outcome()),
				Action::Play(m) => self.play(m),
				Action::Revert(i) =>
				{
					self.revert(i);
					players.iter_mut().for_each(|p| p.reset());
				}
			}
		}
	}

	/// Handle the events already waiting, without blocking, until one of them does something.
	fn pending_action(&self, player: &mut dyn Player, board: GameBoard, turn: Turn) -> io::Result<Option<Action>>
	{
		while event::poll(Duration::ZERO)?
		{
			let action = self.handle_event(event::read()?, player, board, turn, false);
			if !matches!(action, Action::None)
			{
				return Ok(Some(action));
			}
		}
		Ok(None)
	}

	fn handle_event(&self, ev: Event, player: &mut dyn Player, board: GameBoard, turn: Turn, over: bool) -> Action
	{
		match ev
		{
			Event::Key(key) if key.kind == KeyEventKind::Press =>
			{
				let ctrl_c = key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL);
				if ctrl_c || matches!(key.code, KeyCode::Char('q') | KeyCode::Esc)
				{
					return Action::Quit;
				}
				if over
				{
					return Action::None;
				}
				player.handle_input(Input::Key(key), board, turn).map_or(Action::None, Action::Play)
			}
			Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left) =>
			{
				let at = (mouse.column, mouse.row).into();
				if let Some(&(_, i)) = self.history_rows.iter().find(|(rect, _)| rect.contains(at))
				{
					return Action::Revert(i);
				}
				match pos_at(self.board_area, mouse.column, mouse.row)
				{
					Some(pos) if !over => player.handle_input(Input::Click(pos), board, turn).map_or(Action::None, Action::Play),
					_ => Action::None,
				}
			}
			_ => Action::None,
		}
	}

	fn draw(&mut self, frame: &mut Frame, players: &[&mut dyn Player; 2])
	{
		let (_, board_h) = BoardView::size(1);
		// Neighbouring panes overlap by a cell, so they share one border that `panel` merges into ┬ ┴ ┼
		let [top, bottom] = Layout::vertical([Constraint::Min(board_h + 4), Constraint::Length(6)]).spacing(-1).areas(frame.area());
		// History rows are like "123. ta5xc3  [↶]", so a fixed narrow pane fits them; the board gets the rest
		let [board_pane, history_pane] = Layout::horizontal([Constraint::Min(0), Constraint::Length(20)]).spacing(-1).areas(top);
		let [current_pane, last_pane] = Layout::horizontal([Constraint::Percentage(70), Constraint::Fill(1)]).spacing(-1).areas(bottom);

		let outcome = self.outcome();
		let turn = self.turn();
		let player = &*players[match turn { Turn::Sheep => 0, Turn::Tiger => 1 }];

		// The bottom row is drawn first, so the top row's bottom titles (keybinds, revert hint)
		// land on the shared border line over it. Its own titles go on its bottom border.
		// Player output, or the result once the game is over
		match outcome
		{
			Some(o) =>
			{
				let block = panel().title_bottom(" Game over ");
				// A win in green, a draw in yellow
				let (result, colour) = match o
				{
					Outcome::Tiger => (format!("{} ({}) win!", side_name(Turn::Tiger), players[1].name()), Color::Green),
					Outcome::Sheep => (format!("{} ({}) win!", side_name(Turn::Sheep), players[0].name()), Color::Green),
					Outcome::Draw => ("Draw by repetition!".to_string(), Color::Yellow),
				};
				let text = vec![
					Line::styled(result, Style::new().fg(colour).add_modifier(Modifier::BOLD)),
					Line::from("Click a move to revert to it, or press q to quit."),
				];
				frame.render_widget(Paragraph::new(text).block(block), current_pane);
			}
			None =>
			{
				let block = panel().title_bottom(format!(" {}: {} ", side_name(turn), player.name()));
				let inner = block.inner(current_pane);
				frame.render_widget(block, current_pane);
				frame.render_widget(player, inner);
			}
		}

		// The previous player's comments on the move it just made
		let last = turn.next_turn();
		let previous = &*players[match last { Turn::Sheep => 0, Turn::Tiger => 1 }];
		let title = if self.history.is_empty() { " Last turn ".to_string() }
			else
			{
				// Drop the name and shorten the label if the pane is too narrow for it
				let full = format!(" Last turn: {} ({}) ", side_name(last), previous.name());
				let fits = full.chars().count() as u16 + 2 <= last_pane.width;
				if fits { full } else { format!(" Last: {} ", side_name(last)) }
			};
		let block = panel().title_bottom(title);
		let inner = block.inner(last_pane);
		frame.render_widget(block, last_pane);
		if !self.history.is_empty()
		{
			previous.render_last_turn(inner, frame.buffer_mut());
		}

		// Board
		// Keep in step with `handle_event`
		let block = panel().title(" Board ").title_bottom(" click: select / move · q, Esc: quit ");
		let inner = block.inner(board_pane);
		frame.render_widget(block, board_pane);
		// Leave a cell of margin, then centre the biggest board that fits
		let room = inner.inner(Margin::new(1, 1));
		let (board_w, board_h) = BoardView::size(BoardView::scale_to_fit(room.width, room.height));
		let padded = room.centered(Constraint::Length(board_w), Constraint::Length(board_h));
		self.board_area = padded;
		let view = BoardView
		{
			board: self.board(),
			highlights: if outcome.is_none() { player.highlights() } else { Default::default() },
			last_move: self.history.last().map(|&(m, _)| m),
		};
		frame.render_widget(&view, padded);

		// Move history, scrolled to keep the newest move in view
		let block = panel().title(" Moves ").title_bottom(" ↶ reverts ");
		let inner = block.inner(history_pane);
		frame.render_widget(block, history_pane);
		self.history_rows.clear();
		let first = self.history.len().saturating_sub(inner.height as usize);
		for (row, (i, (m, _))) in self.history.iter().enumerate().skip(first).enumerate()
		{
			let rect = Rect { y: inner.y + row as u16, height: 1, ..inner };
			let side = if i % 2 == 0 { Color::White } else { Color::Yellow };
			let line = Line::from(vec![
				Span::styled(format!("{:>3}. ", i + 1), Style::new().fg(Color::DarkGray)),
				Span::styled(format!("{:<8}", m.to_string()), Style::new().fg(side)),
				Span::styled("[↶]", Style::new().fg(Color::Cyan)),
			]);
			frame.render_widget(line, rect);
			self.history_rows.push((rect, i));
		}
	}

}

#[cfg(test)]
mod tests
{
	use super::*;

	#[test]
	fn turn_alternates_starting_with_sheep()
	{
		let mut game = GameHandler::default();
		assert_eq!(game.turn(), Turn::Sheep);
		game.play(Move::PlaceSheep { pos: (2, 2) });
		assert_eq!(game.turn(), Turn::Tiger);
		game.play(Move::MoveTiger { from: (0, 0), to: (1, 0) });
		assert_eq!(game.turn(), Turn::Sheep);
	}

	#[test]
	fn revert_goes_back_to_before_the_move()
	{
		let mut game = GameHandler::default();
		let start = game.board();
		game.play(Move::PlaceSheep { pos: (2, 2) });
		let after_first = game.board();
		game.play(Move::MoveTiger { from: (0, 0), to: (1, 0) });
		game.play(Move::PlaceSheep { pos: (3, 3) });

		game.revert(1);
		assert_eq!(game.board(), after_first);
		assert_eq!(game.turn(), Turn::Tiger);
		assert_eq!(game.history.len(), 1);

		game.revert(0);
		assert_eq!(game.board(), start);
		assert_eq!(game.turn(), Turn::Sheep);
	}

	#[test]
	#[should_panic]
	fn play_rejects_illegal_moves()
	{
		GameHandler::default().play(Move::PlaceSheep { pos: (0, 0) });
	}

	use ratatui::{backend::TestBackend, crossterm::event::{MouseEvent, KeyEvent}, Terminal};
	use crate::{board::PieceType, claude_player::ClaudePlayer, local_player::LocalPlayer};

	fn draw(game: &mut GameHandler, players: &[&mut dyn Player; 2]) -> Terminal<TestBackend>
	{
		let mut terminal = Terminal::new(TestBackend::new(60, 20)).unwrap();
		terminal.draw(|frame| game.draw(frame, players)).unwrap();
		terminal
	}

	fn click(column: u16, row: u16) -> Event
	{
		Event::Mouse(MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), column, row, modifiers: KeyModifiers::NONE })
	}

	#[test]
	fn clicks_on_the_board_and_history()
	{
		let mut game = GameHandler::default();
		let (mut sheep, mut tiger) = (LocalPlayer::default(), LocalPlayer::default());
		sheep.update(game.board(), game.turn());
		let terminal = draw(&mut game, &[&mut sheep, &mut tiger]);
		let screen = format!("{:?}", terminal.backend().buffer());
		assert!(screen.contains("Sheep: You"), "{screen}");
		assert!(screen.contains("Click an empty point to place a sheep"), "{screen}");
		assert!(screen.contains("   a   b   c   d   e"), "{screen}");
		assert!(screen.contains("5  T───·───·───·───T"), "{screen}");

		// Click c3, the centre point
		let area = game.board_area;
		let action = game.handle_event(click(area.x + 3 + 8, area.y + 4), &mut sheep, game.board(), game.turn(), false);
		let Action::Play(m) = action else { panic!("no move") };
		assert_eq!(m, Move::PlaceSheep { pos: (2, 2) });
		game.play(m);

		let terminal = draw(&mut game, &[&mut sheep, &mut tiger]);
		let screen = format!("{:?}", terminal.backend().buffer());
		assert!(screen.contains("1. sc3     [↶]"), "{screen}");
		assert!(screen.contains("Tigers: You"), "{screen}");
		assert!(screen.contains("Last: Sheep"), "{screen}");
		assert!(screen.contains("Played sc3"), "{screen}");

		let (rect, i) = game.history_rows[0];
		let action = game.handle_event(click(rect.x + 2, rect.y), &mut tiger, game.board(), game.turn(), false);
		assert!(matches!(action, Action::Revert(0)) && i == 0);

		let q = Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
		assert!(matches!(game.handle_event(q, &mut tiger, game.board(), game.turn(), false), Action::Quit));
	}

	#[test]
	fn board_grows_in_a_big_terminal()
	{
		let mut game = GameHandler::default();
		game.play(Move::PlaceSheep { pos: (2, 2) });
		let (mut sheep, mut tiger) = (LocalPlayer::default(), LocalPlayer::default());
		let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap();
		terminal.draw(|frame| game.draw(frame, &[&mut sheep, &mut tiger])).unwrap();
		let area = game.board_area;
		assert!(area.height > BoardView::size(1).1, "{area:?}");
		let (w, h) = (area.width, area.height);
		assert_eq!(BoardView::size(BoardView::scale_to_fit(w, h)), (w, h));

		// Find the sheep on screen and click it
		let buf = terminal.backend().buffer();
		let at = (area.left()..area.right()).flat_map(|x| (area.top()..area.bottom()).map(move |y| (x, y)))
			.find(|&(x, y)| buf[(x, y)].symbol() == "S").expect("sheep drawn");
		assert_eq!(pos_at(area, at.0, at.1), Some((2, 2)));
	}

	#[test]
	fn last_turn_panel_shows_the_computer_search()
	{
		let mut game = GameHandler::default();
		let mut sheep = LocalPlayer::default();
		let mut tiger = ClaudePlayer::new(std::time::Duration::from_millis(50));
		game.play(Move::PlaceSheep { pos: (2, 2) });
		let m = tiger.pick_move(game.board(), game.turn(), &[]).unwrap();
		game.play(m);

		let mut terminal = Terminal::new(TestBackend::new(100, 24)).unwrap();
		terminal.draw(|frame| game.draw(frame, &[&mut sheep, &mut tiger])).unwrap();
		let screen = format!("{:?}", terminal.backend().buffer());
		assert!(screen.contains("Last turn: Tigers (Claude)"), "{screen}");
		assert!(screen.contains(&format!("Played {m}")), "{screen}");
		assert!(screen.contains("Searched to depth"), "{screen}");
		assert!(screen.contains("Score: "), "{screen}");

		// Borders are shared and joined where panes meet, and every pane reaches the right edge
		let buf = terminal.backend().buffer();
		let row = |y: u16| (0..100).map(|x| buf[(x, y)].symbol()).collect::<String>();
		assert!(row(0).starts_with("┌ Board ─") && row(0).ends_with("┐"), "{}", row(0));
		assert!(row(0).contains("┬ Moves "), "{}", row(0));
		assert_eq!(row(18).chars().next(), Some('├'), "{}", row(18));
		assert!(row(18).contains('┴') && row(18).contains('┬') && row(18).ends_with("┤"), "{}", row(18));
		assert!(row(18).contains(" click: select / move · q, Esc: quit "), "{}", row(18));
		assert!(row(23).starts_with("└ Sheep: You ─") && row(23).contains('┴') && row(23).ends_with("┘"), "{}", row(23));
	}

	#[test]
	fn no_winner_at_the_start()
	{
		assert_eq!(GameHandler::default().outcome(), None);
	}

	/// Movement phase with 16 sheep: tigers in the corners, and sheep filling rows 1 to 4 except
	/// c4 and c1, with a sheep at b4 free to step to c4 and the tiger at a5 free to step to b5.
	fn movement_game() -> GameHandler
	{
		let mut start = GameBoard::EMPTY;
		for pos in [(0, 0), (4, 0), (0, 4), (4, 4)]
		{
			start.set(pos, PieceType::Tiger);
		}
		for y in 1..5
		{
			for x in 0..5
			{
				if matches!(start.get((x, y)), PieceType::Empty) && (x, y) != (2, 1) && (x, y) != (2, 4)
				{
					start.set((x, y), PieceType::Sheep);
				}
			}
		}
		assert_eq!((start.sheep_count(), start.sheep_left()), (16, 0));
		GameHandler { start, ..Default::default() }
	}

	/// The sheep at b4 and the tiger at a5 step out and back.
	const SHUFFLE: [Move; 4] = [
		Move::MoveSheep { from: (1, 1), to: (2, 1) },
		Move::MoveTiger { from: (0, 0), to: (1, 0) },
		Move::MoveSheep { from: (2, 1), to: (1, 1) },
		Move::MoveTiger { from: (1, 0), to: (0, 0) },
	];

	#[test]
	fn third_repetition_is_a_draw()
	{
		let mut game = movement_game();
		for (i, &m) in SHUFFLE.iter().cycle().take(8).enumerate()
		{
			assert_eq!(game.outcome(), None, "before move {i}");
			game.play(m);
		}
		// The start position, for the third time
		assert_eq!(game.board(), game.start);
		assert_eq!(game.outcome(), Some(Outcome::Draw));
		assert_eq!(game.previous().len(), 8);

		game.revert(7);
		assert_eq!(game.outcome(), None);
	}

	#[test]
	fn game_over_panel_shows_a_draw()
	{
		let mut game = movement_game();
		SHUFFLE.iter().cycle().take(8).for_each(|&m| game.play(m));
		let (mut sheep, mut tiger) = (LocalPlayer::default(), LocalPlayer::default());
		let terminal = draw(&mut game, &[&mut sheep, &mut tiger]);
		let screen = format!("{:?}", terminal.backend().buffer());
		assert!(screen.contains("Draw by repetition!"), "{screen}");
		assert_result_style(&terminal, "Draw by repetition!", Color::Yellow);
	}

	#[test]
	fn game_over_panel_shows_the_winner_in_green()
	{
		// Only 15 sheep left, so the tigers have won
		let mut game = movement_game();
		game.start.set((0, 1), PieceType::Empty);
		assert_eq!(game.outcome(), Some(Outcome::Tiger));
		let mut sheep = LocalPlayer::default();
		let mut tiger = ClaudePlayer::new(std::time::Duration::from_millis(50));
		let terminal = draw(&mut game, &[&mut sheep, &mut tiger]);
		assert_result_style(&terminal, "Tigers (Claude) win!", Color::Green);
	}

	/// Every cell of `text` on screen is bold and in `colour`.
	fn assert_result_style(terminal: &Terminal<TestBackend>, text: &str, colour: Color)
	{
		let buf = terminal.backend().buffer();
		let area = buf.area;
		let found = (0..area.height).find_map(|y|
		{
			let row: String = (0..area.width).map(|x| buf[(x, y)].symbol()).collect();
			row.find(text).map(|i| (row[..i].chars().count() as u16, y))
		});
		let (x0, y) = found.unwrap_or_else(|| panic!("{text} isn't on screen"));
		for x in x0..x0 + text.chars().count() as u16
		{
			let cell = &buf[(x, y)];
			assert_eq!(cell.fg, colour, "{text} at column {x}");
			assert!(cell.modifier.contains(Modifier::BOLD), "{text} at column {x}");
		}
	}
}