Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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