use super::connstr::DEFAULT_PORT;
use super::listener::{BotId, Listener};
use crate::app::match_setup::{GullPressure, MapChoice, RoundLength};
use crate::sim::{
Board, BotLevel, CapPolicy, Level, MAX_PLAYERS, MAX_SIGNPOSTS_PER_PLAYER, PlayerId,
TICKS_PER_SECOND,
};
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
pub struct Args {
rest: Vec<String>,
}
impl Args {
pub fn new(args: impl IntoIterator<Item = String>) -> Args {
Args {
rest: args.into_iter().collect(),
}
}
pub fn all(&mut self, name: &str) -> Result<Vec<String>, String> {
let flag = format!("--{name}");
let eq = format!("--{name}=");
let mut out = Vec::new();
let mut i = 0;
while i < self.rest.len() {
if self.rest[i] == flag {
if i + 1 >= self.rest.len() {
return Err(format!("{flag} needs a value"));
}
out.push(self.rest.remove(i + 1));
self.rest.remove(i);
} else if let Some(value) = self.rest[i].strip_prefix(&eq) {
out.push(value.to_string());
self.rest.remove(i);
} else {
i += 1;
}
}
Ok(out)
}
pub fn value(&mut self, name: &str) -> Result<Option<String>, String> {
Ok(self.all(name)?.pop())
}
pub fn parsed<T: std::str::FromStr>(&mut self, name: &str) -> Result<Option<T>, String> {
match self.value(name)? {
Some(v) => v
.parse()
.map(Some)
.map_err(|_| format!("--{name}: {v:?} is not a valid value")),
None => Ok(None),
}
}
pub fn switch(&mut self, name: &str) -> bool {
let flag = format!("--{name}");
let before = self.rest.len();
self.rest.retain(|a| *a != flag);
self.rest.len() != before
}
pub fn on_off(&mut self, name: &str, default: bool) -> Result<bool, String> {
match self.value(name)?.as_deref() {
None => Ok(default),
Some("on") => Ok(true),
Some("off") => Ok(false),
Some(other) => Err(format!("--{name} is on or off, not {other:?}")),
}
}
pub fn finish(self) -> Result<Vec<String>, String> {
if let Some(flag) = self.rest.iter().find(|a| a.starts_with("--")) {
return Err(format!("unknown flag {flag}"));
}
Ok(self.rest)
}
}
pub struct TableOpts {
pub listen: SocketAddr,
pub beach: Beach,
pub round: RoundLength,
pub deadline: u32,
pub fair: bool,
}
impl TableOpts {
pub fn read(args: &mut Args, default_ip: [u8; 4]) -> Result<TableOpts, String> {
let mut listen: SocketAddr = args
.parsed("listen")?
.unwrap_or_else(|| SocketAddr::from((default_ip, DEFAULT_PORT)));
if let Some(port) = args.parsed::<u16>("port")? {
listen.set_port(port);
}
let beach = match args.value("map")? {
Some(m) => Beach::parse(&m)?,
None => Beach::Map(MapChoice::Classic),
};
let round = match args.value("round")? {
Some(r) => round_length(&r)?,
None => RoundLength::Standard,
};
let deadline = args
.parsed::<u32>("deadline")?
.unwrap_or(ONE_TICK_MS)
.max(1);
let fair = args.on_off("fair-cursor", false)?;
Ok(TableOpts {
listen,
beach,
round,
deadline,
fair,
})
}
}
pub const ONE_TICK_MS: u32 = 1000 / TICKS_PER_SECOND;
pub fn slow_note(deadline: u32) -> &'static str {
if deadline > ONE_TICK_MS {
" (a slow match)"
} else {
""
}
}
pub fn wait_for_rtt(listener: &Listener, id: BotId) {
let asked = Instant::now();
while listener.rtt(id).is_none() && asked.elapsed() < Duration::from_millis(300) {
std::thread::sleep(Duration::from_millis(5));
}
}
pub fn rtt_text(rtt: Duration) -> String {
format!("{} ms", ms(rtt.as_secs_f64() * 1000.0))
}
pub fn reply_text(times: &[f64]) -> String {
match (percentile(times, 50.0), percentile(times, 99.0)) {
(Some(p50), Some(p99)) => format!("{} / {} ms", ms(p50), ms(p99)),
_ => "-".into(),
}
}
pub fn bot_level(text: &str) -> Result<BotLevel, String> {
match text {
"easy" => Ok(BotLevel::Easy),
"normal" => Ok(BotLevel::Normal),
"hard" | "fierce" => Ok(BotLevel::Hard),
other => Err(format!("{other:?} is not an AI level (easy, normal, hard)")),
}
}
pub fn level_token(level: BotLevel) -> &'static str {
match level {
BotLevel::Easy => "easy",
BotLevel::Normal => "normal",
BotLevel::Hard => "hard",
}
}
pub fn ai_seat(text: &str) -> Result<BotLevel, String> {
let level = text
.strip_prefix("ai:")
.ok_or_else(|| format!("{text:?} is not ai:<level>"))?;
bot_level(level)
}
pub fn round_length(text: &str) -> Result<RoundLength, String> {
match text {
"quick" => Ok(RoundLength::Quick),
"short" => Ok(RoundLength::Short),
"standard" => Ok(RoundLength::Standard),
"long" => Ok(RoundLength::Long),
other => Err(format!(
"{other:?} is not a round length (quick, short, standard, long)"
)),
}
}
pub fn round_name(round: RoundLength) -> String {
let secs = round.ticks() / TICKS_PER_SECOND;
let name = match round {
RoundLength::Quick => "quick",
RoundLength::Short => "short",
RoundLength::Standard => "standard",
RoundLength::Long => "long",
};
format!("{name} round ({}:{:02})", secs / 60, secs % 60)
}
#[derive(Clone, Debug)]
pub enum Beach {
Map(MapChoice),
Generated,
File(PathBuf, Box<Level>),
}
impl Beach {
pub fn parse(text: &str) -> Result<Beach, String> {
if text == "generated" {
return Ok(Beach::Generated);
}
let named = MapChoice::from_token(text).filter(|&map| map != MapChoice::Custom);
Ok(match (named, text) {
(Some(map), _) => Beach::Map(map),
(None, path) => {
let text =
std::fs::read_to_string(path).map_err(|e| format!("--map {path}: {e}"))?;
let level = Level::parse(&text).map_err(|e| format!("--map {path}: {e}"))?;
Beach::File(PathBuf::from(path), Box::new(level))
}
})
}
pub fn name(&self) -> String {
match self {
Beach::Generated => "generated".into(),
Beach::Map(map) => map.token().into(),
Beach::File(path, _) => path.display().to_string(),
}
}
pub fn check(&self, seats: u8) -> Result<(), String> {
if !(2..=MAX_PLAYERS as u8).contains(&seats) {
return Err(format!("a table seats 2 to {MAX_PLAYERS}, not {seats}"));
}
match self {
Beach::Map(MapChoice::Classic) if seats > 4 => {
Err("the classic beach seats 2 to 4; use --map generated".into())
}
Beach::Map(map) if !crate::app::match_setup::holds(*map, seats) => {
Err(match map.tables() {
Some(tables) => {
let tables: Vec<String> = tables.iter().map(u8::to_string).collect();
format!(
"the {} beach seats tables of {}; use --map generated",
self.name(),
tables.join(", ")
)
}
None => format!("the {} beach seats 2 to 4", self.name()),
})
}
Beach::File(_, level) => {
if level.fits(seats) {
return Ok(());
}
let mut owners: Vec<PlayerId> = level.board().castle_owners().collect();
owners.sort_unstable();
owners.dedup();
let marked: Vec<String> = owners.iter().map(PlayerId::to_string).collect();
Err(format!(
"{} has castles marked {}; a table of {seats} needs castles marked 0 to {} \
at least, with none skipped",
self.name(),
if marked.is_empty() {
"nowhere".to_string()
} else {
marked.join(", ")
},
seats - 1
))
}
Beach::Map(_) | Beach::Generated => Ok(()),
}
}
pub fn board(&self, seed: u64, seats: u8, round: RoundLength) -> Board {
let mut board = match self {
Beach::File(_, level) => level.table_board(seed, seats),
Beach::Map(map) => crate::app::match_setup::versus_board(*map, seed, seats),
Beach::Generated => {
let map = match crate::app::match_setup::holds(MapChoice::GenClassic, seats) {
true => MapChoice::GenClassic,
false => crate::app::match_setup::sized_for(seats),
};
crate::app::match_setup::versus_board(map, seed, seats)
}
};
crate::sim::set_versus_rules(&mut board, GullPressure::Normal.period());
board.rules_mut().round_length = Some(round.ticks());
board.set_signpost_rule(MAX_SIGNPOSTS_PER_PLAYER as u8, CapPolicy::Evict);
board
}
}
pub fn fresh_seed() -> u64 {
crate::app::clock::fresh_seed() % 100_000
}
pub fn nth_seed(first: u64, n: u64) -> u64 {
first.wrapping_add(n)
}
pub fn stamp() -> String {
let now = crate::app::clock::now_secs();
let local = now as i64 + crate::app::clock::local_offset(now);
let days = local.div_euclid(86_400);
let secs = local.rem_euclid(86_400);
let (y, m, d) = crate::app::clock::civil_ymd(days.max(0) as u32);
format!(
"{y:04}-{m:02}-{d:02}-{:02}{:02}",
secs / 3600,
(secs / 60) % 60
)
}
pub fn free_path(dir: &Path, stem: &str, ext: &str) -> PathBuf {
let first = dir.join(format!("{stem}.{ext}"));
if !first.exists() {
return first;
}
(2..)
.map(|n| dir.join(format!("{stem}-{n}.{ext}")))
.find(|p| !p.exists())
.unwrap_or(first)
}
pub fn table(rows: &[Vec<String>]) -> String {
let cols = rows.iter().map(Vec::len).max().unwrap_or(0);
let width = |c: usize| {
rows.iter()
.filter_map(|r| r.get(c))
.map(|s| s.chars().count())
.max()
.unwrap_or(0)
};
let widths: Vec<usize> = (0..cols).map(width).collect();
let numeric = |c: usize| {
rows.iter()
.skip(1)
.filter_map(|r| r.get(c))
.all(|s| s.starts_with(|ch: char| ch.is_ascii_digit() || ch == '-' || ch == '+'))
};
let mut out = String::new();
for row in rows {
out.push_str(" ");
for (c, cell) in row.iter().enumerate() {
let pad = widths[c].saturating_sub(cell.chars().count());
if numeric(c) {
out.push_str(&" ".repeat(pad));
out.push_str(cell);
} else {
out.push_str(cell);
if c + 1 < row.len() {
out.push_str(&" ".repeat(pad));
}
}
if c + 1 < row.len() {
out.push_str(" ");
}
}
out.push('\n');
}
out
}
pub fn ranked(n: usize, cmp: impl Fn(usize, usize) -> std::cmp::Ordering) -> Vec<(u32, usize)> {
let places = crate::bots::seat::ranks_by(n, &cmp);
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&a, &b| cmp(a, b));
order
.into_iter()
.map(|entry| (places[entry], entry))
.collect()
}
pub fn percentile(times: &[f64], p: f64) -> Option<f64> {
if times.is_empty() {
return None;
}
let mut sorted = times.to_vec();
sorted.sort_by(f64::total_cmp);
let rank = ((p / 100.0) * (sorted.len() - 1) as f64).round() as usize;
sorted.get(rank).copied()
}
pub fn ms(value: f64) -> String {
if value < 10.0 {
format!("{value:.1}")
} else {
format!("{value:.0}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_old_kelp_keep_name_still_names_the_gates() {
let named = |text: &str| match Beach::parse(text) {
Ok(Beach::Map(map)) => Some(map),
Ok(Beach::Generated | Beach::File(..)) | Err(_) => None,
};
assert_eq!(named("kelp-keep"), Some(MapChoice::TheGates));
assert_eq!(named("gates"), Some(MapChoice::TheGates));
}
#[test]
fn a_beach_file_needs_a_castle_for_each_seat_and_none_skipped() {
let beach = |row: &str| {
let text =
format!("name: T\nposts: 0\ngoal: survive\nmap:\n+-+-+-+\n|{row}|\n+-+-+-+\n");
let level = Level::parse(&text).expect("level");
Beach::File(PathBuf::from("t.beach"), Box::new(level))
};
assert_eq!(beach("0 . 1").check(2), Ok(()));
let err = beach("0 . 2").check(2).expect_err("no castle 1");
assert!(err.contains("marked 0, 2"), "{err}");
assert!(beach("0 1 2").check(2).is_ok(), "a castle past the seats");
assert!(beach("0 1 2").check(3).is_ok());
assert!(beach("0 1 2").check(4).is_err());
let board = beach("0 1 2").board(1, 2, RoundLength::Short);
assert_eq!(board.seats_in_play(), 2, "the spare castle put away");
}
#[test]
fn exact_ties_share_a_place_in_the_standings() {
let points = [3.0_f64, 5.0, 3.0, 1.0];
let ranked = ranked(points.len(), |a, b| points[b].total_cmp(&points[a]));
let places: Vec<u32> = ranked.iter().map(|&(place, _)| place).collect();
assert_eq!(places, vec![1, 2, 2, 4]);
assert_eq!(ranked[0].1, 1);
assert_eq!(ranked[3].1, 3);
}
#[test]
fn a_level_token_reads_back_as_its_level() {
for level in [BotLevel::Easy, BotLevel::Normal, BotLevel::Hard] {
assert_eq!(bot_level(level_token(level)), Ok(level));
}
}
#[test]
fn flags_are_read_in_both_spellings_and_typos_are_said() {
let mut args = Args::new(
[
"--seat",
"ai:easy",
"--seat=ai:hard",
"--open",
"2",
"--watch",
"file",
]
.map(String::from),
);
assert_eq!(
args.all("seat"),
Ok(vec!["ai:easy".to_string(), "ai:hard".to_string()])
);
assert_eq!(args.parsed::<u32>("open"), Ok(Some(2)));
assert!(args.switch("watch"));
assert_eq!(args.finish(), Ok(vec!["file".to_string()]));
let args = Args::new(["--sead", "1"].map(String::from));
assert!(args.finish().is_err());
assert!(Args::new(["--open".to_string()]).all("open").is_err());
}
#[test]
fn a_percentile_is_one_of_the_times() {
let times = [5.0, 1.0, 3.0, 2.0, 4.0];
assert_eq!(percentile(×, 50.0), Some(3.0));
assert_eq!(percentile(×, 99.0), Some(5.0));
assert_eq!(percentile(&[], 50.0), None);
}
#[test]
fn a_beach_that_cannot_seat_the_table_says_so() {
assert!(Beach::Map(MapChoice::Classic).check(5).is_err());
assert!(Beach::Generated.check(6).is_ok());
assert!(Beach::Generated.check(7).is_err());
let board = Beach::Generated.board(3, 6, RoundLength::Short);
assert_eq!(board.castle_seats(), 6);
}
#[test]
fn a_generated_arena_is_the_beach_the_game_deals() {
for seats in 2..=MAX_PLAYERS as u8 {
let board = Beach::Generated.board(3, seats, RoundLength::Short);
let mut config = crate::app::match_setup::MatchConfig {
map: MapChoice::GenClassic,
seats,
..Default::default()
};
crate::app::match_setup::settle_map(&mut config, &Default::default());
let (w, h) = config.map.size();
let widened = seats >= 5 && board.width() == w + 1;
assert!(
board.width() == w || widened,
"{seats} seats: {} wide, the game's is {w}",
board.width()
);
assert_eq!(board.height(), h, "{seats} seats");
assert_eq!(config.map.wraps(), board.wrap(), "{seats} seats");
}
}
}