From 8a638a38427b9a431f73febb8f4d76c86557ee3f Mon Sep 17 00:00:00 2001 From: Evan Date: Thu, 30 Apr 2026 16:57:35 -0600 Subject: [PATCH 01/44] =?UTF-8?q?perf(UnitLayer):=20batch=20trail=20clears?= =?UTF-8?q?=20to=20fix=20O(n=C2=B2)=20cost=20on=20mass=20nuke=20explosions?= =?UTF-8?q?=20(#3808)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: When multiple nukes detonated in the same tick, clearTrail was called once per dying unit. Each call scanned all remaining units to repaint overlapping trail tiles — O(dead × alive × trail_len) per tick. Replace with a deferred batch: dying units are queued into pendingTrailClears during drawUnitsCells, then flushTrailClears() processes them all at once after the draw pass. All trail tiles are cleared in a single loop (skipping duplicates), followed by one repaint scan of surviving units — O((dead + alive) × trail_len). Also fixes a minor bug in the original: the surviving unit's relationship is now used when repainting its trail (previously the dying unit's relationship was used, which gave wrong colors in alternate-view mode). ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- src/client/graphics/layers/UnitLayer.ts | 40 +++++++++++++++++-------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index d14f4448c..8ded3ee6f 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -40,6 +40,7 @@ export class UnitLayer implements Layer { private unitTrailContext: CanvasRenderingContext2D; private unitToTrail = new Map(); + private pendingTrailClears: UnitView[] = []; private theme: Theme; @@ -381,6 +382,7 @@ export class UnitLayer implements Layer { // otherwise the sprite of a unit can be drawn on top of another unit this.clearUnitsCells(unitsToUpdate); this.drawUnitsCells(unitsToUpdate); + this.flushTrailClears(); } } @@ -546,19 +548,33 @@ export class UnitLayer implements Layer { } } - private clearTrail(unit: UnitView) { - const trail = this.unitToTrail.get(unit) ?? []; - const rel = this.relationship(unit); - for (const t of trail) { - this.clearCell(this.game.x(t), this.game.y(t), this.unitTrailContext); - } - this.unitToTrail.delete(unit); + private flushTrailClears() { + if (this.pendingTrailClears.length === 0) return; - // Repaint overlapping trails - const trailSet = new Set(trail); + const clearedTiles = new Set(); + for (const unit of this.pendingTrailClears) { + const trail = this.unitToTrail.get(unit); + if (trail) { + for (const t of trail) { + if (!clearedTiles.has(t)) { + this.clearCell( + this.game.x(t), + this.game.y(t), + this.unitTrailContext, + ); + clearedTiles.add(t); + } + } + this.unitToTrail.delete(unit); + } + } + this.pendingTrailClears = []; + + // Single repaint pass for all remaining units for (const [other, trail] of this.unitToTrail) { + const rel = this.relationship(other); for (const t of trail) { - if (trailSet.has(t)) { + if (clearedTiles.has(t)) { this.paintCell( this.game.x(t), this.game.y(t), @@ -609,7 +625,7 @@ export class UnitLayer implements Layer { ); this.drawSprite(unit); if (!unit.isActive()) { - this.clearTrail(unit); + this.pendingTrailClears.push(unit); } } @@ -652,7 +668,7 @@ export class UnitLayer implements Layer { this.drawSprite(unit); if (!unit.isActive()) { - this.clearTrail(unit); + this.pendingTrailClears.push(unit); } } From 02353cf77d32faddb14ed32d5cf6d30f9abce8fd Mon Sep 17 00:00:00 2001 From: evanpelle Date: Thu, 30 Apr 2026 17:17:28 -0600 Subject: [PATCH 02/44] Fix nuke cancellation on alliance to use blast radius MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cancelNukesBetweenAlliedPlayers previously only cancelled a nuke if its exact target tile was owned by the new ally. This meant nukes aimed at neutral or own tiles near allied territory would survive alliance formation and still land (and break the alliance). Now uses wouldNukeBreakAlliance — the same blast-radius logic used by maybeBreakAlliances on impact — so a nuke is cancelled if its blast would have meaningfully hit the ally's tiles or structures. Also switches from the exhaustive listNukeBreakAlliance (scans all players) to wouldNukeBreakAlliance with a single-player allySmallIds set for early-exit performance. --- .../alliance/AllianceRequestExecution.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/core/execution/alliance/AllianceRequestExecution.ts b/src/core/execution/alliance/AllianceRequestExecution.ts index 4a109c45e..379f14b6a 100644 --- a/src/core/execution/alliance/AllianceRequestExecution.ts +++ b/src/core/execution/alliance/AllianceRequestExecution.ts @@ -7,6 +7,7 @@ import { PlayerID, UnitType, } from "../../game/Game"; +import { wouldNukeBreakAlliance } from "../Util"; export class AllianceRequestExecution implements Execution { private req: AllianceRequest | null = null; @@ -100,12 +101,19 @@ export class AllianceRequestExecution implements Execution { const targetTile = unit.targetTile(); if (!targetTile) continue; - const targetOwner = this.mg.owner(targetTile); - if (!targetOwner.isPlayer()) continue; - const other = launcher === this.requestor ? recipient : this.requestor; - if (targetOwner !== other) continue; - + const magnitude = this.mg.config().nukeMagnitudes(unit.type()); + if ( + !wouldNukeBreakAlliance({ + game: this.mg, + targetTile, + magnitude, + allySmallIds: new Set([other.smallID()]), + threshold: this.mg.config().nukeAllianceBreakThreshold(), + }) + ) { + continue; + } unit.delete(false); neutralized.set(launcher, (neutralized.get(launcher) ?? 0) + 1); } From 1f549d0a033d524a24e7b058e513cbecde0055df Mon Sep 17 00:00:00 2001 From: evanpelle Date: Thu, 30 Apr 2026 17:23:11 -0600 Subject: [PATCH 03/44] add malibu glow on hover to ranked, join, create lobby buttons --- src/client/GameModeSelector.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/client/GameModeSelector.ts b/src/client/GameModeSelector.ts index a65853313..eb4888c2b 100644 --- a/src/client/GameModeSelector.ts +++ b/src/client/GameModeSelector.ts @@ -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", + "bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-lobby-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", + "bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-lobby-card-hover)]", ) : html``} ${this.renderSmallActionCard( translateText("main.join"), this.openJoinLobby, - "bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105", + "bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-lobby-card-hover)]", )} @@ -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", + "bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-lobby-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", + "bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-lobby-card-hover)]", ) : html``} ${this.renderSmallActionCard( translateText("main.join"), this.openJoinLobby, - "bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105", + "bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-lobby-card-hover)]", )} From 38bbef6ecf7de7cee96722e94cdeeb2d1320ca3d Mon Sep 17 00:00:00 2001 From: evanpelle Date: Thu, 30 Apr 2026 20:18:38 -0600 Subject: [PATCH 04/44] update structure icon filename to bust cache, previous assets had bad headers --- .../images/{AnchorIcon.png => AnchorIcon.v1.png} | Bin resources/images/{CityIcon.png => CityIcon.v1.png} | Bin .../images/{FactoryUnit.png => FactoryUnit.v1.png} | Bin .../{MissileSiloUnit.png => MissileSiloUnit.v1.png} | Bin .../{SamLauncherUnit.png => SamLauncherUnit.v1.png} | Bin .../images/{ShieldIcon.png => ShieldIcon.v1.png} | Bin src/client/graphics/layers/StructureDrawingUtils.ts | 12 ++++++------ 7 files changed, 6 insertions(+), 6 deletions(-) rename resources/images/{AnchorIcon.png => AnchorIcon.v1.png} (100%) rename resources/images/{CityIcon.png => CityIcon.v1.png} (100%) rename resources/images/{FactoryUnit.png => FactoryUnit.v1.png} (100%) rename resources/images/{MissileSiloUnit.png => MissileSiloUnit.v1.png} (100%) rename resources/images/{SamLauncherUnit.png => SamLauncherUnit.v1.png} (100%) rename resources/images/{ShieldIcon.png => ShieldIcon.v1.png} (100%) diff --git a/resources/images/AnchorIcon.png b/resources/images/AnchorIcon.v1.png similarity index 100% rename from resources/images/AnchorIcon.png rename to resources/images/AnchorIcon.v1.png diff --git a/resources/images/CityIcon.png b/resources/images/CityIcon.v1.png similarity index 100% rename from resources/images/CityIcon.png rename to resources/images/CityIcon.v1.png diff --git a/resources/images/FactoryUnit.png b/resources/images/FactoryUnit.v1.png similarity index 100% rename from resources/images/FactoryUnit.png rename to resources/images/FactoryUnit.v1.png diff --git a/resources/images/MissileSiloUnit.png b/resources/images/MissileSiloUnit.v1.png similarity index 100% rename from resources/images/MissileSiloUnit.png rename to resources/images/MissileSiloUnit.v1.png diff --git a/resources/images/SamLauncherUnit.png b/resources/images/SamLauncherUnit.v1.png similarity index 100% rename from resources/images/SamLauncherUnit.png rename to resources/images/SamLauncherUnit.v1.png diff --git a/resources/images/ShieldIcon.png b/resources/images/ShieldIcon.v1.png similarity index 100% rename from resources/images/ShieldIcon.png rename to resources/images/ShieldIcon.v1.png diff --git a/src/client/graphics/layers/StructureDrawingUtils.ts b/src/client/graphics/layers/StructureDrawingUtils.ts index 90e585081..cff6e1a42 100644 --- a/src/client/graphics/layers/StructureDrawingUtils.ts +++ b/src/client/graphics/layers/StructureDrawingUtils.ts @@ -8,12 +8,12 @@ import { } from "../../../core/game/Game"; import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; import { TransformHandler } from "../TransformHandler"; -const anchorIcon = assetUrl("images/AnchorIcon.png"); -const cityIcon = assetUrl("images/CityIcon.png"); -const factoryIcon = assetUrl("images/FactoryUnit.png"); -const missileSiloIcon = assetUrl("images/MissileSiloUnit.png"); -const SAMMissileIcon = assetUrl("images/SamLauncherUnit.png"); -const shieldIcon = assetUrl("images/ShieldIcon.png"); +const anchorIcon = assetUrl("images/AnchorIcon.v1.png"); +const cityIcon = assetUrl("images/CityIcon.v1.png"); +const factoryIcon = assetUrl("images/FactoryUnit.v1.png"); +const missileSiloIcon = assetUrl("images/MissileSiloUnit.v1.png"); +const SAMMissileIcon = assetUrl("images/SamLauncherUnit.v1.png"); +const shieldIcon = assetUrl("images/ShieldIcon.v1.png"); export const STRUCTURE_SHAPES: Partial> = { [UnitType.City]: "circle", From df05d21fc29ae70f551cb0cc38ce5b7c7000acef Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Fri, 1 May 2026 04:27:35 +0100 Subject: [PATCH 05/44] Clan System Part 2 - UI (#3625) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Continuation from #3276 Adds the complete client-side clan UI as a Lit web component (``), a typed API client with Zod-validated responses, shared response schemas, and a reusable `` component. ### New: `ClanModal.ts` | View | What it does | |------|-------------| | **My Clans** | Lists joined clans + pending join requests (built from `/users/@me`, no extra fetches) | | **Browse** | Search by tag (min 3 chars), paginated results, configurable per-page (10/25/50) | | **Clan Detail** | Stats, paginated + searchable member list, role badges, join/leave/request actions | | **Manage** | Edit name (max 35 chars) + description, toggle open/invite-only, disband | | **Transfer** | Leadership transfer with member selector + confirmation | | **Requests** | Approve/deny join requests (leader/officer) | | **Bans** | View and unban (leader/officer) | | **My Requests** | View and withdraw outgoing requests | ### New: `ConfirmDialog.ts` Reusable `` Lit component — replaces native `confirm()`/`prompt()` which are blocked or broken on mobile and CrazyGames iframes. Supports danger/warning variants and an optional textarea (used for ban reasons). Fires `confirm`/`cancel` events. ### New: `ClanApi.ts` Typed API client covering all clan endpoints. Every response is Zod-validated. Auth header is always last in the spread (can't be overridden by callers). Unknown server error messages always fall back to a generic client-side string — never displayed verbatim. ### New: `ClanApiSchemas.ts` (in `src/core/`) Shared Zod schemas for clan API responses with max-length constraints on `name` (35) and `description` (200). Lives in `core/` so it can be consumed by both client code and the leaderboard table. ### Modified: `ApiSchemas.ts` - Added `clans` and `clanRequests` arrays to `UserMeResponseSchema` - Moved clan leaderboard schemas out to `ClanApiSchemas.ts` - Renamed `LeaderboardClanTagSchema` → `RequiredClanTagSchema` ### Modified: `Api.ts` - Added `invalidateUserMe()` to bust the cached `/users/me` response after mutations - Removed `fetchClanLeaderboard` (moved to `ClanApi.ts`) ### Tests - `ClanModal.test.ts` — rendering, view navigation, user actions - `ClanApiQueries.test.ts` — fetch functions, error handling, pagination - `ClanApiMutations.test.ts` — join, leave, kick, ban, promote, transfer, etc. - `ClanApiBans.test.ts` — ban/unban calls and error paths - `ClanApiSchemas.test.ts` — Zod schema validation edge cases - `LeaderboardModal.test.ts` — updated imports ## Notable design decisions - **Not-logged-in state** — shows "Sign in to join clans" instead of false "no clans" empty state - **Rate limit feedback** — reads `Retry-After` header and surfaces wait time to the user ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n --------- Co-authored-by: evanpelle --- index.html | 5 + resources/lang/en.json | 111 +++ src/client/Api.ts | 36 - src/client/ClanApi.ts | 494 +++++++++++ src/client/ClanModal.ts | 460 ++++++++++ src/client/Main.ts | 2 + src/client/Utils.ts | 12 + src/client/components/BaseModal.ts | 67 +- src/client/components/ConfirmDialog.ts | 129 +++ src/client/components/DesktopNavBar.ts | 5 + src/client/components/MobileNavBar.ts | 5 + src/client/components/clan/ClanBansView.ts | 225 +++++ src/client/components/clan/ClanBrowseView.ts | 185 ++++ src/client/components/clan/ClanCard.ts | 108 +++ src/client/components/clan/ClanDetailView.ts | 540 ++++++++++++ src/client/components/clan/ClanManageView.ts | 615 +++++++++++++ .../components/clan/ClanMyRequestsView.ts | 105 +++ .../components/clan/ClanRequestsView.ts | 218 +++++ src/client/components/clan/ClanShared.ts | 477 ++++++++++ .../components/clan/ClanTransferView.ts | 258 ++++++ .../leaderboard/LeaderboardClanTable.ts | 4 +- src/core/ApiSchemas.ts | 47 +- src/core/ClanApiSchemas.ts | 130 +++ tests/client/LeaderboardModal.test.ts | 20 +- tests/client/clan/ClanApiBans.test.ts | 166 ++++ tests/client/clan/ClanApiMutations.test.ts | 423 +++++++++ tests/client/clan/ClanApiQueries.test.ts | 396 +++++++++ tests/client/clan/ClanApiSchemas.test.ts | 236 +++++ tests/client/clan/ClanModal.handlers.test.ts | 823 ++++++++++++++++++ tests/client/clan/ClanModal.rendering.test.ts | 458 ++++++++++ tests/client/clan/ClanModalTestUtils.ts | 217 +++++ tests/client/clan/ClanShared.test.ts | 143 +++ 32 files changed, 7018 insertions(+), 102 deletions(-) create mode 100644 src/client/ClanApi.ts create mode 100644 src/client/ClanModal.ts create mode 100644 src/client/components/ConfirmDialog.ts create mode 100644 src/client/components/clan/ClanBansView.ts create mode 100644 src/client/components/clan/ClanBrowseView.ts create mode 100644 src/client/components/clan/ClanCard.ts create mode 100644 src/client/components/clan/ClanDetailView.ts create mode 100644 src/client/components/clan/ClanManageView.ts create mode 100644 src/client/components/clan/ClanMyRequestsView.ts create mode 100644 src/client/components/clan/ClanRequestsView.ts create mode 100644 src/client/components/clan/ClanShared.ts create mode 100644 src/client/components/clan/ClanTransferView.ts create mode 100644 src/core/ClanApiSchemas.ts create mode 100644 tests/client/clan/ClanApiBans.test.ts create mode 100644 tests/client/clan/ClanApiMutations.test.ts create mode 100644 tests/client/clan/ClanApiQueries.test.ts create mode 100644 tests/client/clan/ClanApiSchemas.test.ts create mode 100644 tests/client/clan/ClanModal.handlers.test.ts create mode 100644 tests/client/clan/ClanModal.rendering.test.ts create mode 100644 tests/client/clan/ClanModalTestUtils.ts create mode 100644 tests/client/clan/ClanShared.test.ts diff --git a/index.html b/index.html index 9fd86d45b..b196749fd 100644 --- a/index.html +++ b/index.html @@ -238,6 +238,11 @@ class="hidden w-full h-full page-content relative z-50" > + { - try { - const res = await fetch(`${getApiBase()}/public/clans/leaderboard`, { - headers: { Accept: "application/json" }, - }); - - if (!res.ok) { - console.warn( - "fetchClanLeaderboard: unexpected status", - res.status, - res.statusText, - ); - return false; - } - - const json = await res.json(); - const parsed = ClanLeaderboardResponseSchema.safeParse(json); - if (!parsed.success) { - console.warn( - "fetchClanLeaderboard: Zod validation failed", - parsed.error.toString(), - ); - return false; - } - - return parsed.data; - } catch (err) { - console.warn("fetchClanLeaderboard: request failed", err); - return false; - } -} - export async function fetchPlayerLeaderboard( page: number, ): Promise { diff --git a/src/client/ClanApi.ts b/src/client/ClanApi.ts new file mode 100644 index 000000000..5a5e62598 --- /dev/null +++ b/src/client/ClanApi.ts @@ -0,0 +1,494 @@ +import { + type ClanBansResponse, + ClanBansResponseSchema, + type ClanBrowseResponse, + ClanBrowseResponseSchema, + type ClanInfo, + ClanInfoSchema, + type ClanLeaderboardResponse, + ClanLeaderboardResponseSchema, + type ClanMembersResponse, + ClanMembersResponseSchema, + type ClanRequestsResponse, + ClanRequestsResponseSchema, + type ClanStats, + ClanStatsSchema, + JoinClanResponseSchema, +} from "../core/ClanApiSchemas"; +import { getApiBase } from "./Api"; +import { getAuthHeader } from "./Auth"; +export type { + ClanBan, + ClanBansResponse, + ClanBrowseResponse, + ClanInfo, + ClanJoinRequest, + ClanMember, + ClanMembersResponse, + ClanMemberStats, + ClanMemberWL, + ClanRequestsResponse, + ClanStats, +} from "../core/ClanApiSchemas"; + +async function clanFetch( + path: string, + options?: RequestInit, +): Promise { + const url = `${getApiBase()}${path}`; + return fetch(url, { + ...options, + headers: { + Accept: "application/json", + ...options?.headers, + Authorization: await getAuthHeader(), + }, + }); +} + +export async function fetchClanLeaderboard(): Promise< + ClanLeaderboardResponse | false +> { + try { + const res = await fetch(`${getApiBase()}/public/clans/leaderboard`, { + headers: { Accept: "application/json" }, + }); + + if (!res.ok) { + console.warn( + "fetchClanLeaderboard: unexpected status", + res.status, + res.statusText, + ); + return false; + } + + const json = await res.json(); + const parsed = ClanLeaderboardResponseSchema.safeParse(json); + if (!parsed.success) { + console.warn( + "fetchClanLeaderboard: Zod validation failed", + parsed.error.toString(), + ); + return false; + } + + return parsed.data; + } catch (err) { + console.warn("fetchClanLeaderboard: request failed", err); + return false; + } +} + +export async function fetchClanStats(tag: string): Promise { + try { + const res = await fetch( + `${getApiBase()}/public/clan/${encodeURIComponent(tag)}`, + { headers: { Accept: "application/json" } }, + ); + if (!res.ok) return false; + const json = await res.json(); + const parsed = ClanStatsSchema.safeParse(json?.clan); + if (!parsed.success) { + console.warn("fetchClanStats: Zod validation failed", parsed.error); + return false; + } + return parsed.data; + } catch (err) { + console.warn("fetchClanStats: request failed", err); + return false; + } +} + +export async function fetchClans( + search?: string, + page = 1, + limit = 20, +): Promise { + try { + const params = new URLSearchParams(); + params.set("page", String(page)); + params.set("limit", String(limit)); + if (search && search.length >= 3) params.set("search", search); + const res = await clanFetch(`/clans?${params}`); + if (!res.ok) return false; + const json = await res.json(); + const parsed = ClanBrowseResponseSchema.safeParse(json); + if (!parsed.success) { + console.warn("fetchClans: Zod validation failed", parsed.error); + return false; + } + return parsed.data; + } catch { + return false; + } +} + +export async function fetchClanDetail(tag: string): Promise { + try { + const res = await clanFetch(`/clans/${encodeURIComponent(tag)}`); + if (!res.ok) return false; + const json = await res.json(); + const parsed = ClanInfoSchema.safeParse(json); + if (!parsed.success) { + console.warn("fetchClanDetail: Zod validation failed", parsed.error); + return false; + } + return parsed.data; + } catch { + return false; + } +} + +export type ClanMemberSort = + | "default" + | "winsTotal" + | "lossesTotal" + | "winsFfa" + | "lossesFfa" + | "winsTeam" + | "lossesTeam" + | "winsHvn" + | "lossesHvn" + | "winsRanked" + | "lossesRanked" + | "wins1v1" + | "losses1v1"; +export type ClanMemberOrder = "asc" | "desc"; + +export async function fetchClanMembers( + tag: string, + page = 1, + limit = 20, + sort: ClanMemberSort = "default", + order?: ClanMemberOrder, +): Promise { + try { + const params = new URLSearchParams(); + params.set("page", String(page)); + params.set("limit", String(limit)); + if (sort !== "default") params.set("sort", sort); + if (order) params.set("order", order); + const res = await clanFetch( + `/clans/${encodeURIComponent(tag)}/members?${params}`, + ); + if (!res.ok) return false; + const json = await res.json(); + const parsed = ClanMembersResponseSchema.safeParse(json); + if (!parsed.success) { + console.warn("fetchClanMembers: Zod validation failed", parsed.error); + return false; + } + return parsed.data; + } catch { + return false; + } +} + +export async function joinClan( + tag: string, +): Promise< + { status: "joined" | "requested" } | { error: string; reason?: string } +> { + try { + const res = await clanFetch(`/clans/${encodeURIComponent(tag)}/join`, { + method: "POST", + }); + if (res.status === 409) { + const body = await res.json().catch(() => ({})); + const msg = (body as { message?: string }).message ?? ""; + return { + error: msg.toLowerCase().includes("request") + ? "clan_modal.error_request_pending" + : "clan_modal.error_already_member", + }; + } + if (res.status === 429) { + return { error: "clan_modal.error_rate_limited_generic" }; + } + if (res.status === 403) { + const body = await res.json().catch(() => ({})); + const b = body as { code?: string; reason?: string | null }; + if (b.code === "BANNED") { + return { + error: b.reason + ? "clan_modal.error_banned_reason" + : "clan_modal.error_banned", + ...(b.reason ? { reason: b.reason } : {}), + }; + } + return { + error: "clan_modal.error_failed", + }; + } + if (!res.ok) { + return { + error: "clan_modal.error_failed", + }; + } + const json = await res.json(); + const parsed = JoinClanResponseSchema.safeParse(json); + if (!parsed.success) { + console.warn("joinClan: Zod validation failed", parsed.error); + return { error: "clan_modal.error_failed" }; + } + return parsed.data; + } catch { + return { error: "clan_modal.error_network" }; + } +} + +export async function leaveClan( + tag: string, +): Promise { + try { + const res = await clanFetch(`/clans/${encodeURIComponent(tag)}/leave`, { + method: "POST", + }); + if (!res.ok) { + return { + error: "clan_modal.error_failed", + }; + } + return true; + } catch { + return { error: "clan_modal.error_network" }; + } +} + +export async function updateClan( + tag: string, + patch: { name?: string; description?: string; isOpen?: boolean }, +): Promise { + try { + const res = await clanFetch(`/clans/${encodeURIComponent(tag)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + if (!res.ok) { + return { + error: "clan_modal.error_failed", + }; + } + const json = await res.json(); + const parsed = ClanInfoSchema.safeParse(json); + if (!parsed.success) { + console.warn("updateClan: Zod validation failed", parsed.error); + return { error: "clan_modal.error_failed" }; + } + return parsed.data; + } catch { + return { error: "clan_modal.error_network" }; + } +} + +export async function disbandClan( + tag: string, +): Promise { + try { + const res = await clanFetch(`/clans/${encodeURIComponent(tag)}`, { + method: "DELETE", + }); + if (!res.ok) { + return { + error: "clan_modal.error_failed", + }; + } + return true; + } catch { + return { error: "clan_modal.error_network" }; + } +} + +async function memberAction( + tag: string, + targetPublicId: string, + action: string, +): Promise { + try { + const res = await clanFetch(`/clans/${encodeURIComponent(tag)}/${action}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ targetPublicId }), + }); + if (!res.ok) { + return { error: "clan_modal.error_failed" }; + } + return true; + } catch { + return { error: "clan_modal.error_network" }; + } +} + +export const kickMember = (tag: string, targetPublicId: string) => + memberAction(tag, targetPublicId, "kick"); + +export const promoteMember = (tag: string, targetPublicId: string) => + memberAction(tag, targetPublicId, "promote"); + +export const demoteMember = (tag: string, targetPublicId: string) => + memberAction(tag, targetPublicId, "demote"); + +export const transferLeadership = (tag: string, targetPublicId: string) => + memberAction(tag, targetPublicId, "transfer"); + +export async function fetchClanRequests( + tag: string, + page = 1, + limit = 20, +): Promise { + try { + const params = new URLSearchParams(); + params.set("page", String(page)); + params.set("limit", String(limit)); + const res = await clanFetch( + `/clans/${encodeURIComponent(tag)}/requests?${params}`, + ); + if (!res.ok) return false; + const json = await res.json(); + const parsed = ClanRequestsResponseSchema.safeParse(json); + if (!parsed.success) { + console.warn("fetchClanRequests: Zod validation failed", parsed.error); + return false; + } + return parsed.data; + } catch { + return false; + } +} + +export async function approveClanRequest( + tag: string, + targetPublicId: string, +): Promise { + try { + const res = await clanFetch( + `/clans/${encodeURIComponent(tag)}/requests/approve`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ targetPublicId }), + }, + ); + if (!res.ok) { + return { + error: "clan_modal.error_failed", + }; + } + return true; + } catch { + return { error: "clan_modal.error_network" }; + } +} + +export async function denyClanRequest( + tag: string, + targetPublicId: string, +): Promise { + try { + const res = await clanFetch( + `/clans/${encodeURIComponent(tag)}/requests/deny`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ targetPublicId }), + }, + ); + if (!res.ok) { + return { + error: "clan_modal.error_failed", + }; + } + return true; + } catch { + return { error: "clan_modal.error_network" }; + } +} + +export async function withdrawClanRequest( + tag: string, +): Promise { + try { + const res = await clanFetch( + `/clans/${encodeURIComponent(tag)}/requests/withdraw`, + { method: "POST" }, + ); + if (!res.ok) { + return { + error: "clan_modal.error_failed", + }; + } + return true; + } catch { + return { error: "clan_modal.error_network" }; + } +} + +export async function banClanMember( + tag: string, + targetPublicId: string, + reason?: string, +): Promise { + try { + const body: { targetPublicId: string; reason?: string } = { + targetPublicId, + }; + if (reason) body.reason = reason; + const res = await clanFetch(`/clans/${encodeURIComponent(tag)}/ban`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + return { error: "clan_modal.error_failed" }; + } + return true; + } catch { + return { error: "clan_modal.error_network" }; + } +} + +export async function unbanClanMember( + tag: string, + targetPublicId: string, +): Promise { + try { + const res = await clanFetch(`/clans/${encodeURIComponent(tag)}/unban`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ targetPublicId }), + }); + if (!res.ok) { + return { error: "clan_modal.error_failed" }; + } + return true; + } catch { + return { error: "clan_modal.error_network" }; + } +} + +export async function fetchClanBans( + tag: string, + page = 1, + limit = 20, +): Promise { + try { + const params = new URLSearchParams(); + params.set("page", String(page)); + params.set("limit", String(limit)); + const res = await clanFetch( + `/clans/${encodeURIComponent(tag)}/bans?${params}`, + ); + if (!res.ok) return false; + const json = await res.json(); + const parsed = ClanBansResponseSchema.safeParse(json); + if (!parsed.success) { + console.warn("fetchClanBans: Zod validation failed", parsed.error); + return false; + } + return parsed.data; + } catch { + return false; + } +} diff --git a/src/client/ClanModal.ts b/src/client/ClanModal.ts new file mode 100644 index 000000000..f838f7dba --- /dev/null +++ b/src/client/ClanModal.ts @@ -0,0 +1,460 @@ +import { html } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { getUserMe, invalidateUserMe } from "./Api"; +import { type ClanInfo, type ClanMember, type ClanStats } from "./ClanApi"; +import { BaseModal } from "./components/BaseModal"; +import "./components/clan/ClanBansView"; +import "./components/clan/ClanBrowseView"; +import type { BrowseState } from "./components/clan/ClanBrowseView"; +import "./components/clan/ClanCard"; +import "./components/clan/ClanDetailView"; +import "./components/clan/ClanManageView"; +import "./components/clan/ClanMyRequestsView"; +import "./components/clan/ClanRequestsView"; +import type { ClanRole } from "./components/clan/ClanShared"; +import "./components/clan/ClanTransferView"; +import "./components/ConfirmDialog"; +import "./components/CopyButton"; +import { modalHeader } from "./components/ui/ModalHeader"; +import { translateText } from "./Utils"; + +type Tab = "my-clans" | "browse"; +type View = + | "list" + | "detail" + | "manage" + | "transfer" + | "requests" + | "bans" + | "my-requests"; + +@customElement("clan-modal") +export class ClanModal extends BaseModal { + @state() private activeTab: Tab = "my-clans"; + @state() private view: View = "list"; + @state() private loading = false; + + @state() private myClans: ClanInfo[] = []; + @state() private myPendingRequests: { + tag: string; + name: string; + createdAt: string; + }[] = []; + + @state() private selectedClanTag = ""; + @state() private selectedClan: ClanInfo | null = null; + @state() private myRole: ClanRole | null = null; + private myPublicId: string | null = null; + @state() private myClanRoles = new Map(); + + // Lifted browse state — survives tab switches + private browseCache: BrowseState | null = null; + + // Lifted detail cache — survives sub-view navigation + private detailCache: { + tag: string; + members: ClanMember[]; + membersTotal: number; + pendingRequestCount: number; + stats: ClanStats | null; + } | null = null; + + render() { + const content = this.renderInner(); + if (this.inline) return content; + return html` + + ${content} + + `; + } + + protected onOpen(): void { + this.loadMyClans(); + } + + protected onClose(): void { + this.activeTab = "my-clans"; + this.view = "list"; + this.selectedClan = null; + this.selectedClanTag = ""; + this.myRole = null; + this.browseCache = null; + this.detailCache = null; + } + + private async loadMyClans() { + this.loading = true; + try { + const me = await getUserMe(); + if (!this.isModalOpen) return; + if (!me || Object.keys(me.user).length === 0) { + window.dispatchEvent( + new CustomEvent("show-message", { + detail: { + message: translateText("clan_modal.sign_in_for_clans"), + color: "red", + duration: 3000, + }, + }), + ); + this.close(); + window.showPage?.("page-account"); + return; + } + this.myPublicId = me.player.publicId; + this.myPendingRequests = me.player.clanRequests ?? []; + const roles = new Map(); + const clans: ClanInfo[] = []; + for (const c of me.player.clans ?? []) { + roles.set(c.tag, c.role); + clans.push({ + tag: c.tag, + name: c.name, + description: "", + isOpen: false, + memberCount: c.memberCount, + }); + } + this.myClanRoles = roles; + this.myClans = clans; + } finally { + this.loading = false; + } + } + + private renderInner() { + if (this.loading) { + return html` +
+ ${modalHeader({ + title: translateText("clan_modal.title"), + onBack: () => this.close(), + ariaLabel: translateText("common.back"), + })} + ${this.renderLoadingSpinner()} +
+ `; + } + + if (this.view === "my-requests") { + return html` (this.view = "list")} + @request-withdrawn=${(e: CustomEvent<{ tag: string }>) => { + this.myPendingRequests = this.myPendingRequests.filter( + (r) => r.tag !== e.detail.tag, + ); + if (this.myPendingRequests.length === 0) this.view = "list"; + }} + >`; + } + + if (this.selectedClanTag) { + if (this.view === "manage") { + return html` (this.view = "detail")} + @navigate-bans=${() => (this.view = "bans")} + @navigate-transfer=${() => (this.view = "transfer")} + @clan-updated=${(e: CustomEvent>) => { + if (this.selectedClan) { + this.selectedClan = { ...this.selectedClan, ...e.detail }; + } + this.detailCache = null; + invalidateUserMe(); + }} + @clan-disbanded=${(e: CustomEvent<{ tag: string }>) => { + const roles = new Map(this.myClanRoles); + roles.delete(e.detail.tag); + this.myClanRoles = roles; + this.myClans = this.myClans.filter((c) => c.tag !== e.detail.tag); + this.selectedClan = null; + this.selectedClanTag = ""; + this.myRole = null; + this.view = "list"; + this.loadMyClans(); + }} + >`; + } + if (this.view === "transfer") { + return html` (this.view = "manage")} + @leadership-transferred=${() => { + this.loadMyClans().then(() => + this.openDetail(this.selectedClanTag), + ); + }} + >`; + } + if (this.view === "requests") { + return html` (this.view = "detail")} + @request-approved=${() => { + if (this.selectedClan) { + this.selectedClan = { + ...this.selectedClan, + memberCount: (this.selectedClan.memberCount ?? 0) + 1, + }; + } + this.detailCache = null; + }} + >`; + } + if (this.view === "bans") { + return html` (this.view = "manage")} + >`; + } + // Default: detail view + return html` { + this.view = "list"; + this.selectedClan = null; + this.selectedClanTag = ""; + this.myRole = null; + this.detailCache = null; + }} + @detail-loaded=${( + e: CustomEvent<{ + clan: ClanInfo; + myRole: ClanRole | null; + members: ClanMember[]; + membersTotal: number; + pendingRequestCount: number; + stats: ClanStats | null; + }>, + ) => { + this.selectedClan = e.detail.clan; + this.myRole = e.detail.myRole; + this.detailCache = { + tag: e.detail.clan.tag, + members: e.detail.members, + membersTotal: e.detail.membersTotal, + pendingRequestCount: e.detail.pendingRequestCount, + stats: e.detail.stats, + }; + }} + @navigate-manage=${() => (this.view = "manage")} + @navigate-requests=${() => (this.view = "requests")} + @clan-joined=${(e: CustomEvent<{ tag: string }>) => { + this.myClanRoles = new Map([ + ...this.myClanRoles, + [e.detail.tag, "member" as ClanRole], + ]); + this.openDetail(e.detail.tag); + }} + @clan-left=${(e: CustomEvent<{ tag: string }>) => { + const roles = new Map(this.myClanRoles); + roles.delete(e.detail.tag); + this.myClanRoles = roles; + this.selectedClan = null; + this.selectedClanTag = ""; + this.myRole = null; + this.view = "list"; + this.loadMyClans(); + }} + @request-sent=${(e: CustomEvent<{ tag: string; name: string }>) => { + this.myPendingRequests = [ + ...this.myPendingRequests, + { + tag: e.detail.tag, + name: e.detail.name, + createdAt: new Date().toISOString(), + }, + ]; + }} + >`; + } + + // List view (tabs + my clans / browse) + return html` +
+ ${modalHeader({ + title: translateText("clan_modal.title"), + onBack: () => this.close(), + ariaLabel: translateText("common.back"), + })} + ${this.renderTabs()} +
+ ${this.activeTab === "my-clans" + ? this.renderMyClans() + : html`) => { + this.browseCache = e.detail; + }} + @clan-select=${(e: CustomEvent<{ tag: string }>) => + this.openDetail(e.detail.tag)} + >`} +
+
+ `; + } + + private openDetail(tag: string) { + this.selectedClanTag = tag; + 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` +
+ ${tabs.map( + (tab) => html` + + `, + )} +
+ `; + } + + private renderMyClans() { + const hasClans = this.myClans.length > 0; + const hasRequests = this.myPendingRequests.length > 0; + + if (!hasClans && !hasRequests) { + return html` +
+

+ ${translateText("clan_modal.no_clans")} +

+ +
+ `; + } + + return html` +
+ ${hasRequests ? this.renderPendingRequestsButton() : ""} + ${this.myClans.map( + (clan) => html` + ) => + this.openDetail(e.detail.tag)} + > + `, + )} +
+ `; + } + + private renderPendingRequestsButton() { + const count = this.myPendingRequests.length; + return html` + + `; + } +} diff --git a/src/client/Main.ts b/src/client/Main.ts index 3ec11ec08..bae704ac5 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -20,6 +20,7 @@ import { import "./AccountModal"; import { getUserMe } from "./Api"; import { userAuth } from "./Auth"; +import "./ClanModal"; import { joinLobby, type JoinLobbyResult } from "./ClientGameRunner"; import { getPlayerCosmeticsRefs } from "./Cosmetics"; import { crazyGamesSDK } from "./CrazyGamesSDK"; @@ -826,6 +827,7 @@ class Client { "leaderboard-button", "token-login", "matchmaking-modal", + "clan-modal", "lang-selector", "homepage-promos", ].forEach((tag) => { diff --git a/src/client/Utils.ts b/src/client/Utils.ts index e4a0c948f..943e764a4 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -678,6 +678,18 @@ export function getServerNow( return localNowMs + serverTimeOffsetMs; } +export function showToast( + message: string, + color: "red" | "green", + duration = 3500, +): void { + window.dispatchEvent( + new CustomEvent("show-message", { + detail: { message, color, duration }, + }), + ); +} + export function getSecondsUntilServerTimestamp( targetServerTimestampMs: number, serverTimeOffsetMs: number, diff --git a/src/client/components/BaseModal.ts b/src/client/components/BaseModal.ts index c8eff0230..26284c248 100644 --- a/src/client/components/BaseModal.ts +++ b/src/client/components/BaseModal.ts @@ -154,42 +154,43 @@ export abstract class BaseModal extends LitElement { } } - /** - * Renders a standardized loading spinner with optional custom message. - * Use this for consistent loading states across all modals. - * - * @param message - Optional loading message text. Defaults to no message. - * @param spinnerColor - Optional spinner color. Defaults to 'blue'. - * @returns TemplateResult of the loading UI - */ protected renderLoadingSpinner( message?: string, spinnerColor: "blue" | "green" | "yellow" | "white" = "blue", ): TemplateResult { - const colorClasses = { - blue: "border-blue-500/30 border-t-blue-500", - green: "border-green-500/30 border-t-green-500", - yellow: "border-yellow-500/30 border-t-yellow-500", - white: "border-white/20 border-t-white", - }; - - return html` -
-
- ${message - ? html`

- ${message} -

` - : ""} -
- `; + return renderLoadingSpinner(message, spinnerColor); } } + +const spinnerColorClasses: Record = { + blue: "border-blue-500/30 border-t-blue-500", + green: "border-green-500/30 border-t-green-500", + yellow: "border-yellow-500/30 border-t-yellow-500", + white: "border-white/20 border-t-white", +}; + +/** + * Renders a standardized loading spinner with optional custom message. + * Use this for consistent loading states across all modals. + */ +export function renderLoadingSpinner( + message?: string, + spinnerColor: "blue" | "green" | "yellow" | "white" = "blue", +): TemplateResult { + return html` +
+
+ ${message + ? html`

+ ${message} +

` + : ""} +
+ `; +} diff --git a/src/client/components/ConfirmDialog.ts b/src/client/components/ConfirmDialog.ts new file mode 100644 index 000000000..f92e2f14d --- /dev/null +++ b/src/client/components/ConfirmDialog.ts @@ -0,0 +1,129 @@ +import { html, LitElement, render as litRender } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { translateText } from "../Utils"; + +/** + * A reusable inline confirmation dialog. + * + * Usage: + * ```html + * doThing()} + * @cancel=${() => {}} + * > + * ``` + * + * For ban-style flows, add a textarea: + * ```html + * ban(e.detail.text)} + * @cancel=${() => {}} + * > + * ``` + */ +@customElement("confirm-dialog") +export class ConfirmDialog extends LitElement { + @property() message = ""; + @property() variant: "danger" | "warning" = "danger"; + @property() textareaPlaceholder = ""; + @property({ type: Boolean }) disabled = false; + + @state() private text = ""; + + private portal: HTMLDivElement | null = null; + + createRenderRoot() { + return this; + } + + connectedCallback() { + super.connectedCallback(); + this.portal = document.createElement("div"); + document.body.appendChild(this.portal); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this.portal) { + litRender(html``, this.portal); + this.portal.remove(); + this.portal = null; + } + } + + render() { + if (this.portal) { + litRender(this.renderOverlay(), this.portal); + } + return html``; + } + + private renderOverlay() { + const isDanger = this.variant === "danger"; + const borderColor = isDanger ? "border-red-500/50" : "border-amber-500/50"; + const cardBg = "bg-surface"; + const textColor = isDanger ? "text-red-300" : "text-amber-300"; + const btnClass = isDanger + ? "bg-red-600 text-white hover:bg-red-700" + : "bg-amber-600 text-white hover:bg-amber-700"; + + return html` +
{ + if (e.target === e.currentTarget) this.handleCancel(); + }} + > +
+

${this.message}

+ ${this.textareaPlaceholder + ? html`` + : ""} +
+ + +
+
+
+ `; + } + + private handleConfirm() { + this.dispatchEvent( + new CustomEvent("confirm", { detail: { text: this.text } }), + ); + this.text = ""; + } + + private handleCancel() { + this.dispatchEvent(new CustomEvent("cancel")); + this.text = ""; + } +} diff --git a/src/client/components/DesktopNavBar.ts b/src/client/components/DesktopNavBar.ts index 8fa0e31ed..7b149df94 100644 --- a/src/client/components/DesktopNavBar.ts +++ b/src/client/components/DesktopNavBar.ts @@ -123,6 +123,11 @@ export class DesktopNavBar extends LitElement { data-page="page-leaderboard" data-i18n="main.leaderboard" > +
+