use crate::app::i18n::fill;
use crate::app::{Controllers, SeatController, SeatKinds, SeatNames, Sim};
use crate::bots::connstr::{ConnString, DEFAULT_PORT, Key};
use crate::bots::cursor::SeatCursor;
use crate::bots::listener::{Admission, BotId, Config, Event, GameLink, Invite, Listener, View};
use crate::bots::lookahead;
use crate::bots::protocol::{self, Act, Clock, Outcome, SeatKind, Table, You};
use crate::bots::seat::{BotDriver, Journal, Turn, act_of, outcome, placings, refusal};
use crate::sim::{Board, BotLevel, MAX_PLAYERS, PlayerAction, Refusal, Replay, bot_action};
use bevy::prelude::*;
use std::net::SocketAddr;
use std::sync::mpsc::Receiver;
use std::time::{Duration, Instant};
const NO_SEAT: &str = "the table filled up before this bot had a seat";
const SEAT_GONE: &str = "the seat this bot was started for is no longer a bot's seat";
pub const GRACE: Duration = Duration::from_secs(5);
pub const STAND_IN: BotLevel = BotLevel::Normal;
const LIVE_DEADLINE_MS: u32 = 1000 / crate::sim::TICKS_PER_SECOND;
pub struct Chair {
id: u32,
pub seat: u8,
pub key: Key,
pub bot: Option<BotId>,
}
pub struct Doorway {
listener: Listener,
events: std::sync::Mutex<Receiver<Event>>,
here: SocketAddr,
lan: Option<SocketAddr>,
pub showing_lan: bool,
pub chairs: Vec<Chair>,
next_chair: u32,
seats_fixed: bool,
}
impl Drop for Doorway {
fn drop(&mut self) {
self.listener.close();
}
}
impl Doorway {
pub fn open() -> std::io::Result<Doorway> {
let config = Config::new(Admission::Keys(Vec::new()));
let (listener, events) = Listener::bind(
SocketAddr::from(([127, 0, 0, 1], DEFAULT_PORT)),
config.clone(),
)
.or_else(|_| Listener::bind(SocketAddr::from(([127, 0, 0, 1], 0)), config))?;
let here = listener.local_addr();
Ok(Doorway {
listener,
events: std::sync::Mutex::new(events),
here,
lan: None,
showing_lan: false,
chairs: Vec::new(),
next_chair: 0,
seats_fixed: false,
})
}
pub fn open_lan() -> std::io::Result<Doorway> {
let config = Config::new(Admission::Keys(Vec::new()));
let (listener, events) = Listener::bind(
SocketAddr::from(([0, 0, 0, 0], DEFAULT_PORT)),
config.clone(),
)
.or_else(|_| Listener::bind(SocketAddr::from(([0, 0, 0, 0], 0)), config))?;
let here = listener.local_addr();
Ok(Doorway {
listener,
events: std::sync::Mutex::new(events),
here,
lan: Some(here),
showing_lan: true,
chairs: Vec::new(),
next_chair: 0,
seats_fixed: false,
})
}
pub fn keep_a_chair_open(&mut self, room: bool) {
if !room {
for chair in self.chairs.iter().filter(|chair| chair.bot.is_none()) {
self.listener.withdraw(chair.id);
}
self.chairs.retain(|chair| chair.bot.is_some());
return;
}
let open = self.chairs.iter().any(|chair| chair.bot.is_none());
if !open {
let id = self.next_chair;
self.next_chair += 1;
let chair = self.keyed_chair(id, self.chairs.len() as u8);
self.chairs.push(chair);
}
}
fn keyed_chair(&self, id: u32, seat: u8) -> Chair {
let key = match (id, crate::app::dev::bot_key()) {
(0, Some(key)) => key,
_ => Key::draw(),
};
self.listener.invite(Invite {
key: key.clone(),
uses: Some(1),
owner: None,
slot: Some(id),
});
Chair {
id,
seat,
key,
bot: None,
}
}
pub fn open_string(&self) -> Option<String> {
self.chairs
.iter()
.find(|chair| chair.bot.is_none())
.map(|chair| self.string(chair))
}
pub fn arrived(&self, tr: &crate::app::i18n::Tr) -> Vec<(BotId, String)> {
self.chairs
.iter()
.filter_map(|chair| {
let id = chair.bot.filter(|&id| self.listener.connected(id))?;
let info = self.listener.bot(id)?;
let name = match info.owner {
Some(owner) => fill(tr.bot_owned, &[("bot", &info.name), ("o", &owner)]),
None => fill(tr.bot_unowned, &[("bot", &info.name)]),
};
Some((info.id, name))
})
.collect()
}
pub fn let_go(&mut self, id: BotId) {
self.chairs.retain(|chair| chair.bot != Some(id));
self.listener.disconnect(id);
}
pub fn seat_the_table(&mut self, seated: &[BotId]) {
let unseated: Vec<BotId> = self
.chairs
.iter()
.filter_map(|chair| chair.bot)
.filter(|id| !seated.contains(id))
.collect();
for id in unseated {
self.chairs.retain(|chair| chair.bot != Some(id));
self.listener.dismiss(id, NO_SEAT);
}
self.keep_a_chair_open(false);
}
pub fn seat_bots(&mut self, seats: &[u8]) {
self.seats_fixed = true;
let (kept, gone): (Vec<Chair>, Vec<Chair>) = std::mem::take(&mut self.chairs)
.into_iter()
.partition(|chair| seats.contains(&chair.seat));
self.chairs = kept;
for chair in gone {
self.listener.withdraw(chair.id);
if let Some(id) = chair.bot {
self.listener.dismiss(id, SEAT_GONE);
}
}
for &seat in seats {
if self.chairs.iter().any(|chair| chair.seat == seat) {
continue;
}
let id = self.next_chair;
self.next_chair += 1;
let chair = self.keyed_chair(id, seat);
self.chairs.push(chair);
}
self.chairs.sort_by_key(|chair| chair.seat);
}
pub fn poll(&mut self) {
let heard: Vec<Event> = {
let events = self
.events
.get_mut()
.unwrap_or_else(std::sync::PoisonError::into_inner);
std::iter::from_fn(|| events.try_recv().ok()).collect()
};
for event in heard {
match event {
Event::Registered(id) => {
let slot = self.listener.bot(id).and_then(|info| info.slot);
match self
.chairs
.iter_mut()
.find(|c| Some(c.id) == slot && c.bot.is_none())
{
Some(chair) => chair.bot = Some(id),
None => self.listener.dismiss(
id,
match self.seats_fixed {
true => SEAT_GONE,
false => NO_SEAT,
},
),
}
}
Event::Dropped(id) if !self.listener.connected(id) => {
let Some(at) = self.chairs.iter().position(|c| c.bot == Some(id)) else {
continue;
};
self.listener.retire(id);
match self.seats_fixed {
true => {
let (chair, seat) = (self.chairs[at].id, self.chairs[at].seat);
self.chairs[at] = self.keyed_chair(chair, seat);
}
false => {
self.chairs.remove(at);
}
}
}
Event::Dropped(_) | Event::Reconnected(_) | Event::Refused(..) => {}
}
}
}
pub fn ready(&self) -> bool {
self.chairs.iter().all(|chair| chair.bot.is_some())
}
pub fn toggle_lan(&mut self) -> std::io::Result<()> {
match self.lan {
Some(lan) if lan == self.here => {}
Some(lan) => {
self.listener.stop_listening(lan);
self.lan = None;
}
None => {
self.lan = Some(
self.listener
.also_listen(SocketAddr::from(([0, 0, 0, 0], 0)))?,
);
}
}
self.showing_lan = self.lan.is_some();
Ok(())
}
pub fn string(&self, chair: &Chair) -> String {
let (host, port) = match (self.showing_lan, self.lan) {
(true, Some(lan)) => (crate::bots::connstr::reachable_host(lan), lan.port()),
_ => (self.here.ip().to_string(), self.here.port()),
};
ConnString::new(host, port, Some(chair.key.clone())).to_string()
}
pub fn bot_line(&self, chair: &Chair) -> Option<(String, Option<Duration>)> {
let info = self.listener.bot(chair.bot?)?;
let name = match info.version.is_empty() {
true => info.name,
false => format!("{} {}", info.name, info.version),
};
Some((name, self.listener.rtt(info.id)))
}
pub fn named_for(&self, tr: &crate::app::i18n::Tr, owner: &str) -> Option<String> {
let info = self.listener.bot(self.chairs.first()?.bot?)?;
Some(match owner.trim().is_empty() {
true => fill(tr.bot_unowned, &[("bot", &info.name)]),
false => fill(tr.bot_owned, &[("bot", &info.name), ("o", owner.trim())]),
})
}
pub fn seat_name(&self, tr: &crate::app::i18n::Tr, seat: u8) -> Option<String> {
let chair = self.chairs.iter().find(|chair| chair.seat == seat)?;
let info = self.listener.bot(chair.bot?)?;
Some(match info.owner {
Some(owner) => fill(tr.bot_owned, &[("bot", &info.name), ("o", &owner)]),
None => fill(tr.bot_unowned, &[("bot", &info.name)]),
})
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum News {
StandIn,
Back,
}
pub struct BotRound {
online: bool,
in_flight: std::collections::VecDeque<InFlight>,
link: GameLink,
game: u32,
drivers: Vec<BotDriver>,
cursors: [Option<SeatCursor>; MAX_PLAYERS],
last: [Option<(&'static str, Outcome)>; MAX_PLAYERS],
dropped_at: [Option<Instant>; MAX_PLAYERS],
quiet_since: [Option<Instant>; MAX_PLAYERS],
standing_in: [bool; MAX_PLAYERS],
grace: Duration,
unconfirmed: [Option<(PlayerAction, Option<Act>)>; MAX_PLAYERS],
finished: bool,
names: Vec<String>,
kinds: Vec<SeatKind>,
clock: Clock,
log: Box<dyn std::io::Write + Send + Sync>,
}
#[derive(Clone, Copy)]
struct InFlight {
frame: u32,
seat: u8,
act: Act,
action: PlayerAction,
refused: Option<Refusal>,
}
pub struct Committed {
seat: u8,
act: Option<Act>,
action: PlayerAction,
refused: Option<Refusal>,
}
impl BotRound {
fn open(
door: &Doorway,
board: &Board,
seats: &[(u8, BotId)],
names: Vec<String>,
kinds: Vec<SeatKind>,
online: bool,
game: u32,
) -> BotRound {
let deadline = Duration::from_millis(u64::from(LIVE_DEADLINE_MS));
let link = door
.listener
.open_game(game, seats.iter().map(|&(seat, id)| (id, seat)).collect());
let drivers: Vec<BotDriver> = seats
.iter()
.map(|&(seat, id)| {
let name = names.get(usize::from(seat)).cloned().unwrap_or_default();
BotDriver::new(id, seat, name, door.listener.connected(id), deadline)
})
.collect();
let mut cursors = [None; MAX_PLAYERS];
for &(seat, _) in seats {
cursors[usize::from(seat)] = Some(SeatCursor::home(board, seat));
}
let now = Instant::now();
let mut quiet_since = [None; MAX_PLAYERS];
for &(seat, _) in seats {
quiet_since[usize::from(seat)] = Some(now);
}
let round = BotRound {
online,
in_flight: std::collections::VecDeque::new(),
link,
game,
drivers,
cursors,
last: [None; MAX_PLAYERS],
dropped_at: [None; MAX_PLAYERS],
quiet_since,
standing_in: [false; MAX_PLAYERS],
grace: GRACE,
unconfirmed: [None; MAX_PLAYERS],
finished: false,
names,
kinds,
clock: Clock {
live: true,
deadline_ms: LIVE_DEADLINE_MS,
input_delay: match online {
true => crate::sim::DEFAULT_DELAY,
false => 0,
},
},
log: Box::new(std::io::sink()),
};
for driver in &round.drivers {
if driver.connected {
let hello = protocol::hello(board, &round.table(), driver.seat, false);
round.link.send(driver.id, &hello);
}
}
round
}
fn table(&self) -> Table<'_> {
Table {
game: self.game,
names: &self.names,
kinds: &self.kinds,
clock: self.clock,
cursor: true,
}
}
fn drain(&mut self, t: u64, news: &mut Vec<(u8, News)>) {
let mut console = |line: String| info!("{line}");
let mut journal = Journal {
log: &mut self.log,
console: &mut console,
echo_notes: true,
game: self.game,
};
while let Some(msg) = self.link.try_recv() {
let seat = match &msg {
crate::bots::listener::GameMsg::Reply { seat, .. }
| crate::bots::listener::GameMsg::Ready { seat }
| crate::bots::listener::GameMsg::Garbled { seat, .. }
| crate::bots::listener::GameMsg::Dropped { seat }
| crate::bots::listener::GameMsg::Back { seat, .. } => *seat,
};
if let Some(driver) = self.drivers.iter_mut().find(|d| d.seat == seat) {
if let Some(quiet) = self.quiet_since.get_mut(usize::from(seat)) {
*quiet = None;
}
driver.take(msg, t, &mut journal);
}
}
let now = Instant::now();
for driver in &mut self.drivers {
let seat = usize::from(driver.seat);
let gone_for = match driver.connected {
true => {
self.dropped_at[seat] = None;
self.quiet_since[seat].map(|since| now.duration_since(since))
}
false => Some(now.duration_since(*self.dropped_at[seat].get_or_insert(now))),
};
match gone_for {
Some(gone) if gone >= self.grace => {
if !std::mem::replace(&mut self.standing_in[seat], true) {
news.push((driver.seat, News::StandIn));
}
}
Some(_) => {}
None => {
if std::mem::take(&mut self.standing_in[seat]) {
news.push((driver.seat, News::Back));
}
}
}
}
}
pub fn commit(
&mut self,
board: &Board,
actions: &mut [PlayerAction; MAX_PLAYERS],
news: &mut Vec<(u8, News)>,
) -> Vec<Committed> {
let t = board.ticks();
self.drain(t, news);
let mut console = |line: String| info!("{line}");
let mut journal = Journal {
log: &mut self.log,
console: &mut console,
echo_notes: true,
game: self.game,
};
let mut committed = Vec::new();
for driver in &mut self.drivers {
let seat = driver.seat;
let slot = usize::from(seat);
let (action, act) = if let Some(held) = self.unconfirmed[slot] {
held
} else if self.standing_in[slot] {
let action = bot_action(board, seat, STAND_IN);
(action, act_of(action))
} else {
let order = driver.order(&mut journal);
match &mut self.cursors[slot] {
Some(cursor) => cursor.step(board, seat, order, true),
None => (PlayerAction::None, None),
}
};
actions[slot] = action;
if self.online && action != PlayerAction::None {
self.unconfirmed[slot] = Some((action, act));
}
committed.push(Committed {
seat,
act,
action,
refused: refusal(board, seat, action),
});
}
committed
}
pub fn after(&mut self, board: &Board, committed: Vec<Committed>) {
let t = board.ticks();
for c in committed {
let Some(act) = c.act else {
continue;
};
if let Some(result) =
outcome(act, c.action, c.refused, board, c.seat, t.saturating_sub(1))
{
self.last[usize::from(c.seat)] = Some((act.token(), result));
}
}
let cursors: Vec<Option<(u8, u8)>> = (0..self.names.len())
.map(|seat| self.cursors.get(seat).copied().flatten().map(|c| c.at))
.collect();
*self
.link
.view
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(View {
board: board.clone(),
cursors: cursors.clone(),
budget: vec![lookahead::CAP_LIVE; self.names.len()],
});
let now = Instant::now();
for i in 0..self.drivers.len() {
let seat = self.drivers[i].seat;
if std::mem::take(&mut self.drivers[i].rehello) && self.drivers[i].playing() {
let hello = protocol::hello(board, &self.table(), seat, true);
self.link.send(self.drivers[i].id, &hello);
}
if self.drivers[i].turn(t, now) != Turn::Send {
continue;
}
let you = You {
seat,
last: self.last[usize::from(seat)],
};
let msg = protocol::tick(board, self.game, &you, &cursors);
if self.link.send(self.drivers[i].id, &msg) {
self.drivers[i].sent(t, now);
self.quiet_since[usize::from(seat)].get_or_insert(now);
}
}
}
fn commit_online(
&mut self,
board: &Board,
news: &mut Vec<(u8, News)>,
) -> Vec<(u8, PlayerAction, Option<Act>)> {
let mut actions = [PlayerAction::None; MAX_PLAYERS];
self.commit(board, &mut actions, news)
.into_iter()
.map(|c| (c.seat, c.action, c.act))
.collect()
}
fn send_next(&mut self, board: &Board) {
self.after(board, Vec::new());
}
fn finish(&mut self, board: &Board, door: Option<&Doorway>, replay: Option<&Replay>) {
if std::mem::replace(&mut self.finished, true) {
return;
}
let n = self.names.len();
let scores: Vec<u32> = board.scores()[..n].to_vec();
let places = placings(&scores, &vec![false; n]);
let kept = match (door, replay) {
(Some(door), Some(replay)) => {
let id = format!("p-g{}", self.game);
let players = self.drivers.iter().map(|driver| driver.id).collect();
door.listener
.keep_replay(id.clone(), players, replay.to_text());
Some(id)
}
_ => None,
};
for driver in &self.drivers {
let msg = serde_json::json!({
"type": "end", "game": self.game, "scores": scores,
"placing": places[usize::from(driver.seat)], "replay": kept,
});
self.link.send(driver.id, &msg);
}
}
}
#[derive(Resource, Default)]
pub struct BotSeats {
pub door: Option<Doorway>,
round: Option<BotRound>,
pub news: Vec<(u8, News)>,
pub waiting: Option<Waiting>,
pub feedback: String,
pub join_to: Option<SocketAddr>,
games: u32,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum Waiting {
Couch,
Join,
}
impl BotSeats {
pub fn leave_the_beach(&mut self, at_the_beach: bool) {
if self.join_to.is_some() && self.waiting.is_none() && !at_the_beach {
self.door = None;
self.join_to = None;
}
}
pub fn online_seats(&self) -> Vec<u8> {
match &self.round {
Some(round) if round.online => round.drivers.iter().map(|d| d.seat).collect(),
Some(_) | None => Vec::new(),
}
}
pub fn commit_online(&mut self, board: &Board) -> Vec<(u8, PlayerAction, Option<Act>)> {
let BotSeats { round, news, .. } = self;
match round {
Some(round) => round.commit_online(board, news),
None => Vec::new(),
}
}
pub fn committed(&mut self, frame: u32, seat: u8, act: Option<Act>, action: PlayerAction) {
if let Some(held) = self
.round
.as_mut()
.and_then(|round| round.unconfirmed.get_mut(usize::from(seat)))
{
*held = None;
}
if let (Some(round), Some(act)) = (self.round.as_mut(), act) {
round.in_flight.push_back(InFlight {
frame,
seat,
act,
action,
refused: None,
});
while round.in_flight.len() > 256 {
round.in_flight.pop_front();
}
}
}
pub fn before_frame(&mut self, frame: u32, board: &Board) {
let Some(round) = self.round.as_mut() else {
return;
};
for entry in round.in_flight.iter_mut().filter(|e| e.frame == frame) {
entry.refused = refusal(board, entry.seat, entry.action);
}
}
pub fn after_frame(&mut self, frame: u32, board: &Board) {
let Some(round) = self.round.as_mut() else {
return;
};
while let Some(&entry) = round.in_flight.front() {
if entry.frame > frame {
break;
}
round.in_flight.pop_front();
if entry.frame == frame
&& let Some(result) = outcome(
entry.act,
entry.action,
entry.refused,
board,
entry.seat,
u64::from(frame),
)
{
round.last[usize::from(entry.seat)] = Some((entry.act.token(), result));
}
}
}
pub fn send_online(&mut self, board: &Board, replay: Option<&Replay>) {
let BotSeats { door, round, .. } = self;
if let Some(round) = round.as_mut() {
round.send_next(board);
if board.round_over() {
round.finish(board, door.as_ref(), replay);
}
}
}
pub fn commit(
&mut self,
board: &Board,
actions: &mut [PlayerAction; MAX_PLAYERS],
) -> Option<Vec<Committed>> {
let BotSeats { round, news, .. } = self;
Some(round.as_mut()?.commit(board, actions, news))
}
pub fn after(
&mut self,
board: &Board,
committed: Option<Vec<Committed>>,
replay: Option<&Replay>,
) {
let BotSeats { door, round, .. } = self;
let (Some(round), Some(committed)) = (round.as_mut(), committed) else {
return;
};
round.after(board, committed);
if board.round_over() {
round.finish(board, door.as_ref(), replay);
}
}
}
impl BotSeats {
pub fn round_mut(&mut self) -> Option<&mut BotRound> {
self.round.as_mut()
}
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct Drivers<'w> {
pub controllers: Res<'w, Controllers>,
pub bots: ResMut<'w, BotSeats>,
}
impl Drivers<'_> {
pub fn commit(
&mut self,
board: &Board,
actions: &mut [PlayerAction; MAX_PLAYERS],
) -> Option<Vec<Committed>> {
self.bots.commit(board, actions)
}
pub fn after(
&mut self,
board: &Board,
committed: Option<Vec<Committed>>,
replay: Option<&Replay>,
) {
self.bots.after(board, committed, replay);
}
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct RoundTable<'w> {
controllers: Res<'w, Controllers>,
online: Res<'w, crate::app::net::Online>,
names: ResMut<'w, SeatNames>,
kinds: Res<'w, SeatKinds>,
}
pub fn begin_round(
table: RoundTable,
sim: Res<Sim>,
settings: Res<crate::app::settings::GameSettings>,
mut bots: ResMut<BotSeats>,
mut recorder: ResMut<crate::app::Recorder>,
) {
let RoundTable {
controllers,
online,
mut names,
kinds,
} = table;
bots.round = None;
bots.news.clear();
let mine = online
.0
.as_ref()
.filter(|session| session.bot)
.and_then(|session| session.session.seat());
let hosted: Vec<(u8, BotId)> = online
.0
.as_ref()
.map(|session| {
session
.bots_here
.iter()
.map(|(seat, id, _)| (*seat, *id))
.collect()
})
.unwrap_or_default();
if mine.is_none()
&& online.0.as_ref().is_some_and(|session| session.is_host())
&& let Some(door) = &mut bots.door
{
let seated: Vec<BotId> = hosted.iter().map(|(_, id)| *id).collect();
door.seat_the_table(&seated);
}
let seats: Vec<u8> = match mine {
Some(seat) => vec![seat],
None if online.0.is_some() => hosted.iter().map(|(seat, _)| *seat).collect(),
None => (0..MAX_PLAYERS as u8)
.filter(|&seat| controllers.0[usize::from(seat)] == SeatController::Bot)
.collect(),
};
if seats.is_empty() {
return;
}
bots.games += 1;
let game = bots.games;
let Some(door) = &bots.door else {
return;
};
let tr = settings.tr();
if online.0.is_none() {
for &seat in &seats {
if let Some(name) = door.seat_name(tr, seat) {
names.0[usize::from(seat)] = name;
}
}
if let Some(replay) = &mut recorder.0 {
replay.names = names.0.clone();
}
}
let board = &sim.0;
let n = usize::from(board.seats_in_play()).max(2);
let held: Vec<(u8, BotId)> = seats
.iter()
.filter_map(|&seat| {
let id = match (mine, hosted.iter().find(|(s, _)| *s == seat)) {
(Some(_), _) => door.chairs.first().and_then(|c| c.bot),
(None, Some(&(_, id))) => Some(id),
(None, None) => door
.chairs
.iter()
.find(|c| c.seat == seat)
.and_then(|c| c.bot),
};
Some((seat, id?))
})
.collect();
let round = BotRound::open(
door,
board,
&held,
(0..n).map(|seat| names.label(tr, seat as u8)).collect(),
kinds.0[..n].to_vec(),
online.0.is_some(),
game,
);
bots.round = Some(round);
}
pub fn end_round(mut bots: ResMut<BotSeats>) {
bots.round = None;
}
pub fn close_door(mut bots: ResMut<BotSeats>) {
*bots = BotSeats::default();
}
pub fn announce(
mut bots: ResMut<BotSeats>,
names: Res<SeatNames>,
settings: Res<crate::app::settings::GameSettings>,
mut log: ResMut<crate::app::side_panels::EventLog>,
) {
if bots.news.is_empty() {
return;
}
let tr = settings.tr();
for (seat, news) in std::mem::take(&mut bots.news) {
let who = names.label(tr, seat);
let line = match news {
News::StandIn => fill(tr.bot_stand_in, &[("p", &who)]),
News::Back => fill(tr.bot_back, &[("p", &who)]),
};
log.push(line, crate::app::palette::player_color(seat));
}
}
#[derive(Component)]
pub struct DoorCard;
#[derive(Component)]
pub struct DoorLine(usize);
const DOOR_LINES: usize = 3 + MAX_PLAYERS + 1;
fn door_lines(bots: &BotSeats, tr: &crate::app::i18n::Tr, beach: &str) -> Vec<(String, Color)> {
let mut lines = vec![(String::new(), Color::NONE); DOOR_LINES];
let (Some(waiting), Some(door)) = (&bots.waiting, &bots.door) else {
return lines;
};
let title = match waiting {
Waiting::Couch => tr.door_title_table.to_string(),
Waiting::Join => fill(tr.door_title_join, &[("b", beach)]),
};
lines[0] = (title, crate::app::palette::GOLD);
lines[1] = (
tr.door_start_with.to_string(),
crate::app::palette::PARCHMENT,
);
for (i, chair) in door.chairs.iter().take(MAX_PLAYERS).enumerate() {
let who = crate::app::seat_label(tr, chair.seat);
let line = match door.bot_line(chair) {
Some((name, rtt)) => {
let ms = rtt.map_or("-".to_string(), |d| {
format!("{:.1}", d.as_secs_f64() * 1000.0)
});
format!(
"{who} {}",
fill(tr.door_connected, &[("n", &name), ("ms", &ms)])
)
}
None => format!("{who} {} {}", door.string(chair), tr.door_waiting),
};
let ink = match chair.bot {
Some(_) => crate::app::palette::player_color(chair.seat).lighter(0.2),
None => Color::WHITE,
};
lines[2 + i] = (line, ink);
}
lines[2 + MAX_PLAYERS] = (
bots.feedback.clone(),
crate::app::palette::PARCHMENT.with_alpha(0.8),
);
let keys = match door.showing_lan {
true => tr.door_prompt_lan,
false => tr.door_prompt,
};
lines[DOOR_LINES - 1] = (
keys.to_string(),
crate::app::palette::PARCHMENT.with_alpha(0.7),
);
lines
}
pub fn door_card(
mut commands: Commands,
bots: Res<BotSeats>,
settings: Res<crate::app::settings::GameSettings>,
lobby: Res<crate::app::lobby::LobbyState>,
cards: Query<Entity, With<DoorCard>>,
mut lines: Query<(&DoorLine, &mut Text, &mut TextColor, &mut Node)>,
) {
let up = bots.waiting.is_some() && bots.door.is_some();
if !up {
for card in &cards {
commands.entity(card).despawn();
}
return;
}
if cards.is_empty() {
commands
.spawn((
DoorCard,
crate::app::menu_ui::centred_overlay(),
BackgroundColor(Color::BLACK.with_alpha(0.55)),
GlobalZIndex(40),
))
.with_children(|wrap| {
wrap.spawn(crate::app::menu_ui::screen_card())
.with_children(|card| {
for i in 0..DOOR_LINES {
card.spawn((
DoorLine(i),
Text::new(""),
if i == 0 {
crate::app::menu_ui::display_font(
crate::app::menu_ui::type_scale::DISPLAY * 0.7,
)
} else {
TextFont {
font_size: FontSize::Px(
crate::app::menu_ui::type_scale::BODY,
),
..default()
}
},
TextColor(Color::WHITE),
Node {
align_self: AlignSelf::FlexStart,
..default()
},
));
}
});
});
return;
}
let beach = lobby.joining_beach().unwrap_or_default();
let said = door_lines(&bots, settings.tr(), &beach);
for (line, mut text, mut ink, mut node) in &mut lines {
let (words, color) = &said[line.0];
crate::app::menu_ui::set_text(&mut text, words);
crate::app::menu_ui::set_color(&mut ink, *color);
crate::app::menu_ui::set_shown(&mut node, !words.is_empty());
}
}
pub fn door_input(
keyboard: crate::app::keycaps::Keyboard,
mut bots: ResMut<BotSeats>,
mut clipboard: ResMut<Clipboard>,
settings: Res<crate::app::settings::GameSettings>,
) {
let crate::app::keycaps::Keyboard { keys, caps } = keyboard;
if bots.waiting.is_none() {
return;
}
let tr = settings.tr();
if keys.just_pressed(KeyCode::Escape) {
bots.waiting = None;
bots.feedback.clear();
bots.door = None;
return;
}
let BotSeats {
door,
waiting,
feedback,
..
} = &mut *bots;
let Some(door) = door else {
*waiting = None;
return;
};
door.poll();
if caps.just_pressed(&keys, 'C')
&& let Some(chair) = door.chairs.iter().find(|chair| chair.bot.is_none())
{
let string = door.string(chair);
*feedback = match clipboard.set_text(string.clone()) {
Ok(()) => fill(tr.door_copied, &[("s", &string)]),
Err(e) => fill(tr.code_copy_failed, &[("e", &e.to_string())]),
};
}
if caps.just_pressed(&keys, 'L')
&& let Err(e) = door.toggle_lan()
{
*feedback = fill(tr.door_could_not_listen, &[("e", &e.to_string())]);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{BufRead, BufReader, Write};
fn register(string: &str, name: &str, owner: &str) -> std::net::TcpStream {
let parsed = ConnString::parse(string).expect("a string the card printed");
let mut stream =
std::net::TcpStream::connect((parsed.host.as_str(), parsed.port)).expect("connect");
let key = parsed.key.map(|k| k.to_string()).unwrap_or_default();
let line = serde_json::json!({
"type": "register", "protocol": 1, "name": name, "owner": owner, "key": key,
});
writeln!(stream, "{line}").expect("write");
let mut answer = String::new();
BufReader::new(stream.try_clone().expect("clone"))
.read_line(&mut answer)
.expect("read");
assert!(answer.contains("registered"), "{answer}");
stream
}
#[test]
fn a_failed_join_as_a_bot_closes_its_doorway() {
let mut bots = BotSeats {
door: Some(Doorway::open().expect("a port on this machine")),
join_to: Some(SocketAddr::from(([127, 0, 0, 1], 1))),
..BotSeats::default()
};
let here = bots.door.as_ref().expect("open").here;
bots.leave_the_beach(true);
assert!(bots.door.is_some());
bots.waiting = Some(Waiting::Join);
bots.leave_the_beach(false);
assert!(bots.door.is_some(), "the card is still up");
bots.waiting = None;
bots.leave_the_beach(false);
assert!(bots.door.is_none(), "the doorway closed");
assert!(bots.join_to.is_none());
let mut refused = false;
for _ in 0..100 {
if std::net::TcpStream::connect_timeout(&here, Duration::from_millis(50)).is_err() {
refused = true;
break;
}
std::thread::sleep(Duration::from_millis(5));
}
assert!(refused, "nobody listens on {here} any more");
let mut host = BotSeats {
door: Some(Doorway::open().expect("a port on this machine")),
..BotSeats::default()
};
host.leave_the_beach(false);
assert!(host.door.is_some());
}
fn seated(door: &mut Doorway, at: usize) {
for _ in 0..200 {
door.poll();
if door.chairs.get(at).is_some_and(|chair| chair.bot.is_some()) {
return;
}
std::thread::sleep(Duration::from_millis(5));
}
panic!("no bot sat in chair {at}");
}
fn try_register(string: &str, name: &str) -> String {
let parsed = ConnString::parse(string).expect("a string the card printed");
let Ok(mut stream) = std::net::TcpStream::connect_timeout(
&format!("{}:{}", parsed.host, parsed.port)
.parse()
.expect("an address"),
Duration::from_millis(200),
) else {
return "refused".to_string();
};
let key = parsed.key.map(|k| k.to_string()).unwrap_or_default();
let line = serde_json::json!({
"type": "register", "protocol": 1, "name": name, "key": key,
});
let _ = writeln!(stream, "{line}");
let mut answer = String::new();
let _ = BufReader::new(stream).read_line(&mut answer);
answer
}
#[test]
fn a_full_table_withdraws_its_open_chair() {
let mut door = Doorway::open().expect("a port on this machine");
door.keep_a_chair_open(true);
let string = door.open_string().expect("a chair is open");
door.keep_a_chair_open(false);
assert_eq!(door.open_string(), None, "no string is shown");
assert!(door.chairs.is_empty());
let answer = try_register(&string, "Late");
assert!(answer.contains("error"), "refused: {answer}");
door.keep_a_chair_open(true);
let fresh = door.open_string().expect("a chair is open again");
assert_ne!(fresh, string, "the old key is not reissued");
}
#[test]
fn a_bot_whose_chair_went_as_it_came_in_is_told() {
let mut door = Doorway::open().expect("a port on this machine");
door.keep_a_chair_open(true);
let bot = register(&door.open_string().expect("open"), "Just Late", "Ana");
door.keep_a_chair_open(false);
for _ in 0..60 {
door.poll();
std::thread::sleep(Duration::from_millis(5));
}
assert!(door.chairs.is_empty(), "no chair came back for it");
bot.set_read_timeout(Some(Duration::from_secs(5)))
.expect("timeout");
let mut said = String::new();
let mut reader = BufReader::new(bot);
while reader.read_line(&mut said).is_ok_and(|n| n > 0) {
if said.contains(NO_SEAT) {
break;
}
}
assert!(said.contains(NO_SEAT), "told why: {said}");
}
#[test]
fn a_bot_dealt_no_seat_is_told_and_let_go() {
let mut door = Doorway::open().expect("a port on this machine");
door.keep_a_chair_open(true);
let first = register(&door.open_string().expect("open"), "First", "Ana");
seated(&mut door, 0);
door.keep_a_chair_open(true);
let second = register(&door.open_string().expect("open"), "Second", "Bo");
seated(&mut door, 1);
door.keep_a_chair_open(true);
let late = door.open_string().expect("a third chair is open");
let tr = &crate::app::i18n::EN;
let ids: Vec<BotId> = door.arrived(tr).into_iter().map(|(id, _)| id).collect();
door.seat_the_table(&ids[..1]);
assert_eq!(door.arrived(tr).len(), 1, "the second bot was let go");
assert_eq!(door.open_string(), None, "and no chair is open mid-round");
second
.set_read_timeout(Some(Duration::from_secs(5)))
.expect("timeout");
let mut said = String::new();
let mut reader = BufReader::new(second);
while reader.read_line(&mut said).is_ok_and(|n| n > 0) {
if said.contains("error") {
break;
}
}
assert!(said.contains(NO_SEAT), "told why: {said}");
let answer = try_register(&late, "Late");
assert!(
answer.contains("error"),
"the open key was taken back: {answer}"
);
drop(first);
}
#[test]
fn turning_the_lan_off_closes_its_socket() {
let mut door = Doorway::open().expect("a port on this machine");
door.toggle_lan().expect("listen on the LAN");
let lan = door.lan.expect("a LAN socket");
assert!(door.showing_lan);
let wake = SocketAddr::from(([127, 0, 0, 1], lan.port()));
assert!(std::net::TcpStream::connect_timeout(&wake, Duration::from_millis(200)).is_ok());
door.toggle_lan().expect("back to this machine");
assert!(!door.showing_lan);
let mut refused = false;
for _ in 0..100 {
if std::net::TcpStream::connect_timeout(&wake, Duration::from_millis(50)).is_err() {
refused = true;
break;
}
std::thread::sleep(Duration::from_millis(5));
}
assert!(refused, "nobody listens on {wake} any more");
door.toggle_lan().expect("listen on the LAN again");
assert!(door.showing_lan);
assert!(door.lan.is_some());
}
#[test]
fn a_doorway_seats_each_bot_in_the_chair_its_key_was_printed_for() {
let mut door = Doorway::open().expect("a port on this machine");
door.seat_bots(&[2, 3]);
assert_eq!(door.chairs.len(), 2);
assert!(!door.ready());
let for_three = door.string(&door.chairs[1]);
let _bot = register(&for_three, "Greedy", "Ana");
for _ in 0..200 {
door.poll();
if door.chairs[1].bot.is_some() {
break;
}
std::thread::sleep(Duration::from_millis(5));
}
assert_eq!(door.chairs[1].seat, 3);
assert!(
door.chairs[1].bot.is_some(),
"the bot sat where its key said"
);
assert!(door.chairs[0].bot.is_none());
let tr = &crate::app::i18n::EN;
assert_eq!(door.seat_name(tr, 3).as_deref(), Some("Greedy (Ana)"));
door.seat_bots(&[3]);
assert_eq!(door.chairs.len(), 1);
assert!(door.ready());
}
#[test]
fn a_seat_that_stops_wanting_a_bot_lets_its_bot_and_its_key_go() {
let mut door = Doorway::open().expect("a port on this machine");
door.seat_bots(&[2, 3]);
let for_two = door.string(&door.chairs[0]);
let bot = register(&door.string(&door.chairs[1]), "Greedy", "Ana");
seated(&mut door, 1);
door.seat_bots(&[1]);
assert_eq!(door.chairs.len(), 1);
assert_eq!(door.chairs[0].seat, 1);
bot.set_read_timeout(Some(Duration::from_secs(5)))
.expect("timeout");
let mut said = String::new();
let mut reader = BufReader::new(bot);
while reader.read_line(&mut said).is_ok_and(|n| n > 0) {
if said.contains("error") {
break;
}
}
assert!(said.contains(SEAT_GONE), "told why: {said}");
let answer = try_register(&for_two, "Late");
assert!(
answer.contains("error") && !answer.contains(NO_SEAT),
"the key for seat 2 was taken back: {answer}"
);
}
struct TestBot {
stream: std::net::TcpStream,
reader: BufReader<std::net::TcpStream>,
}
impl TestBot {
fn register(string: &str) -> TestBot {
let parsed = ConnString::parse(string).expect("a string the card printed");
let stream =
std::net::TcpStream::connect((parsed.host.as_str(), parsed.port)).expect("connect");
let reader = BufReader::new(stream.try_clone().expect("clone"));
let mut bot = TestBot { stream, reader };
let key = parsed.key.map(|k| k.to_string()).unwrap_or_default();
bot.send(&serde_json::json!({
"type": "register", "protocol": 1, "name": "Greedy", "key": key,
}));
let mut answer = String::new();
bot.stream
.set_read_timeout(Some(Duration::from_secs(5)))
.expect("timeout");
let _ = bot.reader.read_line(&mut answer);
assert!(answer.contains("\"registered\""), "{answer}");
bot
}
fn send(&mut self, msg: &serde_json::Value) {
writeln!(self.stream, "{msg}").expect("write");
}
fn next(&mut self, kind: &str, within: Duration) -> Option<serde_json::Value> {
let until = Instant::now() + within;
loop {
let left = until.checked_duration_since(Instant::now())?;
self.stream
.set_read_timeout(Some(left.max(Duration::from_millis(1))))
.expect("timeout");
let mut line = String::new();
match self.reader.read_line(&mut line) {
Ok(0) | Err(_) => return None,
Ok(_) => {}
}
let msg: serde_json::Value = serde_json::from_str(&line).expect("json");
if msg["type"] == kind {
return Some(msg);
}
}
}
}
fn poll_until(door: &mut Doorway, done: impl Fn(&Doorway) -> bool) -> bool {
for _ in 0..400 {
door.poll();
if done(door) {
return true;
}
std::thread::sleep(Duration::from_millis(5));
}
false
}
const CASTLE: (u8, u8) = (7, 5);
fn beach() -> Board {
let mut board = Board::new(9, 7, 3);
board.set_tile(1, 1, crate::sim::TileKind::Castle(0));
board.set_tile(CASTLE.0, CASTLE.1, crate::sim::TileKind::Castle(1));
board
}
fn a_round_with_a_bot(online: bool) -> (BotSeats, TestBot, Board) {
let mut door = Doorway::open().expect("a port on this machine");
door.seat_bots(&[1]);
let bot = TestBot::register(&door.string(&door.chairs[0]));
assert!(poll_until(&mut door, |d| d.ready()), "the bot sat down");
let id = door.chairs[0].bot.expect("seated");
let board = beach();
let round = BotRound::open(
&door,
&board,
&[(1, id)],
vec!["Ana".into(), "Greedy".into()],
vec![SeatKind::Human, SeatKind::Bot],
online,
1,
);
let bots = BotSeats {
door: Some(door),
round: Some(round),
..BotSeats::default()
};
(bots, bot, board)
}
fn over(mut board: Board) -> (Board, Replay) {
let replay = Replay::new(crate::sim::Level::from_board("T", 3, board.clone()));
board.set_round_length(Some(1));
board.tick_idle();
assert!(board.round_over());
(board, replay)
}
#[test]
fn a_couch_bot_is_told_once_and_can_fetch_the_replay_it_is_told_of() {
let (mut bots, mut bot, board) = a_round_with_a_bot(false);
let (board, replay) = over(board);
bots.after(&board, Some(Vec::new()), Some(&replay));
let end = bot
.next("end", Duration::from_secs(5))
.expect("told the round is over");
let id = end["replay"].as_str().expect("a replay named").to_string();
bot.send(&serde_json::json!({"type": "replay", "id": id}));
let mut answer = String::new();
bot.stream
.set_read_timeout(Some(Duration::from_secs(5)))
.expect("timeout");
bot.reader.read_line(&mut answer).expect("an answer");
let answer: serde_json::Value = serde_json::from_str(&answer).expect("json");
assert_eq!(answer["type"], "replay", "{answer}");
assert_eq!(answer["text"], replay.to_text());
bots.after(&board, Some(Vec::new()), Some(&replay));
assert!(
bot.next("end", Duration::from_millis(300)).is_none(),
"told twice"
);
}
#[test]
fn an_online_bot_is_told_when_the_round_is_over() {
let (mut bots, mut bot, board) = a_round_with_a_bot(true);
let (board, replay) = over(board);
bots.send_online(&board, Some(&replay));
assert!(
bot.next("end", Duration::from_secs(5)).is_some(),
"no end for an online bot"
);
}
#[test]
fn an_act_the_lockstep_did_not_take_goes_out_again() {
let (mut bots, mut bot, board) = a_round_with_a_bot(true);
bot.next("hello", Duration::from_secs(5)).expect("hello");
bot.send(&serde_json::json!({"type": "ready", "game": 1}));
let (x, y) = (CASTLE.0 - 1, CASTLE.1);
bot.send(&serde_json::json!({
"game": 1, "tick": 0, "act": "place", "x": x, "y": y, "dir": "left",
}));
let place = PlayerAction::Place {
x,
y,
dir: crate::sim::Direction::Left,
};
let mut landed = None;
for _ in 0..400 {
let driven = bots.commit_online(&board);
if let Some(&(1, action, act)) = driven.first()
&& action == place
{
landed = Some(act);
break;
}
std::thread::sleep(Duration::from_millis(5));
}
let act = landed.expect("the place landed");
let again = bots.commit_online(&board);
assert_eq!(again.first().map(|d| d.1), Some(place), "the act was lost");
bots.committed(5, 1, act, place);
let after = bots.commit_online(&board);
assert_eq!(after.first().map(|d| d.1), Some(PlayerAction::None));
}
#[test]
fn a_place_on_a_tile_freed_earlier_in_the_tick_is_accepted() {
let (mut bots, _bot, mut board) = a_round_with_a_bot(false);
let (x, y) = (CASTLE.0 - 1, CASTLE.1);
let dir = crate::sim::Direction::Left;
assert!(board.place_signpost(1, x, y, dir));
board.tick_idle();
let round = bots.round.as_mut().expect("a round");
round.after(
&board,
vec![Committed {
seat: 1,
act: Some(Act::Place { x, y, dir }),
action: PlayerAction::Place { x, y, dir },
refused: Some(Refusal::RivalPost),
}],
);
assert_eq!(round.last[1], Some(("place", Outcome::Accepted)));
}
#[test]
fn a_silent_bot_is_stood_in_for_and_gets_its_seat_back() {
let (mut bots, mut bot, board) = a_round_with_a_bot(false);
bots.round.as_mut().expect("a round").grace = Duration::from_millis(50);
let mut actions = [PlayerAction::None; MAX_PLAYERS];
let mut stood_in = false;
for _ in 0..100 {
let committed = bots.commit(&board, &mut actions);
bots.after(&board, committed, None);
if bots.news.contains(&(1, News::StandIn)) {
stood_in = true;
break;
}
std::thread::sleep(Duration::from_millis(5));
}
assert!(stood_in, "nobody stood in for a silent bot");
bots.news.clear();
bot.send(&serde_json::json!({"type": "ready", "game": 1}));
let mut back = false;
for _ in 0..400 {
let committed = bots.commit(&board, &mut actions);
if bots.news.contains(&(1, News::Back)) {
back = true;
break;
}
bots.after(&board, committed, None);
std::thread::sleep(Duration::from_millis(5));
}
assert!(back, "the bot spoke and did not get its seat back");
}
#[test]
fn a_bot_that_goes_gives_its_chair_up() {
let mut door = Doorway::open().expect("a port on this machine");
door.seat_bots(&[1]);
let spent = door.string(&door.chairs[0]);
let bot = TestBot::register(&spent);
assert!(poll_until(&mut door, |d| d.ready()));
drop(bot);
assert!(
poll_until(&mut door, |d| !d.ready()),
"a dead bot kept its chair"
);
assert!(door.arrived(&crate::app::i18n::EN).is_empty());
assert_eq!(door.chairs.len(), 1, "the seat still wants a bot");
assert_ne!(door.string(&door.chairs[0]), spent, "a fresh key");
let _next = TestBot::register(&door.string(&door.chairs[0]));
assert!(poll_until(&mut door, |d| d.ready()));
}
#[test]
fn a_bot_that_leaves_a_hosts_door_is_not_seated() {
let mut door = Doorway::open().expect("a port on this machine");
door.keep_a_chair_open(true);
let bot = TestBot::register(&door.open_string().expect("an open chair"));
assert!(poll_until(&mut door, |d| d
.arrived(&crate::app::i18n::EN)
.len()
== 1));
door.keep_a_chair_open(true);
drop(bot);
let settled = |d: &Doorway| {
d.arrived(&crate::app::i18n::EN).is_empty()
&& d.chairs.len() == 1
&& d.chairs[0].bot.is_none()
};
for _ in 0..400 {
door.poll();
door.keep_a_chair_open(true);
if settled(&door) {
break;
}
std::thread::sleep(Duration::from_millis(5));
}
assert!(
settled(&door),
"a dead bot is still at the beach: {} chairs",
door.chairs.len()
);
}
}