Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -931,6 +931,8 @@
"no_stats": "No stats available",
"player": "Player",
"rank": "Rank",
"ranked_2v2_tab": "2v2 Ranked",
"ranked_no_stats": "No ranked games have been played on this ladder yet",
"ranked_tab": "1v1 Ranked",
"ratio": "Ratio",
"refresh_time": "Refreshed every 1 hour",
Expand Down
37 changes: 32 additions & 5 deletions src/client/LeaderboardModal.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { RankedType } from "../core/game/Game";
import { BaseModal } from "./components/BaseModal";
import "./components/leaderboard/LeaderboardClanTable";
import type { LeaderboardClanTable } from "./components/leaderboard/LeaderboardClanTable";
Expand All @@ -10,7 +11,15 @@ import type { LeaderboardTribeTable } from "./components/leaderboard/Leaderboard
import { modalHeader } from "./components/ui/ModalHeader";
import { translateText } from "./Utils";

const TAB_KEYS = ["players", "clans", "tribes"] as const;
const TAB_KEYS = ["players", "players2v2", "clans", "tribes"] as const;

// Tab key -> ladder. "players" predates the 2v2 ladder and stays the 1v1 tab
// so existing `#modal=leaderboard&tab=players` links keep working. Both tabs
// share one <leaderboard-player-list>: a page fetch returns both ladders.
const PLAYER_TABS: Record<string, RankedType> = {
players: RankedType.OneVOne,
players2v2: RankedType.TwoVTwo,
};

@customElement("leaderboard-modal")
export class LeaderboardModal extends BaseModal {
Expand All @@ -27,6 +36,7 @@ export class LeaderboardModal extends BaseModal {
private tribeTable?: LeaderboardTribeTable;

private loadToken = 0;
private lastRankedType: RankedType = RankedType.OneVOne;

protected modalConfig() {
return {
Expand All @@ -35,14 +45,22 @@ export class LeaderboardModal extends BaseModal {
key: "players",
label: translateText("leaderboard_modal.ranked_tab"),
},
{
key: "players2v2",
label: translateText("leaderboard_modal.ranked_2v2_tab"),
},
{ key: "clans", label: translateText("leaderboard_modal.clans_tab") },
{ key: "tribes", label: translateText("leaderboard_modal.tribes_tab") },
],
};
}

private rankedTypeFor(tab: string): RankedType | null {
return PLAYER_TABS[tab] ?? null;
}

private tabComponent(tab: string) {
if (tab === "players") return this.playerList;
if (this.rankedTypeFor(tab) !== null) return this.playerList;
if (tab === "clans") return this.clanTable;
return this.tribeTable;
}
Expand All @@ -52,6 +70,10 @@ export class LeaderboardModal extends BaseModal {
}

protected onTabEnter(): void {
// The player list is one element shared by both ladder tabs, so it needs a
// ranked type even while another tab is up: keep showing the last one.
this.lastRankedType =
this.rankedTypeFor(this.activeTab) ?? this.lastRankedType;
this.loadActiveTabData();
}

Expand All @@ -63,7 +85,7 @@ export class LeaderboardModal extends BaseModal {
const run = async () => {
if (token !== this.loadToken) return;

if (active === "players") {
if (this.rankedTypeFor(active) !== null) {
await this.playerList?.ensureLoaded();
if (token !== this.loadToken) return;
this.playerList?.handleTabActivated();
Expand Down Expand Up @@ -117,7 +139,8 @@ export class LeaderboardModal extends BaseModal {
${translateText("leaderboard_modal.title")}
</span>
${this.activeTab === "clans" ? dateRange : ""}
${this.activeTab === "players" || this.activeTab === "tribes"
${this.rankedTypeFor(this.activeTab) !== null ||
this.activeTab === "tribes"
? refreshTime
: ""}
</div>
Expand All @@ -131,7 +154,11 @@ export class LeaderboardModal extends BaseModal {
return html`
<div class="flex-1 min-h-0 h-full">
<leaderboard-player-list
class=${this.activeTab === "players" ? "h-full" : "hidden"}
class=${this.rankedTypeFor(this.activeTab) !== null
? "h-full"
: "hidden"}
.rankedType=${this.rankedTypeFor(this.activeTab) ??
this.lastRankedType}
></leaderboard-player-list>
<leaderboard-clan-table
class=${this.activeTab === "clans" ? "h-full" : "hidden"}
Expand Down
2 changes: 1 addition & 1 deletion src/client/components/baseComponents/Modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export class OModal extends LitElement {
return html`
<div
role="tablist"
class="flex justify-center border-b border-white/10 px-4 lg:px-6 gap-1 shrink-0"
class="flex flex-wrap justify-center border-b border-white/10 px-4 lg:px-6 gap-1 shrink-0"
>
${this.tabs.map((tab) => {
const active = this.activeTab === tab.key;
Expand Down
199 changes: 150 additions & 49 deletions src/client/components/leaderboard/LeaderboardPlayerList.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,56 @@
import { html, LitElement, nothing } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { PlayerLeaderboardEntry } from "../../../core/ApiSchemas";
import { html, LitElement, nothing, PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import {
PlayerLeaderboardEntry,
RankedLeaderboardEntry,
} from "../../../core/ApiSchemas";
import { RankedType } from "../../../core/game/Game";
import { fetchPlayerLeaderboard, getUserMe } from "../../Api";
import { translateText } from "../../Utils";
import "../PlayerName";

/** One ranked ladder's loaded rows. */
interface LadderState {
entries: PlayerLeaderboardEntry[];
userEntry: PlayerLeaderboardEntry | null;
hasMore: boolean;
}

const RANKED_TYPES = Object.values(RankedType);

const emptyLadders = (): Record<RankedType, LadderState> => ({
[RankedType.OneVOne]: { entries: [], userEntry: null, hasMore: true },
[RankedType.TwoVTwo]: { entries: [], userEntry: null, hasMore: true },
});

const toPlayerEntry = (
entry: RankedLeaderboardEntry,
): PlayerLeaderboardEntry => ({
rank: entry.rank,
playerId: entry.public_id,
accountUsername: entry.accountUsername ?? null,
elo: entry.elo,
games: entry.total,
wins: entry.wins,
losses: entry.losses,
winRate: entry.total > 0 ? entry.wins / entry.total : 0,
});

@customElement("leaderboard-player-list")
export class LeaderboardPlayerList extends LitElement {
@state() private playerData: PlayerLeaderboardEntry[] = [];
@state() private currentUserEntry: PlayerLeaderboardEntry | null = null;
/**
* Which ladder to display. Each ranked type is ranked independently — the
* same player can hold a different elo and rank on each — but one request
* returns both, so the other ladder rides along and is kept for its own tab.
*/
@property({ attribute: false }) rankedType: RankedType = RankedType.OneVOne;

@state() private ladders: Record<RankedType, LadderState> = emptyLadders();
@state() private showStickyUser = false;
@state() private isLoading = false;
@state() private error: string | null = null;
@state() private isLoadingMore = false;
@state() private loadMoreError: string | null = null;
@state() private playerHasMore = true;

private hasLoadedPlayers = false;
private readonly playerPageSize = 50;
Expand All @@ -25,10 +60,42 @@ export class LeaderboardPlayerList extends LitElement {

@query(".scroll-container") private scrollContainer?: HTMLElement;

private get playerData(): PlayerLeaderboardEntry[] {
return this.ladders[this.rankedType].entries;
}

private get currentUserEntry(): PlayerLeaderboardEntry | null {
return this.ladders[this.rankedType].userEntry;
}

private get playerHasMore(): boolean {
return this.ladders[this.rankedType].hasMore;
}

private mapLadders(
fn: (ladder: LadderState) => LadderState,
): Record<RankedType, LadderState> {
const next = { ...this.ladders };
for (const rankedType of RANKED_TYPES) {
next[rankedType] = fn(this.ladders[rankedType]);
}
return next;
}

createRenderRoot() {
return this;
}

protected updated(changed: PropertyValues) {
if (changed.has("rankedType")) {
// The ladders are separate lists sharing one scroll container: show the
// newly selected one from its top rather than the other one's offset.
if (this.scrollContainer) this.scrollContainer.scrollTop = 0;
this.scheduleStickyVisibilityCheck();
this.schedulePlayerFillCheck();
}
}

public async ensureLoaded() {
if (this.hasLoadedPlayers || this.isLoading) return;
await this.loadPlayerLeaderboard(true);
Expand All @@ -37,10 +104,8 @@ export class LeaderboardPlayerList extends LitElement {
public async loadPlayerLeaderboard(reset = false) {
if (reset) {
this.currentPage = 1;
this.playerHasMore = true;
this.loadMoreError = null;
this.playerData = [];
this.currentUserEntry = null;
this.ladders = emptyLadders();
this.showStickyUser = false;
} else if (!this.playerHasMore) {
return;
Expand All @@ -65,56 +130,57 @@ export class LeaderboardPlayerList extends LitElement {

// Past the last page. Not an error: stop paging, leave the footer clear.
if (result === "reached_limit") {
this.playerHasMore = false;
this.ladders = this.mapLadders((ladder) => ({
...ladder,
hasMore: false,
}));
this.hasLoadedPlayers = true;
return;
}

const nextPlayers: PlayerLeaderboardEntry[] = result[
RankedType.OneVOne
].map((entry) => ({
rank: entry.rank,
playerId: entry.public_id,
accountUsername: entry.accountUsername ?? null,
elo: entry.elo,
games: entry.total,
wins: entry.wins,
losses: entry.losses,
winRate: entry.total > 0 ? entry.wins / entry.total : 0,
}));

const receivedCount = nextPlayers.length;
if (reset) {
this.playerData = nextPlayers;
} else {
const existingIds = new Set(
this.playerData.map((player) => player.playerId),
);
const deduped = nextPlayers.filter(
(player) => !existingIds.has(player.playerId),
);
this.playerData = [...this.playerData, ...deduped];
}

if (receivedCount > 0) {
this.currentPage++;
}

if (receivedCount < this.playerPageSize) {
this.playerHasMore = false;
}

if (reset && !this.currentUserIdLoaded) {
this.currentUserIdLoaded = true;
const userMe = await getUserMe();
this.currentUserId = userMe ? userMe.player.publicId : null;
}

if (this.currentUserId && !this.currentUserEntry) {
this.currentUserEntry =
nextPlayers.find(
(player) => player.playerId === this.currentUserId,
) ?? null;
// One page carries a slice of every ladder, so fold them all in — even
// the ones whose tab isn't showing.
let receivedAny = false;
const nextLadders = { ...this.ladders };
for (const rankedType of RANKED_TYPES) {
const ladder = this.ladders[rankedType];
const nextPlayers = (result[rankedType] ?? []).map(toPlayerEntry);
if (nextPlayers.length > 0) receivedAny = true;

const existingIds = new Set(
ladder.entries.map((player) => player.playerId),
);
nextLadders[rankedType] = {
entries: [
...ladder.entries,
...nextPlayers.filter(
(player) => !existingIds.has(player.playerId),
),
],
// Ranks differ per ladder, so the pinned "your ranking" row is
// whichever row this ladder gave the signed-in player.
userEntry:
ladder.userEntry ??
nextPlayers.find(
(player) => player.playerId === this.currentUserId,
) ??
null,
// A short page ends that ladder. The ladders run out at different
// pages — the 2v2 board is younger and has no backfill — so this is
// tracked per ladder rather than off the 1v1 page size.
hasMore: ladder.hasMore && nextPlayers.length >= this.playerPageSize,
};
}
this.ladders = nextLadders;

if (receivedAny) {
this.currentPage++;
}

this.hasLoadedPlayers = true;
Expand Down Expand Up @@ -420,10 +486,45 @@ export class LeaderboardPlayerList extends LitElement {
`;
}

// An empty ladder is a normal state, not an error: a ranked type that has
// just launched starts with no rows and fills as games are played.
private renderNoData() {
return html`
<div
class="flex flex-col items-center justify-center p-12 text-white/40 h-full"
>
<div class="bg-white/5 p-6 rounded-full mb-6 border border-white/5">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 text-white/20"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
</div>
<h3 class="text-xl font-bold text-white/60 mb-2">
${translateText("leaderboard_modal.no_data_yet")}
</h3>
<p class="text-white/30 text-sm">
${translateText("leaderboard_modal.ranked_no_stats")}
</p>
</div>
`;
}

render() {
if (this.isLoading && this.playerData.length === 0)
return this.renderLoading();
if (this.error) return this.renderError();
if (this.hasLoadedPlayers && this.playerData.length === 0)
return this.renderNoData();

return html`
<div class="h-full">
Expand Down
4 changes: 4 additions & 0 deletions src/core/ApiSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,10 @@ export type RankedLeaderboardEntry = z.infer<

export const RankedLeaderboardResponseSchema = z.object({
[RankedType.OneVOne]: RankedLeaderboardEntrySchema.array(),
// Each ranked type is its own ladder, ranked independently: a player can
// appear on both with a different elo and rank. Defaulted because an API
// deployment that predates the 2v2 ladder omits the key entirely.
[RankedType.TwoVTwo]: RankedLeaderboardEntrySchema.array().default([]),
});
export type RankedLeaderboardResponse = z.infer<
typeof RankedLeaderboardResponseSchema
Expand Down
Loading
Loading