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.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,
|