diff options
46 files changed, 1348 insertions, 481 deletions
diff --git a/planetwars-client/src/main.rs b/planetwars-client/src/main.rs index 5053c51..1821fd3 100644 --- a/planetwars-client/src/main.rs +++ b/planetwars-client/src/main.rs @@ -22,6 +22,9 @@ struct PlayMatch { #[clap(value_parser)] opponent_name: String, + #[clap(value_parser, long = "map")] + map_name: Option<String>, + #[clap( value_parser, long, @@ -34,7 +37,7 @@ struct PlayMatch { #[derive(Deserialize)] struct BotConfig { #[allow(dead_code)] - name: String, + name: Option<String>, command: Command, working_directory: Option<String>, } @@ -69,9 +72,13 @@ async fn main() { let channel = Channel::builder(uri).connect().await.unwrap(); - let created_match = create_match(channel.clone(), play_match.opponent_name) - .await - .unwrap(); + let created_match = create_match( + channel.clone(), + play_match.opponent_name, + play_match.map_name, + ) + .await + .unwrap(); run_player(bot_config, created_match.player_key, channel).await; println!( "Match completed. Watch the replay at {}", @@ -83,10 +90,14 @@ async fn main() { async fn create_match( channel: Channel, opponent_name: String, + map_name: Option<String>, ) -> Result<pb::CreateMatchResponse, Status> { let mut client = ClientApiServiceClient::new(channel); let res = client - .create_match(Request::new(pb::CreateMatchRequest { opponent_name })) + .create_match(Request::new(pb::CreateMatchRequest { + opponent_name, + map_name: map_name.unwrap_or_default(), + })) .await; res.map(|response| response.into_inner()) } diff --git a/planetwars-matchrunner/src/lib.rs b/planetwars-matchrunner/src/lib.rs index 5aff793..fcd4799 100644 --- a/planetwars-matchrunner/src/lib.rs +++ b/planetwars-matchrunner/src/lib.rs @@ -58,7 +58,7 @@ pub struct MatchOutcome { pub async fn run_match(config: MatchConfig) -> MatchOutcome { let pw_config = PwConfig { map_file: config.map_path, - max_turns: 100, + max_turns: 500, }; let event_bus = Arc::new(Mutex::new(EventBus::new())); diff --git a/planetwars-server/Cargo.toml b/planetwars-server/Cargo.toml index f2444c1..2b3916c 100644 --- a/planetwars-server/Cargo.toml +++ b/planetwars-server/Cargo.toml @@ -2,8 +2,16 @@ name = "planetwars-server" version = "0.0.0" edition = "2021" +default-run = "planetwars-server" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[[bin]] +name = "planetwars-server" +path = "src/main.rs" + +[[bin]] +name = "planetwars-server-cli" +path = "src/cli.rs" [dependencies] futures = "0.3" @@ -32,6 +40,7 @@ sha2 = "0.10" tokio-util = { version="0.7.3", features=["io"] } prost = "0.10" tonic = "0.7.2" +clap = { version = "3.2", features = ["derive", "env"]} # TODO: remove me shlex = "1.1" diff --git a/planetwars-server/migrations/2022-07-30-145155_add_public_to_matches/down.sql b/planetwars-server/migrations/2022-07-30-145155_add_public_to_matches/down.sql new file mode 100644 index 0000000..7a0c0bf --- /dev/null +++ b/planetwars-server/migrations/2022-07-30-145155_add_public_to_matches/down.sql @@ -0,0 +1 @@ +ALTER TABLE matches DROP COLUMN is_public; diff --git a/planetwars-server/migrations/2022-07-30-145155_add_public_to_matches/up.sql b/planetwars-server/migrations/2022-07-30-145155_add_public_to_matches/up.sql new file mode 100644 index 0000000..714de8c --- /dev/null +++ b/planetwars-server/migrations/2022-07-30-145155_add_public_to_matches/up.sql @@ -0,0 +1 @@ +ALTER TABLE matches ADD COLUMN is_public boolean NOT NULL DEFAULT false; diff --git a/planetwars-server/migrations/2022-08-23-174628_maps/down.sql b/planetwars-server/migrations/2022-08-23-174628_maps/down.sql new file mode 100644 index 0000000..7489122 --- /dev/null +++ b/planetwars-server/migrations/2022-08-23-174628_maps/down.sql @@ -0,0 +1,3 @@ +ALTER TABLE matches DROP COLUMN map_id; + +DROP TABLE maps;
\ No newline at end of file diff --git a/planetwars-server/migrations/2022-08-23-174628_maps/up.sql b/planetwars-server/migrations/2022-08-23-174628_maps/up.sql new file mode 100644 index 0000000..c66fc9f --- /dev/null +++ b/planetwars-server/migrations/2022-08-23-174628_maps/up.sql @@ -0,0 +1,7 @@ +CREATE TABLE maps ( + id SERIAL PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + file_path TEXT NOT NULL +); + +ALTER TABLE matches ADD COLUMN map_id INTEGER REFERENCES maps(id);
\ No newline at end of file diff --git a/planetwars-server/src/cli.rs b/planetwars-server/src/cli.rs new file mode 100644 index 0000000..f33506e --- /dev/null +++ b/planetwars-server/src/cli.rs @@ -0,0 +1,54 @@ +extern crate planetwars_server; +extern crate tokio; + +use clap::Parser; +use planetwars_server::db; +use planetwars_server::{create_db_pool, get_config}; + +#[derive(clap::Parser)] +struct Args { + #[clap(subcommand)] + action: Action, +} + +#[derive(clap::Subcommand)] +enum Action { + SetPassword(SetPassword), +} + +impl Action { + async fn run(self) { + match self { + Action::SetPassword(set_password) => set_password.run().await, + } + } +} + +#[derive(clap::Parser)] +struct SetPassword { + #[clap(value_parser)] + username: String, + + #[clap(value_parser)] + new_password: String, +} + +impl SetPassword { + async fn run(self) { + let global_config = get_config().unwrap(); + let pool = create_db_pool(&global_config).await; + + let conn = pool.get().await.expect("could not get database connection"); + let credentials = db::users::Credentials { + username: &self.username, + password: &self.new_password, + }; + db::users::set_user_password(credentials, &conn).expect("could not set password"); + } +} + +#[tokio::main] +pub async fn main() { + let args = Args::parse(); + args.action.run().await; +} diff --git a/planetwars-server/src/db/maps.rs b/planetwars-server/src/db/maps.rs new file mode 100644 index 0000000..dffe4fd --- /dev/null +++ b/planetwars-server/src/db/maps.rs @@ -0,0 +1,35 @@ +use diesel::prelude::*; + +use crate::schema::maps; + +#[derive(Insertable)] +#[table_name = "maps"] +pub struct NewMap<'a> { + pub name: &'a str, + pub file_path: &'a str, +} + +#[derive(Queryable, Clone, Debug)] +pub struct Map { + pub id: i32, + pub name: String, + pub file_path: String, +} + +pub fn create_map(new_map: NewMap, conn: &PgConnection) -> QueryResult<Map> { + diesel::insert_into(maps::table) + .values(new_map) + .get_result(conn) +} + +pub fn find_map(id: i32, conn: &PgConnection) -> QueryResult<Map> { + maps::table.find(id).get_result(conn) +} + +pub fn find_map_by_name(name: &str, conn: &PgConnection) -> QueryResult<Map> { + maps::table.filter(maps::name.eq(name)).first(conn) +} + +pub fn list_maps(conn: &PgConnection) -> QueryResult<Vec<Map>> { + maps::table.get_results(conn) +} diff --git a/planetwars-server/src/db/matches.rs b/planetwars-server/src/db/matches.rs index 061e2ea..2041296 100644 --- a/planetwars-server/src/db/matches.rs +++ b/planetwars-server/src/db/matches.rs @@ -1,20 +1,27 @@ pub use crate::db_types::MatchState; use chrono::NaiveDateTime; use diesel::associations::BelongsTo; +use diesel::pg::Pg; +use diesel::query_builder::BoxedSelectStatement; +use diesel::query_source::{AppearsInFromClause, Once}; use diesel::{ BelongingToDsl, ExpressionMethods, JoinOnDsl, NullableExpressionMethods, QueryDsl, RunQueryDsl, }; use diesel::{Connection, GroupedBy, PgConnection, QueryResult}; +use std::collections::{HashMap, HashSet}; -use crate::schema::{bot_versions, bots, match_players, matches}; +use crate::schema::{bot_versions, bots, maps, match_players, matches}; use super::bots::{Bot, BotVersion}; +use super::maps::Map; #[derive(Insertable)] #[table_name = "matches"] pub struct NewMatch<'a> { pub state: MatchState, pub log_path: &'a str, + pub is_public: bool, + pub map_id: Option<i32>, } #[derive(Insertable)] @@ -36,6 +43,8 @@ pub struct MatchBase { pub log_path: String, pub created_at: NaiveDateTime, pub winner: Option<i32>, + pub is_public: bool, + pub map_id: Option<i32>, } #[derive(Queryable, Identifiable, Associations, Clone)] @@ -87,42 +96,133 @@ pub struct MatchData { pub match_players: Vec<MatchPlayer>, } +/// Add player information to MatchBase instances +fn fetch_full_match_data( + matches: Vec<MatchBase>, + conn: &PgConnection, +) -> QueryResult<Vec<FullMatchData>> { + let map_ids: HashSet<i32> = matches.iter().filter_map(|m| m.map_id).collect(); + + let maps_by_id: HashMap<i32, Map> = maps::table + .filter(maps::id.eq_any(map_ids)) + .load::<Map>(conn)? + .into_iter() + .map(|m| (m.id, m)) + .collect(); + + let match_players = MatchPlayer::belonging_to(&matches) + .left_join( + bot_versions::table.on(match_players::bot_version_id.eq(bot_versions::id.nullable())), + ) + .left_join(bots::table.on(bot_versions::bot_id.eq(bots::id.nullable()))) + .order_by(( + match_players::match_id.asc(), + match_players::player_id.asc(), + )) + .load::<FullMatchPlayerData>(conn)? + .grouped_by(&matches); + + let res = matches + .into_iter() + .zip(match_players.into_iter()) + .map(|(base, players)| FullMatchData { + match_players: players.into_iter().collect(), + map: base + .map_id + .and_then(|map_id| maps_by_id.get(&map_id).cloned()), + base, + }) + .collect(); + + Ok(res) +} + +// TODO: this method should disappear pub fn list_matches(amount: i64, conn: &PgConnection) -> QueryResult<Vec<FullMatchData>> { conn.transaction(|| { let matches = matches::table + .filter(matches::state.eq(MatchState::Finished)) .order_by(matches::created_at.desc()) .limit(amount) .get_results::<MatchBase>(conn)?; - let match_players = MatchPlayer::belonging_to(&matches) - .left_join( - bot_versions::table - .on(match_players::bot_version_id.eq(bot_versions::id.nullable())), - ) - .left_join(bots::table.on(bot_versions::bot_id.eq(bots::id.nullable()))) - .order_by(( - match_players::match_id.asc(), - match_players::player_id.asc(), - )) - .load::<FullMatchPlayerData>(conn)? - .grouped_by(&matches); - - let res = matches - .into_iter() - .zip(match_players.into_iter()) - .map(|(base, players)| FullMatchData { - base, - match_players: players.into_iter().collect(), - }) - .collect(); + fetch_full_match_data(matches, conn) + }) +} - Ok(res) +pub fn list_public_matches( + amount: i64, + before: Option<NaiveDateTime>, + after: Option<NaiveDateTime>, + conn: &PgConnection, +) -> QueryResult<Vec<FullMatchData>> { + conn.transaction(|| { + // TODO: how can this common logic be abstracted? + let query = matches::table + .filter(matches::state.eq(MatchState::Finished)) + .filter(matches::is_public.eq(true)) + .into_boxed(); + + let matches = + select_matches_page(query, amount, before, after).get_results::<MatchBase>(conn)?; + fetch_full_match_data(matches, conn) }) } +pub fn list_bot_matches( + bot_id: i32, + amount: i64, + before: Option<NaiveDateTime>, + after: Option<NaiveDateTime>, + conn: &PgConnection, +) -> QueryResult<Vec<FullMatchData>> { + let query = matches::table + .filter(matches::state.eq(MatchState::Finished)) + .filter(matches::is_public.eq(true)) + .order_by(matches::created_at.desc()) + .inner_join(match_players::table) + .inner_join( + bot_versions::table.on(match_players::bot_version_id.eq(bot_versions::id.nullable())), + ) + .filter(bot_versions::bot_id.eq(bot_id)) + .select(matches::all_columns) + .into_boxed(); + + let matches = + select_matches_page(query, amount, before, after).get_results::<MatchBase>(conn)?; + fetch_full_match_data(matches, conn) +} + +fn select_matches_page<QS>( + query: BoxedSelectStatement<'static, matches::SqlType, QS, Pg>, + amount: i64, + before: Option<NaiveDateTime>, + after: Option<NaiveDateTime>, +) -> BoxedSelectStatement<'static, matches::SqlType, QS, Pg> +where + QS: AppearsInFromClause<matches::table, Count = Once>, +{ + // TODO: this is not nice. Replace this with proper cursor logic. + match (before, after) { + (None, None) => query.order_by(matches::created_at.desc()), + (Some(before), None) => query + .filter(matches::created_at.lt(before)) + .order_by(matches::created_at.desc()), + (None, Some(after)) => query + .filter(matches::created_at.gt(after)) + .order_by(matches::created_at.asc()), + (Some(before), Some(after)) => query + .filter(matches::created_at.lt(before)) + .filter(matches::created_at.gt(after)) + .order_by(matches::created_at.desc()), + } + .limit(amount) +} + // TODO: maybe unify this with matchdata? pub struct FullMatchData { pub base: MatchBase, + pub map: Option<Map>, pub match_players: Vec<FullMatchPlayerData>, } @@ -151,6 +251,11 @@ pub fn find_match(id: i32, conn: &PgConnection) -> QueryResult<FullMatchData> { conn.transaction(|| { let match_base = matches::table.find(id).get_result::<MatchBase>(conn)?; + let map = match match_base.map_id { + None => None, + Some(map_id) => Some(super::maps::find_map(map_id, conn)?), + }; + let match_players = MatchPlayer::belonging_to(&match_base) .left_join( bot_versions::table @@ -163,6 +268,7 @@ pub fn find_match(id: i32, conn: &PgConnection) -> QueryResult<FullMatchData> { let res = FullMatchData { base: match_base, match_players, + map, }; Ok(res) diff --git a/planetwars-server/src/db/mod.rs b/planetwars-server/src/db/mod.rs index 84ed2a6..f014cea 100644 --- a/planetwars-server/src/db/mod.rs +++ b/planetwars-server/src/db/mod.rs @@ -1,4 +1,5 @@ pub mod bots; +pub mod maps; pub mod matches; pub mod ratings; pub mod sessions; diff --git a/planetwars-server/src/db/users.rs b/planetwars-server/src/db/users.rs index ebb2268..9676dae 100644 --- a/planetwars-server/src/db/users.rs +++ b/planetwars-server/src/db/users.rs @@ -42,11 +42,17 @@ fn argon2_config() -> argon2::Config<'static> { } } -pub fn create_user(credentials: &Credentials, conn: &PgConnection) -> QueryResult<User> { +pub fn hash_password(password: &str) -> (Vec<u8>, [u8; 32]) { let argon_config = argon2_config(); - let salt: [u8; 32] = rand::thread_rng().gen(); - let hash = argon2::hash_raw(credentials.password.as_bytes(), &salt, &argon_config).unwrap(); + let hash = argon2::hash_raw(password.as_bytes(), &salt, &argon_config).unwrap(); + + (hash, salt) +} + +pub fn create_user(credentials: &Credentials, conn: &PgConnection) -> QueryResult<User> { + let (hash, salt) = hash_password(&credentials.password); + let new_user = NewUser { username: credentials.username, password_salt: &salt, @@ -69,6 +75,22 @@ pub fn find_user_by_name(username: &str, db_conn: &PgConnection) -> QueryResult< .first::<User>(db_conn) } +pub fn set_user_password(credentials: Credentials, db_conn: &PgConnection) -> QueryResult<()> { + let (hash, salt) = hash_password(&credentials.password); + + let n_changes = diesel::update(users::table.filter(users::username.eq(&credentials.username))) + .set(( + users::password_salt.eq(salt.as_slice()), + users::password_hash.eq(hash.as_slice()), + )) + .execute(db_conn)?; + if n_changes == 0 { + Err(diesel::result::Error::NotFound) + } else { + Ok(()) + } +} + pub fn authenticate_user(credentials: &Credentials, db_conn: &PgConnection) -> Option<User> { find_user_by_name(credentials.username, db_conn) .optional() diff --git a/planetwars-server/src/lib.rs b/planetwars-server/src/lib.rs index e16b232..fa1af8d 100644 --- a/planetwars-server/src/lib.rs +++ b/planetwars-server/src/lib.rs @@ -94,11 +94,9 @@ pub async fn seed_simplebot(config: &GlobalConfig, pool: &ConnectionPool) { pub type DbPool = Pool<DieselConnectionManager<PgConnection>>; -pub async fn prepare_db(config: &GlobalConfig) -> DbPool { +pub async fn create_db_pool(config: &GlobalConfig) -> DbPool { let manager = DieselConnectionManager::<PgConnection>::new(&config.database_url); - let pool = bb8::Pool::builder().build(manager).await.unwrap(); - seed_simplebot(config, &pool).await; - pool + bb8::Pool::builder().build(manager).await.unwrap() } // create all directories required for further operation @@ -129,12 +127,13 @@ pub fn api() -> Router { "/bots/:bot_name/upload", post(routes::bots::upload_code_multipart), ) - .route("/matches", get(routes::matches::list_matches)) + .route("/matches", get(routes::matches::list_recent_matches)) .route("/matches/:match_id", get(routes::matches::get_match_data)) .route( "/matches/:match_id/log", get(routes::matches::get_match_log), ) + .route("/maps", get(routes::maps::list_maps)) .route("/leaderboard", get(routes::bots::get_ranking)) .route("/submit_bot", post(routes::demo::submit_bot)) .route("/save_bot", post(routes::bots::save_bot)) @@ -165,7 +164,8 @@ async fn run_registry(config: Arc<GlobalConfig>, db_pool: DbPool) { pub async fn run_app() { let global_config = Arc::new(get_config().unwrap()); - let db_pool = prepare_db(&global_config).await; + let db_pool = create_db_pool(&global_config).await; + seed_simplebot(&global_config, &db_pool).await; init_directories(&global_config).unwrap(); if global_config.ranker_enabled { diff --git a/planetwars-server/src/modules/client_api.rs b/planetwars-server/src/modules/client_api.rs index 7026671..3402964 100644 --- a/planetwars-server/src/modules/client_api.rs +++ b/planetwars-server/src/modules/client_api.rs @@ -111,14 +111,23 @@ impl pb::client_api_service_server::ClientApiService for ClientApiServer { db::bots::find_bot_with_version_by_name(&match_request.opponent_name, &conn) .map_err(|_| Status::not_found("opponent not found"))?; + let map_name = match match_request.map_name.as_str() { + "" => "hex", + name => name, + }; + let map = db::maps::find_map_by_name(map_name, &conn) + .map_err(|_| Status::not_found("map not found"))?; + let player_key = gen_alphanumeric(32); let remote_bot_spec = Box::new(RemoteBotSpec { player_key: player_key.clone(), router: self.router.clone(), }); - let run_match = RunMatch::from_players( + let run_match = RunMatch::new( self.runner_config.clone(), + false, + map, vec![ MatchPlayer::BotSpec { spec: remote_bot_spec, diff --git a/planetwars-server/src/modules/matches.rs b/planetwars-server/src/modules/matches.rs index 4f538ed..ecc7976 100644 --- a/planetwars-server/src/modules/matches.rs +++ b/planetwars-server/src/modules/matches.rs @@ -7,6 +7,7 @@ use tokio::task::JoinHandle; use crate::{ db::{ self, + maps::Map, matches::{MatchData, MatchResult}, }, util::gen_alphanumeric, @@ -17,6 +18,11 @@ pub struct RunMatch { log_file_name: String, players: Vec<MatchPlayer>, config: Arc<GlobalConfig>, + is_public: bool, + // Map is mandatory for now. + // It would be nice to allow "anonymous" (eg. randomly generated) maps + // in the future, too. + map: Map, } pub enum MatchPlayer { @@ -30,19 +36,27 @@ pub enum MatchPlayer { } impl RunMatch { - pub fn from_players(config: Arc<GlobalConfig>, players: Vec<MatchPlayer>) -> Self { + // TODO: create a MatchParams struct + pub fn new( + config: Arc<GlobalConfig>, + is_public: bool, + map: Map, + players: Vec<MatchPlayer>, + ) -> Self { let log_file_name = format!("{}.log", gen_alphanumeric(16)); RunMatch { config, log_file_name, players, + is_public, + map, } } fn into_runner_config(self) -> runner::MatchConfig { runner::MatchConfig { - map_path: PathBuf::from(&self.config.maps_directory).join("hex.json"), - map_name: "hex".to_string(), + map_path: PathBuf::from(&self.config.maps_directory).join(self.map.file_path), + map_name: self.map.name, log_path: PathBuf::from(&self.config.match_logs_directory).join(&self.log_file_name), players: self .players @@ -80,6 +94,8 @@ impl RunMatch { let new_match_data = db::matches::NewMatch { state: db::matches::MatchState::Playing, log_path: &self.log_file_name, + is_public: self.is_public, + map_id: Some(self.map.id), }; let new_match_players = self .players diff --git a/planetwars-server/src/modules/ranking.rs b/planetwars-server/src/modules/ranking.rs index cb699fe..90c4a56 100644 --- a/planetwars-server/src/modules/ranking.rs +++ b/planetwars-server/src/modules/ranking.rs @@ -1,4 +1,5 @@ use crate::db::bots::BotVersion; +use crate::db::maps::Map; use crate::{db::bots::Bot, DbPool, GlobalConfig}; use crate::db; @@ -25,22 +26,31 @@ pub async fn run_ranker(config: Arc<GlobalConfig>, db_pool: DbPool) { .expect("could not get database connection"); loop { interval.tick().await; - let bots = db::bots::all_active_bots_with_version(&db_conn).unwrap(); + let bots = db::bots::all_active_bots_with_version(&db_conn).expect("could not load bots"); if bots.len() < 2 { // not enough bots to play a match continue; } - let selected_bots: Vec<(Bot, BotVersion)> = { - let mut rng = &mut rand::thread_rng(); - bots.choose_multiple(&mut rng, 2).cloned().collect() + + let selected_bots: Vec<(Bot, BotVersion)> = bots + .choose_multiple(&mut rand::thread_rng(), 2) + .cloned() + .collect(); + + let maps = db::maps::list_maps(&db_conn).expect("could not load map"); + let map = match maps.choose(&mut rand::thread_rng()).cloned() { + None => continue, // no maps available + Some(map) => map, }; - play_ranking_match(config.clone(), selected_bots, db_pool.clone()).await; + + play_ranking_match(config.clone(), map, selected_bots, db_pool.clone()).await; recalculate_ratings(&db_conn).expect("could not recalculate ratings"); } } async fn play_ranking_match( config: Arc<GlobalConfig>, + map: Map, selected_bots: Vec<(Bot, BotVersion)>, db_pool: DbPool, ) { @@ -53,7 +63,7 @@ async fn play_ranking_match( players.push(player); } - let (_, handle) = RunMatch::from_players(config, players) + let (_, handle) = RunMatch::new(config, true, map, players) .run(db_pool.clone()) .await .expect("failed to run match"); diff --git a/planetwars-server/src/modules/registry.rs b/planetwars-server/src/modules/registry.rs index 297404a..4a79d59 100644 --- a/planetwars-server/src/modules/registry.rs +++ b/planetwars-server/src/modules/registry.rs @@ -300,8 +300,10 @@ async fn put_upload( while let Some(Ok(chunk)) = stream.next().await { file.write_all(&chunk).await.unwrap(); } - file.flush().await.unwrap(); let range_end = last_byte_pos(&file).await.unwrap(); + // Close the file to ensure all data has been flushed to the kernel. + // If we don't do this, calculating the checksum can fail. + std::mem::drop(file); let expected_digest = params.digest.strip_prefix("sha256:").unwrap(); let digest = file_sha256_digest(&upload_path).unwrap(); diff --git a/planetwars-server/src/routes/demo.rs b/planetwars-server/src/routes/demo.rs index dad9453..1ec8825 100644 --- a/planetwars-server/src/routes/demo.rs +++ b/planetwars-server/src/routes/demo.rs @@ -14,12 +14,13 @@ use serde::{Deserialize, Serialize}; use super::matches::ApiMatch; const DEFAULT_OPPONENT_NAME: &str = "simplebot"; +const DEFAULT_MAP_NAME: &str = "hex"; #[derive(Serialize, Deserialize, Debug)] pub struct SubmitBotParams { pub code: String, - // TODO: would it be better to pass an ID here? pub opponent_name: Option<String>, + pub map_name: Option<String>, } #[derive(Serialize, Deserialize)] @@ -40,16 +41,24 @@ pub async fn submit_bot( .opponent_name .unwrap_or_else(|| DEFAULT_OPPONENT_NAME.to_string()); + let map_name = params + .map_name + .unwrap_or_else(|| DEFAULT_MAP_NAME.to_string()); + let (opponent_bot, opponent_bot_version) = db::bots::find_bot_with_version_by_name(&opponent_name, &conn) .map_err(|_| StatusCode::BAD_REQUEST)?; + let map = db::maps::find_map_by_name(&map_name, &conn).map_err(|_| StatusCode::BAD_REQUEST)?; + let player_bot_version = save_code_string(¶ms.code, None, &conn, &config) // TODO: can we recover from this? .expect("could not save bot code"); - let run_match = RunMatch::from_players( + let run_match = RunMatch::new( config, + false, + map.clone(), vec![ MatchPlayer::BotVersion { bot: None, @@ -81,6 +90,7 @@ pub async fn submit_bot( bot: Some(opponent_bot), }, ], + map: Some(map), }; let api_match = super::matches::match_data_to_api(full_match_data); diff --git a/planetwars-server/src/routes/maps.rs b/planetwars-server/src/routes/maps.rs new file mode 100644 index 0000000..689b11e --- /dev/null +++ b/planetwars-server/src/routes/maps.rs @@ -0,0 +1,19 @@ +use crate::{db, DatabaseConnection}; +use axum::Json; +use hyper::StatusCode; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub struct ApiMap { + pub name: String, +} + +pub async fn list_maps(conn: DatabaseConnection) -> Result<Json<Vec<ApiMap>>, StatusCode> { + let maps = db::maps::list_maps(&conn).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let api_maps = maps + .into_iter() + .map(|map| ApiMap { name: map.name }) + .collect(); + Ok(Json(api_maps)) +} diff --git a/planetwars-server/src/routes/matches.rs b/planetwars-server/src/routes/matches.rs index 58ca478..10b4507 100644 --- a/planetwars-server/src/routes/matches.rs +++ b/planetwars-server/src/routes/matches.rs @@ -1,19 +1,30 @@ -use axum::{extract::Path, Extension, Json}; +use axum::{ + extract::{Path, Query}, + Extension, Json, +}; +use chrono::NaiveDateTime; use hyper::StatusCode; use serde::{Deserialize, Serialize}; use std::{path::PathBuf, sync::Arc}; use crate::{ - db::matches::{self, MatchState}, + db::{ + self, + matches::{self, MatchState}, + }, DatabaseConnection, GlobalConfig, }; +use super::maps::ApiMap; + #[derive(Serialize, Deserialize)] pub struct ApiMatch { id: i32, timestamp: chrono::NaiveDateTime, state: MatchState, players: Vec<ApiMatchPlayer>, + winner: Option<i32>, + map: Option<ApiMap>, } #[derive(Serialize, Deserialize)] @@ -23,10 +34,60 @@ pub struct ApiMatchPlayer { bot_name: Option<String>, } -pub async fn list_matches(conn: DatabaseConnection) -> Result<Json<Vec<ApiMatch>>, StatusCode> { - matches::list_matches(100, &conn) - .map_err(|_| StatusCode::BAD_REQUEST) - .map(|matches| Json(matches.into_iter().map(match_data_to_api).collect())) +#[derive(Serialize, Deserialize)] +pub struct ListRecentMatchesParams { + count: Option<usize>, + // TODO: should timezone be specified here? + before: Option<NaiveDateTime>, + after: Option<NaiveDateTime>, + + bot: Option<String>, +} + +const MAX_NUM_RETURNED_MATCHES: usize = 100; +const DEFAULT_NUM_RETURNED_MATCHES: usize = 50; + +#[derive(Serialize, Deserialize)] +pub struct ListMatchesResponse { + matches: Vec<ApiMatch>, + has_next: bool, +} + +pub async fn list_recent_matches( + Query(params): Query<ListRecentMatchesParams>, + conn: DatabaseConnection, +) -> Result<Json<ListMatchesResponse>, StatusCode> { + let requested_count = std::cmp::min( + params.count.unwrap_or(DEFAULT_NUM_RETURNED_MATCHES), + MAX_NUM_RETURNED_MATCHES, + ); + + // fetch one additional record to check whether a next page exists + let count = (requested_count + 1) as i64; + + let matches_result = match params.bot { + Some(bot_name) => { + let bot = db::bots::find_bot_by_name(&bot_name, &conn) + .map_err(|_| StatusCode::BAD_REQUEST)?; + matches::list_bot_matches(bot.id, count, params.before, params.after, &conn) + } + None => matches::list_public_matches(count, params.before, params.after, &conn), + }; + + let mut matches = matches_result.map_err(|_| StatusCode::BAD_REQUEST)?; + + let mut has_next = false; + if matches.len() > requested_count { + has_next = true; + matches.truncate(requested_count); + } + + let api_matches = matches.into_iter().map(match_data_to_api).collect(); + + Ok(Json(ListMatchesResponse { + matches: api_matches, + has_next, + })) } pub fn match_data_to_api(data: matches::FullMatchData) -> ApiMatch { @@ -43,6 +104,8 @@ pub fn match_data_to_api(data: matches::FullMatchData) -> ApiMatch { bot_name: _p.bot.as_ref().map(|b| b.name.clone()), }) .collect(), + winner: data.base.winner, + map: data.map.map(|m| ApiMap { name: m.name }), } } diff --git a/planetwars-server/src/routes/mod.rs b/planetwars-server/src/routes/mod.rs index b3decb8..9510fd4 100644 --- a/planetwars-server/src/routes/mod.rs +++ b/planetwars-server/src/routes/mod.rs @@ -1,4 +1,5 @@ pub mod bots; pub mod demo; +pub mod maps; pub mod matches; pub mod users; diff --git a/planetwars-server/src/schema.rs b/planetwars-server/src/schema.rs index 5993115..adc6555 100644 --- a/planetwars-server/src/schema.rs +++ b/planetwars-server/src/schema.rs @@ -30,6 +30,17 @@ table! { use diesel::sql_types::*; use crate::db_types::*; + maps (id) { + id -> Int4, + name -> Text, + file_path -> Text, + } +} + +table! { + use diesel::sql_types::*; + use crate::db_types::*; + match_players (match_id, player_id) { match_id -> Int4, player_id -> Int4, @@ -47,6 +58,8 @@ table! { log_path -> Text, created_at -> Timestamp, winner -> Nullable<Int4>, + is_public -> Bool, + map_id -> Nullable<Int4>, } } @@ -86,12 +99,14 @@ table! { joinable!(bots -> users (owner_id)); joinable!(match_players -> bot_versions (bot_version_id)); joinable!(match_players -> matches (match_id)); +joinable!(matches -> maps (map_id)); joinable!(ratings -> bots (bot_id)); joinable!(sessions -> users (user_id)); allow_tables_to_appear_in_same_query!( bot_versions, bots, + maps, match_players, matches, ratings, diff --git a/proto/client_api.proto b/proto/client_api.proto index 3f1b956..07164e5 100644 --- a/proto/client_api.proto +++ b/proto/client_api.proto @@ -10,6 +10,7 @@ service ClientApiService { message CreateMatchRequest { string opponent_name = 1; + string map_name = 2; } message CreateMatchResponse { diff --git a/web/pw-server/src/lib/api_client.ts b/web/pw-server/src/lib/api_client.ts new file mode 100644 index 0000000..706d958 --- /dev/null +++ b/web/pw-server/src/lib/api_client.ts @@ -0,0 +1,80 @@ +import { browser } from "$app/env"; +import { get_session_token } from "./auth"; + +export type FetchFn = (input: RequestInfo, init?: RequestInit) => Promise<Response>; + +export class ApiError extends Error { + constructor(public status: number, message?: string) { + super(message); + } +} + +export class ApiClient { + private fetch_fn: FetchFn; + private sessionToken?: string; + + constructor(fetch_fn?: FetchFn) { + if (fetch_fn) { + this.fetch_fn = fetch_fn; + } else if (browser) { + this.fetch_fn = fetch.bind(window); + } + + // TODO: maybe it is cleaner to pass this as a parameter + this.sessionToken = get_session_token(); + } + + async get(url: string, params?: Record<string, string>): Promise<any> { + const response = await this.getRequest(url, params); + this.checkResponse(response); + return await response.json(); + } + + async getText(url: string, params?: Record<string, string>): Promise<any> { + const response = await this.getRequest(url, params); + this.checkResponse(response); + return await response.text(); + } + + async post(url: string, data: any): Promise<any> { + const headers = { "Content-Type": "application/json" }; + + const token = get_session_token(); + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + const response = await this.fetch_fn(url, { + method: "POST", + headers, + body: JSON.stringify(data), + }); + + this.checkResponse(response); + return await response.json(); + } + + private async getRequest(url: string, params: Record<string, string>): Promise<Response> { + const headers = { "Content-Type": "application/json" }; + + if (this.sessionToken) { + headers["Authorization"] = `Bearer ${this.sessionToken}`; + } + + if (params) { + let searchParams = new URLSearchParams(params); + url = `${url}?${searchParams}`; + } + + return await this.fetch_fn(url, { + method: "GET", + headers, + }); + } + + private checkResponse(response: Response) { + if (!response.ok) { + throw new ApiError(response.status, response.statusText); + } + } +} diff --git a/web/pw-server/src/lib/components/Leaderboard.svelte b/web/pw-server/src/lib/components/Leaderboard.svelte index d29d5d6..ea30384 100644 --- a/web/pw-server/src/lib/components/Leaderboard.svelte +++ b/web/pw-server/src/lib/components/Leaderboard.svelte @@ -1,20 +1,5 @@ <script lang="ts"> - import { onMount } from "svelte"; - - let leaderboard = []; - - onMount(async () => { - const res = await fetch("/api/leaderboard", { - headers: { - "Content-Type": "application/json", - }, - }); - - if (res.ok) { - leaderboard = await res.json(); - console.log(leaderboard); - } - }); + export let leaderboard; function formatRating(entry: object): any { const rating = entry["rating"]; diff --git a/web/pw-server/src/lib/components/LinkButton.svelte b/web/pw-server/src/lib/components/LinkButton.svelte new file mode 100644 index 0000000..0d0e7d6 --- /dev/null +++ b/web/pw-server/src/lib/components/LinkButton.svelte @@ -0,0 +1,7 @@ +<script lang="ts"> + export let href: string | null; + + $: isDisabled = !href; +</script> + +<a class="btn" class:btn-disabled={isDisabled} {href}><slot /></a> diff --git a/web/pw-server/src/lib/components/RulesView.svelte b/web/pw-server/src/lib/components/RulesView.svelte index 92de37e..c7d4a4a 100644 --- a/web/pw-server/src/lib/components/RulesView.svelte +++ b/web/pw-server/src/lib/components/RulesView.svelte @@ -1,11 +1,6 @@ <div class="container"> <div class="game-rules"> - <h2 class="title">Welcome to planetwars!</h2> - - <p> - Planetwars is a game of galactic conquest for busy people. Your goal is to program a bot that - will conquer the galaxy for you, while you take care of more important stuff. - </p> + <h2 class="title">How to play</h2> <p> In every game turn, your bot will receive a json-encoded line on stdin, describing the current state of the game. Each state will hold a set of planets, and a set of spaceship fleets diff --git a/web/pw-server/src/lib/components/SubmitPane.svelte b/web/pw-server/src/lib/components/SubmitPane.svelte index 82f752e..b0f86c8 100644 --- a/web/pw-server/src/lib/components/SubmitPane.svelte +++ b/web/pw-server/src/lib/components/SubmitPane.svelte @@ -1,15 +1,20 @@ <script lang="ts"> + import { ApiClient } from "$lib/api_client"; + import { get_session_token } from "$lib/auth"; import { getBotName, saveBotName } from "$lib/bot_code"; import { currentUser } from "$lib/stores/current_user"; + import { selectedOpponent, selectedMap } from "$lib/stores/editor_state"; + import { createEventDispatcher, onMount } from "svelte"; import Select from "svelte-select"; export let editSession; let availableBots: object[] = []; - let selectedOpponent = undefined; + let maps: object[] = []; + let botName: string | undefined = undefined; // whether to show the "save succesful" message let saveSuccesful = false; @@ -18,24 +23,28 @@ onMount(async () => { botName = getBotName(); + const apiClient = new ApiClient(); - const res = await fetch("/api/bots", { - headers: { - "Content-Type": "application/json", - }, - }); + const [_bots, _maps] = await Promise.all([ + apiClient.get("/api/bots"), + apiClient.get("/api/maps"), + ]); - if (res.ok) { - availableBots = await res.json(); - selectedOpponent = availableBots.find((b) => b["name"] === "simplebot"); + availableBots = _bots; + maps = _maps; + + if (!$selectedOpponent) { + selectedOpponent.set(availableBots.find((b) => b["name"] === "simplebot")); + } + + if (!$selectedMap) { + selectedMap.set(maps.find((m) => m["name"] === "hex")); } }); const dispatch = createEventDispatcher(); async function submitBot() { - const opponentName = selectedOpponent["name"]; - let response = await fetch("/api/submit_bot", { method: "POST", headers: { @@ -43,7 +52,8 @@ }, body: JSON.stringify({ code: editSession.getDocument().getValue(), - opponent_name: opponentName, + opponent_name: $selectedOpponent["name"], + map_name: $selectedMap["name"], }), }); @@ -100,13 +110,23 @@ <div class="submit-pane"> <div class="match-form"> <h4>Play a match</h4> - <div class="play-text">Select an opponent to test your bot</div> - <div class="opponentSelect"> + <div class="play-text">Opponent</div> + <div class="opponent-select"> <Select optionIdentifier="name" labelIdentifier="name" items={availableBots} - bind:value={selectedOpponent} + bind:value={$selectedOpponent} + isClearable={false} + /> + </div> + <span>Map</span> + <div class="map-select"> + <Select + optionIdentifier="name" + labelIdentifier="name" + items={maps} + bind:value={$selectedMap} isClearable={false} /> </div> @@ -145,8 +165,9 @@ margin-bottom: 0.3em; } - .opponentSelect { - margin: 20px 0; + .opponent-select, + .map-select { + margin: 8px 0; } .save-form { diff --git a/web/pw-server/src/lib/components/matches/MatchList.svelte b/web/pw-server/src/lib/components/matches/MatchList.svelte new file mode 100644 index 0000000..e38543e --- /dev/null +++ b/web/pw-server/src/lib/components/matches/MatchList.svelte @@ -0,0 +1,104 @@ +<script lang="ts"> + import { goto } from "$app/navigation"; + import dayjs from "dayjs"; + + export let matches: object[]; + + function match_url(match: object) { + return `/matches/${match["id"]}`; + } +</script> + +<table class="matches-table"> + <tr> + <th class="header-timestamp">timestamp</th> + <th class="col-player-1">player 1</th> + <th /> + <th /> + <th class="col-player-2">player 2</th> + <th class="col-map">map</th> + </tr> + {#each matches as match} + <tr class="match-table-row" on:click={() => goto(match_url(match))}> + <td class="col-timestamp"> + {dayjs(match["timestamp"]).format("YYYY-MM-DD HH:mm")} + </td> + <td class="col-player-1"> + {match["players"][0]["bot_name"]} + </td> + {#if match["winner"] == null} + <td class="col-score-player-1"> TIE </td> + <td class="col-score-player-2"> TIE </td> + {:else if match["winner"] == 0} + <td class="col-score-player-1"> WIN </td> + <td class="col-score-player-2"> LOSS </td> + {:else if match["winner"] == 1} + <td class="col-score-player-1"> LOSS </td> + <td class="col-score-player-2"> WIN </td> + {/if} + <td class="col-player-2"> + {match["players"][1]["bot_name"]} + </td> + <td class="col-map"> + {match["map"]?.name || ""} + </td> + </tr> + {/each} +</table> + +<style lang="scss"> + .matches-table { + width: 100%; + } + .matches-table td, + .matches-table th { + padding: 8px 16px; + } + + .header-timestamp { + text-align: left; + } + + .col-timestamp { + color: #555; + } + + .col-player-1 { + text-align: left; + } + + .col-player-2 { + text-align: right; + } + + @mixin col-player-score { + text-transform: uppercase; + font-weight: 600; + font-size: 14px; + font-family: "Open Sans", sans-serif; + } + + .col-score-player-1 { + @include col-player-score; + text-align: right; + } + + .col-score-player-2 { + @include col-player-score; + text-align: left; + } + + .col-map { + text-align: right; + } + + .matches-table { + margin: 12px auto; + border-collapse: collapse; + } + + .match-table-row:hover { + cursor: pointer; + background-color: #eee; + } +</style> diff --git a/web/pw-server/src/lib/stores/editor_state.ts b/web/pw-server/src/lib/stores/editor_state.ts new file mode 100644 index 0000000..c0462e1 --- /dev/null +++ b/web/pw-server/src/lib/stores/editor_state.ts @@ -0,0 +1,27 @@ +import { writable } from "svelte/store"; + +const MAX_MATCHES = 100; + +function createMatchHistory() { + const { subscribe, update } = writable([]); + + function pushMatch(match: object) { + update((matches) => { + if (matches.length == MAX_MATCHES) { + matches.pop(); + } + matches.unshift(match); + + return matches; + }); + } + + return { + subscribe, + pushMatch, + }; +} + +export const matchHistory = createMatchHistory(); +export const selectedOpponent = writable(null); +export const selectedMap = writable(null); diff --git a/web/pw-server/src/lib/urls.ts b/web/pw-server/src/lib/urls.ts new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/web/pw-server/src/lib/urls.ts diff --git a/web/pw-server/src/lib/utils.ts b/web/pw-server/src/lib/utils.ts index 155d952..ab1faa5 100644 --- a/web/pw-server/src/lib/utils.ts +++ b/web/pw-server/src/lib/utils.ts @@ -1,4 +1,4 @@ -import { get_session_token } from "./auth"; +import { ApiClient, FetchFn } from "./api_client"; export function debounce(func: Function, timeout: number = 300) { let timer: ReturnType<typeof setTimeout>; @@ -10,35 +10,12 @@ export function debounce(func: Function, timeout: number = 300) { }; } -export async function get(url: string, fetch_fn: Function = fetch) { - const headers = { "Content-Type": "application/json" }; - - const token = get_session_token(); - if (token) { - headers["Authorization"] = `Bearer ${token}`; - } - - const response = await fetch_fn(url, { - method: "GET", - headers, - }); - - return JSON.parse(response); +export async function get(url: string, params?: Record<string, string>, fetch_fn: FetchFn = fetch) { + const client = new ApiClient(fetch_fn); + return await client.get(url, params); } -export async function post(url: string, data: any, fetch_fn: Function = fetch) { - const headers = { "Content-Type": "application/json" }; - - const token = get_session_token(); - if (token) { - headers["Authorization"] = `Bearer ${token}`; - } - - const response = await fetch_fn(url, { - method: "POST", - headers, - body: JSON.stringify(data), - }); - - return JSON.parse(response); +export async function post(url: string, data: any, fetch_fn: FetchFn = fetch) { + const client = new ApiClient(fetch_fn); + return await client.post(url, data); } diff --git a/web/pw-server/src/routes/__layout.svelte b/web/pw-server/src/routes/__layout.svelte index 065a82c..86acf5b 100644 --- a/web/pw-server/src/routes/__layout.svelte +++ b/web/pw-server/src/routes/__layout.svelte @@ -6,16 +6,29 @@ <div class="outer-container"> <div class="navbar"> - <div class="navbar-main"> - <a href="/">PlanetWars</a> + <div class="navbar-left"> + <div class="navbar-header"> + <a href="/">PlanetWars</a> + </div> + <div class="navbar-item"> + <a href="/editor">Editor</a> + </div> + <div class="navbar-item"> + <a href="/leaderboard">Leaderboard</a> + </div> + <div class="navbar-item"> + <a href="/docs">How to play</a> + </div> + </div> + <div class="navbar-right"> + <UserControls /> </div> - <UserControls /> </div> <slot /> </div> -<style lang="scss"> - @import "src/styles/variables.scss"; +<style lang="scss" global> + @import "src/styles/global.scss"; .outer-container { width: 100vw; @@ -34,13 +47,33 @@ padding: 0 15px; } - .navbar-main { + .navbar-left { + display: flex; + } + + .navbar-right { + display: flex; + } + + .navbar-header { margin: auto 0; + padding-right: 24px; } - .navbar-main a { + .navbar-header a { font-size: 20px; - color: #eee; + color: #fff; + text-decoration: none; + } + .navbar-item { + margin: auto 0; + padding: 0 8px; + } + + .navbar-item a { + font-size: 14px; + color: #fff; text-decoration: none; + font-weight: 600; } </style> diff --git a/web/pw-server/src/routes/bots/[bot_name].svelte b/web/pw-server/src/routes/bots/[bot_name].svelte index 33a522f..5fe4cc7 100644 --- a/web/pw-server/src/routes/bots/[bot_name].svelte +++ b/web/pw-server/src/routes/bots/[bot_name].svelte @@ -1,18 +1,16 @@ <script lang="ts" context="module"> - import { get_session_token } from "$lib/auth"; + import { ApiClient } from "$lib/api_client"; export async function load({ params, fetch }) { - const token = get_session_token(); - const res = await fetch(`/api/bots/${params["bot_name"]}`, { - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - if (res.ok) { - const { bot, owner, versions } = await res.json(); - // sort most recent first + const apiClient = new ApiClient(fetch); + + try { + const [botData, matchesPage] = await Promise.all([ + apiClient.get(`/api/bots/${params["bot_name"]}`), + apiClient.get("/api/matches", { bot: params["bot_name"], count: "20" }), + ]); + + const { bot, owner, versions } = botData; versions.sort((a: string, b: string) => dayjs(a["created_at"]).isAfter(b["created_at"]) ? -1 : 1 ); @@ -21,25 +19,28 @@ bot, owner, versions, + matches: matchesPage["matches"], }, }; + } catch (error) { + return { + status: error.status, + error: error, + }; } - - return { - status: res.status, - error: new Error("Could not find bot"), - }; } </script> <script lang="ts"> import dayjs from "dayjs"; - import { currentUser } from "$lib/stores/current_user"; + import MatchList from "$lib/components/matches/MatchList.svelte"; + import LinkButton from "$lib/components/LinkButton.svelte"; export let bot: object; export let owner: object; export let versions: object[]; + export let matches: object[]; // function last_updated() { // versions.sort() @@ -92,7 +93,17 @@ </div> {/if} - <div class="versions"> + <div class="matches"> + <h3>Recent matches</h3> + <MatchList {matches} /> + {#if matches.length > 0} + <div class="btn-container"> + <LinkButton href={`/matches?bot=${bot["name"]}`}>All matches</LinkButton> + </div> + {/if} + </div> + + <!-- <div class="versions"> <h4>Versions</h4> <ul class="version-list"> {#each versions as version} @@ -104,7 +115,7 @@ {#if versions.length == 0} This bot does not have any versions yet. {/if} - </div> + </div> --> </div> <style lang="scss"> @@ -136,6 +147,11 @@ margin-bottom: $header-space-above-line; } + .btn-container { + padding: 24px; + text-align: center; + } + .versions { margin: 30px 0; } diff --git a/web/pw-server/src/routes/bots/new.svelte b/web/pw-server/src/routes/bots/new.svelte index 7cb7229..c243fe1 100644 --- a/web/pw-server/src/routes/bots/new.svelte +++ b/web/pw-server/src/routes/bots/new.svelte @@ -16,6 +16,7 @@ async function createBot() { saveErrors = []; + // TODO: how can we handle this with the new ApiClient? let response = await fetch("/api/bots", { method: "POST", headers: { diff --git a/web/pw-server/src/routes/docs.svelte b/web/pw-server/src/routes/docs.svelte new file mode 100644 index 0000000..c7357c0 --- /dev/null +++ b/web/pw-server/src/routes/docs.svelte @@ -0,0 +1,14 @@ +<script> + import RulesView from "$lib/components/RulesView.svelte"; +</script> + +<div class="container"> + <RulesView /> +</div> + +<style scoped lang="scss"> + .container { + max-width: 800px; + margin: 0 auto; + } +</style> diff --git a/web/pw-server/src/routes/editor.svelte b/web/pw-server/src/routes/editor.svelte new file mode 100644 index 0000000..ff8232c --- /dev/null +++ b/web/pw-server/src/routes/editor.svelte @@ -0,0 +1,255 @@ +<script lang="ts"> + import Visualizer from "$lib/components/Visualizer.svelte"; + import EditorView from "$lib/components/EditorView.svelte"; + import { onMount } from "svelte"; + import { DateTime } from "luxon"; + + import type { Ace } from "ace-builds"; + import ace from "ace-builds/src-noconflict/ace?client"; + import * as AcePythonMode from "ace-builds/src-noconflict/mode-python?client"; + import { getBotCode, saveBotCode } from "$lib/bot_code"; + import { matchHistory } from "$lib/stores/editor_state"; + import { debounce } from "$lib/utils"; + import SubmitPane from "$lib/components/SubmitPane.svelte"; + import OutputPane from "$lib/components/OutputPane.svelte"; + import BotName from "./bots/[bot_name].svelte"; + + enum ViewMode { + Editor, + MatchVisualizer, + } + + let viewMode = ViewMode.Editor; + let selectedMatchId: string | undefined = undefined; + let selectedMatchLog: string | undefined = undefined; + + let editSession: Ace.EditSession; + + onMount(() => { + init_editor(); + }); + + function init_editor() { + editSession = new ace.EditSession(getBotCode()); + editSession.setMode(new AcePythonMode.Mode()); + + const saveCode = () => { + const code = editSession.getDocument().getValue(); + saveBotCode(code); + }; + + // cast to any because the type annotations are wrong here + (editSession as any).on("change", debounce(saveCode, 2000)); + } + + async function onMatchCreated(e: CustomEvent) { + const matchData = e.detail["match"]; + matchHistory.pushMatch(matchData); + await selectMatch(matchData["id"]); + } + + async function selectMatch(matchId: string) { + selectedMatchId = matchId; + selectedMatchLog = null; + fetchSelectedMatchLog(matchId); + + viewMode = ViewMode.MatchVisualizer; + } + + async function fetchSelectedMatchLog(matchId: string) { + if (matchId !== selectedMatchId) { + return; + } + + let matchLog = await getMatchLog(matchId); + + if (matchLog) { + selectedMatchLog = matchLog; + } else { + // try again in 1 second + setTimeout(fetchSelectedMatchLog, 1000, matchId); + } + } + + async function getMatchData(matchId: string) { + let response = await fetch(`/api/matches/${matchId}`, { + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw Error(response.statusText); + } + + let matchData = await response.json(); + return matchData; + } + + async function getMatchLog(matchId: string) { + const matchData = await getMatchData(matchId); + console.log(matchData); + if (matchData["state"] !== "Finished") { + // log is not available yet + return null; + } + + const res = await fetch(`/api/matches/${matchId}/log`, { + headers: { + "Content-Type": "application/json", + }, + }); + + let log = await res.text(); + return log; + } + + function setViewMode(viewMode_: ViewMode) { + selectedMatchId = undefined; + selectedMatchLog = undefined; + viewMode = viewMode_; + } + + function formatMatchTimestamp(timestampString: string): string { + let timestamp = DateTime.fromISO(timestampString, { zone: "utc" }).toLocal(); + if (timestamp.startOf("day").equals(DateTime.now().startOf("day"))) { + return timestamp.toFormat("HH:mm"); + } else { + return timestamp.toFormat("dd/MM"); + } + } + + $: selectedMatch = $matchHistory.find((m) => m["id"] === selectedMatchId); +</script> + +<div class="container"> + <div class="sidebar-left"> + <div + class="editor-button sidebar-item" + class:selected={viewMode === ViewMode.Editor} + on:click={() => setViewMode(ViewMode.Editor)} + > + Code + </div> + <div class="sidebar-header">match history</div> + <ul class="match-list"> + {#each $matchHistory as match} + <li + class="match-card sidebar-item" + on:click={() => selectMatch(match.id)} + class:selected={match.id === selectedMatchId} + > + <div class="match-timestamp">{formatMatchTimestamp(match.timestamp)}</div> + <div class="match-card-body"> + <!-- ugly temporary hardcode --> + <div class="match-opponent">{match["players"][1]["bot_name"]}</div> + <div class="match-map">{match["map"]?.name}</div> + </div> + </li> + {/each} + </ul> + </div> + <div class="editor-container"> + {#if viewMode === ViewMode.MatchVisualizer} + <Visualizer matchData={selectedMatch} matchLog={selectedMatchLog} /> + {:else if viewMode === ViewMode.Editor} + <EditorView {editSession} /> + {/if} + </div> + <div class="sidebar-right"> + {#if viewMode === ViewMode.MatchVisualizer} + <OutputPane matchLog={selectedMatchLog} /> + {:else if viewMode === ViewMode.Editor} + <SubmitPane {editSession} on:matchCreated={onMatchCreated} /> + {/if} + </div> +</div> + +<style lang="scss"> + @import "src/styles/variables.scss"; + + .container { + display: flex; + flex-grow: 1; + min-height: 0; + } + + .sidebar-left { + width: 240px; + background-color: $bg-color; + display: flex; + flex-direction: column; + } + .sidebar-right { + width: 400px; + background-color: white; + border-left: 1px solid; + padding: 0; + display: flex; + overflow: hidden; + } + .editor-container { + flex-grow: 1; + flex-shrink: 1; + overflow: hidden; + background-color: white; + } + + .editor-container { + height: 100%; + } + + .sidebar-item { + color: #eee; + padding: 15px; + } + + .sidebar-item:hover { + background-color: #333; + } + + .sidebar-item.selected { + background-color: #333; + } + + .match-list { + list-style: none; + color: #eee; + padding-top: 15px; + overflow-y: scroll; + padding-left: 0px; + } + + .match-card { + padding: 10px 15px; + font-size: 11pt; + display: flex; + } + + .match-timestamp { + color: #ccc; + } + + .match-card-body { + margin: 0 8px; + } + + .match-opponent { + font-weight: 600; + color: #eee; + } + + .match-map { + color: #ccc; + } + + .sidebar-header { + margin-top: 2em; + text-transform: uppercase; + font-weight: 600; + color: rgba(255, 255, 255, 0.7); + font-size: 14px; + font-family: "Open Sans", sans-serif; + padding-left: 14px; + } +</style> diff --git a/web/pw-server/src/routes/index.svelte b/web/pw-server/src/routes/index.svelte index 85c2454..fd1f505 100644 --- a/web/pw-server/src/routes/index.svelte +++ b/web/pw-server/src/routes/index.svelte @@ -1,277 +1,92 @@ -<script lang="ts"> - import Visualizer from "$lib/components/Visualizer.svelte"; - import EditorView from "$lib/components/EditorView.svelte"; - import { onMount } from "svelte"; - - import { DateTime } from "luxon"; - - import type { Ace } from "ace-builds"; - import ace from "ace-builds/src-noconflict/ace?client"; - import * as AcePythonMode from "ace-builds/src-noconflict/mode-python?client"; - import { getBotCode, saveBotCode, hasBotCode } from "$lib/bot_code"; - import { debounce } from "$lib/utils"; - import SubmitPane from "$lib/components/SubmitPane.svelte"; - import OutputPane from "$lib/components/OutputPane.svelte"; - import RulesView from "$lib/components/RulesView.svelte"; - import Leaderboard from "$lib/components/Leaderboard.svelte"; - - enum ViewMode { - Editor, - MatchVisualizer, - Rules, - Leaderboard, - } - - let matches = []; - - let viewMode = ViewMode.Editor; - let selectedMatchId: string | undefined = undefined; - let selectedMatchLog: string | undefined = undefined; - - let editSession: Ace.EditSession; - - onMount(() => { - if (!hasBotCode()) { - viewMode = ViewMode.Rules; - } - init_editor(); - }); - - function init_editor() { - editSession = new ace.EditSession(getBotCode()); - editSession.setMode(new AcePythonMode.Mode()); - - const saveCode = () => { - const code = editSession.getDocument().getValue(); - saveBotCode(code); - }; - - // cast to any because the type annotations are wrong here - (editSession as any).on("change", debounce(saveCode, 2000)); - } - - async function onMatchCreated(e: CustomEvent) { - const matchData = e.detail["match"]; - matches.unshift(matchData); - matches = matches; - await selectMatch(matchData["id"]); - } - - async function selectMatch(matchId: string) { - selectedMatchId = matchId; - selectedMatchLog = null; - fetchSelectedMatchLog(matchId); - - viewMode = ViewMode.MatchVisualizer; - } - - async function fetchSelectedMatchLog(matchId: string) { - if (matchId !== selectedMatchId) { - return; - } - - let matchLog = await getMatchLog(matchId); - - if (matchLog) { - selectedMatchLog = matchLog; - } else { - // try again in 1 second - setTimeout(fetchSelectedMatchLog, 1000, matchId); +<script lang="ts" context="module"> + import { ApiClient } from "$lib/api_client"; + + const NUM_MATCHES = "25"; + + export async function load({ fetch }) { + try { + const apiClient = new ApiClient(fetch); + + let { matches, has_next } = await apiClient.get("/api/matches", { + count: NUM_MATCHES, + }); + + return { + props: { + matches, + hasNext: has_next, + }, + }; + } catch (error) { + return { + status: error.status, + error: new Error("failed to load matches"), + }; } } +</script> - async function getMatchData(matchId: string) { - let response = await fetch(`/api/matches/${matchId}`, { - headers: { - "Content-Type": "application/json", - }, - }); +<script lang="ts"> + import LinkButton from "$lib/components/LinkButton.svelte"; + import MatchList from "$lib/components/matches/MatchList.svelte"; - if (!response.ok) { - throw Error(response.statusText); - } + export let matches; + export let hasNext; - let matchData = await response.json(); - return matchData; - } + $: viewMoreUrl = olderMatchesLink(matches); - async function getMatchLog(matchId: string) { - const matchData = await getMatchData(matchId); - console.log(matchData); - if (matchData["state"] !== "Finished") { - // log is not available yet + // TODO: deduplicate. + // Maybe move to ApiClient logic? + function olderMatchesLink(matches: object[]): string { + if (matches.length == 0 || !hasNext) { return null; } - - const res = await fetch(`/api/matches/${matchId}/log`, { - headers: { - "Content-Type": "application/json", - }, - }); - - let log = await res.text(); - return log; - } - - function setViewMode(viewMode_: ViewMode) { - selectedMatchId = undefined; - selectedMatchLog = undefined; - viewMode = viewMode_; - } - - function selectRules() { - selectedMatchId = undefined; - selectedMatchLog = undefined; - viewMode = ViewMode.Rules; - } - - function formatMatchTimestamp(timestampString: string): string { - let timestamp = DateTime.fromISO(timestampString, { zone: "utc" }).toLocal(); - if (timestamp.startOf("day").equals(DateTime.now().startOf("day"))) { - return timestamp.toFormat("HH:mm"); - } else { - return timestamp.toFormat("dd/MM"); - } + const lastTimestamp = matches[matches.length - 1]["timestamp"]; + return `/matches?before=${lastTimestamp}`; } - - $: selectedMatch = matches.find((m) => m["id"] === selectedMatchId); </script> <div class="container"> - <div class="sidebar-left"> - <div - class="editor-button sidebar-item" - class:selected={viewMode === ViewMode.Editor} - on:click={() => setViewMode(ViewMode.Editor)} - > - Editor - </div> - <div - class="rules-button sidebar-item" - class:selected={viewMode === ViewMode.Rules} - on:click={() => setViewMode(ViewMode.Rules)} - > - Rules - </div> - <div - class="sidebar-item" - class:selected={viewMode === ViewMode.Leaderboard} - on:click={() => setViewMode(ViewMode.Leaderboard)} - > - Leaderboard - </div> - <div class="sidebar-header">match history</div> - <ul class="match-list"> - {#each matches as match} - <li - class="match-card sidebar-item" - on:click={() => selectMatch(match.id)} - class:selected={match.id === selectedMatchId} - > - <span class="match-timestamp">{formatMatchTimestamp(match.timestamp)}</span> - <!-- hex is hardcoded for now, don't show map name --> - <!-- <span class="match-mapname">hex</span> --> - <!-- ugly temporary hardcode --> - <span class="match-opponent">{match["players"][1]["bot_name"]}</span> - </li> - {/each} - </ul> + <div class="introduction"> + <h2>Welcome to PlanetWars!</h2> + <p> + Planetwars is a game of galactic conquest for busy people. Your goal is to program a bot that + will conquer the galaxy for you, while you take care of more important stuff. + </p> + <p> + Feel free to watch some games below to see what it's all about. When you are ready to try + writing your own bot, head over to + <a href="/docs">How to play</a> for instructions. You can program your bot in the browser + using the <a href="/editor">Editor</a>. + </p> </div> - <div class="editor-container"> - {#if viewMode === ViewMode.MatchVisualizer} - <Visualizer matchData={selectedMatch} matchLog={selectedMatchLog} /> - {:else if viewMode === ViewMode.Editor} - <EditorView {editSession} /> - {:else if viewMode === ViewMode.Rules} - <RulesView /> - {:else if viewMode === ViewMode.Leaderboard} - <Leaderboard /> - {/if} - </div> - <div class="sidebar-right"> - {#if viewMode === ViewMode.MatchVisualizer} - <OutputPane matchLog={selectedMatchLog} /> - {:else if viewMode === ViewMode.Editor} - <SubmitPane {editSession} on:matchCreated={onMatchCreated} /> - {/if} + <h2>Recent matches</h2> + <MatchList {matches} /> + <div class="see-more-container"> + <LinkButton href={viewMoreUrl}>View more</LinkButton> </div> </div> -<style lang="scss"> - @import "src/styles/variables.scss"; - +<style scoped lang="scss"> .container { - display: flex; - flex-grow: 1; - min-height: 0; + max-width: 800px; + margin: 0 auto; } - .sidebar-left { - width: 240px; - background-color: $bg-color; - display: flex; - flex-direction: column; - } - .sidebar-right { - width: 400px; - background-color: white; - border-left: 1px solid; - padding: 0; - display: flex; - overflow: hidden; - } - .editor-container { - flex-grow: 1; - flex-shrink: 1; - overflow: hidden; - background-color: white; - } - - .editor-container { - height: 100%; - } - - .sidebar-item { - color: #eee; - padding: 15px; - } - - .sidebar-item:hover { - background-color: #333; - } - - .sidebar-item.selected { - background-color: #333; - } - - .match-list { - list-style: none; - color: #eee; - padding-top: 15px; - overflow-y: scroll; - padding-left: 0px; - } - - .match-card { - padding: 10px 15px; - font-size: 11pt; - } - - .match-timestamp { - color: #ccc; - } + .introduction { + padding-top: 16px; + a { + color: rgb(9, 105, 218); + text-decoration: none; + } - .match-opponent { - padding: 0 0.5em; + a:hover { + text-decoration: underline; + } } - .sidebar-header { - margin-top: 2em; - text-transform: uppercase; - font-weight: 600; - color: rgba(255, 255, 255, 0.7); - font-size: 14px; - font-family: "Open Sans", sans-serif; - padding-left: 14px; + .see-more-container { + padding: 24px; + text-align: center; } </style> diff --git a/web/pw-server/src/routes/leaderboard.svelte b/web/pw-server/src/routes/leaderboard.svelte new file mode 100644 index 0000000..7c4da6e --- /dev/null +++ b/web/pw-server/src/routes/leaderboard.svelte @@ -0,0 +1,28 @@ +<script lang="ts" context="module"> + import { ApiClient } from "$lib/api_client"; + + export async function load({ fetch }) { + try { + const apiClient = new ApiClient(fetch); + const leaderboard = await apiClient.get("/api/leaderboard"); + return { + props: { + leaderboard, + }, + }; + } catch (error) { + return { + status: error.status, + error: error, + }; + } + } +</script> + +<script lang="ts"> + import Leaderboard from "$lib/components/Leaderboard.svelte"; + + export let leaderboard: object[]; +</script> + +<Leaderboard {leaderboard} /> diff --git a/web/pw-server/src/routes/matches/[match_id].svelte b/web/pw-server/src/routes/matches/[match_id].svelte index 2c0a3fa..7c1507c 100644 --- a/web/pw-server/src/routes/matches/[match_id].svelte +++ b/web/pw-server/src/routes/matches/[match_id].svelte @@ -1,33 +1,25 @@ <script lang="ts" context="module"> - function fetchJson(url: string): Promise<Response> { - return fetch(url, { - headers: { - "Content-Type": "application/json", - }, - }); - } - - export async function load({ params }) { - // TODO: handle failure cases better - const matchId = params["match_id"]; - const matchDataResponse = await fetchJson(`/api/matches/${matchId}`); - if (!matchDataResponse.ok) { - } - const matchLogResponse = await fetchJson(`/api/matches/${matchId}/log`); - - if (matchDataResponse.ok && matchLogResponse.ok) { + import { ApiClient } from "$lib/api_client"; + export async function load({ params, fetch }) { + try { + const matchId = params["match_id"]; + const apiClient = new ApiClient(fetch); + const [matchData, matchLog] = await Promise.all([ + apiClient.get(`/api/matches/${matchId}`), + apiClient.getText(`/api/matches/${matchId}/log`), + ]); return { props: { - matchData: await matchDataResponse.json(), - matchLog: await matchLogResponse.text(), + matchData: matchData, + matchLog: matchLog, }, }; + } catch (error) { + return { + status: error.status, + error: error, + }; } - - return { - status: matchDataResponse.status, - error: new Error("failed to load match"), - }; } </script> diff --git a/web/pw-server/src/routes/matches/index.svelte b/web/pw-server/src/routes/matches/index.svelte index 448048b..8c106fa 100644 --- a/web/pw-server/src/routes/matches/index.svelte +++ b/web/pw-server/src/routes/matches/index.svelte @@ -1,36 +1,121 @@ <script lang="ts" context="module"> - export async function load() { - const res = await fetch("/api/matches", { - headers: { - "Content-Type": "application/json", - }, - }); + import { ApiClient } from "$lib/api_client"; + + const PAGE_SIZE = "50"; + + export async function load({ url, fetch }) { + try { + const apiClient = new ApiClient(fetch); + const botName = url.searchParams.get("bot"); + + let query = { + count: PAGE_SIZE, + before: url.searchParams.get("before"), + after: url.searchParams.get("after"), + bot: botName, + }; + + let { matches, has_next } = await apiClient.get("/api/matches", removeUndefined(query)); + + // TODO: should this be done client-side? + if (query["after"]) { + matches = matches.reverse(); + } - if (res.ok) { return { props: { - matches: await res.json(), + matches, + botName, + hasNext: has_next, + query, }, }; + } catch (error) { + return { + status: error.status, + error: new Error("failed to load matches"), + }; } + } - return { - status: res.status, - error: new Error("failed to load matches"), - }; + function removeUndefined(obj: Record<string, string>): Record<string, string> { + Object.keys(obj).forEach((key) => { + if (obj[key] === undefined || obj[key] === null) { + delete obj[key]; + } + }); + return obj; } </script> <script lang="ts"> - import dayjs from "dayjs"; - export let matches; + import LinkButton from "$lib/components/LinkButton.svelte"; + import MatchList from "$lib/components/matches/MatchList.svelte"; + + export let matches: object[]; + export let botName: string | null; + // whether a next page exists in the current iteration direction (before/after) + export let hasNext: boolean; + export let query: object; + + type Cursor = { + before?: string; + after?: string; + }; + + function pageLink(cursor: Cursor) { + let paramsObj = { + ...cursor, + }; + if (botName) { + paramsObj["bot"] = botName; + } + const params = new URLSearchParams(paramsObj); + return `?${params}`; + } + + function olderMatchesLink(matches: object[]): string { + if (matches.length == 0 || (query["before"] && !hasNext)) { + return null; + } + const lastTimestamp = matches[matches.length - 1]["timestamp"]; + return pageLink({ before: lastTimestamp }); + } + + function newerMatchesLink(matches: object[]): string { + if ( + matches.length == 0 || + (query["after"] && !hasNext) || + // we are viewing the first page, so there should be no newer matches. + // alternatively, we could show a "refresh" here. + (!query["before"] && !query["after"]) + ) { + return null; + } + const firstTimestamp = matches[0]["timestamp"]; + return pageLink({ after: firstTimestamp }); + } </script> -<a href="/matches/new">new match</a> -<ul> - {#each matches as match} - <li> - <a href="/matches/{match['id']}">{dayjs(match["created_at"]).format("YYYY-MM-DD HH:mm")}</a> - </li> - {/each} -</ul> +<div class="container"> + <MatchList {matches} /> + <div class="page-controls"> + <div class="btn-group"> + <LinkButton href={newerMatchesLink(matches)}>Newer</LinkButton> + <LinkButton href={olderMatchesLink(matches)}>Older</LinkButton> + </div> + </div> +</div> + +<style lang="scss"> + .container { + width: 800px; + margin: 0 auto; + } + + .page-controls { + display: flex; + justify-content: center; + margin: 24px 0; + } +</style> diff --git a/web/pw-server/src/routes/style.css b/web/pw-server/src/routes/style.css index f7d5388..fa72c7e 100644 --- a/web/pw-server/src/routes/style.css +++ b/web/pw-server/src/routes/style.css @@ -2,3 +2,17 @@ body { margin: 0; font-family: Roboto, Helvetica, sans-serif; } + +/* generic scrollbar styling for chrome & friends */ +::-webkit-scrollbar { + width: 5px; +} + +::-webkit-scrollbar-thumb { + background: #bdbdbd; + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: #6e6e6e; +} diff --git a/web/pw-server/src/styles/buttons.scss b/web/pw-server/src/styles/buttons.scss new file mode 100644 index 0000000..b485014 --- /dev/null +++ b/web/pw-server/src/styles/buttons.scss @@ -0,0 +1,31 @@ +$btn-text-color: rgb(9, 105, 218); +$btn-border-color: rgba(27, 31, 36, 0.25); + +.btn { + color: $btn-text-color; + font-size: 14px; + text-decoration: none; + padding: 6px 16px; + border: 1px solid $btn-border-color; + border-radius: 5px; +} + +.btn.btn-disabled { + color: $btn-border-color; +} + +.btn-group { + display: flex; +} + +.btn-group .btn:not(:last-child) { + border-right: none; +} + +.btn-group .btn:first-child { + border-radius: 5px 0 0 5px; +} + +.btn-group .btn:last-child { + border-radius: 0 5px 5px 0; +} diff --git a/web/pw-server/src/styles/global.scss b/web/pw-server/src/styles/global.scss new file mode 100644 index 0000000..9ead606 --- /dev/null +++ b/web/pw-server/src/styles/global.scss @@ -0,0 +1,2 @@ +@forward "./variables.scss"; +@forward "./buttons.scss"; diff --git a/web/pw-visualizer/src/style.css b/web/pw-visualizer/src/style.css index 09e33e6..02ab069 100644 --- a/web/pw-visualizer/src/style.css +++ b/web/pw-visualizer/src/style.css @@ -275,22 +275,3 @@ background: #ff7000; cursor: pointer; } - - ::-webkit-scrollbar-track { - -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3); - box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3); - border-radius: 10px; - background-color: #444; - border-radius: 10px; - } - - ::-webkit-scrollbar { - width: 10px; - background-color: #444; - } - - ::-webkit-scrollbar-thumb { - border-radius: 10px; - background-color: #F90; - background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .2) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .2) 50%, rgba(255, 255, 255, .2) 75%, transparent 75%, transparent) - }
\ No newline at end of file diff --git a/web/pw-visualizer/src/webgl/util.ts b/web/pw-visualizer/src/webgl/util.ts index e08af3f..7b55d19 100644 --- a/web/pw-visualizer/src/webgl/util.ts +++ b/web/pw-visualizer/src/webgl/util.ts @@ -87,30 +87,39 @@ export class Resizer { this.viewbox = [...viewbox]; this.el_box = [el.width, el.height]; + // QUICK FIX: + // when filling the full display canvas, the turn bar will obstruct the bottom planets. + // to resolve this, we shift the viewbox up by 50px. + // This should be implemented in a cleaner way, though :( + let dy = 50 * viewbox[3] / el.height; + this.viewbox[1] -= dy; + this.viewbox[3] += dy; + if (keep_aspect_ratio) { const or_width = this.viewbox[2]; const or_height = this.viewbox[3]; - const width_percentage = this.viewbox[2] / el.width; - const height_percentage = this.viewbox[3] / el.height; + const width_percentage = this.viewbox[2] / this.el_box[0]; + const height_percentage = this.viewbox[3] / this.el_box[1]; + if (width_percentage < height_percentage) { // width should be larger - this.viewbox[2] = height_percentage * el.width; + this.viewbox[2] = height_percentage * this.el_box[0]; } else { // height should be larger - this.viewbox[3] = width_percentage * el.height; + this.viewbox[3] = width_percentage * this.el_box[1]; } this.viewbox[0] -= (this.viewbox[2] - or_width) / 2; this.viewbox[1] -= (this.viewbox[3] - or_height) / 2; + this.scaleX = this.viewbox[2] / this.viewbox[3]; } this.orig_viewbox = [...this.viewbox]; - el.addEventListener("mouseenter", this.mouseenter.bind(this), { capture: false, passive: true}); el.addEventListener("mouseleave", this.mouseleave.bind(this), { capture: false, passive: true}); el.addEventListener("mousemove", this.mousemove.bind(this), { capture: false, passive: true}); el.addEventListener("mousedown", this.mousedown.bind(this), { capture: false, passive: true}); @@ -127,15 +136,14 @@ export class Resizer { this.viewbox[1] = Math.min(this.viewbox[1] + this.viewbox[3], this.orig_viewbox[1] + this.orig_viewbox[3]) - this.viewbox[3]; } - mouseenter() { - this.hoovering = true; - } - mouseleave() { this.hoovering = false; } mousemove(e: MouseEvent) { + // when using mouseenter, hooveing will not be set to true if the mouse is already on the element when it is being created. + // TODO: is there a better way? + this.hoovering = true; this.mouse_pos = [e.offsetX, this.el_box[1] - e.offsetY]; if (this.dragging) { |