aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--planetwars-client/src/main.rs21
-rw-r--r--planetwars-matchrunner/src/lib.rs2
-rw-r--r--planetwars-server/Cargo.toml9
-rw-r--r--planetwars-server/migrations/2022-07-30-145155_add_public_to_matches/down.sql1
-rw-r--r--planetwars-server/migrations/2022-07-30-145155_add_public_to_matches/up.sql1
-rw-r--r--planetwars-server/migrations/2022-08-23-174628_maps/down.sql3
-rw-r--r--planetwars-server/migrations/2022-08-23-174628_maps/up.sql7
-rw-r--r--planetwars-server/src/cli.rs54
-rw-r--r--planetwars-server/src/db/maps.rs35
-rw-r--r--planetwars-server/src/db/matches.rs152
-rw-r--r--planetwars-server/src/db/mod.rs1
-rw-r--r--planetwars-server/src/db/users.rs28
-rw-r--r--planetwars-server/src/lib.rs12
-rw-r--r--planetwars-server/src/modules/client_api.rs11
-rw-r--r--planetwars-server/src/modules/matches.rs22
-rw-r--r--planetwars-server/src/modules/ranking.rs22
-rw-r--r--planetwars-server/src/modules/registry.rs4
-rw-r--r--planetwars-server/src/routes/demo.rs14
-rw-r--r--planetwars-server/src/routes/maps.rs19
-rw-r--r--planetwars-server/src/routes/matches.rs75
-rw-r--r--planetwars-server/src/routes/mod.rs1
-rw-r--r--planetwars-server/src/schema.rs15
-rw-r--r--proto/client_api.proto1
-rw-r--r--web/pw-server/src/lib/api_client.ts80
-rw-r--r--web/pw-server/src/lib/components/Leaderboard.svelte17
-rw-r--r--web/pw-server/src/lib/components/LinkButton.svelte7
-rw-r--r--web/pw-server/src/lib/components/RulesView.svelte7
-rw-r--r--web/pw-server/src/lib/components/SubmitPane.svelte55
-rw-r--r--web/pw-server/src/lib/components/matches/MatchList.svelte104
-rw-r--r--web/pw-server/src/lib/stores/editor_state.ts27
-rw-r--r--web/pw-server/src/lib/urls.ts0
-rw-r--r--web/pw-server/src/lib/utils.ts37
-rw-r--r--web/pw-server/src/routes/__layout.svelte49
-rw-r--r--web/pw-server/src/routes/bots/[bot_name].svelte56
-rw-r--r--web/pw-server/src/routes/bots/new.svelte1
-rw-r--r--web/pw-server/src/routes/docs.svelte14
-rw-r--r--web/pw-server/src/routes/editor.svelte255
-rw-r--r--web/pw-server/src/routes/index.svelte321
-rw-r--r--web/pw-server/src/routes/leaderboard.svelte28
-rw-r--r--web/pw-server/src/routes/matches/[match_id].svelte40
-rw-r--r--web/pw-server/src/routes/matches/index.svelte129
-rw-r--r--web/pw-server/src/routes/style.css14
-rw-r--r--web/pw-server/src/styles/buttons.scss31
-rw-r--r--web/pw-server/src/styles/global.scss2
-rw-r--r--web/pw-visualizer/src/style.css19
-rw-r--r--web/pw-visualizer/src/webgl/util.ts26
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(&params.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) {