use super::doorway::Doorway;
use crate::app::teams::TeamMode;
use crate::bots::cursor::SeatCursor;
use crate::bots::listener::{BotId, GameLink, 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, refusal};
use crate::sim::{Board, BotLevel, MAX_PLAYERS, PlayerAction, Refusal, Replay, bot_action};
use bevy::prelude::*;
use std::time::{Duration, Instant};
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;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum News {
StandIn,
Back,
}
pub struct BotRound {
lockstep: Option<Lockstep>,
link: GameLink,
game: u32,
seats: Vec<SeatHand>,
grace: Duration,
finished: bool,
party: PartyTable,
clock: Clock,
log: Box<dyn std::io::Write + Send + Sync>,
}
struct SeatHand {
driver: BotDriver,
cursor: SeatCursor,
last: Option<(&'static str, Outcome)>,
dropped_at: Option<Instant>,
quiet_since: Option<Instant>,
standing_in: bool,
}
#[derive(Default)]
struct Lockstep {
in_flight: std::collections::VecDeque<InFlight>,
unconfirmed: [Option<(PlayerAction, Option<Act>)>; MAX_PLAYERS],
}
const PARTY_REPLAYS: u32 = 16;
fn party_replay_id(game: u32) -> String {
format!("p-g{game}")
}
pub(super) struct PartyTable {
names: Vec<String>,
kinds: Vec<SeatKind>,
mode: TeamMode,
teams: Vec<u8>,
}
impl PartyTable {
pub(super) fn new(names: Vec<String>, kinds: Vec<SeatKind>, mode: TeamMode) -> PartyTable {
let teams = (0..names.len() as u8)
.map(|seat| mode.team_of(seat))
.collect();
PartyTable {
names,
kinds,
mode,
teams,
}
}
fn len(&self) -> usize {
self.names.len()
}
fn table(&self, game: u32, clock: Clock) -> Table<'_> {
let PartyTable {
names,
kinds,
mode: _,
teams,
} = self;
Table {
game,
names,
kinds,
clock,
cursor: true,
teams: Some(teams),
}
}
}
fn journal<'a>(
log: &'a mut (dyn std::io::Write + Send + Sync),
console: &'a mut dyn FnMut(String),
game: u32,
) -> Journal<'a> {
Journal {
log,
console,
echo_notes: true,
game,
}
}
#[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 {
pub(super) fn open(
door: &Doorway,
board: &Board,
seats: &[(u8, BotId)],
party: PartyTable,
online: Option<u32>,
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 now = Instant::now();
let seats: Vec<SeatHand> = seats
.iter()
.map(|&(seat, id)| {
let name = party
.names
.get(usize::from(seat))
.cloned()
.unwrap_or_default();
SeatHand {
driver: BotDriver::new(id, seat, name, door.listener().connected(id), deadline),
cursor: SeatCursor::home(board, seat),
last: None,
dropped_at: None,
quiet_since: Some(now),
standing_in: false,
}
})
.collect();
let lockstep = online.map(|_| Lockstep::default());
let round = BotRound {
clock: Clock {
live: true,
deadline_ms: LIVE_DEADLINE_MS,
input_delay: online.unwrap_or(0),
},
lockstep,
link,
game,
seats,
grace: GRACE,
finished: false,
party,
log: Box::new(std::io::sink()),
};
for hand in &round.seats {
let driver = &hand.driver;
if driver.connected {
let hello = protocol::hello(board, &round.table(), driver.seat, false);
round.link.send(driver.id, &hello);
}
}
round
}
fn table(&self) -> Table<'_> {
self.party.table(self.game, self.clock)
}
fn hand(&self, seat: usize) -> Option<&SeatHand> {
self.seats
.iter()
.find(|hand| usize::from(hand.driver.seat) == seat)
}
fn drain(&mut self, t: u64, news: &mut Vec<(u8, News)>) {
let mut console = |line: String| info!("{line}");
let mut journal = journal(&mut self.log, &mut console, self.game);
while let Some(msg) = self.link.try_recv() {
let seat = msg.seat();
if let Some(hand) = self.seats.iter_mut().find(|h| h.driver.seat == seat) {
hand.driver.take(msg, t, &mut journal);
if hand.driver.playing() {
hand.quiet_since = None;
}
}
}
let now = Instant::now();
for hand in &mut self.seats {
let gone_for = match hand.driver.connected {
true => {
hand.dropped_at = None;
hand.quiet_since.map(|since| now.duration_since(since))
}
false => Some(now.duration_since(*hand.dropped_at.get_or_insert(now))),
};
match gone_for {
Some(gone) if gone >= self.grace => {
if !std::mem::replace(&mut hand.standing_in, true) {
news.push((hand.driver.seat, News::StandIn));
}
}
Some(_) => {}
None => {
if std::mem::take(&mut hand.standing_in) {
news.push((hand.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(&mut self.log, &mut console, self.game);
let mut committed = Vec::new();
for hand in &mut self.seats {
let seat = hand.driver.seat;
let slot = usize::from(seat);
let held = self
.lockstep
.as_ref()
.and_then(|lockstep| lockstep.unconfirmed[slot]);
let (action, act) = if let Some(held) = held {
held
} else if hand.standing_in {
hand.cursor.let_go();
let action = bot_action(board, seat, STAND_IN);
(action, act_of(action))
} else if !hand.driver.playing() {
hand.driver.stand_down(&mut hand.cursor);
hand.cursor.step(board, seat, Act::None, true)
} else {
let order = hand.driver.order(&mut journal);
hand.cursor.step(board, seat, order, true)
};
actions[slot] = action;
if let Some(lockstep) = &mut self.lockstep {
lockstep.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>) {
if self.finished {
return;
}
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))
&& let Some(hand) = self.seats.iter_mut().find(|h| h.driver.seat == c.seat)
{
hand.last = Some((act.token(), result));
}
}
let cursors: Vec<Option<(u8, u8)>> = (0..self.party.len())
.map(|seat| self.hand(seat).map(|hand| hand.cursor.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.party.len()],
});
let now = Instant::now();
let BotRound {
link,
game,
seats,
party,
clock,
..
} = self;
let table = party.table(*game, *clock);
let mut send = |id: BotId, msg: &serde_json::Value| link.send(id, msg);
for hand in seats {
let seat = hand.driver.seat;
let you = You {
seat,
last: hand.last,
};
let turn = hand.driver.offer_tick(
t,
now,
&mut send,
|| protocol::hello(board, &table, seat, true),
|| protocol::tick(board, *game, &you, &cursors),
);
if turn == Turn::Send {
hand.quiet_since.get_or_insert(now);
}
}
}
pub(super) 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()
}
pub(super) fn online_seats(&self) -> Vec<u8> {
match self.lockstep {
Some(_) => self.seats.iter().map(|hand| hand.driver.seat).collect(),
None => Vec::new(),
}
}
pub(super) fn committed(
&mut self,
frame: u32,
seat: u8,
act: Option<Act>,
action: PlayerAction,
) {
let Some(lockstep) = &mut self.lockstep else {
return;
};
if let Some(held) = lockstep.unconfirmed.get_mut(usize::from(seat)) {
*held = None;
}
if let Some(act) = act {
lockstep.in_flight.push_back(InFlight {
frame,
seat,
act,
action,
refused: None,
});
while lockstep.in_flight.len() > 256 {
lockstep.in_flight.pop_front();
}
}
}
pub(super) fn before_frame(&mut self, frame: u32, board: &Board) {
let Some(lockstep) = &mut self.lockstep else {
return;
};
for entry in lockstep.in_flight.iter_mut().filter(|e| e.frame == frame) {
entry.refused = refusal(board, entry.seat, entry.action);
}
}
pub(super) fn after_frame(&mut self, frame: u32, board: &Board) {
let Some(lockstep) = &mut self.lockstep else {
return;
};
while let Some(&entry) = lockstep.in_flight.front() {
if entry.frame > frame {
break;
}
lockstep.in_flight.pop_front();
if entry.frame == frame
&& let Some(result) = outcome(
entry.act,
entry.action,
entry.refused,
board,
entry.seat,
u64::from(frame),
)
&& let Some(hand) = self
.seats
.iter_mut()
.find(|hand| hand.driver.seat == entry.seat)
{
hand.last = Some((entry.act.token(), result));
}
}
}
pub(super) fn send_next(&mut self, board: &Board) {
self.after(board, Vec::new());
}
pub(super) fn finish(
&mut self,
board: &Board,
door: Option<&Doorway>,
replay: Option<&Replay>,
) {
if std::mem::replace(&mut self.finished, true) {
return;
}
let n = self.party.len();
let scores: Vec<u32> = board.scores()[..n].to_vec();
let places = crate::app::teams::placings(board.scores(), n as u8, self.party.mode);
let kept = match (door, replay) {
(Some(door), Some(replay)) => {
let id = party_replay_id(self.game);
let players = self.seats.iter().map(|hand| hand.driver.id).collect();
let listener = door.listener();
let mut replay = replay.clone();
replay.scores = Some(*board.scores());
listener.keep_replay(id.clone(), players, replay.to_text());
if let Some(old) = self.game.checked_sub(PARTY_REPLAYS) {
listener.forget_replay(&party_replay_id(old));
}
Some(id)
}
_ => None,
};
for hand in &self.seats {
let driver = &hand.driver;
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);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::bot_seats::BotSeats;
use crate::app::bot_seats::test_bots::{TestBot, poll_until};
use std::io::BufRead;
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) {
a_table_with_a_bot(
online,
vec!["Ana".into(), "Greedy".into()],
vec![SeatKind::Human, SeatKind::Bot],
TeamMode::Solo,
)
}
fn a_table_with_a_bot(
online: bool,
names: Vec<String>,
kinds: Vec<SeatKind>,
mode: TeamMode,
) -> (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 party = PartyTable::new(names, kinds, mode);
let delay = online.then_some(crate::sim::DEFAULT_DELAY);
let round = BotRound::open(&door, &board, &[(1, id)], party, delay, 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.rules_mut().round_length = Some(1);
board.tick_idle();
assert!(board.round_over());
(board, replay)
}
#[test]
fn a_bot_in_pairs_is_told_its_team_and_placed_with_it() {
let names: Vec<String> = ["Ana", "Greedy", "Cy", "Di"].map(String::from).to_vec();
let kinds = vec![SeatKind::Human; 4];
let (mut bots, mut bot, mut board) =
a_table_with_a_bot(false, names, kinds, TeamMode::Pairs);
let hello = bot.next("hello", Duration::from_secs(5)).expect("hello");
assert_eq!(hello["teams"], serde_json::json!([0, 0, 1, 1]));
for (seat, score) in [(0, 9), (1, 2), (2, 6), (3, 4)] {
board.set_score(seat, score);
}
let (board, replay) = over(board);
bots.after(&board, Some(Vec::new()), Some(&replay));
let end = bot.next("end", Duration::from_secs(5)).expect("end");
assert_eq!(end["placing"], 1, "{end}");
assert_eq!(end["scores"], serde_json::json!([9, 2, 6, 4]));
}
#[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}");
let mut ended = replay.clone();
ended.scores = Some(*board.scores());
assert_eq!(answer["text"], ended.to_text());
bots.after(&board, Some(Vec::new()), Some(&replay));
assert!(
bot.next("end", Duration::from_millis(300)).is_none(),
"told twice"
);
}
#[test]
fn a_party_lets_its_oldest_replay_go() {
let (mut bots, mut bot, board) = a_round_with_a_bot(false);
let round = bots.round.as_mut().expect("a round");
round.game = PARTY_REPLAYS + 1;
let id = round.seats[0].driver.id;
let listener = bots.door.as_ref().expect("a door").listener();
listener.keep_replay(party_replay_id(1), vec![id], "old".into());
listener.keep_replay(party_replay_id(2), vec![id], "newer".into());
let (board, replay) = over(board);
bots.after(&board, Some(Vec::new()), Some(&replay));
bot.next("end", Duration::from_secs(5))
.expect("told the round is over");
let mut fetch = |game: u32| {
bot.send(&serde_json::json!({"type": "replay", "id": party_replay_id(game)}));
bot.stream
.set_read_timeout(Some(Duration::from_secs(5)))
.expect("timeout");
loop {
let mut line = String::new();
bot.reader.read_line(&mut line).expect("an answer");
let answer: serde_json::Value = serde_json::from_str(&line).expect("json");
if let Some(kind @ ("replay" | "error")) = answer["type"].as_str() {
return kind.to_string();
}
}
};
assert_eq!(fetch(1), "error", "the oldest is let go");
assert_eq!(fetch(2), "replay");
assert_eq!(fetch(PARTY_REPLAYS + 1), "replay");
}
#[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 frame in 0..400 {
let driven = bots.commit_online(&board);
if let Some(&(1, action, act)) = driven.first() {
if action == place {
landed = Some(act);
break;
}
bots.committed(frame, 1, act, action);
}
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_stalled_lockstep_does_not_walk_the_hand() {
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}));
bot.send(&serde_json::json!({
"game": 1, "tick": 0, "act": "place", "x": 0, "y": 0, "dir": "right",
}));
let hand = |bots: &BotSeats| bots.round.as_ref().expect("a round").seats[0].cursor;
for frame in 0..400 {
let driven = bots.commit_online(&board);
let (seat, action, act) = driven[0];
bots.committed(frame, seat, act, action);
if hand(&bots).order().is_some() {
break;
}
std::thread::sleep(Duration::from_millis(5));
}
assert!(hand(&bots).order().is_some(), "the walk began");
bots.commit_online(&board);
let walking = hand(&bots);
for _ in 0..50 {
let driven = bots.commit_online(&board);
assert_eq!(driven[0].1, PlayerAction::None);
}
assert_eq!(
hand(&bots),
walking,
"the hand walked on frames nobody took"
);
}
#[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.hand(1).expect("seat 1").last,
Some(("place", Outcome::Accepted))
);
}
fn until_playing(bots: &mut BotSeats, board: &Board) {
let mut actions = [PlayerAction::None; MAX_PLAYERS];
for _ in 0..400 {
bots.commit(board, &mut actions);
let round = bots.round.as_ref().expect("a round");
if round.hand(1).expect("seat 1").driver.playing() {
return;
}
std::thread::sleep(Duration::from_millis(5));
}
panic!("the bot never got to play");
}
#[test]
fn a_dropped_bots_walk_does_not_land() {
let (mut bots, mut bot, board) = a_round_with_a_bot(false);
bots.round.as_mut().expect("a round").grace = Duration::from_secs(60);
bot.next("hello", Duration::from_secs(5)).expect("hello");
bot.send(&serde_json::json!({"type": "ready", "game": 1}));
until_playing(&mut bots, &board);
bot.send(&serde_json::json!({
"game": 1, "tick": 0, "act": "place", "x": 2, "y": CASTLE.1, "dir": "left",
}));
let mut actions = [PlayerAction::None; MAX_PLAYERS];
let mut walking = false;
for _ in 0..400 {
bots.commit(&board, &mut actions);
let round = bots.round.as_ref().expect("a round");
if round.hand(1).expect("seat 1").cursor.order().is_some() {
walking = true;
break;
}
std::thread::sleep(Duration::from_millis(5));
}
assert!(walking, "the order was never taken");
let id = bots.round.as_ref().expect("a round").seats[0].driver.id;
drop(bot);
let door = bots.door.as_ref().expect("a door");
for _ in 0..400 {
if !door.listener().connected(id) {
break;
}
std::thread::sleep(Duration::from_millis(5));
}
assert!(!door.listener().connected(id), "the drop was never seen");
for _ in 0..100 {
let committed = bots.commit(&board, &mut actions).expect("a round");
assert_eq!(actions[1], PlayerAction::None, "the walk landed");
assert!(committed.iter().all(|c| c.act.is_none()));
}
}
#[test]
fn a_silent_bot_is_sent_no_tick_after_the_end() {
let (mut bots, mut bot, board) = a_round_with_a_bot(false);
bot.next("hello", Duration::from_secs(5)).expect("hello");
bot.send(&serde_json::json!({"type": "ready", "game": 1}));
until_playing(&mut bots, &board);
let (board, replay) = over(board);
bots.after(&board, Some(Vec::new()), Some(&replay));
bot.next("end", Duration::from_secs(5))
.expect("told the round is over");
let until = Instant::now() + Duration::from_millis(700);
while Instant::now() < until {
bots.after(&board, Some(Vec::new()), Some(&replay));
std::thread::sleep(Duration::from_millis(10));
}
assert!(
bot.next("tick", Duration::from_millis(200)).is_none(),
"a tick after the end"
);
}
#[test]
fn a_bot_that_never_said_ready_is_not_given_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);
bot.next("hello", Duration::from_secs(5)).expect("hello");
let mut actions = [PlayerAction::None; MAX_PLAYERS];
let mut stood_in = false;
for _ in 0..100 {
bots.commit(&board, &mut actions);
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 bot that never came");
bots.news.clear();
bot.send(&serde_json::json!({"game": 1, "tick": 0, "act": "none"}));
for _ in 0..100 {
bots.commit(&board, &mut actions);
std::thread::sleep(Duration::from_millis(5));
}
assert!(
!bots.news.contains(&(1, News::Back)),
"back without a ready"
);
}
#[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");
}
}