feat(client): configurable leaderboard and team stats columns (#4646)

## Description:

- rips out some duped code, making tables use the same layout, where the
only thing different is the row content.
- updated text to be emojis to save space
- updated columns to be as wide as they need to be
- updated cells to have left/right thin borders to read easier
- added more supported column types
- removed the + button to always show everyone, where the current player
is either at the bottom or in the top 5
- added a cogwheel to change options
- can select different options for each menu type (player/team stats)

<img width="582" height="630" alt="image"
src="https://github.com/user-attachments/assets/e54dc3d5-eb2c-4bab-8732-06bea68aec45"
/>

works fine on mobile, will probably need some future love though (has
h-scroll, so all options can be seen by scrolling):


## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory

## Please put your Discord username so you can be contacted if a bug or
regression is found:

w.o.n
This commit is contained in:
Ryan
2026-07-22 13:13:43 -07:00
committed by GitHub
parent 3e2251bbbb
commit d76a0c4009
25 changed files with 1697 additions and 626 deletions
-2
View File
@@ -378,8 +378,6 @@
<game-left-sidebar></game-left-sidebar>
<performance-overlay></performance-overlay>
<player-info-overlay></player-info-overlay>
<leader-board></leader-board>
<team-stats></team-stats>
<heads-up-message></heads-up-message>
<!-- Featured stream: direct <body> child (NOT inside the in-[.in-game]:hidden
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 64 64"><g><path d="M57.456,52.544C51.13,46.218,42.864,42.541,34,42.059V24h14c0.737,0,1.415-0.406,1.764-1.056 c0.348-0.65,0.31-1.439-0.1-2.053L46.403,16l3.261-4.891c0.409-0.614,0.447-1.403,0.1-2.053C49.415,8.406,48.737,8,48,8H32 c-1.104,0-2,0.896-2,2v32.059c-8.864,0.482-17.13,4.159-23.456,10.485c-0.778,0.777-0.74,2.079,0.033,2.861 C6.953,55.786,7.465,56,8,56h48c0.53,0,1.081-0.253,1.456-0.628C58.237,54.591,58.237,53.325,57.456,52.544z"/></g></svg>

After

Width:  |  Height:  |  Size: 523 B

+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>upper-limit</title>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="drop" fill="#000000" transform="translate(85.333333, 64.000000)">
<path d="M170.666667,42.6666667 L28.992,160.704 L56.32,193.493333 L149.333333,115.989333 L149.333333,384 L192,384 L192,115.989333 L284.992,193.493333 L312.32,160.704 L170.666667,42.6666667 Z M1.42108547e-14,42.6666667 L341.333333,42.6666667 L341.333333,0 L1.42108547e-14,0 L1.42108547e-14,42.6666667 Z" id="Mask">
</path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 838 B

+6 -2
View File
@@ -893,16 +893,20 @@
"svg": "uk_us_flag"
},
"leaderboard": {
"allies": "Allies",
"betrayals": "Betrayals",
"cities": "Cities",
"configure_columns": "Configure columns",
"factories": "Factories",
"gold": "Gold",
"launchers": "Launchers",
"maxtroops": "Max troops",
"owned": "Owned",
"player": "Player",
"ports": "Ports",
"sams": "SAMs",
"show_control": "Show Control",
"show_units": "Show Units",
"team": "Team",
"troops": "Troops",
"warships": "Warships"
},
"leaderboard_modal": {
+2 -1
View File
@@ -209,7 +209,8 @@ export class LangSelector extends LitElement {
"host-lobby-modal",
"join-lobby-modal",
"emoji-table",
"leader-board",
"player-stats",
"team-stats",
"leaderboard-player-list",
"leaderboard-clan-table",
"build-menu",
+24
View File
@@ -0,0 +1,24 @@
export const COLUMN_IDS = [
"tiles",
"gold",
"troops",
"maxtroops",
"cities",
"ports",
"factories",
"silos",
"sams",
"warships",
"allies",
"betrayals",
] as const;
export type ColumnId = (typeof COLUMN_IDS)[number];
export const DEFAULT_STATS_COLUMNS = [
"tiles",
"gold",
"maxtroops",
] as const satisfies readonly ColumnId[];
export type StatsTableKind = "player" | "team";
+402
View File
@@ -0,0 +1,402 @@
import { LitElement, html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
import { UserSettings } from "../../core/game/UserSettings";
import { profileIcon } from "../hud/HotbarIcons";
import "../hud/layers/ColumnPicker";
import {
COLUMN_DEFS,
type ColumnDef,
columnById,
} from "../hud/layers/lib/StatsColumns";
import {
type ColumnId,
DEFAULT_STATS_COLUMNS,
type StatsTableKind,
} from "../StatsConstants";
import { translateText } from "../Utils";
import type { GameView } from "../view";
export interface StatsRow {
key: string;
name: string;
values: ReadonlyMap<ColumnId, number>;
emphasized?: boolean;
pinned?: boolean;
onClick?: () => void;
}
interface RenderedStatsRow extends Omit<StatsRow, "values"> {
position: number;
cells: readonly string[];
}
// Fallbacks cover the first render before measurement and jsdom (tests),
// which reports zero element sizes.
const FALLBACK_ROW_HEIGHT_PX = 24;
const FALLBACK_VIEWPORT_HEIGHT_PX = 180;
const OVERSCAN_ROWS = 4;
// The pinned row only renders separately when the viewer sits below the
// always-visible top ranks of the scroll window.
const PINNED_VISIBLE_THRESHOLD = 4;
export abstract class StatsTable extends LitElement {
public game: GameView | null = null;
@property({ type: Boolean }) visible = false;
protected abstract readonly tableKind: StatsTableKind;
protected abstract readonly nameLabelKey: string;
protected abstract buildRows(
game: GameView,
columns: readonly ColumnDef[],
): StatsRow[];
private readonly userSettings = new UserSettings();
private rows: StatsRow[] = [];
@state()
private sortKey: ColumnId = DEFAULT_STATS_COLUMNS[0];
@state()
private sortOrder: "asc" | "desc" = "desc";
@state()
private scrollOffsetPx = 0;
private rowHeightPx = FALLBACK_ROW_HEIGHT_PX;
private viewportHeightPx = FALLBACK_VIEWPORT_HEIGHT_PX;
createRenderRoot() {
return this;
}
willUpdate(changed: Map<string, unknown>) {
if (changed.has("visible") && this.visible) {
// The scroll container is recreated at scroll offset 0 when the table
// was hidden, so the remembered offset would misplace the window.
this.scrollOffsetPx = 0;
this.updateStats();
}
}
updated() {
const scroller = this.querySelector(".stats-table-scroll");
if (!(scroller instanceof HTMLElement)) return;
const row = scroller.querySelector(".stats-table-row");
const rowHeight = row instanceof HTMLElement ? row.offsetHeight : 0;
const viewportHeight = scroller.clientHeight;
let changed = false;
if (rowHeight > 0 && rowHeight !== this.rowHeightPx) {
this.rowHeightPx = rowHeight;
changed = true;
}
if (viewportHeight > 0 && viewportHeight !== this.viewportHeightPx) {
this.viewportHeightPx = viewportHeight;
changed = true;
}
if (changed) this.requestUpdate();
}
private onScroll(event: Event) {
this.scrollOffsetPx = (event.target as HTMLElement).scrollTop;
}
refresh() {
if (this.visible) this.updateStats();
}
private selectedColumns(): ColumnDef[] {
return this.userSettings.statsColumns(this.tableKind).map(columnById);
}
private setSort(key: ColumnId) {
if (this.sortKey === key) {
this.sortOrder = this.sortOrder === "asc" ? "desc" : "asc";
} else {
this.sortKey = key;
this.sortOrder = "desc";
}
this.updateStats();
}
private onColumnsChanged(event: CustomEvent<ColumnId[]>) {
this.userSettings.setStatsColumns(this.tableKind, event.detail);
this.updateStats();
}
private updateStats() {
if (this.game === null) return;
const selected = this.selectedColumns();
if (!selected.some((column) => column.id === this.sortKey)) {
this.sortKey = selected[0].id;
this.sortOrder = "desc";
}
const direction = this.sortOrder === "asc" ? 1 : -1;
this.rows = this.buildRows(this.game, selected).sort(
(a, b) =>
direction *
((a.values.get(this.sortKey) ?? 0) - (b.values.get(this.sortKey) ?? 0)),
);
this.requestUpdate();
}
render() {
const game = this.game;
if (!this.visible || game === null) return html``;
const selected = this.selectedColumns();
const toRendered = (
{ values, ...row }: StatsRow,
position: number,
): RenderedStatsRow => ({
...row,
position,
cells: selected.map((column) =>
column.renderValue(values.get(column.id) ?? 0, game),
),
});
const pinnedIndex = this.rows.findIndex((row) => row.pinned);
const pinnedRow =
pinnedIndex > PINNED_VISIBLE_THRESHOLD
? toRendered(this.rows[pinnedIndex], pinnedIndex + 1)
: null;
const listRows =
pinnedRow === null
? this.rows
: this.rows.filter((_, index) => index !== pinnedIndex);
// Virtualize: only rows near the scroll viewport get DOM; spacers keep
// the scrollbar geometry for the rest. Positions stay list-wide.
const firstIndex = Math.max(
0,
Math.floor(this.scrollOffsetPx / this.rowHeightPx) - OVERSCAN_ROWS,
);
const lastIndex = Math.min(
listRows.length,
Math.ceil(
(this.scrollOffsetPx + this.viewportHeightPx) / this.rowHeightPx,
) + OVERSCAN_ROWS,
);
const scrollableRows = listRows
.slice(firstIndex, lastIndex)
.map((row, sliceIndex) => {
const index = firstIndex + sliceIndex;
return toRendered(
row,
pinnedRow !== null && index >= pinnedIndex ? index + 2 : index + 1,
);
});
const topSpacerPx = firstIndex * this.rowHeightPx;
const bottomSpacerPx = (listRows.length - lastIndex) * this.rowHeightPx;
// Stat tracks stay content-sized intrinsically, then split only the spare
// width supplied by a wider sibling. Rank, name, and picker remain fixed.
const gridTemplate = `30px 100px${" auto".repeat(selected.length)} 32px`;
const scrollHeight =
pinnedRow === null
? "max-h-[7.5rem] md:max-h-[10rem] lg:max-h-[11.25rem]"
: "max-h-[6rem] md:max-h-[8rem] lg:max-h-[9rem]";
const renderRow = (
row: RenderedStatsRow,
borderClass: string,
pinned = false,
) => html`
<div
class="stats-table-row grid col-span-full hover:bg-slate-600/60 ${pinned
? "stats-table-pinned-row bg-gray-700/95"
: ""} ${row.emphasized ? "font-bold" : ""} ${row.onClick
? "cursor-pointer"
: ""}"
style="grid-template-columns: subgrid; grid-column: 1 / -1;"
role="row"
@click=${row.onClick ?? nothing}
>
<div
class="h-6 md:h-8 lg:h-9 flex items-center justify-center ${borderClass}"
role="cell"
>
${row.position}
</div>
<div
class="h-6 md:h-8 lg:h-9 min-w-0 px-1 flex items-center text-left ${borderClass}"
role="cell"
>
<span class="block w-full truncate">${row.name}</span>
</div>
${repeat(
row.cells,
(_cell, index) => selected[index].id,
(cell, index) => {
const alignment =
selected[index].valueAlignment === "center"
? "justify-center text-center"
: "justify-end text-right";
return html`
<div
class="h-6 md:h-8 lg:h-9 px-1 flex items-center ${alignment} tabular-nums whitespace-nowrap border-l border-l-slate-600/40 ${borderClass}"
role="cell"
>
${cell}
</div>
`;
},
)}
<div
class="h-6 md:h-8 lg:h-9 border-l border-l-slate-600/40 ${borderClass}"
aria-hidden="true"
></div>
</div>
`;
return html`
<div class="stats-table relative mt-1 text-white text-xs lg:text-sm">
<div
class="overflow-x-auto rounded-lg bg-gray-800/85"
@contextmenu=${(event: Event) => event.preventDefault()}
>
<div
class="stats-table-content grid w-max min-w-full"
style="grid-template-columns: ${gridTemplate};"
role="table"
>
<div
class="stats-table-header grid col-span-full font-bold bg-gray-700/95"
style="grid-template-columns: subgrid; grid-column: 1 / -1;"
role="row"
>
<div
class="h-6 md:h-8 lg:h-9 flex items-center justify-center border-b border-b-slate-500"
role="columnheader"
>
#
</div>
<div
class="h-6 md:h-8 lg:h-9 min-w-0 px-1 flex items-center justify-center text-center border-b border-b-slate-500"
role="columnheader"
title=${translateText(this.nameLabelKey)}
>
<img
class="size-[1.1rem] object-contain"
src=${profileIcon}
alt=${translateText(this.nameLabelKey)}
/>
</div>
${repeat(
selected,
(column) => column.id,
(column) => {
const label = translateText(column.labelKey);
return html`
<div
class="h-6 md:h-8 lg:h-9 px-1 flex items-center justify-center text-center border-b border-b-slate-500 border-l border-l-slate-500 whitespace-nowrap"
role="columnheader"
aria-sort=${this.sortKey === column.id
? this.sortOrder === "asc"
? "ascending"
: "descending"
: "none"}
>
<button
class="inline-flex items-center justify-center gap-1 hover:text-sky-200 transition-colors"
title=${column.headerVisual === undefined
? nothing
: label}
aria-label=${label}
@click=${() => this.setSort(column.id)}
>
${column.headerVisual?.kind === "icon"
? html`<span class="inline-flex items-start">
<img
class="size-[1.1rem] object-contain ${column
.headerVisual.white === true
? "brightness-0 invert"
: ""}"
src=${column.headerVisual.src}
alt=""
aria-hidden="true"
/>${column.headerVisual.superscript
? html`<img
class="size-[0.825rem] object-contain -ml-0.5 ${column
.headerVisual.superscript.white === true
? "brightness-0 invert"
: ""}"
src=${column.headerVisual.superscript.src}
alt=""
aria-hidden="true"
/>`
: nothing}
</span>`
: column.headerVisual?.kind === "emoji"
? html`<span
class="text-[1.1rem] leading-none"
aria-hidden="true"
>${column.headerVisual.text}</span
>`
: html`<span>${label}</span>`}
${this.sortKey === column.id
? html`<span class="text-sky-300" aria-hidden="true"
>${this.sortOrder === "asc" ? "↑" : "↓"}</span
>`
: nothing}
</button>
</div>
`;
},
)}
<div
class="h-6 md:h-8 lg:h-9 px-1 flex items-center justify-center border-b border-b-slate-500 border-l border-l-slate-500"
role="columnheader"
>
<column-picker
class="inline-flex"
.columns=${COLUMN_DEFS}
.selected=${selected.map((column) => column.id)}
@columns-changed=${this.onColumnsChanged}
></column-picker>
</div>
</div>
<div
class="stats-table-scroll ${scrollHeight} grid col-span-full overflow-y-scroll overflow-x-hidden"
style="grid-template-columns: subgrid; grid-column: 1 / -1;"
role="rowgroup"
@scroll=${this.onScroll}
>
${topSpacerPx > 0
? html`<div
class="stats-table-spacer col-span-full"
style="height: ${topSpacerPx}px"
aria-hidden="true"
></div>`
: nothing}
${repeat(
scrollableRows,
(row) => row.key,
(row, index) =>
renderRow(
row,
index < scrollableRows.length - 1 ||
pinnedRow !== null ||
bottomSpacerPx > 0
? "border-b border-b-slate-500"
: "",
),
)}
${bottomSpacerPx > 0
? html`<div
class="stats-table-spacer col-span-full"
style="height: ${bottomSpacerPx}px"
aria-hidden="true"
></div>`
: nothing}
</div>
${pinnedRow === null ? nothing : renderRow(pinnedRow, "", true)}
</div>
</div>
</div>
`;
}
}
-18
View File
@@ -30,7 +30,6 @@ import { GraphicsSettingsModal } from "./layers/GraphicsSettingsModal";
import { HeadsUpMessage } from "./layers/HeadsUpMessage";
import { ImmunityTimer } from "./layers/ImmunityTimer";
import { InGamePromo } from "./layers/InGamePromo";
import { Leaderboard } from "./layers/Leaderboard";
import { MainRadialMenu } from "./layers/MainRadialMenu";
import { MultiTabModal } from "./layers/MultiTabModal";
import { NewLobbyPrompt } from "./layers/NewLobbyPrompt";
@@ -40,7 +39,6 @@ import { PlayerPanel } from "./layers/PlayerPanel";
import { ReplayPanel } from "./layers/ReplayPanel";
import { SettingsModal } from "./layers/SettingsModal";
import { SpawnTimer } from "./layers/SpawnTimer";
import { TeamStats } from "./layers/TeamStats";
import { UnitDisplay } from "./layers/UnitDisplay";
import { WinModal } from "./layers/WinModal";
import { loadAllSprites } from "./SpriteLoader";
@@ -85,13 +83,6 @@ export function createRenderer(
buildMenu.uiState = uiState;
buildMenu.transformHandler = transformHandler;
const leaderboard = document.querySelector("leader-board") as Leaderboard;
if (!leaderboard || !(leaderboard instanceof Leaderboard)) {
console.error("LeaderBoard element not found in the DOM");
}
leaderboard.eventBus = eventBus;
leaderboard.game = game;
const gameLeftSidebar = document.querySelector(
"game-left-sidebar",
) as GameLeftSidebar;
@@ -101,13 +92,6 @@ export function createRenderer(
gameLeftSidebar.game = game;
gameLeftSidebar.eventBus = eventBus;
const teamStats = document.querySelector("team-stats") as TeamStats;
if (!teamStats || !(teamStats instanceof TeamStats)) {
console.error("TeamStats element not found in the DOM");
}
teamStats.eventBus = eventBus;
teamStats.game = game;
const controlPanel = document.querySelector("control-panel") as ControlPanel;
if (!(controlPanel instanceof ControlPanel)) {
console.error("ControlPanel element not found in the DOM");
@@ -325,7 +309,6 @@ export function createRenderer(
),
spawnTimer,
immunityTimer,
leaderboard,
gameLeftSidebar,
unitDisplay,
gameRightSidebar,
@@ -336,7 +319,6 @@ export function createRenderer(
replayPanel,
settingsModal,
graphicsSettingsModal,
teamStats,
playerPanel,
headsUpMessage,
multiTabModal,
+19
View File
@@ -0,0 +1,19 @@
import { assetUrl } from "../../core/AssetUrls";
export const warshipIcon = assetUrl("images/BattleshipIconWhite.svg");
export const cityIcon = assetUrl("images/CityIconWhite.svg");
export const factoryIcon = assetUrl("images/FactoryIconWhite.svg");
export const goldCoinIcon = assetUrl("images/GoldCoinIcon.svg");
export const mirvIcon = assetUrl("images/MIRVIcon.svg");
export const missileSiloIcon = assetUrl("images/MissileSiloIconWhite.svg");
export const hydrogenBombIcon = assetUrl("images/MushroomCloudIconWhite.svg");
export const atomBombIcon = assetUrl("images/NukeIconWhite.svg");
export const portIcon = assetUrl("images/PortIcon.svg");
export const samLauncherIcon = assetUrl("images/SamLauncherIconWhite.svg");
export const defensePostIcon = assetUrl("images/ShieldIconWhite.svg");
export const soldierIcon = assetUrl("images/SoldierIcon.svg");
export const claimIcon = assetUrl("images/ClaimIcon.svg");
export const profileIcon = assetUrl("images/ProfileIcon.svg");
export const upperLimitIcon = assetUrl("images/UpperLimitIcon.svg");
export const allianceIcon = assetUrl("images/AllianceIcon.svg");
export const traitorIcon = assetUrl("images/TraitorIcon.svg");
+146
View File
@@ -0,0 +1,146 @@
import { html, LitElement, render as litRender } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { ColumnId } from "../../StatsConstants";
import { translateText } from "../../Utils";
import type { ColumnDef } from "./lib/StatsColumns";
/**
* button + checkbox popover for choosing which stat columns a panel
* shows. Emits `columns-changed` (CustomEvent<ColumnId[]>) with the new
* selection in registry order; the host persists and re-renders.
*/
@customElement("column-picker")
export class ColumnPicker extends LitElement {
@property({ attribute: false }) columns: readonly ColumnDef[] = [];
@property({ attribute: false }) selected: readonly ColumnId[] = [];
@state() private open = false;
private portal: HTMLDivElement | null = null;
createRenderRoot() {
return this; // light DOM for Tailwind
}
private onDocumentClick = (e: MouseEvent) => {
const target = e.target as Node;
if (this.open && !this.contains(target) && !this.portal?.contains(target)) {
this.open = false;
}
};
private onViewportChange = (event: Event) => {
const target = event.target;
if (
this.open &&
!(target instanceof Node && this.portal?.contains(target))
) {
this.renderPortal();
}
};
connectedCallback() {
super.connectedCallback();
document.addEventListener("click", this.onDocumentClick);
window.addEventListener("resize", this.onViewportChange);
window.addEventListener("scroll", this.onViewportChange, true);
}
disconnectedCallback() {
document.removeEventListener("click", this.onDocumentClick);
window.removeEventListener("resize", this.onViewportChange);
window.removeEventListener("scroll", this.onViewportChange, true);
this.removePortal();
super.disconnectedCallback();
}
protected updated() {
this.renderPortal();
}
private toggle(id: ColumnId) {
const isSelected = this.selected.includes(id);
if (isSelected && this.selected.length === 1) return; // keep at least one
const next = this.columns
.map((c) => c.id)
.filter((cid) =>
cid === id ? !isSelected : this.selected.includes(cid),
);
this.dispatchEvent(
new CustomEvent<ColumnId[]>("columns-changed", {
detail: next,
bubbles: true,
composed: true,
}),
);
}
private renderPortal() {
if (!this.open || !this.isConnected) {
this.removePortal();
return;
}
const trigger = this.querySelector("button");
if (trigger === null) return;
if (this.portal === null) {
this.portal = document.createElement("div");
this.portal.className = "column-picker-portal";
document.body.appendChild(this.portal);
}
const rect = trigger.getBoundingClientRect();
const right = Math.max(8, window.innerWidth - rect.right);
const top = rect.bottom + 4;
const maxHeight = Math.max(
80,
Math.min(window.innerHeight * 0.4, window.innerHeight - top - 8),
);
litRender(
html`
<div
class="column-picker-popover fixed z-2000 bg-gray-800/95 border border-slate-500 rounded-md p-2 flex flex-col gap-1 overflow-y-auto whitespace-nowrap"
style="top: ${top}px; right: ${right}px; max-height: ${maxHeight}px;"
>
${this.columns.map((column) => {
const checked = this.selected.includes(column.id);
return html`
<label
class="flex items-center gap-2 text-xs lg:text-sm text-white cursor-pointer"
>
<input
type="checkbox"
.checked=${checked}
?disabled=${checked && this.selected.length === 1}
@change=${() => this.toggle(column.id)}
/>
${translateText(column.labelKey)}
</label>
`;
})}
</div>
`,
this.portal,
);
}
private removePortal() {
this.portal?.remove();
this.portal = null;
}
render() {
return html`
<button
class="px-0.5 leading-none text-xs lg:text-sm border rounded-md border-slate-500 transition-colors text-white hover:bg-white/10 bg-gray-700/50"
title=${translateText("leaderboard.configure_columns")}
aria-expanded=${this.open}
aria-haspopup="menu"
@click=${() => (this.open = !this.open)}
>
</button>
`;
}
}
+1 -2
View File
@@ -20,8 +20,7 @@ import {
} from "../../Utils";
import { GameView } from "../../view";
import { PlayerView } from "../../view/PlayerView";
const goldCoinIcon = assetUrl("images/GoldCoinIcon.svg");
const soldierIcon = assetUrl("images/SoldierIcon.svg");
import { goldCoinIcon, soldierIcon } from "../HotbarIcons";
const swordIcon = assetUrl("images/SwordIcon.svg");
@customElement("control-panel")
+67 -52
View File
@@ -1,29 +1,33 @@
import { Colord } from "colord";
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { customElement, property, query, state } from "lit/decorators.js";
import { assetUrl } from "../../../core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import { GameMode, Team } from "../../../core/game/Game";
import { Controller } from "../../Controller";
import type { EventBus } from "../../../core/EventBus";
import { GameMode, type Team } from "../../../core/game/Game";
import type { Controller } from "../../Controller";
import { Platform } from "../../Platform";
import { themeProvider } from "../../theme/ThemeProvider";
import { getTranslatedPlayerTeamLabel, translateText } from "../../Utils";
import { GameView } from "../../view";
import type { GameView } from "../../view";
import { ImmunityBarVisibleEvent } from "./ImmunityTimer";
import "./PlayerStats";
import type { PlayerStats } from "./PlayerStats";
import { SpawnBarVisibleEvent } from "./SpawnTimer";
const leaderboardRegularIcon = assetUrl(
import "./TeamStats";
import type { TeamStats } from "./TeamStats";
const playerStatsRegularIcon = assetUrl(
"images/LeaderboardIconRegularWhite.svg",
);
const leaderboardSolidIcon = assetUrl("images/LeaderboardIconSolidWhite.svg");
const teamRegularIcon = assetUrl("images/TeamIconRegularWhite.svg");
const teamSolidIcon = assetUrl("images/TeamIconSolidWhite.svg");
const playerStatsSolidIcon = assetUrl("images/LeaderboardIconSolidWhite.svg");
const teamStatsRegularIcon = assetUrl("images/TeamIconRegularWhite.svg");
const teamStatsSolidIcon = assetUrl("images/TeamIconSolidWhite.svg");
@customElement("game-left-sidebar")
export class GameLeftSidebar extends LitElement implements Controller {
@state()
private isLeaderboardShow = false;
private isPlayerStatsShown = false;
@state()
private isTeamLeaderboardShow = false;
private isTeamStatsShown = false;
@state()
private isVisible = false;
@state()
@@ -36,9 +40,11 @@ export class GameLeftSidebar extends LitElement implements Controller {
private immunityBarVisible = false;
private playerColor: Colord = new Colord("#FFFFFF");
public game: GameView;
public eventBus: EventBus;
private _shownOnInit = false;
@property({ attribute: false }) public game: GameView | null = null;
@property({ attribute: false }) public eventBus: EventBus | null = null;
@query("player-stats") private playerStats?: PlayerStats;
@query("team-stats") private teamStats?: TeamStats;
private showPlayerStatsAfterSpawn = false;
createRenderRoot() {
return this;
@@ -46,10 +52,10 @@ export class GameLeftSidebar extends LitElement implements Controller {
init() {
this.isVisible = true;
this.eventBus.on(SpawnBarVisibleEvent, (e) => {
this.eventBus?.on(SpawnBarVisibleEvent, (e) => {
this.spawnBarVisible = e.visible;
});
this.eventBus.on(ImmunityBarVisibleEvent, (e) => {
this.eventBus?.on(ImmunityBarVisibleEvent, (e) => {
this.immunityBarVisible = e.visible;
});
if (this.isTeamGame) {
@@ -57,43 +63,46 @@ export class GameLeftSidebar extends LitElement implements Controller {
}
// Make it visible by default on large screens
if (Platform.isDesktopWidth) {
// lg breakpoint
this._shownOnInit = true;
this.showPlayerStatsAfterSpawn = true;
}
this.requestUpdate();
}
getTickIntervalMs() {
return 1000;
}
tick() {
if (!this.playerTeam && this.game.myPlayer()?.team()) {
this.playerTeam = this.game.myPlayer()!.team();
if (this.playerTeam) {
this.playerColor = themeProvider.current().teamColor(this.playerTeam);
this.requestUpdate();
}
if (this.game === null) return;
const team = this.game.myPlayer()?.team();
if (this.playerTeam === null && team !== null && team !== undefined) {
this.playerTeam = team;
this.playerColor = themeProvider.current().teamColor(team);
}
if (this._shownOnInit && !this.game.inSpawnPhase()) {
this._shownOnInit = false;
this.isLeaderboardShow = true;
this.requestUpdate();
if (this.showPlayerStatsAfterSpawn && !this.game.inSpawnPhase()) {
this.showPlayerStatsAfterSpawn = false;
this.isPlayerStatsShown = true;
}
if (!this.game.inSpawnPhase() && this.isPlayerTeamLabelVisible) {
this.isPlayerTeamLabelVisible = false;
this.requestUpdate();
}
this.playerStats?.refresh();
this.teamStats?.refresh();
}
private get barOffset(): number {
return (this.spawnBarVisible ? 7 : 0) + (this.immunityBarVisible ? 7 : 0);
}
private toggleLeaderboard(): void {
this.isLeaderboardShow = !this.isLeaderboardShow;
private togglePlayerStats(): void {
this.isPlayerStatsShown = !this.isPlayerStatsShown;
}
private toggleTeamLeaderboard(): void {
this.isTeamLeaderboardShow = !this.isTeamLeaderboardShow;
private toggleTeamStats(): void {
this.isTeamStatsShown = !this.isTeamStatsShown;
}
private get isTeamGame(): boolean {
@@ -103,7 +112,7 @@ export class GameLeftSidebar extends LitElement implements Controller {
render() {
return html`
<aside
class=${`fixed top-0 min-[1200px]:top-4 left-0 min-[1200px]:left-4 z-900 flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg rounded-br-lg ${this.isLeaderboardShow || this.isTeamLeaderboardShow ? "max-[400px]:w-full max-[400px]:rounded-none" : ""} transition-all duration-300 ease-out transform ${
class=${`fixed top-0 min-[1200px]:top-4 left-0 min-[1200px]:left-4 z-900 flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg rounded-br-lg ${this.isPlayerStatsShown || this.isTeamStatsShown ? "max-[400px]:w-full max-[400px]:rounded-none" : ""} transition-all duration-300 ease-out transform ${
this.isVisible ? "translate-x-0" : "hidden"
}`}
style="margin-top: ${this.barOffset}px;"
@@ -111,20 +120,20 @@ export class GameLeftSidebar extends LitElement implements Controller {
<div class="flex items-center gap-4 xl:gap-6 text-white">
<div
class="cursor-pointer p-0.5 bg-gray-700/50 hover:bg-gray-600 border rounded-md border-slate-500 transition-colors"
@click=${this.toggleLeaderboard}
@click=${this.togglePlayerStats}
role="button"
tabindex="0"
@keydown=${(e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " " || e.code === "Space") {
e.preventDefault();
this.toggleLeaderboard();
this.togglePlayerStats();
}
}}
>
<img
src=${this.isLeaderboardShow
? leaderboardSolidIcon
: leaderboardRegularIcon}
src=${this.isPlayerStatsShown
? playerStatsSolidIcon
: playerStatsRegularIcon}
alt=${translateText("help_modal.icon_alt_player_leaderboard") ||
"Player Leaderboard Icon"}
width="20"
@@ -135,7 +144,7 @@ export class GameLeftSidebar extends LitElement implements Controller {
? html`
<div
class="cursor-pointer p-0.5 bg-gray-700/50 hover:bg-gray-600 border rounded-md border-slate-500 transition-colors"
@click=${this.toggleTeamLeaderboard}
@click=${this.toggleTeamStats}
role="button"
tabindex="0"
@keydown=${(e: KeyboardEvent) => {
@@ -145,14 +154,14 @@ export class GameLeftSidebar extends LitElement implements Controller {
e.code === "Space"
) {
e.preventDefault();
this.toggleTeamLeaderboard();
this.toggleTeamStats();
}
}}
>
<img
src=${this.isTeamLeaderboardShow
? teamSolidIcon
: teamRegularIcon}
src=${this.isTeamStatsShown
? teamStatsSolidIcon
: teamStatsRegularIcon}
alt=${translateText(
"help_modal.icon_alt_team_leaderboard",
) || "Team Leaderboard Icon"}
@@ -162,7 +171,7 @@ export class GameLeftSidebar extends LitElement implements Controller {
</div>
`
: null}
${this.isLeaderboardShow || this.isTeamLeaderboardShow
${this.isPlayerStatsShown || this.isTeamStatsShown
? html`<span
class="ml-auto text-[10px] text-slate-500 select-all leading-none self-start"
title=${translateText("help_modal.game_id_tooltip")}
@@ -187,13 +196,19 @@ export class GameLeftSidebar extends LitElement implements Controller {
</div>
`
: null}
<div
class=${`block lg:flex flex-wrap overflow-x-auto min-w-0 w-full ${this.isLeaderboardShow && this.isTeamLeaderboardShow ? "gap-2" : ""}`}
>
<leader-board .visible=${this.isLeaderboardShow}></leader-board>
<div class="flex flex-col gap-2 min-w-0 w-full">
<player-stats
class=${this.isPlayerStatsShown ? "block min-w-0" : "hidden"}
.game=${this.game}
.eventBus=${this.eventBus}
.visible=${this.isPlayerStatsShown}
></player-stats>
<team-stats
class="flex-1"
.visible=${this.isTeamLeaderboardShow && this.isTeamGame}
class=${this.isTeamStatsShown && this.isTeamGame
? "block min-w-0"
: "hidden"}
.game=${this.game}
.visible=${this.isTeamStatsShown && this.isTeamGame}
></team-stats>
</div>
<slot></slot>
-297
View File
@@ -1,297 +0,0 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
import { renderTroops, translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import { Controller } from "../../Controller";
import { GoToPlayerEvent } from "../../TransformHandler";
import { formatPercentage, renderNumber } from "../../Utils";
import { GameView, PlayerView } from "../../view";
interface Entry {
name: string;
position: number;
score: string;
gold: string;
maxTroops: string;
isMyPlayer: boolean;
isOnSameTeam: boolean;
player: PlayerView;
}
@customElement("leader-board")
export class Leaderboard extends LitElement implements Controller {
public game: GameView | null = null;
public eventBus: EventBus | null = null;
players: Entry[] = [];
@property({ type: Boolean }) visible = false;
private showTopFive = true;
@state()
private _sortKey: "tiles" | "gold" | "maxtroops" = "tiles";
@state()
private _sortOrder: "asc" | "desc" = "desc";
createRenderRoot() {
return this; // use light DOM for Tailwind support
}
init() {}
willUpdate(changed: Map<string, unknown>) {
if (changed.has("visible") && this.visible) {
this.updateLeaderboard();
}
}
getTickIntervalMs() {
return 1000;
}
tick() {
if (this.game === null) throw new Error("Not initialized");
if (!this.visible) return;
this.updateLeaderboard();
}
private setSort(key: "tiles" | "gold" | "maxtroops") {
if (this._sortKey === key) {
this._sortOrder = this._sortOrder === "asc" ? "desc" : "asc";
} else {
this._sortKey = key;
this._sortOrder = "desc";
}
this.updateLeaderboard();
}
private updateLeaderboard() {
if (this.game === null) throw new Error("Not initialized");
const myPlayer = this.game.myPlayer();
interface PlayerViewTroopsCache {
pv: PlayerView;
maxTroops: number;
}
const compare = (a: number, b: number) =>
this._sortOrder === "asc" ? a - b : b - a;
const maxTroops = (p: PlayerView) => this.game!.config().maxTroops(p);
const sorted: PlayerViewTroopsCache[] = this.game
.playerViews()
.map((p) => ({ pv: p, maxTroops: maxTroops(p) }));
switch (this._sortKey) {
case "gold":
sorted.sort((a, b) =>
compare(Number(a.pv.gold()), Number(b.pv.gold())),
);
break;
case "maxtroops":
sorted.sort((a, b) => compare(a.maxTroops, b.maxTroops));
break;
default:
sorted.sort((a, b) =>
compare(a.pv.numTilesOwned(), b.pv.numTilesOwned()),
);
}
const numTilesWithoutFallout =
this.game.numLandTiles() - this.game.numTilesWithFallout();
const alivePlayers = sorted.filter((player) => player.pv.isAlive());
const playersToShow = this.showTopFive
? alivePlayers.slice(0, 5)
: alivePlayers;
this.players = playersToShow.map((playerCache, index) => {
const player = playerCache.pv;
const maxTroops = playerCache.maxTroops;
return {
name: player.displayName(),
position: index + 1,
score: formatPercentage(
player.numTilesOwned() / numTilesWithoutFallout,
),
gold: renderNumber(player.gold()),
maxTroops: renderTroops(maxTroops),
isMyPlayer: player === myPlayer,
isOnSameTeam:
myPlayer !== null &&
(player === myPlayer || player.isOnSameTeam(myPlayer)),
player: player,
};
});
if (
myPlayer !== null &&
this.players.find((p) => p.isMyPlayer) === undefined
) {
let place = 0;
for (const p of sorted) {
place++;
if (p.pv === myPlayer) {
break;
}
}
if (myPlayer.isAlive()) {
const myPlayerMaxTroops = this.game!.config().maxTroops(myPlayer);
this.players.pop();
this.players.push({
name: myPlayer.displayName(),
position: place,
score: formatPercentage(
myPlayer.numTilesOwned() / this.game.numLandTiles(),
),
gold: renderNumber(myPlayer.gold()),
maxTroops: renderTroops(myPlayerMaxTroops),
isMyPlayer: true,
isOnSameTeam: true,
player: myPlayer,
});
}
}
this.requestUpdate();
}
private handleRowClickPlayer(player: PlayerView) {
if (this.eventBus === null) return;
this.eventBus.emit(new GoToPlayerEvent(player));
}
render() {
if (!this.visible) {
return html``;
}
return html`
<div
class="max-h-[35vh] overflow-y-auto text-white text-xs md:text-xs lg:text-sm md:max-h-[50vh] mt-2 ${this
.visible
? ""
: "hidden"}"
@contextmenu=${(e: Event) => e.preventDefault()}
>
<div
class="grid bg-gray-800/85 w-full text-xs md:text-xs lg:text-sm rounded-lg overflow-hidden"
style="grid-template-columns: minmax(24px, 30px) minmax(60px, 100px) minmax(45px, 70px) minmax(40px, 55px) minmax(55px, 105px);"
>
<div class="contents font-bold bg-gray-700/60">
<div class="py-1 md:py-2 text-center border-b border-slate-500">
#
</div>
<div
class="py-1 md:py-2 text-center border-b border-slate-500 truncate"
>
${translateText("leaderboard.player")}
</div>
<div
class="py-1 md:py-2 text-center border-b border-slate-500 cursor-pointer whitespace-nowrap truncate"
@click=${() => this.setSort("tiles")}
>
${translateText("leaderboard.owned")}
${this._sortKey === "tiles"
? this._sortOrder === "asc"
? "⬆️"
: "⬇️"
: ""}
</div>
<div
class="py-1 md:py-2 text-center border-b border-slate-500 cursor-pointer whitespace-nowrap truncate"
@click=${() => this.setSort("gold")}
>
${translateText("leaderboard.gold")}
${this._sortKey === "gold"
? this._sortOrder === "asc"
? "⬆️"
: "⬇️"
: ""}
</div>
<div
class="py-1 md:py-2 text-center border-b border-slate-500 cursor-pointer whitespace-nowrap truncate"
@click=${() => this.setSort("maxtroops")}
>
${translateText("leaderboard.maxtroops")}
${this._sortKey === "maxtroops"
? this._sortOrder === "asc"
? "⬆️"
: "⬇️"
: ""}
</div>
</div>
${repeat(
this.players,
(p) => p.player.id(),
(player, index) => html`
<div
class="contents hover:bg-slate-600/60 ${player.isOnSameTeam
? "font-bold"
: ""} cursor-pointer"
@click=${() => this.handleRowClickPlayer(player.player)}
>
<div
class="py-1 md:py-2 text-center ${index <
this.players.length - 1
? "border-b border-slate-500"
: ""}"
>
${player.position}
</div>
<div
class="py-1 md:py-2 text-center ${index <
this.players.length - 1
? "border-b border-slate-500"
: ""} truncate"
>
${player.name}
</div>
<div
class="py-1 md:py-2 text-center ${index <
this.players.length - 1
? "border-b border-slate-500"
: ""}"
>
${player.score}
</div>
<div
class="py-1 md:py-2 text-center ${index <
this.players.length - 1
? "border-b border-slate-500"
: ""}"
>
${player.gold}
</div>
<div
class="py-1 md:py-2 text-center ${index <
this.players.length - 1
? "border-b border-slate-500"
: ""}"
>
${player.maxTroops}
</div>
</div>
`,
)}
</div>
</div>
<button
class="mt-2 p-0.5 px-1.5 md:px-2 text-xs md:text-xs lg:text-sm
border rounded-md border-slate-500 transition-colors
text-white mx-auto block hover:bg-white/10 bg-gray-700/50"
@click=${() => {
this.showTopFive = !this.showTopFive;
this.updateLeaderboard();
}}
>
${this.showTopFive ? "+" : "-"}
</button>
`;
}
}
+39
View File
@@ -0,0 +1,39 @@
import { customElement } from "lit/decorators.js";
import type { EventBus } from "../../../core/EventBus";
import { type StatsRow, StatsTable } from "../../components/StatsTable";
import { GoToPlayerEvent } from "../../TransformHandler";
import type { GameView } from "../../view";
import { type ColumnDef, columnValues } from "./lib/StatsColumns";
@customElement("player-stats")
export class PlayerStats extends StatsTable {
public eventBus: EventBus | null = null;
protected readonly tableKind = "player";
protected readonly nameLabelKey = "leaderboard.player";
protected buildRows(
game: GameView,
columns: readonly ColumnDef[],
): StatsRow[] {
const myPlayer = game.myPlayer();
return game
.playerViews()
.filter((player) => player.isAlive())
.map((player) => ({
key: player.id(),
name: player.displayName(),
values: columnValues(player, game, columns),
emphasized:
myPlayer !== null &&
(player === myPlayer || player.isOnSameTeam(myPlayer)),
pinned: player === myPlayer,
onClick: () => {
if (this.eventBus !== null) {
this.eventBus.emit(new GoToPlayerEvent(player));
}
},
}));
}
}
+53 -239
View File
@@ -1,250 +1,64 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameMode, Team, UnitType } from "../../../core/game/Game";
import { Controller } from "../../Controller";
import {
formatPercentage,
renderNumber,
renderTroops,
translateText,
} from "../../Utils";
import { GameView, PlayerView } from "../../view";
import { customElement } from "lit/decorators.js";
import type { Team } from "../../../core/game/Game";
import { type StatsRow, StatsTable } from "../../components/StatsTable";
import type { ColumnId } from "../../StatsConstants";
import { translateText } from "../../Utils";
import type { GameView, PlayerView } from "../../view";
import type { ColumnDef } from "./lib/StatsColumns";
interface TeamEntry {
teamName: string;
isMyTeam: boolean;
totalScoreStr: string;
totalGold: string;
totalMaxTroops: string;
totalSAMs: string;
totalLaunchers: string;
totalWarShips: string;
totalCities: string;
totalScoreSort: number;
players: PlayerView[];
export function aggregateTeamValues(
players: readonly PlayerView[],
columns: readonly ColumnDef[],
game: GameView,
): ReadonlyMap<ColumnId, number> {
const values = new Map<ColumnId, number>(
columns.map((column) => [column.id, 0]),
);
for (const player of players) {
if (!player.isAlive()) continue;
for (const column of columns) {
values.set(
column.id,
(values.get(column.id) ?? 0) + column.value(player, game),
);
}
}
return values;
}
@customElement("team-stats")
export class TeamStats extends LitElement implements Controller {
public game: GameView;
public eventBus: EventBus;
export class TeamStats extends StatsTable {
protected readonly tableKind = "team";
protected readonly nameLabelKey = "leaderboard.team";
@property({ type: Boolean }) visible = false;
teams: TeamEntry[] = [];
private _shownOnInit = false;
private showUnits = false;
private _myTeam: Team | null = null;
protected buildRows(
game: GameView,
columns: readonly ColumnDef[],
): StatsRow[] {
const teams = new Map<Team, PlayerView[]>();
const myTeam = game.myPlayer()?.team() ?? null;
createRenderRoot() {
return this; // use light DOM for Tailwind
}
init() {}
getTickIntervalMs() {
return 1000;
}
tick() {
if (this.game.config().gameConfig().gameMode !== GameMode.Team) return;
if (!this._shownOnInit && !this.game.inSpawnPhase()) {
this._shownOnInit = true;
this.updateTeamStats();
for (const player of game.playerViews()) {
const team = player.team();
if (team === null) continue;
const players = teams.get(team) ?? [];
players.push(player);
teams.set(team, players);
}
if (!this.visible) return;
return [...teams.entries()].map(([team, players]) => {
const labelKey = `team_colors.${team.toLowerCase()}`;
const translatedName = translateText(labelKey);
this.updateTeamStats();
}
private updateTeamStats() {
const players = this.game.playerViews();
const grouped: Record<Team, PlayerView[]> = {};
if (this._myTeam === null) {
const myPlayer = this.game.myPlayer();
this._myTeam = myPlayer?.team() ?? null;
}
for (const player of players) {
const rawTeam = player.team();
if (rawTeam === null) continue;
grouped[rawTeam] ??= [];
grouped[rawTeam].push(player);
}
this.teams = Object.entries(grouped)
.map(([rawTeam, teamPlayers]) => {
const key = `team_colors.${rawTeam.toLowerCase()}`;
const translated = translateText(key);
const teamName = translated !== key ? translated : rawTeam;
let totalGold = 0n;
let totalMaxTroops = 0;
let totalScoreSort = 0;
let totalSAMs = 0;
let totalLaunchers = 0;
let totalWarShips = 0;
let totalCities = 0;
for (const p of teamPlayers) {
if (p.isAlive()) {
totalMaxTroops += this.game.config().maxTroops(p);
totalGold += p.gold();
totalScoreSort += p.numTilesOwned();
totalLaunchers += p.totalUnitLevels(UnitType.MissileSilo);
totalSAMs += p.totalUnitLevels(UnitType.SAMLauncher);
totalWarShips += p.totalUnitLevels(UnitType.Warship);
totalCities += p.totalUnitLevels(UnitType.City);
}
}
const numTilesWithoutFallout =
this.game.numLandTiles() - this.game.numTilesWithFallout();
const totalScorePercent = totalScoreSort / numTilesWithoutFallout;
return {
teamName,
isMyTeam: rawTeam === this._myTeam,
totalScoreStr: formatPercentage(totalScorePercent),
totalScoreSort,
totalGold: renderNumber(totalGold),
totalMaxTroops: renderTroops(totalMaxTroops),
players: teamPlayers,
totalLaunchers: renderNumber(totalLaunchers),
totalSAMs: renderNumber(totalSAMs),
totalWarShips: renderNumber(totalWarShips),
totalCities: renderNumber(totalCities),
};
})
.sort((a, b) => b.totalScoreSort - a.totalScoreSort);
this.requestUpdate();
}
render() {
if (!this.visible) return html``;
return html`
<div
class="max-h-[30vh] overflow-x-hidden overflow-y-auto grid bg-slate-800/85 w-full text-white text-xs md:text-sm mt-2 rounded-lg"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
>
<div
class="grid w-full grid-cols-[repeat(var(--cols),1fr)]"
style="--cols:${this.showUnits ? 5 : 4};"
>
<!-- Header -->
<div class="contents font-bold bg-slate-700/60">
<div class="p-1.5 md:p-2.5 text-center border-b border-slate-500">
${translateText("leaderboard.team")}
</div>
${this.showUnits
? html`
<div
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
>
${translateText("leaderboard.launchers")}
</div>
<div
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
>
${translateText("leaderboard.sams")}
</div>
<div
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
>
${translateText("leaderboard.warships")}
</div>
<div
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
>
${translateText("leaderboard.cities")}
</div>
`
: html`
<div
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
>
${translateText("leaderboard.owned")}
</div>
<div
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
>
${translateText("leaderboard.gold")}
</div>
<div
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
>
${translateText("leaderboard.maxtroops")}
</div>
`}
</div>
<!-- Data rows -->
${this.teams.map((team) =>
this.showUnits
? html`
<div
class="contents hover:bg-slate-600/60 text-center cursor-pointer ${team.isMyTeam
? "font-bold"
: ""}"
>
<div class="py-1.5 border-b border-slate-500">
${team.teamName}
</div>
<div class="py-1.5 border-b border-slate-500">
${team.totalLaunchers}
</div>
<div class="py-1.5 border-b border-slate-500">
${team.totalSAMs}
</div>
<div class="py-1.5 border-b border-slate-500">
${team.totalWarShips}
</div>
<div class="py-1.5 border-b border-slate-500">
${team.totalCities}
</div>
</div>
`
: html`
<div
class="contents hover:bg-slate-600/60 text-center cursor-pointer ${team.isMyTeam
? "font-bold"
: ""}"
>
<div class="py-1.5 border-b border-slate-500">
${team.teamName}
</div>
<div class="py-1.5 border-b border-slate-500">
${team.totalScoreStr}
</div>
<div class="py-1.5 border-b border-slate-500">
${team.totalGold}
</div>
<div class="py-1.5 border-b border-slate-500">
${team.totalMaxTroops}
</div>
</div>
`,
)}
</div>
<button
class="team-stats-button"
aria-pressed=${String(this.showUnits)}
@click=${() => {
this.showUnits = !this.showUnits;
this.requestUpdate();
}}
>
${this.showUnits
? translateText("leaderboard.show_control")
: translateText("leaderboard.show_units")}
</button>
</div>
`;
return {
key: team,
name: translatedName === labelKey ? team : translatedName,
values: aggregateTeamValues(players, columns, game),
emphasized: team === myTeam,
pinned: team === myTeam,
};
});
}
}
+13 -12
View File
@@ -1,6 +1,5 @@
import { html, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
import { assetUrl } from "../../../core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import {
BuildableUnit,
@@ -15,17 +14,19 @@ import { ToggleStructureEvent } from "../../InputHandler";
import { UIState } from "../../UIState";
import { renderNumber, translateText } from "../../Utils";
import { GameView } from "../../view";
const warshipIcon = assetUrl("images/BattleshipIconWhite.svg");
const cityIcon = assetUrl("images/CityIconWhite.svg");
const factoryIcon = assetUrl("images/FactoryIconWhite.svg");
const goldCoinIcon = assetUrl("images/GoldCoinIcon.svg");
const mirvIcon = assetUrl("images/MIRVIcon.svg");
const missileSiloIcon = assetUrl("images/MissileSiloIconWhite.svg");
const hydrogenBombIcon = assetUrl("images/MushroomCloudIconWhite.svg");
const atomBombIcon = assetUrl("images/NukeIconWhite.svg");
const portIcon = assetUrl("images/PortIcon.svg");
const samLauncherIcon = assetUrl("images/SamLauncherIconWhite.svg");
const defensePostIcon = assetUrl("images/ShieldIconWhite.svg");
import {
atomBombIcon,
cityIcon,
defensePostIcon,
factoryIcon,
goldCoinIcon,
hydrogenBombIcon,
mirvIcon,
missileSiloIcon,
portIcon,
samLauncherIcon,
warshipIcon,
} from "../HotbarIcons";
@customElement("unit-display")
export class UnitDisplay extends LitElement implements Controller {
+191
View File
@@ -0,0 +1,191 @@
import { UnitType } from "../../../../core/game/Game";
import type { ColumnId } from "../../../StatsConstants";
import { formatPercentage, renderNumber, renderTroops } from "../../../Utils";
import type { GameView, PlayerView } from "../../../view";
import {
allianceIcon,
cityIcon,
claimIcon,
factoryIcon,
goldCoinIcon,
missileSiloIcon,
portIcon,
samLauncherIcon,
soldierIcon,
traitorIcon,
upperLimitIcon,
warshipIcon,
} from "../../HotbarIcons";
export {
COLUMN_IDS,
DEFAULT_STATS_COLUMNS,
type ColumnId,
} from "../../../StatsConstants";
type ValueGetter = (player: PlayerView, game: GameView) => number;
type ValueRenderer = (value: number, game: GameView) => string;
export type ColumnHeaderVisual =
| {
readonly kind: "icon";
readonly src: string;
readonly white?: true;
/** Small icon rendered as a superscript exponent on the main icon. */
readonly superscript?: { readonly src: string; readonly white?: true };
}
| { readonly kind: "emoji"; readonly text: string };
export type ColumnValueAlignment = "center" | "end";
interface ColumnOptions {
readonly headerVisual?: ColumnHeaderVisual;
readonly valueAlignment?: ColumnValueAlignment;
}
const troopHeaderVisual = {
kind: "icon",
src: soldierIcon,
white: true,
} as const satisfies ColumnHeaderVisual;
export interface ColumnDef {
readonly id: ColumnId;
readonly labelKey: string;
readonly headerVisual?: ColumnHeaderVisual;
readonly valueAlignment: ColumnValueAlignment;
/** Raw numeric value used for sorting and team totals. */
readonly value: ValueGetter;
/** Formats either a player's value or an aggregated team total. */
readonly renderValue: ValueRenderer;
}
// renderNumber's second parameter is fixedPoints, so it cannot be a
// ValueRenderer directly (which passes GameView second).
const renderNum: ValueRenderer = (value) => renderNumber(value);
function column(
id: ColumnId,
labelKey: string,
value: ValueGetter,
renderValue: ValueRenderer,
options: ColumnOptions = {},
): ColumnDef {
return {
id,
labelKey,
...options,
valueAlignment: options.valueAlignment ?? "end",
value,
renderValue,
};
}
function unitColumn(
id: ColumnId,
labelKey: string,
unitType: UnitType,
icon: string,
): ColumnDef {
return column(
id,
labelKey,
(player) => player.totalUnitLevels(unitType),
renderNum,
{ headerVisual: { kind: "icon", src: icon }, valueAlignment: "center" },
);
}
// This registry is the source of truth for column IDs and display order.
export const COLUMN_DEFS = [
{
id: "tiles",
labelKey: "leaderboard.owned",
headerVisual: { kind: "icon", src: claimIcon, white: true },
valueAlignment: "end",
value: (player) => player.numTilesOwned(),
renderValue: (tiles, game) => {
const validTiles = game.numLandTiles() - game.numTilesWithFallout();
return formatPercentage(validTiles > 0 ? tiles / validTiles : 0);
},
},
column(
"gold",
"leaderboard.gold",
// Gold is a bigint, but game values remain safely below Number.MAX_SAFE_INTEGER.
(player) => Number(player.gold()),
renderNum,
{ headerVisual: { kind: "icon", src: goldCoinIcon } },
),
column(
"troops",
"leaderboard.troops",
(player) => player.troops(),
renderTroops,
{ headerVisual: troopHeaderVisual },
),
column(
"maxtroops",
"leaderboard.maxtroops",
(player, game) => game.config().maxTroops(player),
renderTroops,
{
headerVisual: {
...troopHeaderVisual,
superscript: { src: upperLimitIcon, white: true },
},
},
),
unitColumn("cities", "leaderboard.cities", UnitType.City, cityIcon),
unitColumn("ports", "leaderboard.ports", UnitType.Port, portIcon),
unitColumn(
"factories",
"leaderboard.factories",
UnitType.Factory,
factoryIcon,
),
unitColumn(
"silos",
"leaderboard.launchers",
UnitType.MissileSilo,
missileSiloIcon,
),
unitColumn("sams", "leaderboard.sams", UnitType.SAMLauncher, samLauncherIcon),
unitColumn("warships", "leaderboard.warships", UnitType.Warship, warshipIcon),
column(
"allies",
"leaderboard.allies",
(player) => player.allies().length,
renderNum,
{
headerVisual: { kind: "icon", src: allianceIcon },
valueAlignment: "center",
},
),
column(
"betrayals",
"leaderboard.betrayals",
(player) => player.betrayals(),
renderNum,
{
headerVisual: { kind: "icon", src: traitorIcon },
valueAlignment: "center",
},
),
] as const satisfies readonly ColumnDef[];
export function columnValues(
player: PlayerView,
game: GameView,
columns: readonly ColumnDef[],
): ReadonlyMap<ColumnId, number> {
return new Map(
columns.map((column) => [column.id, column.value(player, game)] as const),
);
}
const COLUMNS_BY_ID = new Map<ColumnId, ColumnDef>(
COLUMN_DEFS.map((column) => [column.id, column] as const),
);
export function columnById(id: ColumnId): ColumnDef {
return COLUMNS_BY_ID.get(id)!;
}
+40
View File
@@ -4,6 +4,12 @@ import {
GraphicsPresets,
GraphicsPresetsSchema,
} from "../../client/render/gl/GraphicsOverrides";
import {
COLUMN_IDS,
ColumnId,
DEFAULT_STATS_COLUMNS,
StatsTableKind,
} from "../../client/StatsConstants";
import { Cosmetics } from "../CosmeticSchemas";
import { PlayerPattern } from "../Schemas";
@@ -63,6 +69,13 @@ export const KEYBINDS_KEY = "settings.keybinds";
export const GRAPHICS_KEY = "settings.graphics";
export const GRAPHICS_PRESETS_KEY = "settings.graphicsPresets";
export const EFFECTS_KEY = "settings.effects";
// Keep the existing storage key so the rename does not reset saved columns.
export const PLAYER_STATS_COLUMNS_KEY = "settings.leaderboardColumns";
export const TEAM_STATS_COLUMNS_KEY = "settings.teamStatsColumns";
const STATS_COLUMNS_KEYS: Record<StatsTableKind, string> = {
player: PLAYER_STATS_COLUMNS_KEY,
team: TEAM_STATS_COLUMNS_KEY,
};
export class UserSettings {
private static cache = new Map<string, string | null>();
@@ -367,6 +380,33 @@ export class UserSettings {
else this.setString(EFFECTS_KEY, JSON.stringify(map));
}
// Invalid/corrupt storage, unknown ids, or an empty result fall back to
// defaults, matching the getSelectedEffects defensive pattern. Returned
// order is registry (display) order regardless of stored order.
private getColumnIds(key: string, defaults: readonly ColumnId[]): ColumnId[] {
const raw = this.getString(key, "");
if (raw) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
const filtered = COLUMN_IDS.filter((id) => parsed.includes(id));
if (filtered.length > 0) return filtered;
}
} catch {
// fall through to defaults
}
}
return [...defaults];
}
statsColumns(kind: StatsTableKind): ColumnId[] {
return this.getColumnIds(STATS_COLUMNS_KEYS[kind], DEFAULT_STATS_COLUMNS);
}
setStatsColumns(kind: StatsTableKind, ids: ColumnId[]): void {
this.setString(STATS_COLUMNS_KEYS[kind], JSON.stringify(ids));
}
backgroundMusicVolume(): number {
return this.getFloat("settings.backgroundMusicVolume", 0);
}
+35
View File
@@ -0,0 +1,35 @@
import { ColumnPicker } from "../src/client/hud/layers/ColumnPicker";
import { COLUMN_DEFS } from "../src/client/hud/layers/lib/StatsColumns";
import type { ColumnId } from "../src/client/StatsConstants";
describe("ColumnPicker", () => {
it("renders its popup outside the sidebar overflow container", async () => {
const sidebar = document.createElement("div");
const picker = new ColumnPicker();
picker.columns = COLUMN_DEFS;
picker.selected = ["tiles"];
sidebar.appendChild(picker);
document.body.appendChild(sidebar);
await picker.updateComplete;
picker.querySelector("button")?.click();
await picker.updateComplete;
const popup = document.body.querySelector(".column-picker-popover");
expect(popup).not.toBeNull();
expect(sidebar.contains(popup)).toBe(false);
expect(popup?.querySelectorAll('input[type="checkbox"]')).toHaveLength(
COLUMN_DEFS.length,
);
let selection: readonly ColumnId[] | null = null;
picker.addEventListener("columns-changed", (event) => {
selection = (event as CustomEvent<ColumnId[]>).detail;
});
(popup?.querySelectorAll("input")[1] as HTMLInputElement).click();
expect(selection).toEqual(["tiles", "gold"]);
sidebar.remove();
expect(document.body.querySelector(".column-picker-popover")).toBeNull();
});
});
+103
View File
@@ -0,0 +1,103 @@
import { GameLeftSidebar } from "../src/client/hud/layers/GameLeftSidebar";
import type { PlayerStats } from "../src/client/hud/layers/PlayerStats";
import type { TeamStats } from "../src/client/hud/layers/TeamStats";
import type { GameView, PlayerView } from "../src/client/view";
import { EventBus } from "../src/core/EventBus";
import { GameMode } from "../src/core/game/Game";
import { UserSettings } from "../src/core/game/UserSettings";
describe("GameLeftSidebar", () => {
beforeEach(() => {
localStorage.clear();
(
UserSettings as unknown as { cache: Map<string, string | null> }
).cache.clear();
});
it("owns the player and team stats tables", async () => {
const game = {
config: () => ({ gameConfig: () => ({ gameMode: GameMode.Team }) }),
} as unknown as GameView;
const eventBus = new EventBus();
const sidebar = new GameLeftSidebar();
sidebar.game = game;
sidebar.eventBus = eventBus;
document.body.append(sidebar);
await sidebar.updateComplete;
const playerStats = sidebar.querySelector("player-stats");
const teamStats = sidebar.querySelector("team-stats");
expect(typeof (playerStats as PlayerStats).refresh).toBe("function");
expect(typeof (teamStats as TeamStats).refresh).toBe("function");
expect((playerStats as PlayerStats).game).toBe(game);
expect((playerStats as PlayerStats).eventBus).toBe(eventBus);
expect((teamStats as TeamStats).game).toBe(game);
sidebar.remove();
});
it("renders the player stats table after toggling it", async () => {
const player = {
id: () => "player-1",
displayName: () => "Player 1",
numTilesOwned: () => 10,
gold: () => 100n,
isAlive: () => true,
isOnSameTeam: () => false,
team: () => null,
} as unknown as PlayerView;
const game = {
config: () => ({
gameConfig: () => ({ gameMode: GameMode.FFA }),
maxTroops: () => 1_000,
}),
gameID: () => "test-game",
inSpawnPhase: () => false,
myPlayer: () => player,
numLandTiles: () => 100,
numTilesWithFallout: () => 0,
playerViews: () => [player],
} as unknown as GameView;
const sidebar = new GameLeftSidebar();
sidebar.game = game;
sidebar.eventBus = new EventBus();
document.body.append(sidebar);
sidebar.init();
await sidebar.updateComplete;
(sidebar.querySelector('[role="button"]') as HTMLElement).click();
await sidebar.updateComplete;
const playerStats = sidebar.querySelector("player-stats") as PlayerStats;
await playerStats.updateComplete;
expect(playerStats.visible).toBe(true);
expect(playerStats.querySelector(".stats-table")).not.toBeNull();
expect(() => sidebar.tick()).not.toThrow();
await playerStats.updateComplete;
expect(playerStats.querySelector(".stats-table-row")).not.toBeNull();
sidebar.remove();
});
it("stacks the player and team stats tables vertically at every width", async () => {
const game = {
config: () => ({ gameConfig: () => ({ gameMode: GameMode.Team }) }),
} as unknown as GameView;
const sidebar = new GameLeftSidebar();
sidebar.game = game;
sidebar.eventBus = new EventBus();
document.body.append(sidebar);
await sidebar.updateComplete;
const wrapper = sidebar.querySelector("player-stats")?.parentElement;
expect(wrapper?.classList).toContain("flex-col");
expect(wrapper?.className).not.toMatch(/flex-wrap|lg:flex/);
expect(sidebar.querySelector("team-stats")?.className).not.toContain(
"lg:flex-initial",
);
sidebar.remove();
});
});
+168
View File
@@ -0,0 +1,168 @@
import {
goldCoinIcon,
soldierIcon,
upperLimitIcon,
} from "../src/client/hud/HotbarIcons";
import { PlayerStats } from "../src/client/hud/layers/PlayerStats";
import type { GameView, PlayerView } from "../src/client/view";
import { UserSettings } from "../src/core/game/UserSettings";
function player(id: string, tiles: number): PlayerView {
return {
id: () => id,
displayName: () => id,
numTilesOwned: () => tiles,
gold: () => BigInt(tiles),
troops: () => tiles,
totalUnitLevels: () => tiles,
isAlive: () => true,
isOnSameTeam: () => false,
} as unknown as PlayerView;
}
describe("PlayerStats", () => {
beforeEach(() => {
localStorage.clear();
(
UserSettings as unknown as { cache: Map<string, string | null> }
).cache.clear();
});
it("locks rank, name, and picker while stat columns share spare width", async () => {
const me = player("me", 3);
const game = {
myPlayer: () => me,
playerViews: () => [me],
config: () => ({ maxTroops: () => 100 }),
numLandTiles: () => 100,
numTilesWithFallout: () => 0,
} as unknown as GameView;
const playerStats = new PlayerStats();
playerStats.game = game;
playerStats.visible = true;
document.body.append(playerStats);
playerStats.refresh();
await playerStats.updateComplete;
const content = playerStats.querySelector(".stats-table-content");
expect(content?.classList).toContain("min-w-full");
// Only selected stat tracks share definite spare space. Rank, name, and
// picker remain fixed and never participate in stretching.
expect(content?.getAttribute("style")).toContain(
"30px 100px auto auto auto 32px",
);
playerStats.remove();
});
it("keeps every player available without leaving a gap for the pinned row", async () => {
const players = [
player("one", 70),
player("two", 60),
player("three", 50),
player("four", 40),
player("five", 30),
player("six", 20),
player("me", 10),
];
const game = {
myPlayer: () => players[6],
playerViews: () => players,
config: () => ({ maxTroops: () => 100 }),
numLandTiles: () => 100,
numTilesWithFallout: () => 0,
} as unknown as GameView;
const playerStats = new PlayerStats();
playerStats.game = game;
playerStats.visible = true;
document.body.append(playerStats);
playerStats.refresh();
await playerStats.updateComplete;
const scrollable = playerStats.querySelector(".stats-table-scroll");
const pinned = playerStats.querySelector(".stats-table-pinned-row");
const table = playerStats.querySelector(".stats-table");
const picker = playerStats.querySelector("column-picker");
expect(playerStats.querySelectorAll(".stats-table-row")).toHaveLength(7);
expect(playerStats.children).toHaveLength(1);
expect(picker?.closest(".stats-table")).toBe(table);
expect(table?.querySelector(".stats-table-header")).not.toBeNull();
const goldHeader = playerStats.querySelectorAll(
'.stats-table-header > [role="columnheader"]',
)[3];
expect(goldHeader.querySelector("img")?.getAttribute("src")).toBe(
goldCoinIcon,
);
expect(goldHeader.querySelector("button")?.getAttribute("aria-label")).toBe(
"leaderboard.gold",
);
expect(scrollable).not.toBeNull();
expect(pinned?.textContent).toContain("me");
expect(scrollable?.contains(pinned)).toBe(false);
expect(
[...(scrollable?.querySelectorAll(".stats-table-row") ?? [])].map(
(row) => row.textContent?.trim().split(/\s+/)[0],
),
).toEqual(["1", "2", "3", "4", "5", "6"]);
expect(
[...playerStats.querySelectorAll("button")].some((button) =>
["+", "-"].includes(button.textContent?.trim() ?? ""),
),
).toBe(false);
playerStats.remove();
});
it("centers headers and discrete counts and renders white troop icons", async () => {
new UserSettings().setStatsColumns("player", [
"troops",
"maxtroops",
"cities",
]);
const me = player("me", 3);
const game = {
myPlayer: () => me,
playerViews: () => [me],
config: () => ({ maxTroops: () => 100 }),
numLandTiles: () => 100,
numTilesWithFallout: () => 0,
} as unknown as GameView;
const playerStats = new PlayerStats();
playerStats.game = game;
playerStats.visible = true;
document.body.append(playerStats);
playerStats.refresh();
await playerStats.updateComplete;
const headers = playerStats.querySelectorAll(
'.stats-table-header > [role="columnheader"]',
);
const countCell = playerStats.querySelector(
'.stats-table-row > [role="cell"]:nth-child(5)',
);
const troopIcon = headers[2].querySelector("img");
const maxTroopIcons = headers[3].querySelectorAll("img");
expect(headers[1].classList).toContain("justify-center");
expect(headers[2].classList).toContain("justify-center");
expect(headers[3].classList).toContain("justify-center");
expect(headers[4].classList).toContain("justify-center");
expect(troopIcon?.getAttribute("src")).toBe(soldierIcon);
expect(troopIcon?.classList).toContain("brightness-0");
expect(troopIcon?.classList).toContain("invert");
// Max troops header reads as a troop icon raised to the upper-limit icon.
expect(maxTroopIcons.length).toBe(2);
expect(maxTroopIcons[0].getAttribute("src")).toBe(soldierIcon);
expect(maxTroopIcons[0].classList).toContain("brightness-0");
expect(maxTroopIcons[0].classList).toContain("invert");
expect(maxTroopIcons[1].getAttribute("src")).toBe(upperLimitIcon);
expect(maxTroopIcons[1].classList).toContain("brightness-0");
expect(maxTroopIcons[1].classList).toContain("invert");
expect(countCell?.classList).toContain("justify-center");
expect(countCell?.classList).toContain("text-center");
playerStats.remove();
});
});
+49
View File
@@ -0,0 +1,49 @@
import {
COLUMN_DEFS,
columnById,
} from "../src/client/hud/layers/lib/StatsColumns";
import {
COLUMN_IDS,
DEFAULT_STATS_COLUMNS,
} from "../src/client/StatsConstants";
import { makeGameView, makePlayerView, stubConfig } from "./util/viewStubs";
describe("Stats column registry", () => {
it("has unique column ids", () => {
expect(new Set(COLUMN_IDS).size).toBe(COLUMN_DEFS.length);
expect(COLUMN_DEFS.map((column) => column.id)).toEqual(COLUMN_IDS);
});
it("columnById returns the matching def for every id", () => {
for (const def of COLUMN_DEFS) {
expect(columnById(def.id)).toBe(def);
}
});
it("defaults reference valid ids", () => {
for (const id of DEFAULT_STATS_COLUMNS) {
expect(COLUMN_IDS).toContain(id);
}
});
it("evaluates and renders every column", () => {
const game = makeGameView({
config: stubConfig({ maxTroops: () => 1_000 }),
});
const player = makePlayerView({ game });
for (const column of COLUMN_DEFS) {
const value = column.value(player, game);
expect(typeof value).toBe("number");
expect(typeof column.renderValue(value, game)).toBe("string");
}
});
it("renders zero owned percentage when no valid land remains", () => {
const game = makeGameView();
vi.spyOn(game, "numLandTiles").mockReturnValue(0);
vi.spyOn(game, "numTilesWithFallout").mockReturnValue(0);
expect(columnById("tiles").renderValue(1, game)).toBe("0.0%");
});
});
+128
View File
@@ -0,0 +1,128 @@
import { PlayerStats } from "../src/client/hud/layers/PlayerStats";
import type { GameView, PlayerView } from "../src/client/view";
import { UserSettings } from "../src/core/game/UserSettings";
function player(id: string, tiles: number): PlayerView {
return {
id: () => id,
displayName: () => id,
numTilesOwned: () => tiles,
gold: () => BigInt(tiles),
troops: () => tiles,
totalUnitLevels: () => tiles,
isAlive: () => true,
isOnSameTeam: () => false,
} as unknown as PlayerView;
}
function gameWith(players: PlayerView[], me: PlayerView | null): GameView {
return {
myPlayer: () => me,
playerViews: () => players,
config: () => ({ maxTroops: () => 100 }),
numLandTiles: () => 100,
numTilesWithFallout: () => 0,
} as unknown as GameView;
}
async function mount(game: GameView): Promise<PlayerStats> {
const playerStats = new PlayerStats();
playerStats.game = game;
playerStats.visible = true;
document.body.append(playerStats);
playerStats.refresh();
await playerStats.updateComplete;
return playerStats;
}
function scrollRows(el: PlayerStats): string[] {
return [...el.querySelectorAll(".stats-table-scroll .stats-table-row")].map(
(row) => row.textContent?.trim().split(/\s+/)[0] ?? "",
);
}
// jsdom reports zero element sizes, so the component falls back to its
// defaults: 24px rows, 180px viewport, 4 overscan rows. The expected
// window is ceil(180 / 24) + 4 = 12 rows at scrollTop 0.
describe("StatsTable virtualization", () => {
beforeEach(() => {
localStorage.clear();
(
UserSettings as unknown as { cache: Map<string, string | null> }
).cache.clear();
});
afterEach(() => {
document.body.innerHTML = "";
});
it("renders only a window of rows for large player lists", async () => {
const players = Array.from({ length: 300 }, (_, i) =>
player(`p${i}`, 1000 - i),
);
const el = await mount(gameWith(players, null));
expect(scrollRows(el)).toEqual(
Array.from({ length: 12 }, (_, i) => `${i + 1}`),
);
const bottomSpacer = el.querySelector(
".stats-table-scroll .stats-table-spacer:last-child",
);
expect(bottomSpacer?.getAttribute("style")).toContain(
`height: ${(300 - 12) * 24}px`,
);
el.remove();
});
it("reveals later rows with a top spacer when scrolled", async () => {
const players = Array.from({ length: 300 }, (_, i) =>
player(`p${i}`, 1000 - i),
);
const el = await mount(gameWith(players, null));
const scroller = el.querySelector<HTMLElement>(".stats-table-scroll")!;
scroller.scrollTop = 240;
scroller.dispatchEvent(new Event("scroll"));
await el.updateComplete;
// first = floor(240/24) - 4 = 6; last = ceil((240+180)/24) + 4 = 22
expect(scrollRows(el)).toEqual(
Array.from({ length: 16 }, (_, i) => `${i + 7}`),
);
const topSpacer = scroller.querySelector(".stats-table-spacer");
expect(topSpacer?.getAttribute("style")).toContain(`height: ${6 * 24}px`);
el.remove();
});
it("renders the pinned row even when it is far outside the window", async () => {
const players = Array.from({ length: 299 }, (_, i) =>
player(`p${i}`, 1000 - i),
);
const me = player("me", 1);
players.push(me);
const el = await mount(gameWith(players, me));
const pinned = el.querySelector(".stats-table-pinned-row");
expect(pinned?.textContent).toContain("me");
expect(pinned?.textContent?.trim().split(/\s+/)[0]).toBe("300");
expect(scrollRows(el)).toEqual(
Array.from({ length: 12 }, (_, i) => `${i + 1}`),
);
el.remove();
});
it("renders small lists fully without spacers", async () => {
const players = Array.from({ length: 7 }, (_, i) =>
player(`p${i}`, 100 - i),
);
const el = await mount(gameWith(players, null));
expect(scrollRows(el)).toEqual(["1", "2", "3", "4", "5", "6", "7"]);
expect(el.querySelector(".stats-table-spacer")).toBeNull();
el.remove();
});
});
+120
View File
@@ -0,0 +1,120 @@
import type { ColumnDef } from "../src/client/hud/layers/lib/StatsColumns";
import {
aggregateTeamValues,
TeamStats,
} from "../src/client/hud/layers/TeamStats";
import type { GameView, PlayerView } from "../src/client/view";
import { PlayerType } from "../src/core/game/Game";
import { UserSettings } from "../src/core/game/UserSettings";
import { playerInfo, setup } from "./util/Setup";
describe("aggregateTeamValues", () => {
it("sums values for alive players only", async () => {
const game = await setup("plains", {}, [
playerInfo("alive", PlayerType.Human),
playerInfo("other-alive", PlayerType.Human),
playerInfo("dead", PlayerType.Human),
]);
const alivePlayer = game.player("alive");
const otherAlivePlayer = game.player("other-alive");
const deadPlayer = game.player("dead");
for (let x = 0; x < 12; x++) alivePlayer.conquer(game.ref(x, 0));
for (let x = 0; x < 8; x++) otherAlivePlayer.conquer(game.ref(x, 1));
alivePlayer.addGold(50n);
otherAlivePlayer.addGold(30n);
deadPlayer.addGold(100n);
const selected: ColumnDef[] = [
{
id: "tiles",
labelKey: "leaderboard.owned",
valueAlignment: "end",
value: (player) => player.numTilesOwned(),
renderValue: (value) => `tiles:${value}`,
},
{
id: "gold",
labelKey: "leaderboard.gold",
valueAlignment: "end",
value: (player) => Number(player.gold()),
renderValue: (value) => `gold:${value}`,
},
];
expect(
Object.fromEntries(
aggregateTeamValues(
game.allPlayers() as unknown as PlayerView[],
selected,
game as unknown as GameView,
),
),
).toEqual({ tiles: 20, gold: 80 });
});
});
describe("TeamStats", () => {
beforeEach(() => {
localStorage.clear();
(
UserSettings as unknown as { cache: Map<string, string | null> }
).cache.clear();
});
it("renders team ranks with the shared stats table", async () => {
const players = [
{
id: () => "blue-player",
team: () => "Blue",
numTilesOwned: () => 10,
gold: () => 10n,
isAlive: () => true,
},
{
id: () => "red-player",
team: () => "Red",
numTilesOwned: () => 5,
gold: () => 20n,
isAlive: () => true,
},
] as unknown as PlayerView[];
const game = {
myPlayer: () => players[0],
playerViews: () => players,
config: () => ({ maxTroops: () => 100 }),
numLandTiles: () => 100,
numTilesWithFallout: () => 0,
} as unknown as GameView;
const teamStats = new TeamStats();
teamStats.game = game;
teamStats.visible = true;
document.body.append(teamStats);
teamStats.refresh();
await teamStats.updateComplete;
const table = teamStats.querySelector(".stats-table");
const picker = teamStats.querySelector("column-picker");
const rowCells = teamStats.querySelectorAll(
'.stats-table-row > [role="cell"]',
);
expect(teamStats.children).toHaveLength(1);
expect(picker?.closest(".stats-table")).toBe(table);
expect(table?.querySelector(".stats-table-header")).not.toBeNull();
expect(rowCells[0]?.textContent?.trim()).toBe("1");
expect(rowCells[1]?.textContent?.trim()).toContain("Blue");
const headers = teamStats.querySelectorAll(
'.stats-table-header > [role="columnheader"]',
);
(headers[3].querySelector("button") as HTMLButtonElement).click();
await teamStats.updateComplete;
expect(
teamStats.querySelectorAll('.stats-table-row > [role="cell"]')[1]
?.textContent,
).toContain("Red");
teamStats.remove();
});
});
+78 -1
View File
@@ -1,4 +1,9 @@
import { EFFECTS_KEY, UserSettings } from "../src/core/game/UserSettings";
import {
EFFECTS_KEY,
PLAYER_STATS_COLUMNS_KEY,
TEAM_STATS_COLUMNS_KEY,
UserSettings,
} from "../src/core/game/UserSettings";
describe("UserSettings effect selection", () => {
beforeEach(() => {
@@ -58,3 +63,75 @@ describe("UserSettings effect selection", () => {
expect(s.getSelectedEffectName("hydro")).toBe("hydro_boom");
});
});
describe("UserSettings stats columns", () => {
beforeEach(() => {
localStorage.clear();
(
UserSettings as unknown as { cache: Map<string, string | null> }
).cache.clear();
});
it("returns defaults when nothing is stored", () => {
expect(new UserSettings().statsColumns("player")).toEqual([
"tiles",
"gold",
"maxtroops",
]);
expect(new UserSettings().statsColumns("team")).toEqual([
"tiles",
"gold",
"maxtroops",
]);
});
it("round-trips a selection in registry order", () => {
const s = new UserSettings();
// Stored order is check order; getter returns registry (display) order.
s.setStatsColumns("player", ["warships", "gold"]);
expect(s.statsColumns("player")).toEqual(["gold", "warships"]);
});
it("filters unknown ids", () => {
localStorage.setItem(
PLAYER_STATS_COLUMNS_KEY,
JSON.stringify(["gold", "bogus"]),
);
expect(new UserSettings().statsColumns("player")).toEqual(["gold"]);
});
it("drops the removed attacks column from persisted selections", () => {
localStorage.setItem(
PLAYER_STATS_COLUMNS_KEY,
JSON.stringify(["attacks", "gold"]),
);
expect(new UserSettings().statsColumns("player")).toEqual(["gold"]);
});
it("falls back to defaults on corrupt JSON", () => {
localStorage.setItem(PLAYER_STATS_COLUMNS_KEY, "not json");
expect(new UserSettings().statsColumns("player")).toEqual([
"tiles",
"gold",
"maxtroops",
]);
});
it("falls back to defaults when no valid ids remain", () => {
localStorage.setItem(PLAYER_STATS_COLUMNS_KEY, JSON.stringify(["bogus"]));
expect(new UserSettings().statsColumns("player")).toEqual([
"tiles",
"gold",
"maxtroops",
]);
});
it("keeps player and team selections independent", () => {
const s = new UserSettings();
s.setStatsColumns("player", ["gold"]);
s.setStatsColumns("team", ["warships"]);
expect(s.statsColumns("player")).toEqual(["gold"]);
expect(s.statsColumns("team")).toEqual(["warships"]);
expect(localStorage.getItem(TEAM_STATS_COLUMNS_KEY)).toBe('["warships"]');
});
});