Merge branch 'v31'

This commit is contained in:
evanpelle
2026-05-06 13:09:58 -06:00
21 changed files with 366 additions and 597 deletions
+43 -74
View File
@@ -60,8 +60,20 @@ export class ClanModal extends BaseModal {
} | null = null;
render() {
const content = this.renderInner();
if (this.inline) return content;
const onListView = this.view === "list" && !this.selectedClanTag;
const tabs = onListView
? [
{ key: "my-clans", label: translateText("clan_modal.my_clans") },
{ key: "browse", label: translateText("clan_modal.browse") },
]
: [];
const header = onListView
? modalHeader({
title: translateText("clan_modal.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
})
: null;
return html`
<o-modal
id="clan-modal"
@@ -69,12 +81,26 @@ export class ClanModal extends BaseModal {
?hideCloseButton=${true}
?inline=${this.inline}
hideHeader
.tabs=${tabs}
.activeTab=${this.activeTab}
.onTabChange=${(key: string) => this.handleTabChange(key as Tab)}
>
${content}
${header ? html`<div slot="header">${header}</div>` : ""}
${this.renderInner()}
</o-modal>
`;
}
private handleTabChange(tab: Tab) {
this.activeTab = tab;
this.view = "list";
this.selectedClan = null;
this.selectedClanTag = "";
if (tab === "my-clans") {
this.loadMyClans();
}
}
protected onOpen(): void {
this.loadMyClans();
}
@@ -131,16 +157,7 @@ export class ClanModal extends BaseModal {
private renderInner() {
if (this.loading) {
return html`
<div class="${this.modalContainerClass}">
${modalHeader({
title: translateText("clan_modal.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
})}
${this.renderLoadingSpinner()}
</div>
`;
return this.renderLoadingSpinner();
}
if (this.view === "my-requests") {
@@ -289,30 +306,20 @@ export class ClanModal extends BaseModal {
></clan-detail-view>`;
}
// List view (tabs + my clans / browse)
// List view (my clans / browse) — header + tabs are rendered by o-modal
return html`
<div class="${this.modalContainerClass}">
${modalHeader({
title: translateText("clan_modal.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
})}
${this.renderTabs()}
<div class="flex-1 overflow-y-auto custom-scrollbar mr-1">
${this.activeTab === "my-clans"
? this.renderMyClans()
: html`<clan-browse-view
.myClanRoles=${this.myClanRoles}
.myPendingRequests=${this.myPendingRequests}
.cachedState=${this.browseCache}
@browse-updated=${(e: CustomEvent<BrowseState>) => {
this.browseCache = e.detail;
}}
@clan-select=${(e: CustomEvent<{ tag: string }>) =>
this.openDetail(e.detail.tag)}
></clan-browse-view>`}
</div>
</div>
${this.activeTab === "my-clans"
? this.renderMyClans()
: html`<clan-browse-view
.myClanRoles=${this.myClanRoles}
.myPendingRequests=${this.myPendingRequests}
.cachedState=${this.browseCache}
@browse-updated=${(e: CustomEvent<BrowseState>) => {
this.browseCache = e.detail;
}}
@clan-select=${(e: CustomEvent<{ tag: string }>) =>
this.openDetail(e.detail.tag)}
></clan-browse-view>`}
`;
}
@@ -321,44 +328,6 @@ export class ClanModal extends BaseModal {
this.view = "detail";
}
private renderTabs() {
const tabs: { key: Tab; label: string }[] = [
{ key: "my-clans", label: translateText("clan_modal.my_clans") },
{ key: "browse", label: translateText("clan_modal.browse") },
];
return html`
<div class="flex border-b border-white/10 px-4 lg:px-6 gap-1">
${tabs.map(
(tab) => html`
<button
@click=${() => {
this.activeTab = tab.key;
this.view = "list";
this.selectedClan = null;
this.selectedClanTag = "";
if (tab.key === "my-clans") {
this.loadMyClans();
}
}}
class="px-4 py-3 text-sm font-bold uppercase tracking-wider transition-all relative
${this.activeTab === tab.key
? "text-aquarius"
: "text-white/40 hover:text-white/70"}"
>
${tab.label}
${this.activeTab === tab.key
? html`<div
class="absolute bottom-0 left-0 right-0 h-0.5 bg-malibu-blue"
></div>`
: ""}
</button>
`,
)}
</div>
`;
}
private renderMyClans() {
const hasClans = this.myClans.length > 0;
const hasRequests = this.myPendingRequests.length > 0;
+1 -1
View File
@@ -73,7 +73,7 @@ export class FlagInput extends LitElement {
return html`
<button
id="flag-input"
class="flag-btn p-0 m-0 border-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-surface hover:brightness-[1.08] active:brightness-[0.95] rounded-lg overflow-hidden"
class="flag-btn p-0 m-0 border-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:shadow-[var(--shadow-action-card-hover)] rounded-lg overflow-hidden"
title=${buttonTitle}
@click=${this.onInputClick}
>
+8 -8
View File
@@ -120,7 +120,7 @@ export class GameModeSelector extends LitElement {
${this.renderSmallActionCard(
translateText("main.solo"),
this.openSinglePlayerModal,
"bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80",
"bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80 hover:scale-y-105 hover:scale-x-[1.01]",
)}
</div>
<!-- Create/ranked/join: mobile only, below solo -->
@@ -128,19 +128,19 @@ export class GameModeSelector extends LitElement {
${this.renderSmallActionCard(
translateText("main.create"),
this.openHostLobby,
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-lobby-card-hover)]",
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]",
)}
${!crazyGamesSDK.isOnCrazyGames()
? this.renderSmallActionCard(
translateText("mode_selector.ranked_title"),
this.openRankedMenu,
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-lobby-card-hover)]",
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]",
)
: html`<div class="invisible"></div>`}
${this.renderSmallActionCard(
translateText("main.join"),
this.openJoinLobby,
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-lobby-card-hover)]",
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]",
)}
</div>
<!-- iOS Add to Home Screen banner -->
@@ -192,7 +192,7 @@ export class GameModeSelector extends LitElement {
${this.renderSmallActionCard(
translateText("main.solo"),
this.openSinglePlayerModal,
"bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80",
"bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80 hover:scale-y-105 hover:scale-x-[1.01]",
)}
</div>
<!-- Bottom row: create + ranked + join (desktop only) -->
@@ -200,19 +200,19 @@ export class GameModeSelector extends LitElement {
${this.renderSmallActionCard(
translateText("main.create"),
this.openHostLobby,
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-lobby-card-hover)]",
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]",
)}
${!crazyGamesSDK.isOnCrazyGames()
? this.renderSmallActionCard(
translateText("mode_selector.ranked_title"),
this.openRankedMenu,
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-lobby-card-hover)]",
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]",
)
: html`<div class="invisible"></div>`}
${this.renderSmallActionCard(
translateText("main.join"),
this.openJoinLobby,
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-lobby-card-hover)]",
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]",
)}
</div>
</div>
-1
View File
@@ -210,7 +210,6 @@ export class LangSelector extends LitElement {
"join-lobby-modal",
"emoji-table",
"leader-board",
"leaderboard-tabs",
"leaderboard-player-list",
"leaderboard-clan-table",
"build-menu",
+39 -42
View File
@@ -5,7 +5,6 @@ import "./components/leaderboard/LeaderboardClanTable";
import type { LeaderboardClanTable } from "./components/leaderboard/LeaderboardClanTable";
import "./components/leaderboard/LeaderboardPlayerList";
import type { LeaderboardPlayerList } from "./components/leaderboard/LeaderboardPlayerList";
import "./components/leaderboard/LeaderboardTabs";
import { modalHeader } from "./components/ui/ModalHeader";
import { translateText } from "./Utils";
@@ -81,46 +80,13 @@ export class LeaderboardModal extends BaseModal {
>(${translateText("leaderboard_modal.refresh_time")})</span
>`;
const content = html`
<div class="${this.modalContainerClass}">
${modalHeader({
titleContent: html`
<div class="flex flex-wrap items-center gap-2">
<span
class="text-white text-xl sm:text-2xl font-bold uppercase tracking-widest"
>
${translateText("leaderboard_modal.title")}
</span>
${this.activeTab === "clans" ? dateRange : ""}
${this.activeTab === "players" ? refreshTime : ""}
</div>
`,
onBack: () => this.close(),
ariaLabel: translateText("common.close"),
})}
<div class="flex-1 flex flex-col min-h-0">
<leaderboard-tabs
.activeTab=${this.activeTab}
@tab-change=${(event: CustomEvent<"players" | "clans">) =>
this.handleTabChange(event.detail)}
></leaderboard-tabs>
<div class="flex-1 min-h-0">
<leaderboard-player-list
class=${this.activeTab === "players" ? "h-full" : "hidden"}
></leaderboard-player-list>
<leaderboard-clan-table
class=${this.activeTab === "clans" ? "h-full" : "hidden"}
@date-range-change=${(
event: CustomEvent<{ start: string; end: string }>,
) => this.handleClanDateRangeChange(event)}
></leaderboard-clan-table>
</div>
</div>
</div>
`;
if (this.inline) return content;
const tabs = [
{
key: "players",
label: translateText("leaderboard_modal.ranked_tab"),
},
{ key: "clans", label: translateText("leaderboard_modal.clans_tab") },
];
return html`
<o-modal
@@ -128,8 +94,39 @@ export class LeaderboardModal extends BaseModal {
?inline=${this.inline}
hideCloseButton
hideHeader
.tabs=${tabs}
.activeTab=${this.activeTab}
.onTabChange=${(key: string) =>
this.handleTabChange(key as "players" | "clans")}
>
${content}
<div slot="header">
${modalHeader({
titleContent: html`
<div class="flex flex-wrap items-center gap-2">
<span
class="text-white text-xl sm:text-2xl font-bold uppercase tracking-widest"
>
${translateText("leaderboard_modal.title")}
</span>
${this.activeTab === "clans" ? dateRange : ""}
${this.activeTab === "players" ? refreshTime : ""}
</div>
`,
onBack: () => this.close(),
ariaLabel: translateText("common.close"),
})}
</div>
<div class="flex-1 min-h-0 h-full">
<leaderboard-player-list
class=${this.activeTab === "players" ? "h-full" : "hidden"}
></leaderboard-player-list>
<leaderboard-clan-table
class=${this.activeTab === "clans" ? "h-full" : "hidden"}
@date-range-change=${(
event: CustomEvent<{ start: string; end: string }>,
) => this.handleClanDateRangeChange(event)}
></leaderboard-clan-table>
</div>
</o-modal>
`;
}
+1 -1
View File
@@ -131,7 +131,7 @@ export class PatternInput extends LitElement {
return html`
<button
id="pattern-input"
class="pattern-btn m-0 p-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-surface hover:brightness-[1.08] active:brightness-[0.95] rounded-lg overflow-hidden"
class="pattern-btn m-0 p-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:shadow-[var(--shadow-action-card-hover)] rounded-lg overflow-hidden"
title=${buttonTitle}
@click=${this.onInputClick}
>
+23 -53
View File
@@ -41,43 +41,12 @@ export class StoreModal extends BaseModal {
}
private renderHeader(): TemplateResult {
return html`
${modalHeader({
title: translateText("store.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent: html`<not-logged-in-warning></not-logged-in-warning>`,
})}
<div class="flex items-center gap-2 justify-center pt-2">
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "packs"
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "packs")}
>
${translateText("store.packs")}
</button>
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "patterns"
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "patterns")}
>
${translateText("store.patterns")}
</button>
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "flags"
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "flags")}
>
${translateText("store.flags")}
</button>
</div>
`;
return modalHeader({
title: translateText("store.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent: html`<not-logged-in-warning></not-logged-in-warning>`,
});
}
private renderPatternGrid(): TemplateResult {
@@ -188,22 +157,18 @@ export class StoreModal extends BaseModal {
render() {
if (!this.isActive && !this.inline) return html``;
const content = html`
<div class="${this.modalContainerClass}">
${this.renderHeader()}
<div class="overflow-y-auto pr-2 custom-scrollbar mr-1">
${this.activeTab === "patterns"
? this.renderPatternGrid()
: this.activeTab === "flags"
? this.renderFlagGrid()
: this.renderPackGrid()}
</div>
</div>
`;
const tabs = [
{ key: "packs", label: translateText("store.packs") },
{ key: "patterns", label: translateText("store.patterns") },
{ key: "flags", label: translateText("store.flags") },
];
if (this.inline) {
return content;
}
const grid =
this.activeTab === "patterns"
? this.renderPatternGrid()
: this.activeTab === "flags"
? this.renderFlagGrid()
: this.renderPackGrid();
return html`
<o-modal
@@ -212,8 +177,13 @@ export class StoreModal extends BaseModal {
?inline=${this.inline}
?hideHeader=${true}
?hideCloseButton=${true}
.tabs=${tabs}
.activeTab=${this.activeTab}
.onTabChange=${(key: string) =>
(this.activeTab = key as "patterns" | "flags" | "packs")}
>
${content}
<div slot="header">${this.renderHeader()}</div>
${grid}
</o-modal>
`;
}
+17 -46
View File
@@ -319,51 +319,10 @@ export class UserSettingModal extends BaseModal {
? this.renderBasicSettings()
: this.renderKeybindSettings();
const content = html`
<div class="${this.modalContainerClass}">
<div
class="relative flex flex-col border-b border-white/10 lg:pb-4 shrink-0"
>
${modalHeader({
title: translateText("user_setting.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
showDivider: true,
})}
<div class="hidden lg:flex items-center gap-2 justify-center mt-4">
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "basic"
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "basic")}
>
${translateText("user_setting.tab_basic")}
</button>
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "keybinds"
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "keybinds")}
>
${translateText("user_setting.tab_keybinds")}
</button>
</div>
</div>
<div
class="pt-6 flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent px-6 pb-6 mr-1"
>
<div class="flex flex-col gap-2">${activeContent}</div>
</div>
</div>
`;
if (this.inline) {
return content;
}
const tabs = [
{ key: "basic", label: translateText("user_setting.tab_basic") },
{ key: "keybinds", label: translateText("user_setting.tab_keybinds") },
];
return html`
<o-modal
@@ -371,8 +330,20 @@ export class UserSettingModal extends BaseModal {
?inline=${this.inline}
hideCloseButton
hideHeader
.tabs=${tabs}
.activeTab=${this.activeTab}
.onTabChange=${(key: string) =>
(this.activeTab = key as "basic" | "keybinds")}
>
${content}
<div slot="header">
${modalHeader({
title: translateText("user_setting.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
showDivider: true,
})}
</div>
<div class="flex flex-col gap-2">${activeContent}</div>
</o-modal>
`;
}
+89 -34
View File
@@ -2,6 +2,8 @@ import { LitElement, html, unsafeCSS } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import tailwindStyles from "../../styles.css?inline";
export type OModalTab = { key: string; label: string };
@customElement("o-modal")
export class OModal extends LitElement {
static styles = [unsafeCSS(tailwindStyles)];
@@ -28,6 +30,15 @@ export class OModal extends LitElement {
@property({ type: String })
public maxWidth = "";
@property({ type: Array })
public tabs: OModalTab[] = [];
@property({ type: String })
public activeTab = "";
@property({ attribute: false })
public onTabChange?: (key: string) => void;
public onClose?: () => void;
public open() {
@@ -60,7 +71,48 @@ export class OModal extends LitElement {
super.disconnectedCallback();
}
private handleTabClick(key: string) {
this.onTabChange?.(key);
}
private renderTabs() {
return html`
<div
role="tablist"
class="flex justify-center border-b border-white/10 px-4 lg:px-6 gap-1 shrink-0"
>
${this.tabs.map((tab) => {
const active = this.activeTab === tab.key;
return html`
<button
type="button"
role="tab"
data-key=${tab.key}
aria-selected=${active}
class="px-4 py-3 text-sm font-bold uppercase tracking-wider transition-all relative cursor-pointer ${active
? "text-aquarius"
: "text-white/40 hover:text-white/70"}"
@click=${() => this.handleTabClick(tab.key)}
>
${tab.label}
${active
? html`<div
class="absolute bottom-0 left-0 right-0 h-0.5 bg-malibu-blue"
></div>`
: ""}
</button>
`;
})}
</div>
`;
}
render() {
const shouldRender = this.isModalOpen || this.inline;
if (!shouldRender) {
return html``;
}
const backdropClass = this.inline
? "relative z-10 w-full h-full flex items-stretch bg-transparent"
: "fixed inset-0 z-[9999] bg-black/60 flex items-center justify-center overflow-hidden";
@@ -73,42 +125,45 @@ export class OModal extends LitElement {
const wrapperStyle =
!this.inline && this.maxWidth ? `max-width: ${this.maxWidth};` : "";
const hasTabs = this.tabs.length > 0;
const sectionClass = hasTabs
? "relative flex-1 min-h-0 flex flex-col text-white bg-black/70 backdrop-blur-xl lg:rounded-2xl lg:border border-white/10 overflow-hidden"
: "relative flex-1 min-h-0 flex flex-col text-white bg-[#23232382] backdrop-blur-md lg:rounded-lg overflow-hidden";
return html`
${this.isModalOpen
? html`
<aside
class="${backdropClass}"
@click=${this.inline ? null : () => this.close()}
>
<div
@click=${(e: Event) => e.stopPropagation()}
class="${wrapperClass}"
style="${wrapperStyle}"
<aside
class="${backdropClass}"
@click=${this.inline ? null : () => this.close()}
>
<div
@click=${(e: Event) => e.stopPropagation()}
class="${wrapperClass}"
style="${wrapperStyle}"
>
${this.inline || this.hideCloseButton
? html``
: html`<div
class="absolute top-5 right-5 z-10 text-white cursor-pointer"
@click=${() => this.close()}
>
${this.inline || this.hideCloseButton
? html``
: html`<div
class="absolute top-5 right-5 z-10 text-white cursor-pointer"
@click=${() => this.close()}
>
</div>`}
${!this.hideHeader && this.title
? html`<div
class="px-[1.4rem] py-[1rem] text-2xl font-bold text-white"
>
${this.title}
</div>`
: html``}
<section
class="relative flex-1 min-h-0 p-0 lg:p-[1.4rem] text-white bg-[#23232382] backdrop-blur-md lg:rounded-lg overflow-y-auto"
>
<slot></slot>
</section>
</div>
</aside>
`
: html``}
</div>`}
${!this.hideHeader && this.title
? html`<div
class="px-[1.4rem] py-[1rem] text-2xl font-bold text-white"
>
${this.title}
</div>`
: html``}
<section class="${sectionClass}">
<slot name="header"></slot>
${hasTabs ? this.renderTabs() : html``}
<div class="flex-1 min-h-0 overflow-y-auto p-0 lg:p-[1.4rem]">
<slot></slot>
</div>
</section>
</div>
</aside>
`;
}
}
@@ -1,75 +0,0 @@
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { translateText } from "../../Utils";
export type LeaderboardTab = "players" | "clans";
@customElement("leaderboard-tabs")
export class LeaderboardTabs extends LitElement {
@property({ type: String }) activeTab: LeaderboardTab = "players";
createRenderRoot() {
return this;
}
private baseTabClass =
"px-6 py-2 rounded-full text-sm font-bold uppercase tracking-wider transition-all cursor-pointer select-none";
private activeTabClass = "bg-blue-600 text-white";
private inactiveTabClass =
"text-white/40 hover:text-white/60 hover:bg-white/5";
private getTabClass(active: boolean) {
return [
this.baseTabClass,
active ? this.activeTabClass : this.inactiveTabClass,
].join(" ");
}
@state()
private playerClass = this.getTabClass(this.activeTab === "players");
@state()
private clanClass = this.getTabClass(this.activeTab === "clans");
private handleTabChange(tab: LeaderboardTab) {
this.dispatchEvent(
new CustomEvent<LeaderboardTab>("tab-change", {
detail: tab,
bubbles: true,
composed: true,
}),
);
this.playerClass = this.getTabClass(tab === "players");
this.clanClass = this.getTabClass(tab === "clans");
}
render() {
return html`
<div
role="tablist"
class="flex gap-2 p-1 bg-white/5 rounded-full border border-white/10 mb-4 w-fit mx-auto mt-4"
>
<button
type="button"
role="tab"
class="${this.playerClass}"
@click=${() => this.handleTabChange("players")}
id="player-leaderboard-tab"
aria-selected=${this.activeTab === "players"}
>
${translateText("leaderboard_modal.ranked_tab")}
</button>
<button
type="button"
role="tab"
class="${this.clanClass}"
@click=${() => this.handleTabChange("clans")}
id="clan-leaderboard-tab"
aria-selected=${this.activeTab === "clans"}
>
${translateText("leaderboard_modal.clans_tab")}
</button>
</div>
`;
}
}
@@ -1,54 +1,33 @@
import { assetUrl } from "../../../core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import { Cell } from "../../../core/game/Game";
import { Cell, PlayerType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { AlternateViewEvent } from "../../InputHandler";
import { renderTroops } from "../../Utils";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
const soldierIcon = assetUrl("images/SoldierIcon.svg");
// Match AttacksDisplay: aquarius for outgoing, red-400 for incoming.
const OUTGOING_COLOR = "var(--color-aquarius)";
const INCOMING_COLOR = "var(--color-red-400)";
// At/above this zoom, the label stays at its full screen size. Below it the
// label shrinks linearly with zoom-out, floored so it never disappears.
const LABEL_FULL_SIZE_ZOOM = 1.5;
const LABEL_MIN_SCREEN_SCALE = 0.5;
const OUTGOING_ICON_FILTER =
"brightness(0) saturate(100%) invert(62%) sepia(80%) saturate(500%) hue-rotate(175deg) brightness(100%)";
const INCOMING_ICON_FILTER =
"brightness(0) saturate(100%) invert(27%) sepia(91%) saturate(4551%) hue-rotate(348deg) brightness(89%) contrast(97%)";
// At/above this zoom the label is rendered at full size; below it shrinks
// linearly toward LABEL_MIN_RENDERED_SIZE as zoom→0.
const LABEL_FULL_SIZE_ZOOM = 4.0;
const LABEL_MIN_RENDERED_SIZE = 0.63;
// Overall size multiplier applied to the rendered label.
const LABEL_SIZE_MULTIPLIER = 1.0;
// Vertical strength bar to the left of the icon: grows in height as the
// attacker outnumbers the opposition. Maxes out at BAR_MAX_HEIGHT_PX when the
// attacker has BAR_FULL_HEIGHT_RATIO× the opposing troops.
const BAR_FULL_HEIGHT_RATIO = 2;
const BAR_MAX_HEIGHT_PX = 13;
// Element scale factor that, combined with the container's `scale(zoom)`,
// yields the desired on-screen label size: constant screen size when zoomed
// in past LABEL_FULL_SIZE_ZOOM, then shrinking linearly as zoom drops, with a
// floor at LABEL_MIN_SCREEN_SCALE so the label never disappears.
// Counter-scale against the container's `scale(zoom)`. At/above
// LABEL_FULL_SIZE_ZOOM the rendered size is capped at LABEL_SIZE_MULTIPLIER;
// below it the rendered size shrinks linearly toward
// LABEL_SIZE_MULTIPLIER * LABEL_MIN_RENDERED_SIZE as zoom→0.
export function computeLabelScale(zoom: number): number {
const netScale = Math.max(
LABEL_MIN_SCREEN_SCALE,
Math.min(1, zoom / LABEL_FULL_SIZE_ZOOM),
);
return netScale / zoom;
}
// Fraction (01) of BAR_MAX_HEIGHT_PX the strength bar should occupy. 0 means
// the attacker is harmless; 1 means they have BAR_FULL_HEIGHT_RATIO× or more
// of the opposing troops.
export function computeBarStrength(
attackerTroops: number,
opposingTroops: number,
): number {
if (opposingTroops <= 0) return 1;
return Math.min(1, attackerTroops / opposingTroops / BAR_FULL_HEIGHT_RATIO);
const t = Math.min(1, zoom / LABEL_FULL_SIZE_ZOOM);
const renderedSize =
LABEL_SIZE_MULTIPLIER *
(LABEL_MIN_RENDERED_SIZE + (1 - LABEL_MIN_RENDERED_SIZE) * t);
return renderedSize / zoom;
}
// Worker returns clusters sorted by size; two near-equal-size fronts can flip
@@ -70,7 +49,6 @@ interface AttackLabel {
positions: (Cell | null)[];
isIncoming: boolean;
attackerTroops: number;
barStrength: number;
}
export class AttackingTroopsOverlay implements Layer {
@@ -144,7 +122,7 @@ export class AttackingTroopsOverlay implements Layer {
const activeIDs = new Set<string>();
// Outgoing: cyan bar widens as our attack outnumbers the defender.
// Outgoing: only label attacks targeting another player.
for (const attack of myPlayer.outgoingAttacks()) {
activeIDs.add(attack.id);
if (!attack.targetID) {
@@ -156,20 +134,22 @@ export class AttackingTroopsOverlay implements Layer {
this.removeLabel(attack.id);
continue;
}
const barStrength = computeBarStrength(attack.troops, defender.troops());
this.ensureLabel(attack.id, attack.troops, false, barStrength);
this.ensureLabel(attack.id, attack.troops, false);
}
// Incoming: red bar widens as the attacker outnumbers the player.
// Incoming: only label attacks coming from another player; skip tribes.
for (const attack of myPlayer.incomingAttacks()) {
activeIDs.add(attack.id);
const attacker = this.game.playerBySmallID(attack.attackerID);
if (!attacker || !attacker.isPlayer()) {
if (
!attacker ||
!attacker.isPlayer() ||
attacker.type() === PlayerType.Bot
) {
this.removeLabel(attack.id);
continue;
}
const barStrength = computeBarStrength(attack.troops, myPlayer.troops());
this.ensureLabel(attack.id, attack.troops, true, barStrength);
this.ensureLabel(attack.id, attack.troops, true);
}
for (const [id] of this.labels) {
@@ -202,7 +182,6 @@ export class AttackingTroopsOverlay implements Layer {
attackID: string,
attackerTroops: number,
isIncoming: boolean,
barStrength: number,
) {
let label = this.labels.get(attackID);
if (!label) {
@@ -211,15 +190,13 @@ export class AttackingTroopsOverlay implements Layer {
positions: [],
isIncoming,
attackerTroops,
barStrength,
};
this.labels.set(attackID, label);
} else {
label.attackerTroops = attackerTroops;
label.barStrength = barStrength;
}
for (const el of label.elements) {
this.updateLabelContent(el, attackerTroops, barStrength);
this.updateLabelContent(el, attackerTroops);
}
}
@@ -235,6 +212,7 @@ export class AttackingTroopsOverlay implements Layer {
// Hoist the per-frame label scale once; zoom is constant within a frame.
const scale = this.labelScale();
const innerTransform = `scale(${scale})`;
for (const label of this.labels.values()) {
for (let i = 0; i < label.elements.length; i++) {
const el = label.elements[i];
@@ -245,15 +223,17 @@ export class AttackingTroopsOverlay implements Layer {
continue;
}
el.style.display = "inline-flex";
// Centre the label on its world position; counter-scale keeps the
// label at constant screen size while zoomed in, then it shrinks
// (floored) as zoom drops below LABEL_FULL_SIZE_ZOOM.
const transform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%) scale(${scale})`;
if (this.lastTransform.get(el) !== transform) {
el.style.transform = transform;
this.lastTransform.set(el, transform);
el.style.display = "";
const inner = el.children[0] as HTMLDivElement;
// Outer: world position only — the 0.25s transition smooths cluster
// shifts. Inner: scale only — applied without transition so zoom is
// instant.
const outerTransform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%)`;
if (this.lastTransform.get(el) !== outerTransform) {
el.style.transform = outerTransform;
this.lastTransform.set(el, outerTransform);
}
inner.style.transform = innerTransform;
}
}
}
@@ -262,11 +242,7 @@ export class AttackingTroopsOverlay implements Layer {
// Add elements for new clusters.
while (lbl.elements.length < positions.length) {
lbl.elements.push(
this.createLabelElement(
lbl.attackerTroops,
lbl.isIncoming,
lbl.barStrength,
),
this.createLabelElement(lbl.attackerTroops, lbl.isIncoming),
);
lbl.positions.push(null);
}
@@ -286,9 +262,9 @@ export class AttackingTroopsOverlay implements Layer {
if (old && Math.hypot(next.x - old.x, next.y - old.y) > 200) {
const el = lbl.elements[i];
el.style.transition = "none";
const transform = `translate(${next.x}px, ${next.y}px) translate(-50%, -50%) scale(${this.labelScale()})`;
el.style.transform = transform;
this.lastTransform.set(el, transform);
const outerTransform = `translate(${next.x}px, ${next.y}px) translate(-50%, -50%)`;
el.style.transform = outerTransform;
this.lastTransform.set(el, outerTransform);
requestAnimationFrame(() => {
el.style.transition = "transform 0.25s linear";
});
@@ -297,73 +273,48 @@ export class AttackingTroopsOverlay implements Layer {
}
}
// Outer wraps position+transition (animates cluster moves). Inner holds the
// scale (instant on zoom) plus all visual chrome. Splitting them keeps the
// 0.25s transition off zoom changes.
private createLabelTemplate(): HTMLDivElement {
const el = document.createElement("div");
el.style.position = "absolute";
el.style.display = "none";
el.style.alignItems = "center";
el.style.gap = "3px";
el.style.whiteSpace = "nowrap";
el.style.fontSize = "14px";
el.style.fontWeight = "bold";
el.style.padding = "2px 5px";
el.style.borderRadius = "3px";
el.style.backgroundColor = "rgba(0,0,0,0.85)";
el.style.pointerEvents = "none";
el.style.lineHeight = "1.3";
el.style.transition = "transform 0.25s linear";
el.style.width = "max-content";
const outer = document.createElement("div");
outer.style.position = "absolute";
outer.style.display = "none";
outer.style.pointerEvents = "none";
outer.style.transition = "transform 0.25s linear";
const bar = document.createElement("div");
bar.style.width = "2px";
bar.style.borderRadius = "1px";
bar.style.alignSelf = "flex-end";
bar.style.transition = "height 0.25s linear";
el.appendChild(bar);
const inner = document.createElement("div");
inner.style.whiteSpace = "nowrap";
inner.style.fontSize = "17px";
inner.style.fontWeight = "bold";
inner.style.lineHeight = "1.3";
inner.style.width = "max-content";
// No background — let the territory border show through. Stacked black
// text-shadows form a soft dark glow so the number stays readable over
// any terrain.
inner.style.textShadow =
"0 0 2px rgba(0,0,0,1), 0 0 3px rgba(0,0,0,0.85), 0 0 5px rgba(0,0,0,0.5)";
outer.appendChild(inner);
const icon = document.createElement("img");
icon.style.width = "13px";
icon.style.height = "13px";
el.appendChild(icon);
const span = document.createElement("span");
span.style.minWidth = "25px";
el.appendChild(span);
return el;
return outer;
}
private createLabelElement(
attackerTroops: number,
isIncoming: boolean,
barStrength: number,
): HTMLDivElement {
const el = this.labelTemplate.cloneNode(true) as HTMLDivElement;
el.style.fontFamily = this.game.config().theme().font();
const bar = el.children[0] as HTMLDivElement;
const icon = el.children[1] as HTMLImageElement;
const span = el.children[2] as HTMLSpanElement;
icon.src = soldierIcon;
icon.style.filter = isIncoming
? INCOMING_ICON_FILTER
: OUTGOING_ICON_FILTER;
span.style.color = isIncoming ? INCOMING_COLOR : OUTGOING_COLOR;
span.textContent = renderTroops(attackerTroops);
bar.style.backgroundColor = isIncoming ? INCOMING_COLOR : OUTGOING_COLOR;
bar.style.height = `${barStrength * BAR_MAX_HEIGHT_PX}px`;
const inner = el.children[0] as HTMLDivElement;
inner.style.fontFamily = this.game.config().theme().font();
inner.style.color = isIncoming ? INCOMING_COLOR : OUTGOING_COLOR;
inner.textContent = renderTroops(attackerTroops);
this.container.appendChild(el);
return el;
}
private updateLabelContent(
el: HTMLDivElement,
attackerTroops: number,
barStrength: number,
) {
const bar = el.children[0] as HTMLDivElement;
const span = el.children[2] as HTMLSpanElement;
span.textContent = renderTroops(attackerTroops);
bar.style.height = `${barStrength * BAR_MAX_HEIGHT_PX}px`;
private updateLabelContent(el: HTMLDivElement, attackerTroops: number) {
const inner = el.children[0] as HTMLDivElement;
inner.textContent = renderTroops(attackerTroops);
}
private removeLabel(attackID: string) {
+2
View File
@@ -33,6 +33,8 @@
--shadow-malibu-blue-ring-sm: 0 0 0 4px rgba(0, 132, 209, 0.2);
--shadow-malibu-blue-ring-lg: 0 0 0 6px rgba(0, 132, 209, 0.3);
--shadow-lobby-card-hover: 0 0 0 2px #0084d1, 0 0 20px rgba(0, 132, 209, 0.5);
--shadow-action-card-hover:
0 0 0 1px #0084d1, 0 0 12px rgba(0, 132, 209, 0.35);
}
@layer base {
+6 -57
View File
@@ -13,45 +13,10 @@ import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
import { ClientID, GameStartInfo, Turn } from "../Schemas";
import { generateID } from "../Util";
import { WorkerMessage } from "./WorkerMessages";
// ?worker&url returns the worker bundle's URL as a string. We load it via a
// same-origin Blob trampoline because browsers refuse cross-origin
// `new Worker(url)` even with valid CORS+CORP. A Blob URL is same-origin to
// the page so the constructor accepts it, and dynamic `import()` inside the
// Blob IS CORS-checked and can fetch the real worker module from the CDN.
// R2 must serve the worker bundle with `Access-Control-Allow-Origin`.
import workerUrl from "./Worker.worker.ts?worker&url";
function createGameWorker(): Worker {
const cdnBase = getCdnBase().replace(/\/+$/, "");
// Same-origin path (dev, or any deploy without CDN_BASE set): construct the
// worker directly. The Blob trampoline below is only needed for cross-origin
// loads — browsers refuse `new Worker(url)` cross-origin even with valid
// CORS+CORP, and Vite's dev server doesn't serve `?worker&url` URLs as
// regular ES modules so the trampoline's dynamic `import()` would hang.
if (!cdnBase) {
return new Worker(workerUrl, { type: "module" });
}
const fullUrl = `${cdnBase}${workerUrl}`;
// Buffer-and-replay: the worker's port enables when the trampoline script
// starts, so any messages posted before the imported module attaches its
// `message` handler would dispatch to no listener and be dropped. Capture
// them here, then re-dispatch after the import resolves.
const trampoline = `
const buffered = [];
const buffer = (e) => buffered.push(e);
self.addEventListener("message", buffer);
import(${JSON.stringify(fullUrl)}).then(() => {
self.removeEventListener("message", buffer);
for (const e of buffered) self.dispatchEvent(new MessageEvent("message", { data: e.data }));
}).catch((e) => self.postMessage({ type: "trampoline_error", message: String((e && e.message) || e) }));
`;
const blobUrl = URL.createObjectURL(
new Blob([trampoline], { type: "application/javascript" }),
);
const worker = new Worker(blobUrl, { type: "module" });
URL.revokeObjectURL(blobUrl);
return worker;
}
// Inlined into the main bundle as a same-origin Blob, sidestepping the
// cross-origin `new Worker(url)` restriction that would otherwise apply when
// the worker bundle is served from the CDN.
import GameWorker from "./Worker.worker.ts?worker&inline";
export class WorkerClient {
private worker: Worker;
@@ -65,7 +30,7 @@ export class WorkerClient {
private gameStartInfo: GameStartInfo,
private clientID: ClientID | undefined,
) {
this.worker = createGameWorker();
this.worker = new GameWorker();
this.messageHandlers = new Map();
// Set up global message handler
@@ -112,21 +77,8 @@ export class WorkerClient {
return new Promise((resolve, reject) => {
const messageId = generateID();
const onTrampolineError = (event: MessageEvent) => {
if (event.data?.type !== "trampoline_error") return;
this.worker.removeEventListener("message", onTrampolineError);
this.messageHandlers.delete(messageId);
reject(
new Error(
`Worker trampoline import failed: ${event.data.message ?? "unknown error"}`,
),
);
};
this.worker.addEventListener("message", onTrampolineError);
this.messageHandlers.set(messageId, (message) => {
if (message.type === "initialized") {
this.worker.removeEventListener("message", onTrampolineError);
this.isInitialized = true;
resolve();
}
@@ -140,15 +92,12 @@ export class WorkerClient {
cdnBase: getCdnBase(),
});
// Backstop for the worker hanging after a successful import (the
// trampoline_error path handles the cross-origin / CORS load failure).
setTimeout(() => {
if (!this.isInitialized) {
this.worker.removeEventListener("message", onTrampolineError);
this.messageHandlers.delete(messageId);
reject(new Error("Worker initialization timeout"));
}
}, 20000);
}, 60000);
});
}
+2 -13
View File
@@ -28,8 +28,7 @@ export type WorkerMessageType =
| "attack_clustered_positions"
| "attack_clustered_positions_result"
| "transport_ship_spawn"
| "transport_ship_spawn_result"
| "trampoline_error";
| "transport_ship_spawn_result";
// Base interface for all messages
interface BaseWorkerMessage {
@@ -138,15 +137,6 @@ export interface TransportShipSpawnResultMessage extends BaseWorkerMessage {
result: TileRef | false;
}
// Posted by the Blob trampoline (see WorkerClient.createGameWorker) when the
// dynamic import of the real worker module fails. The real worker module
// never loaded, so no other message will ever arrive — initialize() must
// reject on this rather than wait out its timeout.
export interface TrampolineErrorMessage extends BaseWorkerMessage {
type: "trampoline_error";
message: string;
}
// Union types for type safety
export type MainThreadMessage =
| InitMessage
@@ -169,5 +159,4 @@ export type WorkerMessage =
| PlayerProfileResultMessage
| PlayerBorderTilesResultMessage
| AttackClusteredPositionsResultMessage
| TransportShipSpawnResultMessage
| TrampolineErrorMessage;
| TransportShipSpawnResultMessage;
+3 -15
View File
@@ -3,8 +3,7 @@ import { ClientID } from "../core/Schemas";
const INTENTS_PER_SECOND = 10;
const INTENTS_PER_MINUTE = 150;
const MAX_INTENT_SIZE = 500;
const MAX_CONFIG_INTENT_SIZE = 2000;
const MAX_INTENT_SIZE = 2000;
const TOTAL_BYTES = 2 * 1024 * 1024; // 2MB per client
export type RateLimitResult = "ok" | "limit" | "kick";
@@ -17,30 +16,19 @@ interface ClientBucket {
export class ClientMsgRateLimiter {
private buckets = new Map<ClientID, ClientBucket>();
check(
clientID: ClientID,
type: string,
bytes: number,
intentType?: string,
): RateLimitResult {
check(clientID: ClientID, type: string, bytes: number): RateLimitResult {
const bucket = this.getOrCreate(clientID);
bucket.totalBytes += bytes;
if (bucket.totalBytes >= TOTAL_BYTES) return "kick";
if (type === "intent") {
// Config updates are lobby-only and not stored in turn history,
// so they can be larger than regular intents.
const maxSize =
intentType === "update_game_config"
? MAX_CONFIG_INTENT_SIZE
: MAX_INTENT_SIZE;
// Intents are stored in turn history for the duration of the game, so
// oversized intents would accumulate and fill up server RAM.
// Intents are also sent to all players, so it increase outgoing
// data.
// Intents should never be larger than MAX_INTENT_SIZE, so we assume the client is malicious.
if (bytes > maxSize) {
if (bytes > MAX_INTENT_SIZE) {
return "kick";
}
if (
-3
View File
@@ -349,13 +349,10 @@ export class GameServer {
}
const clientMsg = parsed.data;
const bytes = Buffer.byteLength(message, "utf8");
const intentType =
clientMsg.type === "intent" ? clientMsg.intent.type : undefined;
const rateResult = this.intentRateLimiter.check(
client.clientID,
clientMsg.type,
bytes,
intentType,
);
if (rateResult === "kick") {
this.log.warn(`Client rate limit exceeded, kicking`, {
+8 -1
View File
@@ -6,6 +6,7 @@ import {
pattern,
resolveConfusablesTransformer,
resolveLeetSpeakTransformer,
skipNonAlphabeticTransformer,
toAsciiLowerCaseTransformer,
} from "obscenity";
import countries from "resources/countries.json";
@@ -71,15 +72,21 @@ export function createMatcher(bannedWords: string[]): RegExpMatcher {
];
// substringMatcher: literal patterns, no collapse — catches "niggertesting" as a substring
// collapseMatcher: deduped patterns + collapse transformer — catches "niiiigger", "hiiitler"
// skipNonAlphabeticTransformer is applied last to catch punctuation-separated bypasses
// like "n.i.g.g.e.r".
const substringMatcher = new RegExpMatcher({
...buildDataset(bannedWords, false),
blacklistMatcherTransformers: baseTransformers,
blacklistMatcherTransformers: [
...baseTransformers,
skipNonAlphabeticTransformer(),
],
});
const collapseMatcher = new RegExpMatcher({
...buildDataset(bannedWords, true),
blacklistMatcherTransformers: [
...baseTransformers,
collapseDuplicatesTransformer(),
skipNonAlphabeticTransformer(),
],
});
return {
+16 -5
View File
@@ -114,11 +114,9 @@ describe("UsernameCensor", () => {
expect(matcher.hasMatch("MyChairName")).toBe(true);
});
test("detects banned words with underscores/dots/numbers mixed in", () => {
// These should NOT bypass the filter (skipNonAlphabetic was intentionally removed)
// Words separated by non-alpha chars are treated as separate tokens
expect(matcher.hasMatch("n.i.g.g.e.r")).toBe(false); // dots break the word
expect(matcher.hasMatch("hi_tler")).toBe(false); // underscore breaks it
test("detects banned words with non-alphabetic characters mixed in", () => {
expect(matcher.hasMatch("n.i.g.g.e.r")).toBe(true);
expect(matcher.hasMatch("hi_tler")).toBe(true);
});
test("allows clean usernames", () => {
@@ -141,6 +139,19 @@ describe("UsernameCensor", () => {
expect(matcher.hasMatch("kkklover")).toBe(true);
expect(matcher.hasMatch("ilovekkkboys")).toBe(true);
});
test("catches slurs separated by periods (bypass attempt)", () => {
expect(matcher.hasMatch("n.i.g.g.e.r")).toBe(true);
expect(matcher.hasMatch("N.I.G.G.E.R")).toBe(true);
expect(matcher.hasMatch("n.i.g.g.a")).toBe(true);
expect(matcher.hasMatch("h.i.t.l.e.r")).toBe(true);
expect(matcher.hasMatch("hello n.i.g.g.e.r world")).toBe(true);
});
test("censor replaces period-separated slur usernames", () => {
const result = checker.censor("n.i.g.g.e.r", null);
expect(shadowNames).toContain(result.username);
});
});
describe("censor", () => {
+9 -1
View File
@@ -101,6 +101,7 @@ beforeEach(() => {
);
});
import "../../src/client/components/baseComponents/Modal";
import { LeaderboardModal } from "../../src/client/LeaderboardModal";
describe("LeaderboardModal", () => {
@@ -334,7 +335,14 @@ describe("LeaderboardModal", () => {
}),
});
const tab = modal.querySelector("#clan-leaderboard-tab");
modal.inline = true;
await modal.updateComplete;
const oModal = modal.querySelector("o-modal");
await (oModal as unknown as { updateComplete: Promise<unknown> })
.updateComplete;
const tab = oModal!.shadowRoot!.querySelector(
'button[role="tab"][data-key="clans"]',
);
expect(tab).toBeTruthy();
tab!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@@ -1,66 +1,37 @@
import { describe, expect, test } from "vitest";
import {
alignClusterOrder,
computeBarStrength,
computeLabelScale,
} from "../../../../src/client/graphics/layers/AttackingTroopsOverlay";
import { Cell } from "../../../../src/core/game/Game";
describe("computeLabelScale", () => {
test("counter-scales the zoom when above the full-size threshold", () => {
// zoom = 2 → label rendered at 1/2 to stay at full screen size.
expect(computeLabelScale(2)).toBeCloseTo(0.5);
// LABEL_FULL_SIZE_ZOOM = 4, LABEL_MIN_RENDERED_SIZE = 0.63,
// LABEL_SIZE_MULTIPLIER = 1.0. Rendered size at zoom z:
// 1.0 * (0.63 + 0.37 * min(1, z/4)).
test("at the full-size threshold, rendered size is capped at the multiplier", () => {
// zoom = 4 → rendered = 1.0 → scale = 1.0 / 4.
expect(computeLabelScale(4)).toBeCloseTo(1.0 / 4);
});
test("counter-scales exactly at the full-size threshold", () => {
// zoom = 1.5 → label rendered at 1/1.5 ≈ 0.6667.
expect(computeLabelScale(1.5)).toBeCloseTo(1 / 1.5);
test("above the threshold, rendered size stays capped (counter-scales zoom)", () => {
// zoom = 8 → rendered still 1.0 → scale = 1.0 / 8.
expect(computeLabelScale(8)).toBeCloseTo(1.0 / 8);
});
test("rides the world transform between the floor and the threshold", () => {
// Below the threshold, netScale = zoom / 1.5, so the factor is constant 1/1.5.
expect(computeLabelScale(1)).toBeCloseTo(1 / 1.5);
expect(computeLabelScale(0.9)).toBeCloseTo(1 / 1.5);
test("at zoom = 0+, rendered size approaches the floor", () => {
// As zoom→0, t→0, rendered → 1.0 * 0.63 (the floor).
// At zoom = 0.001, rendered ≈ floor, so scale ≈ floor / zoom = huge.
const scale = computeLabelScale(0.001);
const floorRendered = 1.0 * 0.63;
// Within 1% of the floor-divided-by-zoom value.
expect(scale).toBeGreaterThan((floorRendered / 0.001) * 0.99);
expect(scale).toBeLessThan((floorRendered / 0.001) * 1.01);
});
test("floor engages exactly at zoom = 0.75 (LABEL_MIN_SCREEN_SCALE * LABEL_FULL_SIZE_ZOOM)", () => {
expect(computeLabelScale(0.75)).toBeCloseTo(1 / 1.5);
});
test("grows in screen space when zoomed out past the floor", () => {
// zoom = 0.5 → netScale clamped to 0.5, factor = 0.5 / 0.5 = 1.
expect(computeLabelScale(0.5)).toBeCloseTo(1);
// zoom = 0.25 → factor = 0.5 / 0.25 = 2.
expect(computeLabelScale(0.25)).toBeCloseTo(2);
});
});
describe("computeBarStrength", () => {
test("equal troops sit at the midpoint", () => {
// 1000 vs 1000 → ratio 1, divided by full-height ratio of 2 → 0.5.
expect(computeBarStrength(1000, 1000)).toBeCloseTo(0.5);
});
test("attacker with no troops yields a zero-height bar", () => {
expect(computeBarStrength(0, 1000)).toBe(0);
});
test("scales linearly between zero and the full-height threshold", () => {
// 500 vs 1000 → ratio 0.5 → 0.25.
expect(computeBarStrength(500, 1000)).toBeCloseTo(0.25);
// 1500 vs 1000 → ratio 1.5 → 0.75.
expect(computeBarStrength(1500, 1000)).toBeCloseTo(0.75);
});
test("clamps at full height when attacker has 2× the opposition", () => {
expect(computeBarStrength(2000, 1000)).toBeCloseTo(1);
expect(computeBarStrength(10_000, 1000)).toBeCloseTo(1);
});
test("returns full height when the opposing side has no troops", () => {
// Avoids division-by-zero: an undefended target is maximum strength.
expect(computeBarStrength(500, 0)).toBe(1);
expect(computeBarStrength(0, 0)).toBe(1);
test("interpolates linearly between floor and full-size threshold", () => {
// zoom = 2 → t = 0.5 → rendered = 1.0 * (0.63 + 0.185) = 0.815.
expect(computeLabelScale(2)).toBeCloseTo(0.815 / 2);
});
});
+10
View File
@@ -28,6 +28,16 @@ describe("ClientMsgRateLimiter", () => {
}
expect(limiter.check(CLIENT_B, "intent", SMALL)).toBe("ok");
});
it("allows intents up to MAX_INTENT_SIZE", () => {
const limiter = new ClientMsgRateLimiter();
expect(limiter.check(CLIENT_A, "intent", 2000)).toBe("ok");
});
it("kicks intents exceeding MAX_INTENT_SIZE", () => {
const limiter = new ClientMsgRateLimiter();
expect(limiter.check(CLIENT_A, "intent", 2001)).toBe("kick");
});
});
describe("non-intent messages", () => {