diff --git a/CREDITS.md b/CREDITS.md index 518e89cff1..ff50a1419c 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -111,6 +111,7 @@ Licensed under ODbL Stats icon by [Meko](https://thenounproject.com/mekoda/) – https://thenounproject.com/icon/stats-4942475/ Pay Per Click icon by [Fauzan Adiima](https://thenounproject.com/creator/fauzan94/) – https://thenounproject.com/icon/pay-per-click-2586454/ Medal icon by [Snow](https://thenounproject.com/snowdoll/) – https://thenounproject.com/icon/medal-4567887/ +Guild icon by Trimanggolo Mulyo – https://thenounproject.com/icon/guild-8266144/ ### Flags diff --git a/resources/images/GuildIconWhite.svg b/resources/images/GuildIconWhite.svg new file mode 100644 index 0000000000..cef58e8e07 --- /dev/null +++ b/resources/images/GuildIconWhite.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/lang/en.json b/resources/lang/en.json index fc092cc0ac..a18fb9dc2a 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -902,6 +902,7 @@ "allies": "Allies", "betrayals": "Betrayals", "cities": "Cities", + "clan": "Clan", "configure_columns": "Configure columns", "factories": "Factories", "gold": "Gold", @@ -910,6 +911,7 @@ "owned": "Owned", "player": "Player", "ports": "Ports", + "rank": "Rank", "sams": "SAMs", "team": "Team", "troops": "Troops", diff --git a/src/client/StatsConstants.ts b/src/client/StatsConstants.ts index 15ce8c58c8..8a62dac3d4 100644 --- a/src/client/StatsConstants.ts +++ b/src/client/StatsConstants.ts @@ -1,4 +1,8 @@ export const COLUMN_IDS = [ + "rank", + "clan", + "player", + "team", "tiles", "gold", "troops", @@ -15,10 +19,9 @@ export const COLUMN_IDS = [ export type ColumnId = (typeof COLUMN_IDS)[number]; -export const DEFAULT_STATS_COLUMNS = [ - "tiles", - "gold", - "maxtroops", -] as const satisfies readonly ColumnId[]; +export const DEFAULT_STATS_COLUMNS = { + player: ["clan", "tiles", "gold", "maxtroops"], + team: ["tiles", "gold", "maxtroops"], +} as const satisfies Record; export type StatsTableKind = "player" | "team"; diff --git a/src/client/components/StatsTable.ts b/src/client/components/StatsTable.ts index d2c6bdc32f..bcfa707bd1 100644 --- a/src/client/components/StatsTable.ts +++ b/src/client/components/StatsTable.ts @@ -2,33 +2,30 @@ 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 ColumnAlignment, type ColumnDef, - columnById, + columnsFor, } from "../hud/layers/lib/StatsColumns"; -import { - type ColumnId, - DEFAULT_STATS_COLUMNS, - type StatsTableKind, -} from "../StatsConstants"; +import { type ColumnId, type StatsTableKind } from "../StatsConstants"; import { translateText } from "../Utils"; import type { GameView } from "../view"; export interface StatsRow { key: string; name: string; + clanTag?: string | null; values: ReadonlyMap; emphasized?: boolean; pinned?: boolean; onClick?: () => void; } -interface RenderedStatsRow extends Omit { +/** A row plus the rank it renders at (list-wide, not window-relative). */ +interface PlacedRow { + row: StatsRow; position: number; - cells: readonly string[]; } // Fallbacks cover the first render before measurement and jsdom (tests), @@ -39,6 +36,17 @@ 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; +// Trailing chrome track holding the ⚙️ menu, not a column of data. +const PICKER_TRACK = "32px"; + +const ALIGNMENT_CLASS: Record = { + start: "justify-start text-left", + center: "justify-center text-center", + end: "justify-end text-right", +}; +const CELL_CLASS = "h-6 md:h-8 lg:h-9 min-w-0 px-1 flex items-center"; +const DIVIDER_CLASS = "border-l border-l-slate-600/40"; +const HEADER_DIVIDER_CLASS = "border-l border-l-slate-500"; export abstract class StatsTable extends LitElement { public game: GameView | null = null; @@ -46,7 +54,6 @@ export abstract class StatsTable extends LitElement { @property({ type: Boolean }) visible = false; protected abstract readonly tableKind: StatsTableKind; - protected abstract readonly nameLabelKey: string; protected abstract buildRows( game: GameView, columns: readonly ColumnDef[], @@ -56,7 +63,7 @@ export abstract class StatsTable extends LitElement { private rows: StatsRow[] = []; @state() - private sortKey: ColumnId = DEFAULT_STATS_COLUMNS[0]; + private sortKey: ColumnId | null = null; @state() private sortOrder: "asc" | "desc" = "desc"; @@ -106,8 +113,15 @@ export abstract class StatsTable extends LitElement { if (this.visible) this.updateStats(); } - private selectedColumns(): ColumnDef[] { - return this.userSettings.statsColumns(this.tableKind).map(columnById); + /** + * Columns this render shows: every non-hideable column plus the hideable + * ones the ⚙️ menu has selected, in registry order. + */ + private visibleColumns(): readonly ColumnDef[] { + const selected = this.userSettings.statsColumns(this.tableKind); + return columnsFor(this.tableKind).filter( + (column) => !column.isHideable || selected.includes(column.id), + ); } private setSort(key: ColumnId) { @@ -128,40 +142,163 @@ export abstract class StatsTable extends LitElement { private updateStats() { if (this.game === null) return; - const selected = this.selectedColumns(); - if (!selected.some((column) => column.id === this.sortKey)) { - this.sortKey = selected[0].id; + const columns = this.visibleColumns(); + // Only orderable columns have a number to sort on. When none is visible + // the rows keep the order buildRows produced. + const orderable = columns.filter((column) => column.isOrderable); + if ( + orderable.length > 0 && + !orderable.some((column) => column.id === this.sortKey) + ) { + this.sortKey = orderable[0].id; this.sortOrder = "desc"; } + const sortKey = this.sortKey; + const rows = this.buildRows(this.game, columns); 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.rows = + orderable.length === 0 || sortKey === null + ? rows + : rows.sort( + (a, b) => + direction * + ((a.values.get(sortKey) ?? 0) - (b.values.get(sortKey) ?? 0)), + ); this.requestUpdate(); } + private renderHeaderVisual(column: ColumnDef, label: string) { + const visual = column.headerVisual; + if (visual === undefined) return html`${label}`; + if (visual.kind === "text") return html`${visual.text}`; + return html` + ${visual.superscript + ? html`` + : nothing} + `; + } + + private renderHeaderCell(column: ColumnDef, index: number) { + const label = translateText(column.labelKey); + const visual = this.renderHeaderVisual(column, label); + const sorted = this.sortKey === column.id; + return html` +
+ ${column.isOrderable + ? html`` + : visual} +
+ `; + } + + private renderCell( + column: ColumnDef, + index: number, + text: string, + borderClass: string, + ) { + return html` +
+ ${text} +
+ `; + } + + private renderRow( + game: GameView, + columns: readonly ColumnDef[], + { row, position }: PlacedRow, + borderClass: string, + pinned = false, + ) { + return html` +
+ ${repeat( + columns, + (column) => column.id, + (column, index) => + this.renderCell( + column, + index, + column.cell( + { ...row, position, value: row.values.get(column.id) ?? 0 }, + game, + ), + borderClass, + ), + )} + +
+ `; + } + 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 columns = this.visibleColumns(); const pinnedIndex = this.rows.findIndex((row) => row.pinned); - const pinnedRow = + const pinnedRow: PlacedRow | null = pinnedIndex > PINNED_VISIBLE_THRESHOLD - ? toRendered(this.rows[pinnedIndex], pinnedIndex + 1) + ? { row: this.rows[pinnedIndex], position: pinnedIndex + 1 } : null; const listRows = pinnedRow === null @@ -179,77 +316,28 @@ export abstract class StatsTable extends LitElement { (this.scrollOffsetPx + this.viewportHeightPx) / this.rowHeightPx, ) + OVERSCAN_ROWS, ); - const scrollableRows = listRows + const scrollableRows: PlacedRow[] = listRows .slice(firstIndex, lastIndex) .map((row, sliceIndex) => { const index = firstIndex + sliceIndex; - return toRendered( + return { row, - pinnedRow !== null && index >= pinnedIndex ? index + 2 : index + 1, - ); + position: + 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`; + // "auto" tracks stay content-sized intrinsically, then split only the + // spare width supplied by a wider sibling; fixed tracks never stretch. + const gridTemplate = `${columns + .map((column) => column.width) + .join(" ")} ${PICKER_TRACK}`; 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` -
-
- ${row.position} -
-
- ${row.name} -
- ${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` -
- ${cell} -
- `; - }, - )} - -
- `; - return html`
-
- # -
-
- ${translateText(this.nameLabelKey)} -
${repeat( - selected, + columns, (column) => column.id, - (column) => { - const label = translateText(column.labelKey); - return html` -
- -
- `; - }, + (column, index) => this.renderHeaderCell(column, index), )}
column.id)} + .columns=${columnsFor(this.tableKind).filter( + (column) => column.isHideable, + )} + .selected=${this.userSettings.statsColumns(this.tableKind)} @columns-changed=${this.onColumnsChanged} >
@@ -373,10 +389,12 @@ export abstract class StatsTable extends LitElement { : nothing} ${repeat( scrollableRows, - (row) => row.key, - (row, index) => - renderRow( - row, + (placed) => placed.row.key, + (placed, index) => + this.renderRow( + game, + columns, + placed, index < scrollableRows.length - 1 || pinnedRow !== null || bottomSpacerPx > 0 @@ -393,7 +411,9 @@ export abstract class StatsTable extends LitElement { : nothing}
- ${pinnedRow === null ? nothing : renderRow(pinnedRow, "", true)} + ${pinnedRow === null + ? nothing + : this.renderRow(game, columns, pinnedRow, "", true)}
diff --git a/src/client/components/leaderboard/LeaderboardPlayerList.ts b/src/client/components/leaderboard/LeaderboardPlayerList.ts index 749910ecd6..e0ed3a4023 100644 --- a/src/client/components/leaderboard/LeaderboardPlayerList.ts +++ b/src/client/components/leaderboard/LeaderboardPlayerList.ts @@ -76,7 +76,6 @@ export class LeaderboardPlayerList extends LitElement { rank: entry.rank, playerId: entry.public_id, accountUsername: entry.accountUsername ?? null, - clanTag: entry.clanTag ?? undefined, elo: entry.elo, games: entry.total, wins: entry.wins, @@ -219,6 +218,61 @@ export class LeaderboardPlayerList extends LitElement { void this.updateComplete.then(() => this.maybeLoadMorePlayers()); } + // The pinned "your ranking" row lives in the table's own rather than + // in an overlay. `table-fixed w-full` stretches the colgroup widths to fill + // the container, so the resolved widths depend on the modal size and cannot + // be reproduced by a separate element; and below 34rem the table scrolls + // horizontally, which an overlay outside .scroll-container would not follow. + private renderStickyUserRow() { + if (!this.currentUserEntry || !this.showStickyUser) return nothing; + const entry = this.currentUserEntry; + + return html` + + + +
+ ${entry.rank} +
+ + +
+ ${translateText("leaderboard_modal.your_ranking")} + this.openProfile(entry.playerId)} + > +
+ + + ${entry.elo} + + + ${entry.games} + + +
+ ${(entry.winRate * 100).toFixed(1)}% + ${translateText("leaderboard_modal.ratio")} +
+ + + + `; + } + private renderPlayerRow(player: PlayerLeaderboardEntry) { const isCurrentUser = this.currentUserEntry?.playerId === player.playerId; const displayRank = player.rank; @@ -253,13 +307,6 @@ export class LeaderboardPlayerList extends LitElement {
- ${player.clanTag - ? html`
- ${player.clanTag} -
` - : ""} -
+ +
this.handleScroll()} > @@ -420,62 +471,10 @@ export class LeaderboardPlayerList extends LitElement { ${this.playerData.map((player) => this.renderPlayerRow(player))} + ${this.renderStickyUserRow()}
${this.renderPlayerFooter()}
- ${this.currentUserEntry - ? html` -
-
-
-
- ${this.currentUserEntry.rank} -
-
-
- ${translateText( - "leaderboard_modal.your_ranking", - )} -
- ${this.currentUserEntry.clanTag - ? html`
- ${this.currentUserEntry.clanTag} -
` - : ""} - - this.openProfile(this.currentUserEntry!.playerId)} - > -
-
-
-
- ${this.currentUserEntry.elo} - ${translateText("leaderboard_modal.elo")} -
-
-
-
- ` - : ""}
`; diff --git a/src/client/hud/HotbarIcons.ts b/src/client/hud/HotbarIcons.ts index c8f9bdeca5..988e96cdd4 100644 --- a/src/client/hud/HotbarIcons.ts +++ b/src/client/hud/HotbarIcons.ts @@ -14,6 +14,8 @@ 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 guildIcon = assetUrl("images/GuildIconWhite.svg"); +export const teamIcon = assetUrl("images/TeamIconSolidWhite.svg"); export const upperLimitIcon = assetUrl("images/UpperLimitIcon.svg"); export const allianceIcon = assetUrl("images/AllianceIcon.svg"); export const traitorIcon = assetUrl("images/TraitorIcon.svg"); diff --git a/src/client/hud/layers/PlayerStats.ts b/src/client/hud/layers/PlayerStats.ts index 135ea9b5a3..fab04e60d6 100644 --- a/src/client/hud/layers/PlayerStats.ts +++ b/src/client/hud/layers/PlayerStats.ts @@ -10,7 +10,6 @@ export class PlayerStats extends StatsTable { public eventBus: EventBus | null = null; protected readonly tableKind = "player"; - protected readonly nameLabelKey = "leaderboard.player"; protected buildRows( game: GameView, @@ -23,7 +22,8 @@ export class PlayerStats extends StatsTable { .filter((player) => player.isAlive()) .map((player) => ({ key: player.id(), - name: player.displayName(), + name: player.name(), + clanTag: player.clanTag(), values: columnValues(player, game, columns), emphasized: myPlayer !== null && diff --git a/src/client/hud/layers/TeamStats.ts b/src/client/hud/layers/TeamStats.ts index cb114b04c6..99088bf8a1 100644 --- a/src/client/hud/layers/TeamStats.ts +++ b/src/client/hud/layers/TeamStats.ts @@ -4,20 +4,25 @@ 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"; +import type { ColumnDef, ValueGetter } from "./lib/StatsColumns"; export function aggregateTeamValues( players: readonly PlayerView[], columns: readonly ColumnDef[], game: GameView, ): ReadonlyMap { + // Only columns with a number behind them have anything to add up. + const totalled = columns.filter( + (column): column is ColumnDef & { value: ValueGetter } => + column.value !== undefined, + ); const values = new Map( - columns.map((column) => [column.id, 0]), + totalled.map((column) => [column.id, 0]), ); for (const player of players) { if (!player.isAlive()) continue; - for (const column of columns) { + for (const column of totalled) { values.set( column.id, (values.get(column.id) ?? 0) + column.value(player, game), @@ -31,7 +36,6 @@ export function aggregateTeamValues( @customElement("team-stats") export class TeamStats extends StatsTable { protected readonly tableKind = "team"; - protected readonly nameLabelKey = "leaderboard.team"; protected buildRows( game: GameView, diff --git a/src/client/hud/layers/lib/StatsColumns.ts b/src/client/hud/layers/lib/StatsColumns.ts index 0631569db8..243a0c00f2 100644 --- a/src/client/hud/layers/lib/StatsColumns.ts +++ b/src/client/hud/layers/lib/StatsColumns.ts @@ -1,5 +1,5 @@ import { UnitType } from "../../../../core/game/Game"; -import type { ColumnId } from "../../../StatsConstants"; +import type { ColumnId, StatsTableKind } from "../../../StatsConstants"; import { formatPercentage, renderNumber, renderTroops } from "../../../Utils"; import type { GameView, PlayerView } from "../../../view"; import { @@ -8,10 +8,13 @@ import { claimIcon, factoryIcon, goldCoinIcon, + guildIcon, missileSiloIcon, portIcon, + profileIcon, samLauncherIcon, soldierIcon, + teamIcon, traitorIcon, upperLimitIcon, warshipIcon, @@ -23,8 +26,6 @@ export { type ColumnId, } from "../../../StatsConstants"; -type ValueGetter = (player: PlayerView, game: GameView) => number; -type ValueRenderer = (value: number, game: GameView) => string; export type ColumnHeaderVisual = | { readonly kind: "icon"; @@ -33,49 +34,50 @@ export type ColumnHeaderVisual = /** 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"; + // A literal symbol, not copy — "#" reads the same in every language. + | { readonly kind: "text"; readonly text: string }; +export type ColumnAlignment = "start" | "center" | "end"; -interface ColumnOptions { - readonly headerVisual?: ColumnHeaderVisual; - readonly valueAlignment?: ColumnValueAlignment; +/** One row as a column sees it. `value` is that column's own number. */ +export interface ColumnRow { + readonly position: number; + readonly name: string; + readonly clanTag?: string | null; + readonly value: number; } -const troopHeaderVisual = { - kind: "icon", - src: soldierIcon, - white: true, -} as const satisfies ColumnHeaderVisual; +export type ValueGetter = (player: PlayerView, game: GameView) => number; export interface ColumnDef { readonly id: ColumnId; + /** Tooltip copy, and the header text when there is no headerVisual. */ 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; + /** Grid track: a fixed width, or "auto" to share the row's spare width. */ + readonly width: string; + readonly align: ColumnAlignment; + /** Tables this column appears on. */ + readonly kinds: readonly StatsTableKind[]; + /** Offered in the ⚙️ menu; non-hideable columns always render. */ + readonly isHideable: boolean; + /** Header is a sort button. True exactly when `value` is set. */ + readonly isOrderable: boolean; + /** Number behind the column — sorting and team totals. */ + readonly value?: ValueGetter; + readonly cell: (row: ColumnRow, game: GameView) => string; } -// renderNumber's second parameter is fixedPoints, so it cannot be a -// ValueRenderer directly (which passes GameView second). -const renderNum: ValueRenderer = (value) => renderNumber(value); +type ColumnInput = Pick & + Partial>; -function column( - id: ColumnId, - labelKey: string, - value: ValueGetter, - renderValue: ValueRenderer, - options: ColumnOptions = {}, -): ColumnDef { +function defineColumn(input: ColumnInput): ColumnDef { return { - id, - labelKey, - ...options, - valueAlignment: options.valueAlignment ?? "end", - value, - renderValue, + width: "auto", + align: "end", + kinds: ["player", "team"], + isHideable: true, + ...input, + isOrderable: input.value !== undefined, }; } @@ -85,55 +87,98 @@ function unitColumn( unitType: UnitType, icon: string, ): ColumnDef { - return column( + return defineColumn({ id, labelKey, - (player) => player.totalUnitLevels(unitType), - renderNum, - { headerVisual: { kind: "icon", src: icon }, valueAlignment: "center" }, - ); + headerVisual: { kind: "icon", src: icon }, + align: "center", + value: (player) => player.totalUnitLevels(unitType), + cell: (row) => renderNumber(row.value), + }); } +const troopHeaderVisual = { + kind: "icon", + src: soldierIcon, + white: true, +} as const satisfies ColumnHeaderVisual; + // This registry is the source of truth for column IDs and display order. -export const COLUMN_DEFS = [ - { +export const COLUMN_DEFS: readonly ColumnDef[] = [ + defineColumn({ + id: "rank", + labelKey: "leaderboard.rank", + headerVisual: { kind: "text", text: "#" }, + width: "34px", + align: "center", + isHideable: false, + cell: (row) => String(row.position), + }), + defineColumn({ + id: "clan", + labelKey: "leaderboard.clan", + headerVisual: { kind: "icon", src: guildIcon }, + // Fits "WWWWW" — the widest 5-character tag — in the row font. + width: "80px", + align: "center", + kinds: ["player"], + cell: (row) => row.clanTag ?? "", + }), + defineColumn({ + id: "player", + labelKey: "leaderboard.player", + headerVisual: { kind: "icon", src: profileIcon }, + width: "100px", + align: "start", + kinds: ["player"], + isHideable: false, + cell: (row) => row.name, + }), + defineColumn({ + id: "team", + labelKey: "leaderboard.team", + headerVisual: { kind: "icon", src: teamIcon }, + width: "100px", + align: "start", + kinds: ["team"], + isHideable: false, + cell: (row) => row.name, + }), + defineColumn({ id: "tiles", labelKey: "leaderboard.owned", headerVisual: { kind: "icon", src: claimIcon, white: true }, - valueAlignment: "end", value: (player) => player.numTilesOwned(), - renderValue: (tiles, game) => { + cell: (row, game) => { const validTiles = game.numLandTiles() - game.numTilesWithFallout(); - return formatPercentage(validTiles > 0 ? tiles / validTiles : 0); + return formatPercentage(validTiles > 0 ? row.value / validTiles : 0); }, - }, - column( - "gold", - "leaderboard.gold", + }), + defineColumn({ + id: "gold", + labelKey: "leaderboard.gold", + headerVisual: { kind: "icon", src: goldCoinIcon }, // 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 }, - }, + value: (player) => Number(player.gold()), + cell: (row) => renderNumber(row.value), + }), + defineColumn({ + id: "troops", + labelKey: "leaderboard.troops", + headerVisual: troopHeaderVisual, + value: (player) => player.troops(), + cell: (row) => renderTroops(row.value), + }), + defineColumn({ + id: "maxtroops", + labelKey: "leaderboard.maxtroops", + headerVisual: { + ...troopHeaderVisual, + superscript: { src: upperLimitIcon, white: true }, }, - ), + value: (player, game) => game.config().maxTroops(player), + cell: (row) => renderTroops(row.value), + }), unitColumn("cities", "leaderboard.cities", UnitType.City, cityIcon), unitColumn("ports", "leaderboard.ports", UnitType.Port, portIcon), unitColumn( @@ -150,36 +195,39 @@ export const COLUMN_DEFS = [ ), 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[]; + defineColumn({ + id: "allies", + labelKey: "leaderboard.allies", + headerVisual: { kind: "icon", src: allianceIcon }, + align: "center", + value: (player) => player.allies().length, + cell: (row) => renderNumber(row.value), + }), + defineColumn({ + id: "betrayals", + labelKey: "leaderboard.betrayals", + headerVisual: { kind: "icon", src: traitorIcon }, + align: "center", + value: (player) => player.betrayals(), + cell: (row) => renderNumber(row.value), + }), +]; + +export function columnsFor(kind: StatsTableKind): readonly ColumnDef[] { + return COLUMN_DEFS.filter((column) => column.kinds.includes(kind)); +} export function columnValues( player: PlayerView, game: GameView, columns: readonly ColumnDef[], ): ReadonlyMap { - return new Map( - columns.map((column) => [column.id, column.value(player, game)] as const), - ); + const values = new Map(); + for (const column of columns) { + if (column.value === undefined) continue; + values.set(column.id, column.value(player, game)); + } + return values; } const COLUMNS_BY_ID = new Map( diff --git a/src/client/render/types/Renderer.ts b/src/client/render/types/Renderer.ts index 12f5c35342..a7b46684e3 100644 --- a/src/client/render/types/Renderer.ts +++ b/src/client/render/types/Renderer.ts @@ -20,6 +20,7 @@ export interface PlayerStatic { id: string; name: string; displayName: string; + clanTag: string | null; clientID: string | null; playerType: PlayerTypeEnum; team: string | null; diff --git a/src/client/view/GameView.ts b/src/client/view/GameView.ts index 23dd41f035..39959de923 100644 --- a/src/client/view/GameView.ts +++ b/src/client/view/GameView.ts @@ -349,6 +349,7 @@ export class GameView implements GameMap { if (pu.clientID !== undefined && pu.clientID === this._myClientID) { pu.name = this._myUsername; pu.displayName = myDisplayName; + pu.clanTag = this._myClanTag; } if (pu.smallID !== undefined) { diff --git a/src/client/view/PlayerView.ts b/src/client/view/PlayerView.ts index e501e52e0a..fd63ada7fb 100644 --- a/src/client/view/PlayerView.ts +++ b/src/client/view/PlayerView.ts @@ -61,6 +61,7 @@ function staticFromUpdate(pu: PlayerUpdate): PlayerStatic { id: pu.id, name: pu.name!, displayName: pu.displayName!, + clanTag: pu.clanTag ?? null, clientID: pu.clientID ?? null, playerType: gamePlayerTypeToEnum(pu.playerType!), team: pu.team ?? null, @@ -437,7 +438,11 @@ export class PlayerView { ? this.anonymousName : this.static.displayName; } - + clanTag(): string | null { + return this.anonymousName !== null && userSettings.anonymousNames() + ? null + : this.static.clanTag; + } clientID(): ClientID | null { return this.static.clientID; } diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index bc2954e1b3..aee0905141 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -471,7 +471,8 @@ export const PlayerLeaderboardEntrySchema = z.object({ // Account username (null = never set). The leaderboard displays this or // the playerId — the per-session name is deliberately ignored. accountUsername: z.string().nullable().optional(), - clanTag: RequiredClanTagSchema.nullable().optional(), + // No clanTag: 1v1 ranked is an individual ladder, and the tag the API + // reports is whatever the player last happened to use in any game mode. flag: z.string().optional(), elo: z.number(), games: z.number(), diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index 8e987db533..9ce35c1d0a 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -88,7 +88,19 @@ class SAMTargetingSystem { const nukeTickToReach = i - currentIndex; const samTickToReach = this.tickToReach(samTile, trajectoryTile.tile); const tickBeforeShooting = nukeTickToReach - samTickToReach; - if (samTickToReach < explosionTick && tickBeforeShooting >= 0) { + if (samTickToReach <= explosionTick && tickBeforeShooting >= 0) { + // Edge case: last tick was out of range, this one is in range, but exploding + if ( + i > currentIndex && + this.mg.euclideanDistSquared(samTile, trajectory[i - 1].tile) > + rangeSquared && + samTickToReach === explosionTick + ) { + return { + tick: tickBeforeShooting - 1, + tile: trajectory[i - 1].tile, + }; + } return { tick: tickBeforeShooting, tile: trajectoryTile.tile }; } } @@ -96,13 +108,59 @@ class SAMTargetingSystem { return undefined; } - public getSingleTarget(ticks: number): Target | null { + private computeTargetScore(target: Target): number { + const samTile = this.sam.tile(); + const unit = target.unit; + const trajectory = unit.trajectory(); + const currentIndex = unit.trajectoryIndex(); + const timeToExplode = Math.max(1, trajectory.length - currentIndex); + + const targetTile = + unit.targetTile() ?? + (trajectory.length > 0 + ? trajectory[trajectory.length - 1].tile + : samTile); + + const distToSilo = this.mg.manhattanDist(samTile, targetTile); + + // Type bonus: Hydro gets +70,001 + // (70,000 offset balances the distance bonus between Hydro at 100 and Atom at 30) + const typeBonus = unit.type() === UnitType.HydrogenBomb ? 70_001 : 0; + + // Distance bonus: Closer to silo higher score (-1,000 pts per unit distance) + // due to manhattanDist, distToSilo can exceed 150 diagonally, 200000 starting point. + const distanceBonus = Math.max(0, 200_000 - distToSilo * 1000); + + // Time based score: +100 pts per tick earlier + // Since all nukes are already guaranteed to need a SAM response at this tick, + // this is only a very minor tiebreaker. + const urgencyBonus = Math.max(0, 10_000 - timeToExplode * 100); + + return typeBonus + distanceBonus + urgencyBonus; + } + + private sortTargets(targets: Target[]): Target[] { + if (targets.length <= 1) return targets; + + // Create a map for quick look-up time. + const scores = new Map(); + for (const target of targets) { + scores.set(target, this.computeTargetScore(target)); + } + + // Sort by score, js' Timsort guarantees O(n log n) + return targets.sort((a, b) => scores.get(b)! - scores.get(a)!); + } + + public getValidTargets(ticks: number): Target[] { const samTile = this.sam.tile(); const range = this.mg.config().samRange(this.sam.level()); const rangeSquared = range * range; - // Look beyond the SAM range so it can preshot nukes - const detectionRange = this.mg.config().maxSamRange() * 2; + // Look beyond the SAM range so it can preshot nukes. + // Every missile should be spotted in time to allow it to be shot down at maxSamRange + // Times 3 is barely not enough for a MIRV warhead (speed 22 vs SAM 12) + const detectionRange = this.mg.config().maxSamRange() * 4; const nukes = this.mg.nearbyUnits( samTile, detectionRange, @@ -128,7 +186,7 @@ class SAMTargetingSystem { // Clear unreachable nukes that went out of range this.updateUnreachableNukes(nukes); - let best: Target | null = null; + const targets: Target[] = []; for (const nuke of nukes) { const nukeId = nuke.unit.id(); const cached = this.precomputedNukes.get(nukeId); @@ -139,14 +197,7 @@ class SAMTargetingSystem { } if (cached.tick === ticks) { // Time to shoot! - const target = { tile: cached.tile, unit: nuke.unit }; - if ( - best === null || - (target.unit.type() === UnitType.HydrogenBomb && - best.unit.type() !== UnitType.HydrogenBomb) - ) { - best = target; - } + targets.push({ tile: cached.tile, unit: nuke.unit }); this.precomputedNukes.delete(nukeId); continue; } @@ -165,15 +216,10 @@ class SAMTargetingSystem { if (interceptionTile !== undefined) { if (interceptionTile.tick <= 1) { // Shoot instantly - - const target = { unit: nuke.unit, tile: interceptionTile.tile }; - if ( - best === null || - (target.unit.type() === UnitType.HydrogenBomb && - best.unit.type() !== UnitType.HydrogenBomb) - ) { - best = target; - } + targets.push({ + unit: nuke.unit, + tile: interceptionTile.tile, + }); } else { // Nuke will be reachable but not yet. Store the result. this.precomputedNukes.set(nukeId, { @@ -187,7 +233,7 @@ class SAMTargetingSystem { } } - return best; + return this.sortTargets(targets); } } @@ -260,8 +306,11 @@ export class SAMLauncherExecution implements Execution { this.pseudoRandom ??= new PseudoRandom(this.sam.id()); // target is already filtered to exclude nukes targeted by other SAMs - const target = this.targetingSystem.getSingleTarget(ticks); - if (target !== null) { + const targets = this.targetingSystem.getValidTargets(ticks); + for (const target of targets) { + if (this.sam.isInCooldown()) { + break; + } this.sam.launch(); target.unit.setTargetedBySAM(true); this.mg.addExecution( diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index d462841fc9..bfc6ba4720 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -549,6 +549,7 @@ export interface Player { info(): PlayerInfo; name(): string; displayName(): string; + clanTag(): string | null; clientID(): ClientID | null; id(): PlayerID; type(): PlayerType; diff --git a/src/core/game/GameUpdateUtils.ts b/src/core/game/GameUpdateUtils.ts index b5919f7710..42dd3c376f 100644 --- a/src/core/game/GameUpdateUtils.ts +++ b/src/core/game/GameUpdateUtils.ts @@ -39,6 +39,7 @@ export function diffPlayerUpdate( prev.clientID === next.clientID && prev.name === next.name && prev.displayName === next.displayName && + prev.clanTag === next.clanTag && prev.team === next.team && prev.smallID === next.smallID && prev.playerType === next.playerType && @@ -86,6 +87,7 @@ export function diffPlayerUpdate( setIfDifferent("clientID", prev.clientID === next.clientID); setIfDifferent("name", prev.name === next.name); setIfDifferent("displayName", prev.displayName === next.displayName); + setIfDifferent("clanTag", prev.clanTag === next.clanTag); setIfDifferent("team", prev.team === next.team); setIfDifferent("smallID", prev.smallID === next.smallID); setIfDifferent("playerType", prev.playerType === next.playerType); diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index fd93aa47ee..48bb56ce85 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -221,6 +221,7 @@ export interface PlayerUpdate { clientID?: ClientID | null; name?: string; displayName?: string; + clanTag?: string | null; team?: Team; smallID?: number; playerType?: PlayerType; diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 099ecb5fcf..a96d02bf41 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -317,6 +317,7 @@ export class PlayerImpl implements Player { clientID: this.clientID(), name: this.name(), displayName: this.displayName(), + clanTag: this.clanTag(), id: this.id(), team: this.team() ?? undefined, smallID: this.smallID(), @@ -358,7 +359,9 @@ export class PlayerImpl implements Player { displayName(): string { return this.playerInfo.displayName; } - + clanTag(): string | null { + return this.playerInfo.clanTag; + } clientID(): ClientID | null { return this.playerInfo.clientID; } diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts index 7be154af9e..d80ef92a86 100644 --- a/src/core/game/UserSettings.ts +++ b/src/core/game/UserSettings.ts @@ -400,7 +400,10 @@ export class UserSettings { } statsColumns(kind: StatsTableKind): ColumnId[] { - return this.getColumnIds(STATS_COLUMNS_KEYS[kind], DEFAULT_STATS_COLUMNS); + return this.getColumnIds( + STATS_COLUMNS_KEYS[kind], + DEFAULT_STATS_COLUMNS[kind], + ); } setStatsColumns(kind: StatsTableKind, ids: ColumnId[]): void { diff --git a/src/core/utilities/Line.ts b/src/core/utilities/Line.ts index 67024e9c62..2d80a1c0f6 100644 --- a/src/core/utilities/Line.ts +++ b/src/core/utilities/Line.ts @@ -146,7 +146,8 @@ export class DistanceBasedBezierCurve extends CubicBezierCurve { if (cumulativeDistance >= pixelSpacing) { this.cachedPoints.push(currentPoint); - cumulativeDistance = 0; + // Reset cumulative distance, don't discard excess + cumulativeDistance -= pixelSpacing; } prevPoint = currentPoint; diff --git a/tests/ColumnPicker.test.ts b/tests/ColumnPicker.test.ts index d4d72914cc..7b7b36ba99 100644 --- a/tests/ColumnPicker.test.ts +++ b/tests/ColumnPicker.test.ts @@ -1,12 +1,15 @@ import { ColumnPicker } from "../src/client/hud/layers/ColumnPicker"; -import { COLUMN_DEFS } from "../src/client/hud/layers/lib/StatsColumns"; +import { columnsFor } from "../src/client/hud/layers/lib/StatsColumns"; import type { ColumnId } from "../src/client/StatsConstants"; +// The menu only ever lists hideable columns; rank and player always render. +const hideable = columnsFor("player").filter((column) => column.isHideable); + 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.columns = hideable; picker.selected = ["tiles"]; sidebar.appendChild(picker); document.body.appendChild(sidebar); @@ -19,16 +22,22 @@ describe("ColumnPicker", () => { expect(popup).not.toBeNull(); expect(sidebar.contains(popup)).toBe(false); expect(popup?.querySelectorAll('input[type="checkbox"]')).toHaveLength( - COLUMN_DEFS.length, + hideable.length, ); + expect(hideable.map((column) => column.id)).not.toContain("rank"); + expect(hideable.map((column) => column.id)).not.toContain("player"); let selection: readonly ColumnId[] | null = null; picker.addEventListener("columns-changed", (event) => { selection = (event as CustomEvent).detail; }); - (popup?.querySelectorAll("input")[1] as HTMLInputElement).click(); + // Registry order, so index 0 is the clan column and 2 is gold. + (popup?.querySelectorAll("input")[2] as HTMLInputElement).click(); expect(selection).toEqual(["tiles", "gold"]); + (popup?.querySelectorAll("input")[0] as HTMLInputElement).click(); + expect(selection).toEqual(["clan", "tiles"]); + sidebar.remove(); expect(document.body.querySelector(".column-picker-popover")).toBeNull(); }); diff --git a/tests/GameLeftSidebar.test.ts b/tests/GameLeftSidebar.test.ts index b4f54d7308..233f8b5640 100644 --- a/tests/GameLeftSidebar.test.ts +++ b/tests/GameLeftSidebar.test.ts @@ -40,7 +40,9 @@ describe("GameLeftSidebar", () => { it("renders the player stats table after toggling it", async () => { const player = { id: () => "player-1", + name: () => "Player 1", displayName: () => "Player 1", + clanTag: () => null, numTilesOwned: () => 10, gold: () => 100n, isAlive: () => true, diff --git a/tests/GameUpdateUtils.test.ts b/tests/GameUpdateUtils.test.ts index 41c7306908..e8cd7d011e 100644 --- a/tests/GameUpdateUtils.test.ts +++ b/tests/GameUpdateUtils.test.ts @@ -70,6 +70,20 @@ describe("diffPlayerUpdate", () => { expect(diff.hasSpawned).toBeUndefined(); }); + it("carries a changed clanTag and stays quiet when it is unchanged", () => { + expect( + diffPlayerUpdate( + makePlayerUpdate({ clanTag: "ABCDE" }), + makePlayerUpdate({ clanTag: "ABCDE" }), + ), + ).toBeNull(); + const diff = diffPlayerUpdate( + makePlayerUpdate({ clanTag: null }), + makePlayerUpdate({ clanTag: "ABCDE" }), + )!; + expect(diff.clanTag).toBe("ABCDE"); + }); + it("emits killedBy + deathPosition when a player is eliminated", () => { const prev = makePlayerUpdate({ killedBy: null, deathPosition: null }); const next = makePlayerUpdate({ killedBy: "client-b", deathPosition: 3 }); diff --git a/tests/PlayerStats.test.ts b/tests/PlayerStats.test.ts index cef8dcb415..91ef20d04b 100644 --- a/tests/PlayerStats.test.ts +++ b/tests/PlayerStats.test.ts @@ -1,16 +1,25 @@ import { goldCoinIcon, + guildIcon, + profileIcon, soldierIcon, upperLimitIcon, } from "../src/client/hud/HotbarIcons"; import { PlayerStats } from "../src/client/hud/layers/PlayerStats"; +import { columnsFor } from "../src/client/hud/layers/lib/StatsColumns"; import type { GameView, PlayerView } from "../src/client/view"; import { UserSettings } from "../src/core/game/UserSettings"; -function player(id: string, tiles: number): PlayerView { +function player( + id: string, + tiles: number, + clanTag: string | null = null, +): PlayerView { return { id: () => id, - displayName: () => id, + name: () => id, + displayName: () => (clanTag === null ? id : `[${clanTag}] ${id}`), + clanTag: () => clanTag, numTilesOwned: () => tiles, gold: () => BigInt(tiles), troops: () => tiles, @@ -47,10 +56,10 @@ describe("PlayerStats", () => { 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. + // Only selected stat tracks share definite spare space. Rank, the clan + // column, name, and picker remain fixed and never stretch. expect(content?.getAttribute("style")).toContain( - "30px 100px auto auto auto 32px", + "34px 80px 100px auto auto auto 32px", ); playerStats.remove(); @@ -91,7 +100,7 @@ describe("PlayerStats", () => { expect(table?.querySelector(".stats-table-header")).not.toBeNull(); const goldHeader = playerStats.querySelectorAll( '.stats-table-header > [role="columnheader"]', - )[3]; + )[4]; expect(goldHeader.querySelector("img")?.getAttribute("src")).toBe( goldCoinIcon, ); @@ -166,3 +175,178 @@ describe("PlayerStats", () => { playerStats.remove(); }); }); + +describe("PlayerStats clan column", () => { + beforeEach(() => { + localStorage.clear(); + ( + UserSettings as unknown as { cache: Map } + ).cache.clear(); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + async function mount(players: PlayerView[]): Promise { + const game = { + myPlayer: () => players[0], + 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; + return playerStats; + } + + it("shows the tag and the bare name in separate columns, without brackets", async () => { + const playerStats = await mount([player("alice", 3, "ABCDE")]); + + const cells = playerStats.querySelectorAll( + '.stats-table-row > [role="cell"]', + ); + expect(cells[1].textContent?.trim()).toBe("ABCDE"); + expect(cells[2].textContent?.trim()).toBe("alice"); + // The merged "[ABCDE] alice" form must not survive into the table. + expect(playerStats.textContent).not.toContain("[ABCDE]"); + }); + + it("headers the clan column with the guild icon and reserves a fixed track", async () => { + const playerStats = await mount([player("alice", 3, "ABCDE")]); + + const headers = playerStats.querySelectorAll( + '.stats-table-header > [role="columnheader"]', + ); + expect(headers[1].querySelector("img")?.getAttribute("src")).toBe( + guildIcon, + ); + expect(headers[1].getAttribute("title")).toBe("leaderboard.clan"); + expect(headers[2].querySelector("img")?.getAttribute("src")).toBe( + profileIcon, + ); + expect( + playerStats.querySelector(".stats-table-content")?.getAttribute("style"), + ).toContain("34px 80px 100px"); + }); + + it("rules off every column but the first, so none read as merged", async () => { + const playerStats = await mount([player("alice", 3, "ABCDE")]); + + const hasRule = (nodes: Element[]) => + nodes.map((node) => node.className.includes("border-l")); + // rank | clan | player | tiles | gold | maxtroops (+ the ⚙️ track). + expect( + hasRule([ + ...playerStats.querySelectorAll( + '.stats-table-header > [role="columnheader"]', + ), + ]), + ).toEqual([false, true, true, true, true, true, true]); + expect( + hasRule([ + ...playerStats.querySelectorAll('.stats-table-row > [role="cell"]'), + ]), + ).toEqual([false, true, true, true, true, true]); + }); + + it("leaves an empty cell for clanless players", async () => { + const playerStats = await mount([ + player("alice", 3, "ABCDE"), + player("bob", 2), + ]); + + const rows = playerStats.querySelectorAll( + ".stats-table-scroll .stats-table-row", + ); + expect(rows.length).toBe(2); + expect( + [...rows].map((row) => + row.querySelectorAll('[role="cell"]')[1].textContent?.trim(), + ), + ).toEqual(["ABCDE", ""]); + }); + + it("is on by default and disappears when deselected in the picker", async () => { + const settings = new UserSettings(); + expect(settings.statsColumns("player")).toContain("clan"); + + settings.setStatsColumns("player", ["tiles", "gold", "maxtroops"]); + const playerStats = await mount([player("alice", 3, "ABCDE")]); + + expect( + [ + ...playerStats.querySelectorAll( + '.stats-table-header > [role="columnheader"]', + ), + ].map((header) => header.getAttribute("title")), + ).not.toContain("leaderboard.clan"); + expect( + playerStats.querySelector(".stats-table-content")?.getAttribute("style"), + ).toContain("34px 100px"); + expect(playerStats.textContent).not.toContain("ABCDE"); + }); + + it("gives only orderable columns a sort button", async () => { + const playerStats = await mount([player("alice", 3, "ABCDE")]); + + const headers = [ + ...playerStats.querySelectorAll( + '.stats-table-header > [role="columnheader"]', + ), + ]; + // rank, clan and player are not orderable; the stat tracks are. + expect( + headers + .slice(0, 5) + .map((header) => header.querySelector("button") !== null), + ).toEqual([false, false, false, true, true]); + expect( + headers.slice(0, 4).map((header) => header.getAttribute("aria-sort")), + ).toEqual([null, null, null, "descending"]); + }); + + it("survives being the only selected column, leaving the rows unsorted", async () => { + new UserSettings().setStatsColumns("player", ["clan"]); + const playerStats = await mount([ + player("alice", 3, "AAA"), + player("bob", 90, "BBB"), + ]); + + // rank | clan | player | picker — no stat track, so nothing to sort on. + expect( + playerStats.querySelector(".stats-table-content")?.getAttribute("style"), + ).toContain("34px 80px 100px 32px"); + expect( + [ + ...playerStats.querySelectorAll(".stats-table-scroll .stats-table-row"), + ].map((row) => + row.querySelectorAll('[role="cell"]')[1].textContent?.trim(), + ), + ).toEqual(["AAA", "BBB"]); + }); +}); + +describe("TeamStats clan column", () => { + beforeEach(() => { + localStorage.clear(); + ( + UserSettings as unknown as { cache: Map } + ).cache.clear(); + }); + + it("is not a team default, and a stored one still cannot render", () => { + const settings = new UserSettings(); + expect(settings.statsColumns("team")).not.toContain("clan"); + + // The clan def is player-only, so a hand-edited team selection carrying + // it has nothing to match. + settings.setStatsColumns("team", ["clan", "gold"]); + expect(columnsFor("team").map((column) => column.id)).not.toContain("clan"); + }); +}); diff --git a/tests/PlayerUpdateDiff.test.ts b/tests/PlayerUpdateDiff.test.ts index 37e9c8a2fd..2ec2ff74b3 100644 --- a/tests/PlayerUpdateDiff.test.ts +++ b/tests/PlayerUpdateDiff.test.ts @@ -64,6 +64,54 @@ describe("Player update diffing (toUpdate)", () => { expect(full!.outgoingEmojis).toEqual([]); }); + test("first toUpdate carries the clan tag unmerged next to displayName", () => { + const eveInfo = new PlayerInfo( + "eve", + PlayerType.Human, + "eve_client", + "eve_id", + false, + "ABCDE", + ); + game.addPlayer(eveInfo); + const full = game.player("eve_id").toUpdate(); + expect(full).not.toBeNull(); + // Consumers that lay the two out separately read clanTag; consumers that + // want the merged form keep reading displayName. + expect(full!.clanTag).toBe("ABCDE"); + expect(full!.name).toBe("eve"); + expect(full!.displayName).toBe("[ABCDE] eve"); + }); + + test("clanTag is null in the snapshot for a player without a clan", () => { + const franInfo = new PlayerInfo( + "fran", + PlayerType.Human, + "fran_client", + "fran_id", + ); + game.addPlayer(franInfo); + expect(game.player("fran_id").toUpdate()!.clanTag).toBeNull(); + }); + + test("an unchanged clan tag stays out of later diffs", () => { + const gusInfo = new PlayerInfo( + "gus", + PlayerType.Human, + "gus_client", + "gus_id", + false, + "ABCDE", + ); + game.addPlayer(gusInfo); + const gus = game.player("gus_id"); + gus.toUpdate(); // first full snapshot + gus.markTraitor(); + const diff = gus.toUpdate(); + expect(diff).not.toBeNull(); + expect(diff!.clanTag).toBeUndefined(); + }); + test("toUpdate returns null when nothing changed", () => { alice.toUpdate(); // first full snapshot expect(alice.toUpdate()).toBeNull(); diff --git a/tests/StatsColumns.test.ts b/tests/StatsColumns.test.ts index cf9f361056..71be1d151a 100644 --- a/tests/StatsColumns.test.ts +++ b/tests/StatsColumns.test.ts @@ -1,6 +1,8 @@ +import { profileIcon, teamIcon } from "../src/client/hud/HotbarIcons"; import { COLUMN_DEFS, columnById, + columnsFor, } from "../src/client/hud/layers/lib/StatsColumns"; import { COLUMN_IDS, @@ -8,6 +10,13 @@ import { } from "../src/client/StatsConstants"; import { makeGameView, makePlayerView, stubConfig } from "./util/viewStubs"; +const emptyRow = { + position: 1, + name: "alice", + clanTag: null, + value: 0, +}; + describe("Stats column registry", () => { it("has unique column ids", () => { expect(new Set(COLUMN_IDS).size).toBe(COLUMN_DEFS.length); @@ -20,30 +29,96 @@ describe("Stats column registry", () => { } }); - it("defaults reference valid ids", () => { - for (const id of DEFAULT_STATS_COLUMNS) { - expect(COLUMN_IDS).toContain(id); + it("marks exactly the columns with a value as orderable", () => { + for (const def of COLUMN_DEFS) { + expect(def.isOrderable).toBe(def.value !== undefined); } + expect(columnById("rank").isOrderable).toBe(false); + expect(columnById("clan").isOrderable).toBe(false); + expect(columnById("player").isOrderable).toBe(false); + expect(columnById("tiles").isOrderable).toBe(true); + }); + + it("gives the team column its own icon, not the player one", () => { + expect(columnById("team").headerVisual).toEqual({ + kind: "icon", + src: teamIcon, + }); + expect(columnById("player").headerVisual).toEqual({ + kind: "icon", + src: profileIcon, + }); }); - it("evaluates and renders every column", () => { + it("keeps rank and the identity column out of the cog menu", () => { + expect(columnById("rank").isHideable).toBe(false); + expect(columnById("player").isHideable).toBe(false); + expect(columnById("team").isHideable).toBe(false); + expect(columnById("clan").isHideable).toBe(true); + expect(columnById("gold").isHideable).toBe(true); + }); + + it("gives identity columns a fixed track and stat columns a shared one", () => { + for (const def of COLUMN_DEFS) { + expect(def.width === "auto").toBe(def.isOrderable); + } + }); + + it("splits the tables on clan and the identity column", () => { + expect(columnsFor("player").map((column) => column.id)).toEqual([ + "rank", + "clan", + "player", + ...COLUMN_IDS.slice(4), + ]); + expect(columnsFor("team").map((column) => column.id)).toEqual([ + "rank", + "team", + ...COLUMN_IDS.slice(4), + ]); + }); + + it("defaults reference hideable ids that exist", () => { + for (const [kind, ids] of Object.entries(DEFAULT_STATS_COLUMNS)) { + for (const id of ids) { + expect(columnById(id).isHideable).toBe(true); + expect(columnById(id).kinds).toContain(kind); + } + } + expect(DEFAULT_STATS_COLUMNS.player).toContain("clan"); + expect(DEFAULT_STATS_COLUMNS.team).not.toContain("clan"); + }); + + it("renders a cell for 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"); + if (column.value !== undefined) { + expect(typeof column.value(player, game)).toBe("number"); + } + expect(typeof column.cell(emptyRow, game)).toBe("string"); } }); + it("reads identity cells off the row, not the value map", () => { + const game = makeGameView(); + const row = { ...emptyRow, position: 7, clanTag: "ABCDE" }; + + expect(columnById("rank").cell(row, game)).toBe("7"); + expect(columnById("clan").cell(row, game)).toBe("ABCDE"); + expect(columnById("player").cell(row, game)).toBe("alice"); + expect(columnById("team").cell(row, game)).toBe("alice"); + expect(columnById("clan").cell(emptyRow, game)).toBe(""); + }); + 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%"); + expect(columnById("tiles").cell(emptyRow, game)).toBe("0.0%"); }); }); diff --git a/tests/StatsTableVirtualization.test.ts b/tests/StatsTableVirtualization.test.ts index 0fe228e69c..88c0f58698 100644 --- a/tests/StatsTableVirtualization.test.ts +++ b/tests/StatsTableVirtualization.test.ts @@ -5,7 +5,9 @@ import { UserSettings } from "../src/core/game/UserSettings"; function player(id: string, tiles: number): PlayerView { return { id: () => id, + name: () => id, displayName: () => id, + clanTag: () => null, numTilesOwned: () => tiles, gold: () => BigInt(tiles), troops: () => tiles, diff --git a/tests/TeamStats.test.ts b/tests/TeamStats.test.ts index e152dcc749..99bed66048 100644 --- a/tests/TeamStats.test.ts +++ b/tests/TeamStats.test.ts @@ -1,4 +1,4 @@ -import type { ColumnDef } from "../src/client/hud/layers/lib/StatsColumns"; +import { columnById } from "../src/client/hud/layers/lib/StatsColumns"; import { aggregateTeamValues, TeamStats, @@ -25,33 +25,33 @@ describe("aggregateTeamValues", () => { 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, + [columnById("tiles"), columnById("gold")], game as unknown as GameView, ), ), ).toEqual({ tiles: 20, gold: 80 }); }); + + it("skips columns that have no number behind them", async () => { + const game = await setup("plains", {}, [ + playerInfo("alive", PlayerType.Human), + ]); + game.player("alive").conquer(game.ref(0, 0)); + + expect( + Object.fromEntries( + aggregateTeamValues( + game.allPlayers() as unknown as PlayerView[], + [columnById("rank"), columnById("player"), columnById("tiles")], + game as unknown as GameView, + ), + ), + ).toEqual({ tiles: 1 }); + }); }); describe("TeamStats", () => { diff --git a/tests/UserSettings.test.ts b/tests/UserSettings.test.ts index b526425858..264c45db41 100644 --- a/tests/UserSettings.test.ts +++ b/tests/UserSettings.test.ts @@ -73,7 +73,9 @@ describe("UserSettings stats columns", () => { }); it("returns defaults when nothing is stored", () => { + // The player table opens with the clan tag shown; a team has no tag. expect(new UserSettings().statsColumns("player")).toEqual([ + "clan", "tiles", "gold", "maxtroops", @@ -111,6 +113,7 @@ describe("UserSettings stats columns", () => { it("falls back to defaults on corrupt JSON", () => { localStorage.setItem(PLAYER_STATS_COLUMNS_KEY, "not json"); expect(new UserSettings().statsColumns("player")).toEqual([ + "clan", "tiles", "gold", "maxtroops", @@ -120,6 +123,7 @@ describe("UserSettings stats columns", () => { 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([ + "clan", "tiles", "gold", "maxtroops", diff --git a/tests/client/LeaderboardModal.test.ts b/tests/client/LeaderboardModal.test.ts index c7cd7d58d9..a6baceb2d7 100644 --- a/tests/client/LeaderboardModal.test.ts +++ b/tests/client/LeaderboardModal.test.ts @@ -154,6 +154,7 @@ describe("LeaderboardModal", () => { updateComplete: Promise; playerData: Array>; currentUserEntry?: { playerId: string } | null; + showStickyUser: boolean; } | null; beforeEach(async () => { @@ -301,7 +302,6 @@ describe("LeaderboardModal", () => { expect.objectContaining({ playerId: "player-1", accountUsername: "alpha.4821", - clanTag: "[AAA]", elo: 1200, games: 10, wins: 6, @@ -315,12 +315,121 @@ describe("LeaderboardModal", () => { expect.objectContaining({ playerId: "player-2", accountUsername: null, - clanTag: undefined, winRate: 0.4, }), ); expect(playerList!.currentUserEntry?.playerId).toBe("player-2"); }); + + // 1v1 ranked is an individual ladder, and the tag the API reports is + // whatever the player last used in any mode — so it is not carried over. + it("drops the clan tag the API sends", async () => { + (global.fetch as ReturnType).mockResolvedValueOnce( + jsonRes({ + "1v1": [ + { + rank: 1, + elo: 1200, + peakElo: 1300, + wins: 6, + losses: 4, + total: 10, + public_id: "player-1", + username: "Alpha", + accountUsername: "alpha.4821", + clanTag: "[AAA]", + }, + ], + }), + ); + + const playerList = getPlayerList()!; + await playerList.loadPlayerLeaderboard(true); + await playerList.updateComplete; + + expect(playerList.playerData[0]).not.toHaveProperty("clanTag"); + expect(modal.textContent).not.toContain("[AAA]"); + }); + + // The pinned "your ranking" row has to stay inside the table — its column + // widths come from the table's own colgroup, so anything rendered outside + // it goes out of alignment the moment the modal is resized. The cell count + // is what breaks if a column is added to the header and not the footer. + it("pins the user row as a tfoot inside the table", async () => { + const { getUserMe } = await import("../../src/client/Api"); + (getUserMe as ReturnType).mockResolvedValueOnce({ + player: { publicId: "player-1" }, + }); + + (global.fetch as ReturnType).mockResolvedValueOnce( + jsonRes({ + "1v1": [ + { + rank: 1, + elo: 1200, + peakElo: 1300, + wins: 6, + losses: 4, + total: 10, + public_id: "player-1", + username: "Alpha", + accountUsername: "alpha.4821", + }, + ], + }), + ); + + const playerList = getPlayerList()!; + await playerList.loadPlayerLeaderboard(true); + // jsdom has no layout, so the scroll-position check that normally drives + // this always concludes the user's own row is on screen. Force it on. + playerList.showStickyUser = true; + await playerList.updateComplete; + + const table = modal.querySelector("leaderboard-player-list table")!; + const tfoot = table.querySelector(":scope > tfoot"); + expect(tfoot).toBeTruthy(); + expect(tfoot!.querySelectorAll("td")).toHaveLength( + table.querySelectorAll("thead th").length, + ); + }); + + // opacity leaves the row in flow: hiding it that way reserved a row of + // blank space at the end of the table. + it("removes the pinned row from the table when it is not showing", async () => { + const { getUserMe } = await import("../../src/client/Api"); + (getUserMe as ReturnType).mockResolvedValueOnce({ + player: { publicId: "player-1" }, + }); + + (global.fetch as ReturnType).mockResolvedValueOnce( + jsonRes({ + "1v1": [ + { + rank: 1, + elo: 1200, + peakElo: 1300, + wins: 6, + losses: 4, + total: 10, + public_id: "player-1", + username: "Alpha", + accountUsername: "alpha.4821", + }, + ], + }), + ); + + const playerList = getPlayerList()!; + await playerList.loadPlayerLeaderboard(true); + playerList.showStickyUser = false; + await playerList.updateComplete; + + expect(playerList.currentUserEntry?.playerId).toBe("player-1"); + expect( + modal.querySelector("leaderboard-player-list table tfoot"), + ).toBeNull(); + }); }); describe("Player Pagination", () => { diff --git a/tests/client/view/PlayerView.test.ts b/tests/client/view/PlayerView.test.ts index d99e82d74c..cc2bfa0660 100644 --- a/tests/client/view/PlayerView.test.ts +++ b/tests/client/view/PlayerView.test.ts @@ -338,3 +338,51 @@ describe("PlayerView emoji display setting (#4430)", () => { expect(p.state.outgoingEmojis).toEqual([]); }); }); + +describe("PlayerView clan tag", () => { + function resetSettings() { + localStorage.clear(); + ( + UserSettings as unknown as { cache: Map } + ).cache.clear(); + } + + function setAnonymousNames(enabled: boolean) { + localStorage.setItem("settings.anonymousNames", String(enabled)); + ( + UserSettings as unknown as { cache: Map } + ).cache.clear(); + } + + beforeEach(resetSettings); + + it("forwards the tag without brackets, alongside the merged displayName", () => { + const p = makePlayerView({ + data: { name: "Alice", displayName: "[ABCDE] Alice", clanTag: "ABCDE" }, + }); + expect(p.clanTag()).toBe("ABCDE"); + expect(p.name()).toBe("Alice"); + expect(p.displayName()).toBe("[ABCDE] Alice"); + }); + + it("is null when the update carries no tag", () => { + expect(makePlayerView().clanTag()).toBeNull(); + expect( + makePlayerView({ data: { clanTag: undefined } }).clanTag(), + ).toBeNull(); + }); + + it("is hidden under anonymous names, like displayName", () => { + const p = makePlayerView({ + data: { + clientID: "someone-else", + name: "Alice", + displayName: "[ABCDE] Alice", + clanTag: "ABCDE", + }, + }); + setAnonymousNames(true); + expect(p.clanTag()).toBeNull(); + expect(p.displayName()).not.toContain("ABCDE"); + }); +}); diff --git a/tests/core/executions/SAMLauncherExecution.test.ts b/tests/core/executions/SAMLauncherExecution.test.ts index 4273159a30..167cdbf5db 100644 --- a/tests/core/executions/SAMLauncherExecution.test.ts +++ b/tests/core/executions/SAMLauncherExecution.test.ts @@ -7,6 +7,7 @@ import { Player, PlayerInfo, PlayerType, + Unit, UnitType, } from "../../../src/core/game/Game"; import { GameID } from "../../../src/core/Schemas"; @@ -305,4 +306,110 @@ describe("SAM", () => { expect(sam.missileTimerQueue()).toHaveLength(0); }); + + test("SAM should prioritize nuke targeting close to SAM launcher over distant nuke", async () => { + const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); + game.addExecution(new SAMLauncherExecution(defender, null, sam)); + + // Distant AtomBomb landing far away (at 10, 1) + attacker.buildUnit(UnitType.AtomBomb, game.ref(2, 1), { + targetTile: game.ref(10, 1), + trajectory: [ + { tile: game.ref(2, 1), targetable: true }, + { tile: game.ref(5, 3), targetable: true }, + { tile: game.ref(10, 1), targetable: true }, + ], + }); + + // Close AtomBomb targeting right on the SAM (1, 1) + const dangerousNuke = attacker.buildUnit( + UnitType.AtomBomb, + game.ref(1, 2), + { + targetTile: game.ref(1, 1), + trajectory: [ + { tile: game.ref(1, 1), targetable: true }, + { tile: game.ref(1, 2), targetable: true }, + { tile: game.ref(1, 1), targetable: true }, + ], + }, + ); + + executeTicks(game, 3); + + // The dangerous nuke aimed directly at the SAM launcher should be intercepted first + expect(dangerousNuke.isActive()).toBeFalsy(); + }); + + test("upgraded SAM launcher should launch multiple missiles in a single tick if multiple targets arrive", async () => { + const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); + sam.increaseLevel(); // Level 2 allows 2 missile slots + sam.reloadMissile(); // Reload the slot added by increaseLevel() + expect(sam.level()).toBe(2); + expect(sam.isInCooldown()).toBeFalsy(); + + game.addExecution(new SAMLauncherExecution(defender, null, sam)); + + const nuke1 = attacker.buildUnit(UnitType.AtomBomb, game.ref(2, 1), { + targetTile: game.ref(3, 1), + trajectory: [ + { tile: game.ref(1, 1), targetable: true }, + { tile: game.ref(2, 1), targetable: true }, + { tile: game.ref(3, 1), targetable: true }, + ], + }); + + const nuke2 = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 2), { + targetTile: game.ref(1, 3), + trajectory: [ + { tile: game.ref(1, 1), targetable: true }, + { tile: game.ref(1, 2), targetable: true }, + { tile: game.ref(1, 3), targetable: true }, + ], + }); + + executeTicks(game, 3); + + // Both nukes should be intercepted simultaneously by the level-2 SAM launcher + expect(nuke1.isActive()).toBeFalsy(); + expect(nuke2.isActive()).toBeFalsy(); + }); + + test("high-level SAM launcher should shoot down multiple MIRV warheads arriving in the same tick", async () => { + const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); + for (let i = 1; i < 50; i++) { + sam.increaseLevel(); + sam.reloadMissile(); + } + expect(sam.level()).toBe(50); + expect(sam.isInCooldown()).toBeFalsy(); + + game.addExecution(new SAMLauncherExecution(defender, null, sam)); + + const warheads: Unit[] = []; + for (let i = 0; i < 10; i++) { + const warhead = attacker.buildUnit( + UnitType.MIRVWarhead, + game.ref(1, 2 + i), + { + targetTile: game.ref(1, 15 + i), + trajectory: [ + { tile: game.ref(1, 1), targetable: true }, + { tile: game.ref(1, 2 + i), targetable: true }, + { tile: game.ref(1, 15 + i), targetable: true }, + ], + }, + ); + warheads.push(warhead); + } + + expect(attacker.units(UnitType.MIRVWarhead)).toHaveLength(10); + + executeTicks(game, 3); + + expect(attacker.units(UnitType.MIRVWarhead)).toHaveLength(0); + for (const w of warheads) { + expect(w.isActive()).toBeFalsy(); + } + }); }); diff --git a/tests/core/pathfinding/UniversalPathFinding.Parabola.test.ts b/tests/core/pathfinding/UniversalPathFinding.Parabola.test.ts index b00215da3e..24b02eaeea 100644 --- a/tests/core/pathfinding/UniversalPathFinding.Parabola.test.ts +++ b/tests/core/pathfinding/UniversalPathFinding.Parabola.test.ts @@ -22,7 +22,7 @@ describe("UniversalPathFinding.Parabola", () => { const path = finder.findPath(from, to); expect(path).not.toBeNull(); - expect(path!.length).toBe(39); + expect(path!.length).toBe(40); expect(path![0]).toBe(from); expect(path![path!.length - 1]).toBe(to); }); @@ -61,7 +61,7 @@ describe("UniversalPathFinding.Parabola", () => { const path = finder.findPath(from, to); expect(path).not.toBeNull(); - expect(path!.length).toBe(43); + expect(path!.length).toBe(45); expect(path![0]).toBe(from); expect(path![path!.length - 1]).toBe(to); }); @@ -240,7 +240,7 @@ describe("UniversalPathFinding.Parabola", () => { const path = finder.findPath(from, to); expect(path).not.toBeNull(); - expect(path!.length).toBe(28); + expect(path!.length).toBe(29); expect(path![0]).toBe(from); expect(path![path!.length - 1]).toBe(to); }); diff --git a/tests/util/viewStubs.ts b/tests/util/viewStubs.ts index 868990b9c6..483e326395 100644 --- a/tests/util/viewStubs.ts +++ b/tests/util/viewStubs.ts @@ -118,6 +118,7 @@ export function makePlayerUpdate( clientID: "client-a", name: "Alice", displayName: "Alice", + clanTag: null, id: "player-a", smallID: 1, playerType: PlayerType.Human,