mirror of
https://github.com/ApfelTeeSaft/LiveSplitOne.git
synced 2026-09-03 11:53:24 +00:00
Add Leaderboards to the Run Editor
This adds initial support for showing the Leaderboard in the Run Editor and downloading those runs.
This commit is contained in:
+1
-1
Submodule livesplit-core updated: c6cff4506a...506d40c7a8
+47
-2
@@ -1,11 +1,16 @@
|
||||
import * as fuzzy from "fuzzy";
|
||||
import { getGameHeaders, getCategories as apiGetCategories, Category } from "./SpeedrunCom";
|
||||
import {
|
||||
getGameHeaders, getCategories as apiGetCategories, Category,
|
||||
getLeaderboard as apiGetLeaderboard, Leaderboard,
|
||||
} from "./SpeedrunCom";
|
||||
import { Option } from "../util/OptionUtil";
|
||||
|
||||
const gameList: string[] = [];
|
||||
const gameNameToIdMap: Map<string, string> = new Map();
|
||||
const gameIdToCategoriesMap: Map<string, Category[]> = new Map();
|
||||
const gameIdToCategoriesPromises: Map<string, Promise<void>> = new Map();
|
||||
const gameAndCategoryToLeaderboardPromises: Map<string, Promise<void>> = new Map();
|
||||
const gameAndCategoryToLeaderboardMap: Map<string, Leaderboard> = new Map();
|
||||
let gameListPromise: Option<Promise<void>> = null;
|
||||
|
||||
export function searchGames(currentName: string): string[] {
|
||||
@@ -32,7 +37,12 @@ export function getCategories(gameName: string): Category[] | undefined {
|
||||
return gameIdToCategoriesMap.get(gameId);
|
||||
}
|
||||
|
||||
export function downloadCategories(gameId: string): Promise<void> {
|
||||
export function getLeaderboard(gameName: string, categoryName: string): Leaderboard | undefined {
|
||||
const key = `${gameName}::cx::${categoryName}`; // TODO Properly escape
|
||||
return gameAndCategoryToLeaderboardMap.get(key);
|
||||
}
|
||||
|
||||
function downloadCategoriesByGameId(gameId: string): Promise<void> {
|
||||
let categoryPromise = gameIdToCategoriesPromises.get(gameId);
|
||||
if (categoryPromise == null) {
|
||||
categoryPromise = (async () => {
|
||||
@@ -45,6 +55,15 @@ export function downloadCategories(gameId: string): Promise<void> {
|
||||
return categoryPromise;
|
||||
}
|
||||
|
||||
export async function downloadCategories(gameName: string): Promise<Option<string>> {
|
||||
await downloadGameList();
|
||||
const gameId = getGameId(gameName);
|
||||
if (gameId != null) {
|
||||
await downloadCategoriesByGameId(gameId);
|
||||
}
|
||||
return gameId;
|
||||
}
|
||||
|
||||
export function downloadGameList(): Promise<void> {
|
||||
if (gameListPromise == null) {
|
||||
gameListPromise = (async () => {
|
||||
@@ -57,3 +76,29 @@ export function downloadGameList(): Promise<void> {
|
||||
}
|
||||
return gameListPromise;
|
||||
}
|
||||
|
||||
export async function downloadLeaderboard(gameName: string, categoryName: string): Promise<void> {
|
||||
const key = `${gameName}::cx::${categoryName}`; // TODO Properly escape
|
||||
let promise = gameAndCategoryToLeaderboardPromises.get(key);
|
||||
if (promise == null) {
|
||||
promise = (async () => {
|
||||
const gameId = await downloadCategories(gameName);
|
||||
if (gameId == null) {
|
||||
return;
|
||||
}
|
||||
const categories = getCategories(gameName);
|
||||
if (categories == null) {
|
||||
return;
|
||||
}
|
||||
const index = categories.map((c) => c.name).indexOf(categoryName);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
const category = categories[index];
|
||||
const leaderboard = await apiGetLeaderboard(gameId, category.id, ["players"]);
|
||||
gameAndCategoryToLeaderboardMap.set(key, leaderboard);
|
||||
})();
|
||||
gameAndCategoryToLeaderboardPromises.set(key, promise);
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
|
||||
+65
-1
@@ -51,7 +51,53 @@ export interface Category {
|
||||
id: string,
|
||||
name: string,
|
||||
type: "per-game" | "per-level",
|
||||
rules?: string,
|
||||
rules: Option<string>,
|
||||
}
|
||||
|
||||
export interface Leaderboard {
|
||||
runs: Record[],
|
||||
players?: PlayerData,
|
||||
}
|
||||
|
||||
export interface PlayerData {
|
||||
data: Player[],
|
||||
}
|
||||
|
||||
export interface Player {
|
||||
id: string,
|
||||
names: Names,
|
||||
weblink: string,
|
||||
}
|
||||
|
||||
export interface Record {
|
||||
place: number,
|
||||
run: Run,
|
||||
}
|
||||
|
||||
export interface Run {
|
||||
comment: string,
|
||||
players: Array<PlayerUserRef | PlayerGuestRef>,
|
||||
times: Times,
|
||||
splits: Option<Splits>,
|
||||
weblink: string,
|
||||
}
|
||||
|
||||
export interface PlayerUserRef {
|
||||
rel: "user",
|
||||
id: string,
|
||||
}
|
||||
|
||||
export interface PlayerGuestRef {
|
||||
rel: "guest",
|
||||
name: string,
|
||||
}
|
||||
|
||||
export interface Times {
|
||||
primary: string,
|
||||
}
|
||||
|
||||
export interface Splits {
|
||||
uri: string,
|
||||
}
|
||||
|
||||
function evaluateParameters(parameters: string[]): string {
|
||||
@@ -68,6 +114,11 @@ function getGamesUri(subUri: string): string {
|
||||
return `${BASE_URI}${GAMES_URI}${subUri}`;
|
||||
}
|
||||
|
||||
function getLeaderboardsUri(subUri: string): string {
|
||||
const LEADERBOARDS_URI = "leaderboards";
|
||||
return `${BASE_URI}${LEADERBOARDS_URI}${subUri}`;
|
||||
}
|
||||
|
||||
async function executeRequest<T>(uri: string): Promise<T> {
|
||||
const response = await fetch(uri);
|
||||
const responseData = await response.json();
|
||||
@@ -160,3 +211,16 @@ export async function getCategories(gameId: string): Promise<Category[]> {
|
||||
const uri = getGamesUri(`/${gameId}/categories`);
|
||||
return executeRequest<Category[]>(uri);
|
||||
}
|
||||
|
||||
export async function getLeaderboard(
|
||||
gameId: string,
|
||||
categoryId: string,
|
||||
embeds?: Array<"players">,
|
||||
): Promise<Leaderboard> {
|
||||
const parameters = [];
|
||||
if (embeds != null) {
|
||||
parameters.push(`embed=${embeds.join(",")}`);
|
||||
}
|
||||
const uri = getLeaderboardsUri(`/${gameId}/category/${categoryId}${evaluateParameters(parameters)}`);
|
||||
return executeRequest<Leaderboard>(uri);
|
||||
}
|
||||
|
||||
+10
-1
@@ -413,6 +413,11 @@ button {
|
||||
|
||||
a {
|
||||
color: hsla(50, 100%, 50%, 1);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.run-editor {
|
||||
@@ -572,7 +577,7 @@ input {
|
||||
font-family: 'fira', sans-serif;
|
||||
}
|
||||
|
||||
input.number {
|
||||
.number {
|
||||
text-align: right;
|
||||
font-weight: bold;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
@@ -633,6 +638,10 @@ table.run-editor-table td>input {
|
||||
background: linear-gradient(rgb(51, 115, 244) 0%, rgb(21, 53, 116) 100%) !important;
|
||||
}
|
||||
|
||||
.leaderboard-row:hover {
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
input[Type="text"] {
|
||||
font-family:inherit;
|
||||
}
|
||||
|
||||
+178
-43
@@ -5,12 +5,13 @@ import { openFileAsArrayBuffer } from "../util/FileUtil";
|
||||
import { TextBox } from "./TextBox";
|
||||
import { toast } from "react-toastify";
|
||||
import {
|
||||
downloadGameList, searchGames, getGameId, getCategories, downloadCategories,
|
||||
downloadGameList, searchGames, getGameId, getCategories, downloadCategories, downloadLeaderboard, getLeaderboard,
|
||||
} from "../api/GameList";
|
||||
import { Category, getGame } from "../api/SpeedrunCom";
|
||||
import { Option } from "../util/OptionUtil";
|
||||
import { Option, expect, map, assert } from "../util/OptionUtil";
|
||||
import { Parser as CommonMarkParser } from "commonmark";
|
||||
import CommonMarkRenderer = require("commonmark-react-renderer");
|
||||
import { downloadById } from "../util/SplitsIO";
|
||||
|
||||
export interface Props { editor: LiveSplit.RunEditor }
|
||||
export interface State {
|
||||
@@ -18,7 +19,7 @@ export interface State {
|
||||
offsetIsValid: boolean,
|
||||
attemptCountIsValid: boolean,
|
||||
rowState: RowState,
|
||||
isOnRulesTab: boolean,
|
||||
tab: Tab,
|
||||
}
|
||||
|
||||
interface RowState {
|
||||
@@ -33,6 +34,7 @@ enum Tab {
|
||||
RealTime,
|
||||
GameTime,
|
||||
Rules,
|
||||
Leaderboard,
|
||||
}
|
||||
|
||||
export class RunEditor extends React.Component<Props, State> {
|
||||
@@ -43,9 +45,10 @@ export class RunEditor extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
const state = props.editor.stateAsJson() as LiveSplit.RunEditorStateJson;
|
||||
this.state = {
|
||||
attemptCountIsValid: true,
|
||||
editor: props.editor.stateAsJson(),
|
||||
editor: state,
|
||||
offsetIsValid: true,
|
||||
rowState: {
|
||||
bestSegmentTimeIsValid: true,
|
||||
@@ -54,15 +57,18 @@ export class RunEditor extends React.Component<Props, State> {
|
||||
segmentTimeIsValid: true,
|
||||
splitTimeIsValid: true,
|
||||
},
|
||||
isOnRulesTab: false,
|
||||
tab: state.timing_method === "RealTime" ? Tab.RealTime : Tab.GameTime,
|
||||
};
|
||||
|
||||
this.gameIcon = "";
|
||||
this.segmentIconUrls = [];
|
||||
|
||||
this.processIconChanges(state);
|
||||
|
||||
// TODO Handle closing the Run Editor
|
||||
this.refreshGameList();
|
||||
this.refreshCategoryList();
|
||||
this.refreshCategoryList(state.game);
|
||||
this.refreshLeaderboard(state.game, state.category);
|
||||
}
|
||||
|
||||
public render() {
|
||||
@@ -224,7 +230,7 @@ export class RunEditor extends React.Component<Props, State> {
|
||||
Game Time
|
||||
</button>
|
||||
<button
|
||||
className={"toggle-right" + (
|
||||
className={"toggle-middle" + (
|
||||
tab === Tab.Rules
|
||||
? " button-pressed"
|
||||
: ""
|
||||
@@ -233,6 +239,16 @@ export class RunEditor extends React.Component<Props, State> {
|
||||
>
|
||||
Rules
|
||||
</button>
|
||||
<button
|
||||
className={"toggle-right" + (
|
||||
tab === Tab.Leaderboard
|
||||
? " button-pressed"
|
||||
: ""
|
||||
)}
|
||||
onClick={(_) => this.switchTab(Tab.Leaderboard)}
|
||||
>
|
||||
Leaderboard
|
||||
</button>
|
||||
</div>
|
||||
<div className="editor-group">
|
||||
<div className="btn-group">
|
||||
@@ -286,17 +302,99 @@ export class RunEditor extends React.Component<Props, State> {
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
{
|
||||
tab === Tab.Rules
|
||||
? this.renderRulesTab(category)
|
||||
: this.renderSegmentsTable()
|
||||
}
|
||||
{this.renderTab(tab, category)}
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
||||
private renderRulesTab(category: Option<Category>) {
|
||||
private renderTab(tab: Tab, category: Option<Category>): JSX.Element {
|
||||
switch (tab) {
|
||||
case Tab.RealTime:
|
||||
case Tab.GameTime:
|
||||
return this.renderSegmentsTable();
|
||||
case Tab.Rules:
|
||||
return this.renderRulesTab(category);
|
||||
case Tab.Leaderboard:
|
||||
return this.renderLeaderboard();
|
||||
}
|
||||
}
|
||||
|
||||
private renderLeaderboard(): JSX.Element {
|
||||
const leaderboard = getLeaderboard(this.state.editor.game, this.state.editor.category);
|
||||
if (leaderboard == null) {
|
||||
return <div />;
|
||||
}
|
||||
return (
|
||||
<table className="table run-editor-table">
|
||||
<thead className="table-header">
|
||||
<tr>
|
||||
<th>Rank</th>
|
||||
<th>Player</th>
|
||||
<th>Time</th>
|
||||
<th>Splits</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="table-body">
|
||||
{leaderboard.runs.map((r) => (
|
||||
<tr title={r.run.comment} className="leaderboard-row">
|
||||
<td style={{ textAlign: "right" }}>{r.place}</td>
|
||||
<td>
|
||||
{
|
||||
r.run.players.map((p, i) => {
|
||||
if (p.rel === "user") {
|
||||
const players = expect(
|
||||
leaderboard.players,
|
||||
"The leaderboard is supposed to have a players embed.",
|
||||
);
|
||||
const found = expect(
|
||||
players.data.find((f) => f.id === p.id),
|
||||
"The player is supposed to be embedded.",
|
||||
);
|
||||
return [
|
||||
i !== 0 ? ", " : null,
|
||||
<a target="_blank" href={found.weblink}>
|
||||
{found.names.international}
|
||||
</a>,
|
||||
];
|
||||
} else {
|
||||
return [i !== 0 ? ", " : null, p.name];
|
||||
}
|
||||
})
|
||||
}
|
||||
</td>
|
||||
<td style={{ width: 120 }} className="number">
|
||||
<a href={r.run.weblink} target="_blank" style={{ color: "white" }}>
|
||||
{
|
||||
r.run.times.primary
|
||||
.replace("PT", "")
|
||||
.replace("S", "")
|
||||
.replace("H", ":")
|
||||
.replace("M", ":")
|
||||
.split(":")
|
||||
.map((s) => s.padStart(2, "0"))
|
||||
.join(":")
|
||||
}
|
||||
</a>
|
||||
</td>
|
||||
<td style={{ textAlign: "center" }}>
|
||||
{
|
||||
map(r.run.splits, (s) => <i
|
||||
onClick={(_) => this.downloadSplits(s.uri)}
|
||||
className="fa fa-download"
|
||||
style={{ cursor: "pointer" }}
|
||||
aria-hidden="true"
|
||||
/>)
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
private renderRulesTab(category: Option<Category>): JSX.Element {
|
||||
let rules = null;
|
||||
if (category != null && category.rules != null) {
|
||||
const parsed = new CommonMarkParser().parse(category.rules);
|
||||
@@ -643,14 +741,24 @@ export class RunEditor extends React.Component<Props, State> {
|
||||
this.props.editor.activeRemoveIcon();
|
||||
}
|
||||
|
||||
private processIconChanges(state: LiveSplit.RunEditorStateJson) {
|
||||
let index = 0;
|
||||
for (const segment of state.segments) {
|
||||
while (index >= this.segmentIconUrls.length) {
|
||||
this.segmentIconUrls.push("");
|
||||
}
|
||||
const iconChange = segment.icon_change;
|
||||
if (iconChange != null) {
|
||||
this.segmentIconUrls[index] = iconChange;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
if (state.icon_change != null) {
|
||||
this.gameIcon = state.icon_change;
|
||||
}
|
||||
}
|
||||
|
||||
private getSegmentIconUrl(index: number): string {
|
||||
while (index >= this.segmentIconUrls.length) {
|
||||
this.segmentIconUrls.push("");
|
||||
}
|
||||
const iconChange = this.state.editor.segments[index].icon_change;
|
||||
if (iconChange != null) {
|
||||
this.segmentIconUrls[index] = iconChange;
|
||||
}
|
||||
return this.segmentIconUrls[index];
|
||||
}
|
||||
|
||||
@@ -667,30 +775,31 @@ export class RunEditor extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
private getGameIcon(): string {
|
||||
if (this.state.editor.icon_change != null) {
|
||||
this.gameIcon = this.state.editor.icon_change;
|
||||
}
|
||||
return this.gameIcon;
|
||||
}
|
||||
|
||||
private update(isOnRulesTab?: boolean) {
|
||||
private update(switchTab?: Tab) {
|
||||
const state = this.props.editor.stateAsJson();
|
||||
this.processIconChanges(state);
|
||||
this.setState({
|
||||
...this.state,
|
||||
editor: this.props.editor.stateAsJson(),
|
||||
isOnRulesTab: isOnRulesTab === undefined
|
||||
? this.state.isOnRulesTab
|
||||
: isOnRulesTab,
|
||||
editor: state,
|
||||
tab: switchTab === undefined
|
||||
? this.state.tab
|
||||
: switchTab,
|
||||
});
|
||||
}
|
||||
|
||||
private handleGameChange(event: any) {
|
||||
this.props.editor.setGameName(event.target.value);
|
||||
this.refreshCategoryList();
|
||||
this.refreshCategoryList(event.target.value);
|
||||
this.refreshLeaderboard(event.target.value, this.state.editor.category);
|
||||
this.update();
|
||||
}
|
||||
|
||||
private handleCategoryChange(event: any) {
|
||||
this.props.editor.setCategoryName(event.target.value);
|
||||
this.refreshLeaderboard(this.state.editor.game, event.target.value);
|
||||
this.update();
|
||||
}
|
||||
|
||||
@@ -912,8 +1021,11 @@ export class RunEditor extends React.Component<Props, State> {
|
||||
case Tab.Rules: {
|
||||
break;
|
||||
}
|
||||
case Tab.Leaderboard: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.update(tab === Tab.Rules);
|
||||
this.update(tab);
|
||||
}
|
||||
|
||||
private async downloadBoxArt() {
|
||||
@@ -957,22 +1069,45 @@ export class RunEditor extends React.Component<Props, State> {
|
||||
this.update();
|
||||
}
|
||||
|
||||
private async refreshCategoryList() {
|
||||
await downloadGameList();
|
||||
const gameId = getGameId(this.state.editor.game);
|
||||
if (gameId != null) {
|
||||
await downloadCategories(gameId);
|
||||
this.update();
|
||||
}
|
||||
private async refreshCategoryList(gameName: string) {
|
||||
await downloadCategories(gameName);
|
||||
this.update();
|
||||
}
|
||||
|
||||
private async refreshLeaderboard(gameName: string, categoryName: string) {
|
||||
await downloadLeaderboard(gameName, categoryName);
|
||||
this.update();
|
||||
}
|
||||
|
||||
private getTab(): Tab {
|
||||
if (this.state.isOnRulesTab) {
|
||||
return Tab.Rules;
|
||||
} else if (this.state.editor.timing_method === "RealTime") {
|
||||
return Tab.RealTime;
|
||||
} else {
|
||||
return Tab.GameTime;
|
||||
return this.state.tab;
|
||||
}
|
||||
|
||||
private async downloadSplits(apiUri: string) {
|
||||
const baseUri = "https://splits.io/api/v3/runs/";
|
||||
assert(apiUri.startsWith(baseUri), "Unexpected splits i/o URL");
|
||||
const splitsId = apiUri.slice(baseUri.length);
|
||||
try {
|
||||
const run = await downloadById(splitsId);
|
||||
// TODO Race Condition with the Run Editor closing (and probably others)
|
||||
run.with((run) => {
|
||||
run.setGameName(this.state.editor.game);
|
||||
run.setCategoryName(this.state.editor.category);
|
||||
// TODO Metadata as well?
|
||||
|
||||
const newEditor = LiveSplit.RunEditor.new(run);
|
||||
if (newEditor != null) {
|
||||
// TODO Oh no, not internal pointer stuff
|
||||
this.props.editor.dispose();
|
||||
this.props.editor.ptr = newEditor.ptr;
|
||||
|
||||
this.update();
|
||||
} else {
|
||||
toast.error("The downloaded splits are not suitable for being edited.");
|
||||
}
|
||||
});
|
||||
} catch (_) {
|
||||
toast.error("Failed to download the splits.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ interface MaybeDisposable {
|
||||
|
||||
export function assertNever(x: never): never { return x; }
|
||||
|
||||
export function assert(condition: boolean, message: string) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertNull(obj: Option<MaybeDisposable>, message: string) {
|
||||
if (obj != null) {
|
||||
if (obj.dispose) {
|
||||
|
||||
Reference in New Issue
Block a user