aboutsummaryrefslogtreecommitdiff
path: root/web/pw-server
diff options
context:
space:
mode:
Diffstat (limited to 'web/pw-server')
-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
21 files changed, 878 insertions, 396 deletions
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";