Merge branch 'main' into player-text-opacity

This commit is contained in:
bijx
2026-03-20 13:17:17 -04:00
104 changed files with 3322 additions and 1558 deletions
+32 -20
View File
@@ -56,6 +56,7 @@ export interface LobbyConfig {
serverConfig: ServerConfig;
cosmetics: PlayerCosmeticRefs;
playerName: string;
playerClanTag: string | null;
gameID: GameID;
turnstileToken: string | null;
// GameStartInfo only exists when playing a singleplayer game.
@@ -64,15 +65,24 @@ export interface LobbyConfig {
gameRecord?: GameRecord;
}
export interface JoinLobbyResult {
stop: (force?: boolean) => boolean;
prestart: Promise<void>;
join: Promise<void>;
}
export function joinLobby(
eventBus: EventBus,
lobbyConfig: LobbyConfig,
onPrestart: () => void,
onJoin: () => void,
): (force?: boolean) => boolean {
): JoinLobbyResult {
// Mutable clientID state — assigned by server (multiplayer) or derived from gameStartInfo (singleplayer)
let clientID: ClientID | undefined;
let resolvePrestart: () => void;
let resolveJoin: () => void;
const prestartPromise = new Promise<void>((r) => (resolvePrestart = r));
const joinPromise = new Promise<void>((r) => (resolveJoin = r));
console.log(`joining lobby: gameID: ${lobbyConfig.gameID}`);
const userSettings: UserSettings = new UserSettings();
@@ -105,17 +115,17 @@ export function joinLobby(
message.gameMapSize,
terrainMapFileLoader,
);
onPrestart();
resolvePrestart();
}
if (message.type === "start") {
// Trigger prestart for singleplayer games
onPrestart();
resolvePrestart();
console.log(
`lobby: game started: ${JSON.stringify(message, replacer, 2)}`,
);
// Server tells us our assigned clientID (also sent on start for late joins)
clientID = message.myClientID;
onJoin();
resolveJoin();
// For multiplayer games, GameStartInfo is not known until game starts.
lobbyConfig.gameStartInfo = message.gameStartInfo;
createClientGame(
@@ -157,7 +167,7 @@ export function joinLobby(
if (message.error === "full-lobby") {
document.dispatchEvent(
new CustomEvent("leave-lobby", {
detail: { lobby: lobbyConfig.gameID },
detail: { lobby: lobbyConfig.gameID, cause: "full-lobby" },
bubbles: true,
composed: true,
}),
@@ -176,19 +186,19 @@ export function joinLobby(
}
};
transport.connect(onconnect, onmessage);
return (force: boolean = false) => {
if (!force && currentGameRunner?.shouldPreventWindowClose()) {
console.log("Player is active, prevent leaving game");
return false;
}
console.log("leaving game");
currentGameRunner = null;
transport.leaveGame();
return true;
return {
stop: (force: boolean = false) => {
if (!force && currentGameRunner?.shouldPreventWindowClose()) {
console.log("Player is active, prevent leaving game");
return false;
}
console.log("leaving game");
currentGameRunner = null;
transport.leaveGame();
return true;
},
prestart: prestartPromise,
join: joinPromise,
};
}
@@ -228,6 +238,7 @@ async function createClientGame(
gameMap,
clientID,
lobbyConfig.playerName,
lobbyConfig.playerClanTag,
lobbyConfig.gameStartInfo.gameID,
lobbyConfig.gameStartInfo.players,
);
@@ -301,6 +312,7 @@ export class ClientGameRunner {
{
persistentID: getPersistentID(),
username: this.lobby.playerName,
clanTag: this.lobby.playerClanTag ?? null,
clientID: this.clientID,
stats: update.allPlayersStats[this.clientID],
},
+4 -15
View File
@@ -4,7 +4,6 @@ import { GameEndInfo } from "../core/Schemas";
import { GameMapType } from "../core/game/Game";
import { fetchGameById } from "./Api";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import { UsernameInput } from "./UsernameInput";
import { renderDuration, translateText } from "./Utils";
import {
PlayerInfo,
@@ -28,7 +27,7 @@ export class GameInfoModal extends LitElement {
@property({ type: String }) gameId: string | null = null;
@property({ type: String }) rankType = RankType.Lifetime;
@state() private username: string | null = null;
@state() private currentClientID: string | null = null;
@state() private isLoadingGame: boolean = true;
private ranking: Ranking | null = null;
@@ -152,7 +151,7 @@ export class GameInfoModal extends LitElement {
.score=${this.ranking?.score(player, this.rankType) ?? 0}
.rankType=${this.rankType}
.bestScore=${bestScore}
.currentPlayer=${this.username === player.rawUsername}
.currentPlayer=${this.currentClientID === player.id}
></player-row>
`,
)}
@@ -183,26 +182,16 @@ export class GameInfoModal extends LitElement {
}
}
public loadUserName() {
const usernameInput = document.querySelector(
"username-input",
) as UsernameInput;
if (usernameInput) {
this.username = usernameInput.getCurrentUsername();
}
}
public async loadGame(gameId: string) {
public async loadGame(gameId: string, currentClientID: string | null = null) {
try {
this.isLoadingGame = true;
this.loadUserName();
this.currentClientID = currentClientID;
const session = await fetchGameById(gameId);
if (!session) return;
this.gameInfo = session.info;
this.ranking = new Ranking(session);
this.updateRanking();
this.isLoadingGame = false;
await this.loadMapImage(session.info.config.gameMap);
} catch (err) {
console.error("Failed to load game:", err);
+9 -21
View File
@@ -17,9 +17,12 @@ import { PublicLobbySocket } from "./LobbySocket";
import { JoinLobbyEvent } from "./Main";
import { SinglePlayerModal } from "./SinglePlayerModal";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import { UsernameInput } from "./UsernameInput";
import {
calculateServerTimeOffset,
getMapName,
getModifierLabels,
getSecondsUntilServerTimestamp,
renderDuration,
translateText,
} from "./Utils";
@@ -46,20 +49,10 @@ export class GameModeSelector extends LitElement {
* Returns true if valid, false otherwise.
*/
private validateUsername(): boolean {
const usernameInput = document.querySelector("username-input") as any;
if (usernameInput?.isValid?.() === false) {
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: usernameInput.validationError,
color: "red",
duration: 3000,
},
}),
);
return false;
}
return true;
const usernameInput = document.querySelector(
"username-input",
) as UsernameInput | null;
return usernameInput ? usernameInput.validateOrShowError() : true;
}
connectedCallback() {
@@ -81,7 +74,7 @@ export class GameModeSelector extends LitElement {
private handleLobbiesUpdate(lobbies: PublicGames) {
this.lobbies = lobbies;
this.serverTimeOffset = lobbies.serverTime - Date.now();
this.serverTimeOffset = calculateServerTimeOffset(lobbies.serverTime);
document.dispatchEvent(
new CustomEvent("public-lobbies-update", {
detail: { payload: lobbies },
@@ -279,12 +272,7 @@ export class GameModeSelector extends LitElement {
const useContain =
aspectRatio !== undefined && (aspectRatio > 4 || aspectRatio < 0.25);
const timeRemaining = lobby.startsAt
? Math.max(
0,
Math.floor(
(lobby.startsAt - this.serverTimeOffset - Date.now()) / 1000,
),
)
? getSecondsUntilServerTimestamp(lobby.startsAt, this.serverTimeOffset)
: undefined;
let timeDisplay: string = "";
+14 -2
View File
@@ -1,9 +1,12 @@
import { html, TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import {
calculateServerTimeOffset,
getActiveModifiers,
getGameModeLabel,
getMapName,
getSecondsUntilServerTimestamp,
getServerNow,
renderDuration,
renderNumber,
translateText,
@@ -44,6 +47,7 @@ export class JoinLobbyModal extends BaseModal {
@state() private currentClientID: string = "";
@state() private nationCount: number = 0;
@state() private lobbyStartAt: number | null = null;
@state() private serverTimeOffset: number = 0;
@state() private isConnecting: boolean = true;
@state() private lobbyCreatorClientID: string | null = null;
@@ -77,7 +81,10 @@ export class JoinLobbyModal extends BaseModal {
// Post-join state: show lobby info (identical for public & private)
const secondsRemaining =
this.lobbyStartAt !== null
? Math.max(0, Math.floor((this.lobbyStartAt - Date.now()) / 1000))
? getSecondsUntilServerTimestamp(
this.lobbyStartAt,
this.serverTimeOffset,
)
: null;
const statusLabel =
secondsRemaining === null
@@ -328,6 +335,7 @@ export class JoinLobbyModal extends BaseModal {
this.players = [];
this.nationCount = 0;
this.lobbyStartAt = null;
this.serverTimeOffset = 0;
this.lobbyCreatorClientID = null;
this.isConnecting = true;
this.handledJoinTimeout = false;
@@ -377,6 +385,7 @@ export class JoinLobbyModal extends BaseModal {
this.currentClientID = "";
this.nationCount = 0;
this.lobbyStartAt = null;
this.serverTimeOffset = 0;
this.lobbyCreatorClientID = null;
this.isConnecting = true;
this.leaveLobbyOnClose = true;
@@ -513,6 +522,9 @@ export class JoinLobbyModal extends BaseModal {
private updateFromLobby(lobby: GameInfo | PublicGameInfo) {
this.players = "clients" in lobby ? (lobby.clients ?? []) : [];
if ("serverTime" in lobby && typeof lobby.serverTime === "number") {
this.serverTimeOffset = calculateServerTimeOffset(lobby.serverTime);
}
this.lobbyStartAt = lobby.startsAt ?? null;
this.syncCountdownTimer();
if (lobby.gameConfig) {
@@ -577,7 +589,7 @@ export class JoinLobbyModal extends BaseModal {
) {
return;
}
if (Date.now() < this.lobbyStartAt) {
if (getServerNow(this.serverTimeOffset) < this.lobbyStartAt) {
return;
}
this.handledJoinTimeout = true;
+1 -2
View File
@@ -15,7 +15,6 @@ import {
import {
createPartialGameRecord,
decompressGameRecord,
getClanTag,
replacer,
} from "../core/Util";
import { getPersistentID } from "./Auth";
@@ -273,10 +272,10 @@ export class LocalServer {
{
persistentID: getPersistentID(),
username: this.lobbyConfig.playerName,
clanTag: this.lobbyConfig.playerClanTag ?? null,
clientID: this.clientID!,
stats: this.allPlayersStats[this.clientID!],
cosmetics: this.lobbyConfig.gameStartInfo?.players[0].cosmetics,
clanTag: getClanTag(this.lobbyConfig.playerName) ?? undefined,
},
];
if (this.lobbyConfig.gameStartInfo === undefined) {
+132 -109
View File
@@ -15,7 +15,7 @@ import { UserSettings } from "../core/game/UserSettings";
import "./AccountModal";
import { getUserMe } from "./Api";
import { userAuth } from "./Auth";
import { joinLobby } from "./ClientGameRunner";
import { joinLobby, type JoinLobbyResult } from "./ClientGameRunner";
import { getPlayerCosmeticsRefs } from "./Cosmetics";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import "./FlagInput";
@@ -180,12 +180,17 @@ declare global {
ramp: {
que: Array<() => void>;
passiveMode: boolean;
spaAddAds: (ads: Array<{ type: string; selectorId: string }>) => void;
destroyUnits: (adType: string) => void;
spaAddAds: (ads: Array<{ type: string; selectorId?: string }>) => void;
destroyUnits: (adType: string | string[]) => Promise<void>;
settings?: {
slots?: any;
};
spaNewPage: (url?: string) => void;
spaAds: (config?: {
ads?: Array<{ type: string; selectorId?: string }>;
countPageview?: boolean;
path?: string;
}) => void;
// Video ad methods
onPlayerReady: (() => void) | null;
addUnits: (units: Array<{ type: string }>) => Promise<void>;
@@ -230,7 +235,7 @@ export interface JoinLobbyEvent {
}
class Client {
private gameStop: ((force?: boolean) => boolean) | null = null;
private lobbyHandle: JoinLobbyResult | null = null;
private eventBus: EventBus = new EventBus();
private currentUrl: string | null = null;
@@ -300,8 +305,8 @@ class Client {
window.addEventListener("beforeunload", async () => {
console.log("Browser is closing");
if (this.gameStop !== null) {
this.gameStop(true);
if (this.lobbyHandle !== null) {
this.lobbyHandle.stop(true);
await crazyGamesSDK.gameplayStop();
}
});
@@ -521,10 +526,10 @@ class Client {
};
const onPopState = () => {
if (this.currentUrl !== null && this.gameStop !== null) {
if (this.currentUrl !== null && this.lobbyHandle !== null) {
console.info("Game is active");
if (!this.gameStop()) {
if (!this.lobbyHandle.stop()) {
console.info("Player is active, ask before leaving game");
const isConfirmed = confirm(
@@ -552,7 +557,7 @@ class Client {
};
const onJoinChanged = () => {
if (this.gameStop !== null) {
if (this.lobbyHandle !== null) {
this.handleLeaveLobby();
}
@@ -642,7 +647,7 @@ class Client {
return;
}
const patternName = params.get("pattern");
const patternName = params.get("cosmetic");
if (!patternName) {
alert("Something went wrong. Please contact support.");
console.error("purchase-completed but no pattern name");
@@ -732,10 +737,14 @@ class Client {
private async handleJoinLobby(event: CustomEvent<JoinLobbyEvent>) {
const lobby = event.detail;
if (this.usernameInput && !this.usernameInput.validateOrShowError()) {
return;
}
console.log(`joining lobby ${lobby.gameID}`);
if (this.gameStop !== null) {
if (this.lobbyHandle !== null) {
console.log("joining lobby, stopping existing game");
this.gameStop(true);
this.lobbyHandle.stop(true);
document.body.classList.remove("in-game");
}
if (lobby.source === "public") {
@@ -746,106 +755,105 @@ class Client {
if (lobby.source !== "public") {
this.updateJoinUrlForShare(lobby.gameID, config);
}
this.gameStop = joinLobby(
this.eventBus,
{
gameID: lobby.gameID,
serverConfig: config,
cosmetics: await getPlayerCosmeticsRefs(),
turnstileToken: await this.getTurnstileToken(lobby),
playerName:
this.usernameInput?.getCurrentUsername() ?? genAnonUsername(),
gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info,
gameRecord: lobby.gameRecord,
},
() => {
console.log("Closing modals");
document.getElementById("settings-button")?.classList.add("hidden");
if (this.usernameInput) {
// fix edge case where username-validation-error is re-rendered and hidden tag removed
this.usernameInput.validationError = "";
this.lobbyHandle = joinLobby(this.eventBus, {
gameID: lobby.gameID,
serverConfig: config,
cosmetics: await getPlayerCosmeticsRefs(),
turnstileToken: await this.getTurnstileToken(lobby),
playerName: this.usernameInput?.getUsername() ?? genAnonUsername(),
playerClanTag: this.usernameInput?.getClanTag() ?? null,
gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info,
gameRecord: lobby.gameRecord,
});
this.lobbyHandle.prestart.then(() => {
console.log("Closing modals");
document.getElementById("settings-button")?.classList.add("hidden");
if (this.usernameInput) {
// fix edge case where username-validation-error is re-rendered and hidden tag removed
this.usernameInput.validationError = "";
}
document
.getElementById("username-validation-error")
?.classList.add("hidden");
this.joinModal?.closeWithoutLeaving();
[
"single-player-modal",
"host-lobby-modal",
"game-starting-modal",
"game-top-bar",
"help-modal",
"user-setting",
"troubleshooting-modal",
"territory-patterns-modal",
"language-modal",
"news-modal",
"flag-input-modal",
"account-button",
"leaderboard-button",
"token-login",
"matchmaking-modal",
"lang-selector",
"gutter-ads",
].forEach((tag) => {
const modal = document.querySelector(tag) as HTMLElement & {
close?: () => void;
isModalOpen?: boolean;
};
if (modal?.close) {
modal.close();
} else if (modal && "isModalOpen" in modal) {
modal.isModalOpen = false;
}
document
.getElementById("username-validation-error")
?.classList.add("hidden");
this.joinModal?.closeWithoutLeaving();
[
"single-player-modal",
"host-lobby-modal",
"game-starting-modal",
"game-top-bar",
"help-modal",
"user-setting",
"troubleshooting-modal",
"territory-patterns-modal",
"language-modal",
"news-modal",
"flag-input-modal",
"account-button",
"leaderboard-button",
"token-login",
"matchmaking-modal",
"lang-selector",
"gutter-ads",
].forEach((tag) => {
const modal = document.querySelector(tag) as HTMLElement & {
close?: () => void;
isModalOpen?: boolean;
};
if (modal?.close) {
modal.close();
} else if (modal && "isModalOpen" in modal) {
modal.isModalOpen = false;
}
});
this.gameModeSelector.stop();
document.querySelectorAll(".ad").forEach((ad) => {
(ad as HTMLElement).style.display = "none";
});
});
this.gameModeSelector.stop();
document.querySelectorAll(".ad").forEach((ad) => {
(ad as HTMLElement).style.display = "none";
});
crazyGamesSDK.loadingStart();
crazyGamesSDK.loadingStart();
// show when the game loads
const startingModal = document.querySelector(
"game-starting-modal",
) as GameStartingModal;
if (startingModal && startingModal instanceof GameStartingModal) {
startingModal.show();
}
},
() => {
this.joinModal?.closeWithoutLeaving();
this.gameModeSelector.stop();
incrementGamesPlayed();
// show when the game loads
const startingModal = document.querySelector(
"game-starting-modal",
) as GameStartingModal;
if (startingModal && startingModal instanceof GameStartingModal) {
startingModal.show();
}
});
document.querySelectorAll(".ad").forEach((ad) => {
(ad as HTMLElement).style.display = "none";
});
this.lobbyHandle.join.then(() => {
this.joinModal?.closeWithoutLeaving();
this.gameModeSelector.stop();
incrementGamesPlayed();
if (window.PageOS?.session?.newPageView) {
window.PageOS.session.newPageView();
}
crazyGamesSDK.loadingStop();
crazyGamesSDK.gameplayStart();
document.body.classList.add("in-game");
document.querySelectorAll(".ad").forEach((ad) => {
(ad as HTMLElement).style.display = "none";
});
// Ensure there's a homepage entry in history before adding the lobby entry
if (window.location.hash === "" || window.location.hash === "#") {
history.replaceState(null, "", window.location.origin + "#refresh");
}
const lobbyIdHidden = !this.userSettings.lobbyIdVisibility();
history.pushState(
null,
"",
lobbyIdHidden
? "/streamer-mode"
: `/${config.workerPath(lobby.gameID)}/game/${lobby.gameID}?live`,
);
if (window.PageOS?.session?.newPageView) {
window.PageOS.session.newPageView();
}
crazyGamesSDK.loadingStop();
crazyGamesSDK.gameplayStart();
document.body.classList.add("in-game");
// Store current URL for popstate confirmation
this.currentUrl = window.location.href;
},
);
// Ensure there's a homepage entry in history before adding the lobby entry
if (window.location.hash === "" || window.location.hash === "#") {
history.replaceState(null, "", window.location.origin + "#refresh");
}
const lobbyIdHidden = !this.userSettings.lobbyIdVisibility();
history.pushState(
null,
"",
lobbyIdHidden
? "/streamer-mode"
: `/${config.workerPath(lobby.gameID)}/game/${lobby.gameID}?live`,
);
// Store current URL for popstate confirmation
this.currentUrl = window.location.href;
});
}
private updateJoinUrlForShare(
@@ -863,13 +871,13 @@ class Client {
}
}
private async handleLeaveLobby(/* event: CustomEvent */) {
if (this.gameStop === null) {
private async handleLeaveLobby(event?: CustomEvent) {
if (this.lobbyHandle === null) {
return;
}
console.log("leaving lobby, cancelling game");
this.gameStop(true);
this.gameStop = null;
this.lobbyHandle.stop(true);
this.lobbyHandle = null;
this.currentUrl = null;
try {
@@ -880,6 +888,21 @@ class Client {
document.body.classList.remove("in-game");
if (this.joinModal.isOpen()) {
this.joinModal.close();
if (event?.detail.cause === "full-lobby") {
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: translateText("public_lobby.join_timeout"),
color: "red",
duration: 3500,
},
}),
);
}
}
crazyGamesSDK.gameplayStop();
}
+2 -4
View File
@@ -654,9 +654,6 @@ export class SinglePlayerModal extends BaseModal {
const usernameInput = document.querySelector(
"username-input",
) as UsernameInput;
if (!usernameInput) {
console.warn("Username input element not found");
}
await crazyGamesSDK.requestMidgameAd();
@@ -669,7 +666,8 @@ export class SinglePlayerModal extends BaseModal {
players: [
{
clientID,
username: usernameInput.getCurrentUsername(),
username: usernameInput.getUsername(),
clanTag: usernameInput.getClanTag() ?? null,
cosmetics: await getPlayerCosmetics(),
},
],
+1
View File
@@ -399,6 +399,7 @@ export class Transport {
gameID: this.lobbyConfig.gameID,
// Note: clientID is not sent - server assigns it based on persistentID
username: this.lobbyConfig.playerName,
clanTag: this.lobbyConfig.playerClanTag ?? null,
cosmetics: this.lobbyConfig.cosmetics,
turnstileToken: this.lobbyConfig.turnstileToken,
token: await getPlayToken(),
+59 -62
View File
@@ -2,15 +2,19 @@ import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { v4 as uuidv4 } from "uuid";
import { translateText } from "../client/Utils";
import { getClanTagOriginalCase, sanitizeClanTag } from "../core/Util";
import { sanitizeClanTag } from "../core/Util";
import {
MAX_CLAN_TAG_LENGTH,
MAX_USERNAME_LENGTH,
MIN_CLAN_TAG_LENGTH,
MIN_USERNAME_LENGTH,
validateClanTag,
validateUsername,
} from "../core/validations/username";
import { crazyGamesSDK } from "./CrazyGamesSDK";
const usernameKey: string = "username";
const clanTagKey: string = "clanTag";
@customElement("username-input")
export class UsernameInput extends LitElement {
@@ -27,46 +31,45 @@ export class UsernameInput extends LitElement {
return this;
}
public getCurrentUsername(): string {
return this.constructFullUsername();
public getUsername(): string {
return this.baseUsername.trim();
}
private constructFullUsername(): string {
if (this.clanTag.length >= 2) {
return `[${this.clanTag}] ${this.baseUsername}`;
}
return this.baseUsername;
public getClanTag(): string | null {
return this.clanTag.length >= MIN_CLAN_TAG_LENGTH &&
this.clanTag.length <= MAX_CLAN_TAG_LENGTH &&
validateClanTag(this.clanTag).isValid
? this.clanTag
: null;
}
connectedCallback() {
super.connectedCallback();
const stored = this.getUsername();
this.parseAndSetUsername(stored);
this.loadStoredUsername();
crazyGamesSDK.getUsername().then((username) => {
if (username) {
this.parseAndSetUsername(username ?? genAnonUsername());
this.requestUpdate();
this.baseUsername = username;
this.validateAndStore();
}
});
crazyGamesSDK.addAuthListener((user) => {
if (user) {
this.parseAndSetUsername(user?.username);
this.baseUsername = user.username;
this.validateAndStore();
}
this.requestUpdate();
});
}
private parseAndSetUsername(fullUsername: string) {
const tag = getClanTagOriginalCase(fullUsername);
if (tag) {
this.clanTag = tag.toUpperCase();
this.baseUsername = fullUsername.replace(`[${tag}]`, "").trim();
private loadStoredUsername() {
const storedUsername = localStorage.getItem(usernameKey);
if (storedUsername) {
this.clanTag = localStorage.getItem(clanTagKey) ?? "";
this.baseUsername = storedUsername;
this.validateAndStore();
} else {
this.clanTag = "";
this.baseUsername = fullUsername;
this.baseUsername = genAnonUsername();
this.validateAndStore();
}
this.validateAndStore();
}
render() {
@@ -77,7 +80,8 @@ export class UsernameInput extends LitElement {
.value=${this.clanTag}
@input=${this.handleClanTagChange}
placeholder="${translateText("username.tag")}"
maxlength="5"
minlength="${MIN_CLAN_TAG_LENGTH}"
maxlength="${MAX_CLAN_TAG_LENGTH}"
class="w-[6rem] text-xl font-medium tracking-wider text-center uppercase shrink-0 bg-transparent text-white placeholder-white/70 focus:placeholder-transparent border-0 border-b border-white/40 focus:outline-none focus:border-white/60"
/>
<input
@@ -85,6 +89,7 @@ export class UsernameInput extends LitElement {
.value=${this.baseUsername}
@input=${this.handleUsernameChange}
placeholder="${translateText("username.enter_username")}"
minlength="${MIN_USERNAME_LENGTH}"
maxlength="${MAX_USERNAME_LENGTH}"
class="flex-1 min-w-0 border-0 text-2xl font-medium tracking-wider text-left text-white placeholder-white/70 focus:outline-none focus:ring-0 overflow-x-auto whitespace-nowrap text-ellipsis pr-2 bg-transparent"
/>
@@ -147,59 +152,51 @@ export class UsernameInput extends LitElement {
}
private validateAndStore() {
// Prevent empty username even if clan tag is present
const trimmedBase = this.baseUsername.trim();
if (!trimmedBase || trimmedBase.length < MIN_USERNAME_LENGTH) {
const trimmedBase = this.getUsername();
const clanTagResult = validateClanTag(this.clanTag);
if (!clanTagResult.isValid) {
this._isValid = false;
this.validationError = translateText("username.too_short", {
min: MIN_USERNAME_LENGTH,
});
this.validationError = clanTagResult.error ?? "";
return;
}
// Validate clan tag if present
if (this.clanTag.length > 0 && this.clanTag.length < 2) {
this._isValid = false;
this.validationError = translateText("username.tag_too_short");
return;
}
const full = this.constructFullUsername();
const trimmedFull = full.trim();
const result = validateUsername(trimmedFull);
const result = validateUsername(trimmedBase);
this._isValid = result.isValid;
if (result.isValid) {
this.storeUsername(trimmedFull);
localStorage.setItem(usernameKey, trimmedBase);
localStorage.setItem(clanTagKey, this.getClanTag() ?? "");
this.validationError = "";
} else {
this.validationError = result.error ?? "";
}
}
private getUsername(): string {
const storedUsername = localStorage.getItem(usernameKey);
if (storedUsername) {
return storedUsername;
}
return this.generateNewUsername();
}
private storeUsername(username: string) {
if (username) {
localStorage.setItem(usernameKey, username);
}
}
private generateNewUsername(): string {
const newUsername = genAnonUsername();
this.storeUsername(newUsername);
return newUsername;
}
public isValid(): boolean {
return this._isValid;
}
public showValidationFeedback(): void {
const message =
this.validationError || translateText("username.invalid_chars");
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message,
color: "red",
duration: 2500,
},
}),
);
}
public validateOrShowError(): boolean {
if (this.isValid()) {
return true;
}
this.showValidationFeedback();
return false;
}
}
export function genAnonUsername(): string {
+30 -2
View File
@@ -159,7 +159,7 @@ export function getActiveModifiers(
(modifiers.startingGold / 1_000_000).toPrecision(12),
);
result.push({
labelKey: "host_modal.starting_gold",
labelKey: "public_game_modifier.starting_gold_label",
badgeKey: "public_game_modifier.starting_gold",
badgeParams: {
amount: millions,
@@ -181,8 +181,9 @@ export function getActiveModifiers(
}
if (modifiers.isAlliancesDisabled) {
result.push({
labelKey: "host_modal.disable_alliances",
labelKey: "public_game_modifier.disable_alliances_label",
badgeKey: "public_game_modifier.disable_alliances",
formattedValue: translateText("common.disabled"),
});
}
return result;
@@ -628,3 +629,30 @@ export function getDiscordAvatarUrl(user: {
return null;
}
export function calculateServerTimeOffset(
serverTimeMs: number,
localNowMs: number = Date.now(),
): number {
return serverTimeMs - localNowMs;
}
export function getServerNow(
serverTimeOffsetMs: number,
localNowMs: number = Date.now(),
): number {
return localNowMs + serverTimeOffsetMs;
}
export function getSecondsUntilServerTimestamp(
targetServerTimestampMs: number,
serverTimeOffsetMs: number,
localNowMs: number = Date.now(),
): number {
return Math.max(
0,
Math.floor(
(targetServerTimestampMs - getServerNow(serverTimeOffsetMs, localNowMs)) /
1000,
),
);
}
+4
View File
@@ -35,6 +35,10 @@ export abstract class BaseModal extends LitElement {
return this;
}
public isOpen(): boolean {
return this.isModalOpen;
}
protected firstUpdated(): void {
if (this.modalEl) {
this.modalEl.onClose = () => {
+47 -17
View File
@@ -16,7 +16,7 @@ import {
import { assignTeamsLobbyPreview } from "../../core/game/TeamAssignment";
import { UserSettings } from "../../core/game/UserSettings";
import { ClientInfo, TeamCountConfig } from "../../core/Schemas";
import { createRandomName } from "../../core/Util";
import { createRandomName, formatPlayerDisplayName } from "../../core/Util";
import { getTranslatedPlayerTeamLabel, translateText } from "../Utils";
export interface TeamPreviewData {
@@ -122,9 +122,12 @@ export class LobbyTeamView extends LitElement {
this.clients,
(c) => c.clientID ?? c.username,
(client) => {
const displayName = this.displayUsername(client);
const displayName = this.getClientDisplayName(client);
return html`<div
class="px-2 py-1 rounded-sm bg-gray-700/70 mb-1 text-xs text-white"
class="px-2 py-1 rounded-sm mb-1 text-xs text-white border
${this.isCurrentPlayer(client)
? "bg-sky-600/20 border-sky-500/40"
: "bg-gray-700/70 border-transparent"}"
>
${displayName}
</div>`;
@@ -167,8 +170,12 @@ export class LobbyTeamView extends LitElement {
this.clients,
(c) => c.clientID ?? c.username,
(client) => {
const displayName = this.displayUsername(client);
return html`<span class="player-tag">
const displayName = this.getClientDisplayName(client);
return html`<span
class="player-tag ${this.isCurrentPlayer(client)
? "current-player"
: ""}"
>
<span class="text-white">${displayName}</span>
${client.clientID === this.lobbyCreatorClientID
? html`<span class="host-badge"
@@ -204,7 +211,12 @@ export class LobbyTeamView extends LitElement {
const teamLabel = getTranslatedPlayerTeamLabel(preview.team);
return html`
<div class="bg-gray-800 border border-gray-700 rounded-xl flex flex-col">
<div
class="bg-gray-800 border rounded-xl flex flex-col
${this.teamContainsCurrentPlayer(preview)
? "border-sky-500/60"
: "border-gray-700"}"
>
<div
class="px-2 py-1 font-bold flex items-center justify-between text-white rounded-t-xl text-[13px] gap-2 bg-gray-700/70"
>
@@ -226,9 +238,12 @@ export class LobbyTeamView extends LitElement {
preview.players,
(p) => p.clientID ?? p.username,
(p) => {
const displayName = this.displayUsername(p);
const displayName = this.getClientDisplayName(p);
return html` <div
class="bg-gray-700/70 px-2 py-1 rounded-sm text-xs flex items-center justify-between"
class="px-2 py-1 rounded-sm text-xs flex items-center justify-between border
${this.isCurrentPlayer(p)
? "bg-sky-600/20 border-sky-500/40"
: "bg-gray-700/70 border-transparent"}"
>
<span class="truncate text-white">${displayName}</span>
${p.clientID === this.lobbyCreatorClientID
@@ -318,7 +333,14 @@ export class LobbyTeamView extends LitElement {
const players = this.clients.map(
(c) =>
new PlayerInfo(c.username, PlayerType.Human, c.clientID, c.clientID),
new PlayerInfo(
c.username,
PlayerType.Human,
c.clientID,
c.clientID,
false,
c.clanTag,
),
);
const assignment = assignTeamsLobbyPreview(
players,
@@ -358,17 +380,25 @@ export class LobbyTeamView extends LitElement {
}));
}
private displayUsername(client: ClientInfo): string {
private isCurrentPlayer(client: ClientInfo): boolean {
return !!this.currentClientID && client.clientID === this.currentClientID;
}
private teamContainsCurrentPlayer(preview: TeamPreviewData): boolean {
return preview.players.some((p) => this.isCurrentPlayer(p));
}
private getClientDisplayName(client: ClientInfo): string {
const full = formatPlayerDisplayName(client.username, client.clanTag);
if (!this.userSettings.anonymousNames()) {
return client.username;
return full;
}
if (this.currentClientID && client.clientID === this.currentClientID) {
return client.username;
return full;
}
return (
createRandomName(client.username, PlayerType.Human) ?? client.username
);
// Keep clan tag visible while anonymizing only the username.
const anonymizedUsername =
createRandomName(client.username, PlayerType.Human) ?? client.username;
return formatPlayerDisplayName(anonymizedUsername, client.clanTag);
}
}
-213
View File
@@ -1,213 +0,0 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
const VIDEO_AD_UNIT_TYPE = "precontent_ad_video";
@customElement("video-ad")
export class VideoAd extends LitElement {
@state()
private isVisible: boolean = true;
@property({ attribute: false })
onComplete?: () => void;
@property({ attribute: false })
onMidpoint?: () => void;
@property({ attribute: false })
onAdBlocked?: () => void;
private adLoadTimeout: ReturnType<typeof setTimeout> | null = null;
private rampCheckInterval: ReturnType<typeof setInterval> | null = null;
private rampWaitTimeout: ReturnType<typeof setTimeout> | null = null;
private adStarted = false;
// How long to wait for ad to start before assuming it's blocked
private static readonly AD_LOAD_TIMEOUT_MS = 8000;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
// Set dimensions on the custom element itself (required by Playwire)
// Playwire requires explicit pixel dimensions, use max-width for responsiveness
this.style.display = "block";
this.style.width = "100%";
this.style.maxWidth = "800px";
this.style.aspectRatio = "16/9";
this.showVideoAd();
}
disconnectedCallback() {
super.disconnectedCallback();
// Clean up timeout if component is removed
if (this.adLoadTimeout) {
clearTimeout(this.adLoadTimeout);
this.adLoadTimeout = null;
}
if (this.rampCheckInterval) {
clearInterval(this.rampCheckInterval);
this.rampCheckInterval = null;
}
if (this.rampWaitTimeout) {
clearTimeout(this.rampWaitTimeout);
this.rampWaitTimeout = null;
}
}
public showVideoAd(): void {
if (!window.ramp) {
// Wait for ramp to be available, but give up after timeout
this.rampCheckInterval = setInterval(() => {
if (window.ramp && window.ramp.que) {
if (this.rampCheckInterval) {
clearInterval(this.rampCheckInterval);
this.rampCheckInterval = null;
}
if (this.rampWaitTimeout) {
clearTimeout(this.rampWaitTimeout);
this.rampWaitTimeout = null;
}
this.loadVideoAd();
}
}, 100);
// Stop polling after timeout (e.g. adblocker preventing ramp from loading)
this.rampWaitTimeout = setTimeout(() => {
if (this.rampCheckInterval) {
clearInterval(this.rampCheckInterval);
this.rampCheckInterval = null;
}
console.log("[VideoAd] Ramp SDK never loaded - possible adblocker");
this.handleAdBlocked();
}, VideoAd.AD_LOAD_TIMEOUT_MS);
return;
}
this.loadVideoAd();
}
private loadVideoAd(): void {
// Start timeout to detect if ad doesn't load (e.g., due to adblocker)
this.adLoadTimeout = setTimeout(() => {
if (!this.adStarted) {
console.log("[VideoAd] Ad load timeout - possible adblocker detected");
this.handleAdBlocked();
}
}, VideoAd.AD_LOAD_TIMEOUT_MS);
// Set up event listeners when player is ready, chaining any existing handler
const prevOnPlayerReady = window.ramp.onPlayerReady;
window.ramp.onPlayerReady = () => {
if (prevOnPlayerReady) prevOnPlayerReady();
if (window.Bolt) {
// Listen for ad start to know ad is loading successfully
window.Bolt.on(
VIDEO_AD_UNIT_TYPE,
window.Bolt.BOLT_AD_STARTED ?? "boltAdStarted",
() => {
console.log("[VideoAd] Ad started");
this.adStarted = true;
// Clear the timeout since ad is playing
if (this.adLoadTimeout) {
clearTimeout(this.adLoadTimeout);
this.adLoadTimeout = null;
}
},
);
window.Bolt.on(VIDEO_AD_UNIT_TYPE, window.Bolt.BOLT_AD_COMPLETE, () => {
console.log("[VideoAd] Ad completed");
this.hideElement();
});
window.Bolt.on(VIDEO_AD_UNIT_TYPE, window.Bolt.BOLT_AD_ERROR, () => {
console.log("[VideoAd] Ad error/no fill");
this.handleAdBlocked();
});
window.Bolt.on(VIDEO_AD_UNIT_TYPE, window.Bolt.BOLT_MIDPOINT, () => {
console.log("[VideoAd] Ad midpoint");
if (this.onMidpoint) {
this.onMidpoint();
}
});
window.Bolt.on(
VIDEO_AD_UNIT_TYPE,
window.Bolt.SHOW_HIDDEN_CONTAINER ?? "showHiddenContainer",
() => {
console.log("[VideoAd] Ad finished");
this.hideElement();
},
);
}
};
// Queue the video ad initialization
window.ramp.que.push(() => {
const pwUnits = [{ type: VIDEO_AD_UNIT_TYPE }];
window.ramp
.addUnits(pwUnits)
.then(() => {
window.ramp.displayUnits();
})
.catch((e: Error) => {
console.error("[VideoAd] Error adding units:", e);
window.ramp.displayUnits();
});
});
}
private handleAdBlocked(): void {
// Clear timeout if still pending
if (this.adLoadTimeout) {
clearTimeout(this.adLoadTimeout);
this.adLoadTimeout = null;
}
// Call the callback if provided
if (this.onAdBlocked) {
this.onAdBlocked();
}
}
private hideElement(): void {
this.style.display = "none";
this.isVisible = false;
// Call the callback if provided
if (this.onComplete) {
this.onComplete();
}
// Also dispatch event for backwards compatibility
this.dispatchEvent(
new CustomEvent("ad-complete", {
bubbles: true,
composed: true,
}),
);
}
render() {
if (!this.isVisible) {
return html``;
}
// Provide a container for the Playwire video player to render into
// Structure matches Playwire example: wrapper > game-video-ad > precontent-video-location
return html`
<div
class="game-video-ad"
style="width: 100%; height: 100%; overflow: hidden;"
>
<div
id="precontent-video-location"
style="width: 100%; height: 100%;"
></div>
</div>
`;
}
}
@@ -26,9 +26,8 @@ export enum RankType {
export interface PlayerInfo {
id: string;
rawUsername: string;
username: string;
tag?: string;
clanTag: string | null;
killedAt?: number;
gold: bigint[];
conquests: bigint[];
@@ -77,18 +76,12 @@ export class Ranking {
for (const player of session.info.players) {
if (player === undefined || !hasPlayed(player)) continue;
const stats = player.stats!;
const match = player.username.match(/^\[(.*?)\]\s*(.*)$/);
let username = player.username;
if (player.clanTag && match) {
username = match[2];
}
const gold = (stats.gold ?? []).map((v) => BigInt(v ?? 0));
const conquests = (stats.conquests ?? []).map((v) => BigInt(v ?? 0));
players[player.clientID] = {
id: player.clientID,
rawUsername: player.username,
username,
tag: player.clanTag,
username: player.username,
clanTag: player.clanTag,
conquests,
flag: player.cosmetics?.flag ?? undefined,
killedAt: stats.killedAt !== null ? Number(stats.killedAt) : undefined,
@@ -220,7 +220,7 @@ export class PlayerRow extends LitElement {
private renderPlayerName() {
return html`
<div class="flex gap-1 items-center w-50 shrink-0">
${this.player.tag ? this.renderTag(this.player.tag) : ""}
${this.player.clanTag ? this.renderTag(this.player.clanTag) : ""}
<div
class="text-xs sm:text-sm font-bold tracking-wide text-white/80 text-ellipsis w-37.5 shrink-0 overflow-hidden whitespace-nowrap"
>
@@ -249,9 +249,7 @@ export class LeaderboardPlayerList extends LitElement {
</div>`
: ""}
<span class="font-bold text-blue-300 truncate text-base"
>${player.clanTag
? player.username.replace(/^\[.*?\]\s*/, "")
: player.username}</span
>${player.username}</span
>
</div>
</td>
@@ -434,14 +432,18 @@ export class LeaderboardPlayerList extends LitElement {
"leaderboard_modal.your_ranking",
)}</span
>
<span class="font-bold text-white text-base"
>${this.currentUserEntry.clanTag
? this.currentUserEntry.username.replace(
/^\[.*?\]\s*/,
"",
)
: this.currentUserEntry.username}</span
>
<div class="flex items-center gap-2">
${this.currentUserEntry.clanTag
? html`<div
class="px-2 py-0.5 rounded bg-blue-500/10 border border-blue-300/40 text-[10px] font-bold text-blue-100 shrink-0"
>
${this.currentUserEntry.clanTag}
</div>`
: ""}
<span class="font-bold text-white text-base"
>${this.currentUserEntry.username}</span
>
</div>
</div>
<div class="flex flex-col items-end w-20">
<div class="font-mono text-white font-bold text-lg">
+8 -16
View File
@@ -7,6 +7,7 @@ import { FrameProfiler } from "./FrameProfiler";
import { TransformHandler } from "./TransformHandler";
import { UIState } from "./UIState";
import { AlertFrame } from "./layers/AlertFrame";
import { AttackingTroopsOverlay } from "./layers/AttackingTroopsOverlay";
import { AttacksDisplay } from "./layers/AttacksDisplay";
import { BuildMenu } from "./layers/BuildMenu";
import { ChatDisplay } from "./layers/ChatDisplay";
@@ -21,7 +22,7 @@ import { GameLeftSidebar } from "./layers/GameLeftSidebar";
import { GameRightSidebar } from "./layers/GameRightSidebar";
import { HeadsUpMessage } from "./layers/HeadsUpMessage";
import { ImmunityTimer } from "./layers/ImmunityTimer";
import { InGameHeaderAd } from "./layers/InGameHeaderAd";
import { InGamePromo } from "./layers/InGamePromo";
import { Layer } from "./layers/Layer";
import { Leaderboard } from "./layers/Leaderboard";
import { MainRadialMenu } from "./layers/MainRadialMenu";
@@ -36,7 +37,6 @@ import { ReplayPanel } from "./layers/ReplayPanel";
import { SAMRadiusLayer } from "./layers/SAMRadiusLayer";
import { SettingsModal } from "./layers/SettingsModal";
import { SpawnTimer } from "./layers/SpawnTimer";
import { SpawnVideoAd } from "./layers/SpawnVideoReward";
import { StructureIconsLayer } from "./layers/StructureIconsLayer";
import { StructureLayer } from "./layers/StructureLayer";
import { TeamStats } from "./layers/TeamStats";
@@ -262,19 +262,11 @@ export function createRenderer(
immunityTimer.game = game;
immunityTimer.eventBus = eventBus;
const inGameHeaderAd = document.querySelector(
"in-game-header-ad",
) as InGameHeaderAd;
if (!(inGameHeaderAd instanceof InGameHeaderAd)) {
console.error("in-game header ad not found");
const inGamePromo = document.querySelector("in-game-promo") as InGamePromo;
if (!(inGamePromo instanceof InGamePromo)) {
console.error("in-game promo not found");
}
inGameHeaderAd.game = game;
const spawnVideoAd = document.querySelector("spawn-video-ad") as SpawnVideoAd;
if (!(spawnVideoAd instanceof SpawnVideoAd)) {
console.error("spawn video ad not found");
}
spawnVideoAd.game = game;
inGamePromo.game = game;
// When updating these layers please be mindful of the order.
// Try to group layers by the return value of shouldTransform.
@@ -293,6 +285,7 @@ export function createRenderer(
new StructureIconsLayer(game, eventBus, uiState, transformHandler),
new DynamicUILayer(game, transformHandler, eventBus),
new NameLayer(game, transformHandler, eventBus),
new AttackingTroopsOverlay(game, transformHandler, eventBus, userSettings),
eventsDisplay,
attacksDisplay,
chatDisplay,
@@ -321,8 +314,7 @@ export function createRenderer(
playerPanel,
headsUpMessage,
multiTabModal,
inGameHeaderAd,
spawnVideoAd,
inGamePromo,
alertFrame,
performanceOverlay,
];
+1 -1
View File
@@ -52,7 +52,7 @@ export function placeName(game: Game, player: Player): NameViewData {
),
);
const fontSize = calculateFontSize(largestRectangle, player.name());
const fontSize = calculateFontSize(largestRectangle, player.displayName());
center = new Cell(center.x, center.y - fontSize / 3);
return {
@@ -0,0 +1,305 @@
import { EventBus } from "../../../core/EventBus";
import { Cell } 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";
import shieldIcon from "/images/ShieldIconWhite.svg?url";
import swordIcon from "/images/SwordIconWhite.svg?url";
export function troopAttackColor(
attackerTroops: number,
defenderTroops: number,
): string {
return attackerTroops > defenderTroops ? "#66ff66" : "#ffbe3c";
}
export function troopDefenceColor(
attackerTroops: number,
myTroops: number,
): string {
return attackerTroops > myTroops ? "#ff4444" : "#ff9944";
}
// An attack can have multiple disconnected front-line segments, so elements
// and positions are parallel arrays with one entry per segment.
interface AttackLabel {
elements: HTMLDivElement[];
positions: (Cell | null)[];
isIncoming: boolean;
attackerTroops: number;
defenderTroops: number;
}
export class AttackingTroopsOverlay implements Layer {
private container: HTMLDivElement;
private labels = new Map<string, AttackLabel>();
// Guard against queuing multiple worker requests in the same tick window.
private inFlightRequest = false;
private isVisible = true;
private onAlternateView: (e: AlternateViewEvent) => void;
constructor(
private readonly game: GameView,
private readonly transformHandler: TransformHandler,
private readonly eventBus: EventBus,
private readonly userSettings: UserSettings,
) {}
shouldTransform(): boolean {
return false;
}
init() {
this.container = document.createElement("div");
this.container.style.position = "fixed";
this.container.style.left = "50%";
this.container.style.top = "50%";
this.container.style.pointerEvents = "none";
// z-index 4 places labels above NameLayer (z-index 3).
this.container.style.zIndex = "4";
document.body.appendChild(this.container);
this.onAlternateView = (e) => {
this.isVisible = !e.alternateView;
this.container.style.display = this.isVisible ? "" : "none";
};
this.eventBus.on(AlternateViewEvent, this.onAlternateView);
}
destroy() {
if (!this.container) return;
this.clearAllLabels();
this.container.remove();
this.eventBus.off(AlternateViewEvent, this.onAlternateView);
}
getTickIntervalMs() {
return 200;
}
tick() {
if (!this.userSettings.attackingTroopsOverlay() || !this.isVisible) {
if (this.labels.size > 0) this.clearAllLabels();
return;
}
const myPlayer = this.game.myPlayer();
if (!myPlayer) {
this.clearAllLabels();
return;
}
const activeIDs = new Set<string>();
// Outgoing attacks — green if winning, amber if losing.
for (const attack of myPlayer.outgoingAttacks()) {
activeIDs.add(attack.id);
if (!attack.targetID) {
this.removeLabel(attack.id);
continue;
}
const defender = this.game.playerBySmallID(attack.targetID);
if (!defender || !defender.isPlayer()) {
this.removeLabel(attack.id);
continue;
}
this.ensureLabel(attack.id, attack.troops, defender.troops(), false);
}
// Incoming attacks — red if the attacker outnumbers the player, orange otherwise.
for (const attack of myPlayer.incomingAttacks()) {
activeIDs.add(attack.id);
const attacker = this.game.playerBySmallID(attack.attackerID);
if (!attacker || !attacker.isPlayer()) {
this.removeLabel(attack.id);
continue;
}
this.ensureLabel(attack.id, attack.troops, myPlayer.troops(), true);
}
for (const [id] of this.labels) {
if (!activeIDs.has(id)) this.removeLabel(id);
}
// Single worker request per tick; skip if the previous one is still in flight.
if (this.inFlightRequest) return;
this.inFlightRequest = true;
void myPlayer
.attackClusteredPositions()
.then((attacks) => {
for (const { id, positions } of attacks) {
const lbl = this.labels.get(id);
if (!lbl) continue;
this.reconcileLabelPositions(lbl, positions);
}
})
.catch(() => {
// On error, hide all labels until the next successful response.
for (const lbl of this.labels.values()) lbl.positions.fill(null);
})
.finally(() => {
this.inFlightRequest = false;
});
}
private ensureLabel(
attackID: string,
attackerTroops: number,
defenderTroops: number,
isIncoming: boolean,
) {
let label = this.labels.get(attackID);
if (!label) {
label = {
elements: [],
positions: [],
isIncoming,
attackerTroops,
defenderTroops,
};
this.labels.set(attackID, label);
} else {
label.attackerTroops = attackerTroops;
label.defenderTroops = defenderTroops;
}
for (const el of label.elements) {
this.updateLabelContent(el, attackerTroops, defenderTroops, isIncoming);
}
}
renderLayer(_context: CanvasRenderingContext2D) {
const screenPosOld = this.transformHandler.worldToScreenCoordinates(
new Cell(0, 0),
);
const screenPos = new Cell(
screenPosOld.x - window.innerWidth / 2,
screenPosOld.y - window.innerHeight / 2,
);
this.container.style.transform = `translate(${screenPos.x}px, ${screenPos.y}px) scale(${this.transformHandler.scale})`;
for (const label of this.labels.values()) {
for (let i = 0; i < label.elements.length; i++) {
const el = label.elements[i];
const pos = label.positions[i];
if (!pos || !this.transformHandler.isOnScreen(pos)) {
el.style.display = "none";
continue;
}
el.style.display = "inline-flex";
// Centre the label on its world position and counter-scale so text
// stays the same screen size regardless of zoom level.
el.style.transform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%) scale(${1 / this.transformHandler.scale})`;
}
}
}
private reconcileLabelPositions(lbl: AttackLabel, positions: Cell[]) {
// Add elements for new clusters.
while (lbl.elements.length < positions.length) {
lbl.elements.push(
this.createLabelElement(
lbl.attackerTroops,
lbl.defenderTroops,
lbl.isIncoming,
),
);
lbl.positions.push(null);
}
// Remove elements for clusters that no longer exist.
while (lbl.elements.length > positions.length) {
lbl.elements.pop()!.remove();
lbl.positions.pop();
}
// Snap large jumps instantly; let the CSS transition handle small advances.
for (let i = 0; i < positions.length; i++) {
const old = lbl.positions[i];
const next = positions[i];
if (old && Math.hypot(next.x - old.x, next.y - old.y) > 50) {
const el = lbl.elements[i];
el.style.transition = "none";
el.style.transform = `translate(${next.x}px, ${next.y}px) translate(-50%, -50%) scale(${1 / this.transformHandler.scale})`;
requestAnimationFrame(() => {
el.style.transition = "transform 0.2s ease-out";
});
}
lbl.positions[i] = next;
}
}
private createLabelElement(
attackerTroops: number,
defenderTroops: number,
isIncoming: boolean,
): HTMLDivElement {
const el = document.createElement("div");
el.style.position = "absolute";
el.style.display = "none";
el.style.alignItems = "center";
el.style.gap = "3px";
el.style.width = "max-content";
el.style.whiteSpace = "nowrap";
el.style.fontSize = "11px";
el.style.fontWeight = "bold";
el.style.fontFamily = this.game.config().theme().font();
el.style.padding = "1px 4px";
el.style.borderRadius = "3px";
el.style.backgroundColor = "rgba(0,0,0,0.55)";
el.style.pointerEvents = "none";
el.style.lineHeight = "1.3";
// Smooth the label to its new position as the front line advances.
el.style.transition = "transform 0.2s ease-out";
this.updateLabelContent(el, attackerTroops, defenderTroops, isIncoming);
this.container.appendChild(el);
return el;
}
private updateLabelContent(
el: HTMLDivElement,
attackerTroops: number,
defenderTroops: number,
isIncoming: boolean,
) {
// Reuse existing children to avoid DOM churn on every tick.
let icon = el.querySelector("img") as HTMLImageElement | null;
let span = el.querySelector("span") as HTMLSpanElement | null;
if (!icon || !span) {
icon = document.createElement("img");
icon.style.width = "10px";
icon.style.height = "10px";
span = document.createElement("span");
el.replaceChildren(icon, span);
}
if (isIncoming) {
icon.src = shieldIcon;
span.style.color = troopDefenceColor(attackerTroops, defenderTroops);
span.textContent = renderTroops(attackerTroops);
} else {
icon.src = swordIcon;
span.style.color = troopAttackColor(attackerTroops, defenderTroops);
span.textContent = renderTroops(attackerTroops);
}
}
private removeLabel(attackID: string) {
const label = this.labels.get(attackID);
if (!label) return;
for (const el of label.elements) el.remove();
this.labels.delete(attackID);
}
private clearAllLabels() {
for (const label of this.labels.values()) {
for (const el of label.elements) el.remove();
}
this.labels.clear();
}
}
+13 -17
View File
@@ -184,17 +184,13 @@ export class AttacksDisplay extends LitElement implements Layer {
const playerView = this.game.playerBySmallID(attack.attackerID);
if (playerView !== undefined) {
if (playerView instanceof PlayerView) {
const averagePosition = await playerView.attackAveragePosition(
attack.attackerID,
attack.id,
);
const attacks = await playerView.attackClusteredPositions(attack.id);
const pos = attacks[0]?.positions[0];
if (averagePosition === null) {
if (!pos) {
this.emitGoToPlayerEvent(attack.attackerID);
} else {
this.eventBus.emit(
new GoToPositionEvent(averagePosition.x, averagePosition.y),
);
this.eventBus.emit(new GoToPositionEvent(pos.x, pos.y));
}
}
} else {
@@ -222,7 +218,7 @@ export class AttacksDisplay extends LitElement implements Layer {
return this.incomingAttacks.map(
(attack) => html`
<div
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
class="flex items-center gap-0.5 w-full bg-gray-800/92 backdrop-blur-sm sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`<span class="inline-flex items-center"
@@ -235,7 +231,7 @@ export class AttacksDisplay extends LitElement implements Layer {
<span class="truncate ml-1"
>${(
this.game.playerBySmallID(attack.attackerID) as PlayerView
)?.name()}</span
)?.displayName()}</span
>
${attack.retreating
? `(${translateText("events_display.retreating")}...)`
@@ -269,7 +265,7 @@ export class AttacksDisplay extends LitElement implements Layer {
return this.outgoingAttacks.map(
(attack) => html`
<div
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
class="flex items-center gap-0.5 w-full bg-gray-800/92 backdrop-blur-sm sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`<span class="inline-flex items-center"
@@ -282,7 +278,7 @@ export class AttacksDisplay extends LitElement implements Layer {
<span class="truncate ml-1"
>${(
this.game.playerBySmallID(attack.targetID) as PlayerView
)?.name()}</span
)?.displayName()}</span
> `,
onClick: async () => this.attackWarningOnClick(attack),
className:
@@ -310,7 +306,7 @@ export class AttacksDisplay extends LitElement implements Layer {
return this.outgoingLandAttacks.map(
(landAttack) => html`
<div
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
class="flex items-center gap-0.5 w-full bg-gray-800/92 backdrop-blur-sm sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`<span class="inline-flex items-center"
@@ -346,7 +342,7 @@ export class AttacksDisplay extends LitElement implements Layer {
const ownerID = this.game.ownerID(target);
if (ownerID === 0) return "";
const player = this.game.playerBySmallID(ownerID) as PlayerView;
return player?.name() ?? "";
return player?.displayName() ?? "";
}
private renderBoatIcon(boat: UnitView) {
@@ -365,7 +361,7 @@ export class AttacksDisplay extends LitElement implements Layer {
return this.outgoingBoats.map(
(boat) => html`
<div
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
class="flex items-center gap-0.5 w-full bg-gray-800/92 backdrop-blur-sm sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`${this.renderBoatIcon(boat)}
@@ -401,7 +397,7 @@ export class AttacksDisplay extends LitElement implements Layer {
return this.incomingBoats.map(
(boat) => html`
<div
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
class="flex items-center gap-0.5 w-full bg-gray-800/92 backdrop-blur-sm sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`${this.renderBoatIcon(boat)}
@@ -409,7 +405,7 @@ export class AttacksDisplay extends LitElement implements Layer {
>${renderTroops(boat.troops())}</span
>
<span class="truncate text-xs ml-1"
>${boat.owner()?.name()}</span
>${boat.owner()?.displayName()}</span
>`,
onClick: () => this.eventBus.emit(new GoToUnitEvent(boat)),
className:
+6 -5
View File
@@ -147,7 +147,7 @@ export class ChatModal extends LitElement {
.toHex()};"
@click=${() => this.selectPlayer(player)}
>
${player.name()}
${player.displayName()}
</button>
`,
)}
@@ -216,7 +216,8 @@ export class ChatModal extends LitElement {
private selectPlayer(player: PlayerView) {
if (this.previewText) {
this.previewText =
this.selectedPhraseTemplate?.replace("[P1]", player.name()) ?? null;
this.selectedPhraseTemplate?.replace("[P1]", player.displayName()) ??
null;
this.selectedPlayer = player;
this.requiresPlayerSelection = false;
this.requestUpdate();
@@ -255,13 +256,13 @@ export class ChatModal extends LitElement {
private getSortedFilteredPlayers(): PlayerView[] {
const sorted = [...this.players].sort((a, b) =>
a.name().localeCompare(b.name()),
a.displayName().localeCompare(b.displayName()),
);
const filtered = sorted.filter((p) =>
p.name().toLowerCase().includes(this.playerSearchQuery),
p.displayName().toLowerCase().includes(this.playerSearchQuery),
);
const others = sorted.filter(
(p) => !p.name().toLowerCase().includes(this.playerSearchQuery),
(p) => !p.displayName().toLowerCase().includes(this.playerSearchQuery),
);
return [...filtered, ...others];
}
+12 -12
View File
@@ -283,7 +283,7 @@ export class EventsDisplay extends LitElement implements Layer {
this.addEvent({
description: translateText("events_display.about_to_expire", {
name: other.name(),
name: other.displayName(),
}),
type: MessageType.RENEW_ALLIANCE,
duration: this.game.config().allianceExtensionPromptOffset() - 3 * 10, // 3 second buffer
@@ -296,7 +296,7 @@ export class EventsDisplay extends LitElement implements Layer {
},
{
text: translateText("events_display.renew_alliance", {
name: other.name(),
name: other.displayName(),
}),
className: "btn",
action: () =>
@@ -460,7 +460,7 @@ export class EventsDisplay extends LitElement implements Layer {
this.addEvent({
description: translateText("events_display.request_alliance", {
name: requestor.name(),
name: requestor.displayName(),
}),
buttons: [
{
@@ -525,7 +525,7 @@ export class EventsDisplay extends LitElement implements Layer {
) as PlayerView;
this.addEvent({
description: translateText("events_display.alliance_request_status", {
name: recipient.name(),
name: recipient.displayName(),
status: update.accepted
? translateText("events_display.alliance_accepted")
: translateText("events_display.alliance_rejected"),
@@ -569,7 +569,7 @@ export class EventsDisplay extends LitElement implements Layer {
this.addEvent({
description: translateText("events_display.betrayal_description", {
name: betrayed.name(),
name: betrayed.displayName(),
malusPercent: malusPercent,
durationText: durationText,
}),
@@ -589,7 +589,7 @@ export class EventsDisplay extends LitElement implements Layer {
];
this.addEvent({
description: translateText("events_display.betrayed_you", {
name: traitor.name(),
name: traitor.displayName(),
}),
type: MessageType.ALLIANCE_BROKEN,
highlight: true,
@@ -616,7 +616,7 @@ export class EventsDisplay extends LitElement implements Layer {
this.addEvent({
description: translateText("events_display.alliance_expired", {
name: other.name(),
name: other.displayName(),
}),
type: MessageType.ALLIANCE_EXPIRED,
highlight: true,
@@ -641,8 +641,8 @@ export class EventsDisplay extends LitElement implements Layer {
this.addEvent({
description: translateText("events_display.attack_request", {
name: other.name(),
target: target.name(),
name: other.displayName(),
target: target.displayName(),
}),
type: MessageType.ATTACK_REQUEST,
highlight: true,
@@ -809,7 +809,7 @@ export class EventsDisplay extends LitElement implements Layer {
`,
onClick: this.toggleHidden,
className:
"text-white cursor-pointer pointer-events-auto w-fit p-2 lg:p-3 min-[1200px]:rounded-lg sm:rounded-tl-lg bg-gray-800/70 backdrop-blur-xs",
"text-white cursor-pointer pointer-events-auto w-fit p-2 lg:p-3 min-[1200px]:rounded-lg sm:rounded-tl-lg bg-gray-800/92 backdrop-blur-sm",
})}
</div>
`
@@ -820,7 +820,7 @@ export class EventsDisplay extends LitElement implements Layer {
>
<!-- Button Bar -->
<div
class="w-full p-2 lg:p-3 bg-gray-800/70 sm:rounded-tl-lg min-[1200px]:rounded-t-lg"
class="w-full p-2 lg:p-3 bg-gray-800/92 backdrop-blur-sm sm:rounded-tl-lg min-[1200px]:rounded-t-lg"
>
<div class="flex justify-between items-center gap-3">
<div class="flex gap-4">
@@ -864,7 +864,7 @@ export class EventsDisplay extends LitElement implements Layer {
<!-- Content Area -->
<div
class="bg-gray-800/70 max-h-[15vh] lg:max-h-[30vh] overflow-y-auto w-full h-full min-[1200px]:rounded-b-xl events-container"
class="bg-gray-800/92 backdrop-blur-sm max-h-[15vh] lg:max-h-[30vh] overflow-y-auto w-full h-full min-[1200px]:rounded-b-xl events-container"
>
<div>
<table
@@ -102,7 +102,7 @@ export class GameLeftSidebar extends LitElement implements Layer {
render() {
return html`
<aside
class=${`fixed top-0 min-[1200px]:top-4 left-0 min-[1200px]:left-4 z-900 flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg rounded-br-lg ${this.isLeaderboardShow || this.isTeamLeaderboardShow ? "max-[400px]:w-full max-[400px]:rounded-none" : ""} transition-all duration-300 ease-out transform ${
class=${`fixed top-0 min-[1200px]:top-4 left-0 min-[1200px]:left-4 z-900 flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg rounded-br-lg ${this.isLeaderboardShow || this.isTeamLeaderboardShow ? "max-[400px]:w-full max-[400px]:rounded-none" : ""} transition-all duration-300 ease-out transform ${
this.isVisible ? "translate-x-0" : "hidden"
}`}
style="margin-top: ${this.barOffset}px;"
@@ -184,7 +184,7 @@ export class GameRightSidebar extends LitElement implements Layer {
return html`
<aside
class=${`w-fit flex flex-row items-center gap-3 py-2 px-3 bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg rounded-bl-lg transition-transform duration-300 ease-out transform text-white ${
class=${`w-fit flex flex-row items-center gap-3 py-2 px-3 bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg rounded-bl-lg transition-transform duration-300 ease-out transform text-white ${
this._isVisible ? "translate-x-0" : "translate-x-full"
}`}
@contextmenu=${(e: Event) => e.preventDefault()}
@@ -1,119 +0,0 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { GameView } from "../../../core/game/GameView";
import { Layer } from "./Layer";
const AD_SHOW_TICKS = 10 * 60 * 10; // 2 minutes
const HEADER_AD_TYPE = "standard_iab_head1";
const HEADER_AD_CONTAINER_ID = "header-ad-container";
const TWO_XL_BREAKPOINT = 1536;
@customElement("in-game-header-ad")
export class InGameHeaderAd extends LitElement implements Layer {
public game: GameView;
private isHidden: boolean = false;
private adLoaded: boolean = false;
private shouldShow: boolean = false;
createRenderRoot() {
return this;
}
init() {
// TODO: move ad and re-enable.
// this.showHeaderAd();
}
private showHeaderAd(): void {
// Don't show header ad on screens smaller than 2xl
if (window.innerWidth < TWO_XL_BREAKPOINT) {
return;
}
if (!window.adsEnabled) {
return;
}
this.shouldShow = true;
this.requestUpdate();
// Wait for the element to render before loading the ad
this.updateComplete.then(() => {
this.loadAd();
});
}
private loadAd(): void {
if (!window.ramp) {
console.warn("Playwire RAMP not available for header ad");
return;
}
try {
window.ramp.que.push(() => {
try {
window.ramp.spaAddAds([
{
type: HEADER_AD_TYPE,
selectorId: HEADER_AD_CONTAINER_ID,
},
]);
this.adLoaded = true;
console.log("Header ad loaded:", HEADER_AD_TYPE);
} catch (e) {
console.error("Failed to add header ad:", e);
}
});
} catch (error) {
console.error("Failed to load header ad:", error);
}
}
private hideHeaderAd(): void {
this.shouldShow = false;
this.adLoaded = false;
try {
window.ramp.destroyUnits(HEADER_AD_TYPE);
console.log("successfully destroyed in game header ad");
} catch (e) {
console.error("error destroying in game header ad", e);
}
this.requestUpdate();
}
public tick() {
if (this.isHidden) {
return;
}
const gameTicks =
this.game.ticks() - this.game.config().numSpawnPhaseTurns();
if (gameTicks > AD_SHOW_TICKS) {
console.log("destroying header ad and refreshing PageOS");
this.hideHeaderAd();
this.isHidden = true;
if (window.PageOS?.session?.newPageView) {
window.PageOS.session.newPageView();
}
return;
}
}
shouldTransform(): boolean {
return false;
}
render() {
if (!this.shouldShow) {
return html``;
}
return html`
<div
id="${HEADER_AD_CONTAINER_ID}"
class="hidden 2xl:flex fixed top-0 left-1/2 -translate-x-1/2 z-[100] justify-center items-center pointer-events-auto p-0 -mt-[20px]"
></div>
`;
}
}
+146
View File
@@ -0,0 +1,146 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { GameView } from "../../../core/game/GameView";
import { Layer } from "./Layer";
const AD_TYPE = "standard_iab_left1";
const AD_CONTAINER_ID = "in-game-bottom-left-ad";
const BOTTOM_RAIL_TYPE = "bottom_rail";
@customElement("in-game-promo")
export class InGamePromo extends LitElement implements Layer {
public game: GameView;
private shouldShow: boolean = false;
private bottomRailActive: boolean = false;
private cornerAdShown: boolean = false;
createRenderRoot() {
return this;
}
init() {
this.showBottomRail();
}
tick() {
if (!this.game.inSpawnPhase()) {
if (this.bottomRailActive) {
this.destroyBottomRail();
}
if (!this.cornerAdShown) {
this.cornerAdShown = true;
this.showAd();
}
}
}
private showBottomRail(): void {
if (!window.adsEnabled) return;
if (!this.game.inSpawnPhase()) return;
if (!window.ramp) {
console.warn("Playwire RAMP not available for bottom_rail ad");
return;
}
this.bottomRailActive = true;
try {
window.ramp.que.push(() => {
try {
window.ramp.spaAddAds([{ type: BOTTOM_RAIL_TYPE }]);
console.log("Bottom rail ad loaded during spawn phase");
} catch (e) {
console.error("Failed to add bottom_rail ad:", e);
}
});
} catch (error) {
console.error("Failed to load bottom_rail ad:", error);
}
}
private destroyBottomRail(): void {
if (!this.bottomRailActive) return;
this.bottomRailActive = false;
if (!window.ramp) return;
try {
window.ramp.spaAds({ ads: [], countPageview: false });
console.log("Bottom rail ad destroyed via spaAds after spawn phase");
} catch (e) {
console.error("Error destroying bottom_rail ad:", e);
}
}
private showAd(): void {
if (!window.adsEnabled) return;
if (window.innerWidth < 1100) return;
if (window.innerHeight < 750) return;
this.shouldShow = true;
this.requestUpdate();
this.updateComplete.then(() => {
this.loadAd();
});
}
private loadAd(): void {
if (!window.ramp) {
console.warn("Playwire RAMP not available for in-game ad");
return;
}
try {
window.ramp.que.push(() => {
try {
window.ramp.spaAddAds([
{
type: AD_TYPE,
selectorId: AD_CONTAINER_ID,
},
]);
console.log("In-game bottom-left ad loaded:", AD_TYPE);
} catch (e) {
console.error("Failed to add in-game ad:", e);
}
});
} catch (error) {
console.error("Failed to load in-game ad:", error);
}
}
public hideAd(): void {
this.destroyBottomRail();
if (!window.ramp) {
console.warn("Playwire RAMP not available for in-game ad");
return;
}
this.shouldShow = false;
try {
window.ramp.destroyUnits(AD_TYPE);
console.log("successfully destroyed in-game bottom-left ad");
} catch (e) {
console.error("error destroying in-game ad:", e);
}
this.requestUpdate();
}
shouldTransform(): boolean {
return false;
}
render() {
if (!this.shouldShow) {
return html``;
}
return html`
<div
id="${AD_CONTAINER_ID}"
class="fixed left-0 z-[100] pointer-events-auto"
style="bottom: -0.7cm"
></div>
`;
}
}
+2 -2
View File
@@ -239,7 +239,7 @@ export class NameLayer implements Layer {
const nameSpan = document.createElement("span");
nameSpan.className = "player-name-span";
nameSpan.textContent = player.name();
nameSpan.textContent = player.displayName();
nameDiv.appendChild(nameSpan);
element.appendChild(nameDiv);
@@ -343,7 +343,7 @@ export class NameLayer implements Layer {
".player-name-span",
) as HTMLSpanElement | null;
if (span) {
span.textContent = render.player.name();
span.textContent = render.player.displayName();
span.style.opacity = `${nameOpacity}`;
}
if (flagDiv) {
@@ -380,7 +380,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
src=${"/flags/" + player.cosmetics.flag! + ".svg"}
/>`
: html``}
<span>${player.name()}</span>
<span>${player.displayName()}</span>
${playerTeam !== "" && player.type() !== PlayerType.Bot
? html`<div class="flex flex-col leading-tight">
<span class="text-gray-400 text-xs font-normal"
@@ -488,7 +488,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
return html`
<div class="p-2">
<div class="font-bold mb-1 ${isAlly ? "text-green-500" : "text-white"}">
${unit.owner().name()}
${unit.owner().displayName()}
</div>
<div class="mt-1">
<div class="text-sm opacity-80">${unit.type()}</div>
@@ -524,7 +524,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
>
<div
class="bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg sm:rounded-b-lg shadow-lg text-white text-lg lg:text-base w-full sm:w-[500px] overflow-hidden ${containerClasses}"
class="bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg sm:rounded-b-lg shadow-lg text-white text-lg lg:text-base w-full sm:w-[500px] overflow-hidden ${containerClasses}"
>
${this.player !== null ? this.renderPlayerInfo(this.player) : ""}
${this.unit !== null ? this.renderUnitInfo(this.unit) : ""}
@@ -65,7 +65,7 @@ export class PlayerModerationModal extends LitElement {
if (!targetClientID || targetClientID.length === 0) return;
const confirmed = confirm(
translateText("player_panel.kick_confirm", { name: other.name() }),
translateText("player_panel.kick_confirm", { name: other.displayName() }),
);
if (!confirmed) return;
@@ -142,9 +142,9 @@ export class PlayerModerationModal extends LitElement {
>
<div
class="text-sm font-semibold text-zinc-100 truncate"
title=${other.name()}
title=${other.displayName()}
>
${other.name()}
${other.displayName()}
</div>
</div>
+5 -5
View File
@@ -505,9 +505,9 @@ export class PlayerPanel extends LitElement implements Layer {
<div class="flex-1 min-w-0">
<h2
class="text-xl font-bold tracking-[-0.01em] text-zinc-50 truncate"
title=${other.name()}
title=${other.displayName()}
>
${other.name()}
${other.displayName()}
</h2>
</div>
${chip
@@ -626,7 +626,7 @@ export class PlayerPanel extends LitElement implements Layer {
const nameCollator = new Intl.Collator(undefined, { sensitivity: "base" });
const alliesSorted = [...allies].sort((a, b) =>
nameCollator.compare(a.name(), b.name()),
nameCollator.compare(a.displayName(), b.displayName()),
);
return html`
@@ -669,9 +669,9 @@ export class PlayerPanel extends LitElement implements Layer {
rounded-md border border-white/10 bg-white/5
px-2.5 py-1 text-[14px] text-zinc-100
hover:bg-white/8 active:scale-[0.99] transition"
title=${p.name()}
title=${p.displayName()}
>
<span class="truncate">${p.name()}</span>
<span class="truncate">${p.displayName()}</span>
</li>`,
)}
</ul>
+1 -1
View File
@@ -75,7 +75,7 @@ export class ReplayPanel extends LitElement implements Layer {
return html`
<div
class="p-2 bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg rounded-l-lg"
class="p-2 bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg rounded-l-lg"
@contextmenu=${(e: Event) => e.preventDefault()}
>
<label class="block mb-2 text-white" translate="no">
@@ -18,6 +18,7 @@ import mouseIcon from "/images/MouseIconWhite.svg?url";
import ninjaIcon from "/images/NinjaIconWhite.svg?url";
import settingsIcon from "/images/SettingIconWhite.svg?url";
import sirenIcon from "/images/SirenIconWhite.svg?url";
import swordIcon from "/images/SwordIconWhite.svg?url";
import treeIcon from "/images/TreeIconWhite.svg?url";
import musicIcon from "/images/music.svg?url";
@@ -163,6 +164,11 @@ export class SettingsModal extends LitElement implements Layer {
this.requestUpdate();
}
private onToggleAttackingTroopsOverlayButtonClick() {
this.userSettings.toggleAttackingTroopsOverlay();
this.requestUpdate();
}
private onTogglePerformanceOverlayButtonClick() {
this.userSettings.togglePerformanceOverlay();
this.requestUpdate();
@@ -414,6 +420,28 @@ export class SettingsModal extends LitElement implements Layer {
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleAttackingTroopsOverlayButtonClick}"
>
<img src=${swordIcon} alt="swordIcon" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText(
"user_setting.attacking_troops_overlay_label",
)}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.attacking_troops_overlay_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.attackingTroopsOverlay()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleCursorCostLabelButtonClick}"
@@ -1,67 +0,0 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { crazyGamesSDK } from "src/client/CrazyGamesSDK";
import { Platform } from "src/client/Platform";
import { getGamesPlayed } from "src/client/Utils";
import { GameType } from "src/core/game/Game";
import { GameView } from "../../../core/game/GameView";
import "../../components/VideoPromo";
import { Layer } from "./Layer";
@customElement("spawn-video-ad")
export class SpawnVideoAd extends LitElement implements Layer {
public game: GameView;
@state() private shouldShow = false;
@state() private adComplete = false;
createRenderRoot() {
return this;
}
init() {
if (
!window.adsEnabled ||
Platform.isMobileWidth ||
crazyGamesSDK.isOnCrazyGames() ||
this.game.config().gameConfig().gameType === GameType.Singleplayer ||
getGamesPlayed() < 3 // Don't show to new players
) {
return;
}
this.shouldShow = true;
}
tick() {
if (this.adComplete) return;
// Hide when spawn phase ends
if (this.shouldShow && !this.game.inSpawnPhase()) {
this.shouldShow = false;
this.requestUpdate();
}
}
private handleComplete = () => {
this.adComplete = true;
this.shouldShow = false;
};
shouldTransform(): boolean {
return false;
}
render() {
if (!this.shouldShow || this.adComplete) {
return html``;
}
return html`
<div class="fixed bottom-0 left-0 z-[9999] pointer-events-auto">
<video-ad
style="width: 400px; max-width: 400px; height: 225px; aspect-ratio: auto;"
.onComplete="${this.handleComplete}"
></video-ad>
</div>
`;
}
}
+1 -1
View File
@@ -334,7 +334,7 @@ export class WinModal extends LitElement implements Layer {
crazyGamesSDK.happytime();
} else {
this._title = translateText("win_modal.other_won", {
player: winner.name(),
player: winner.displayName(),
});
this.isWin = false;
}
+6
View File
@@ -295,6 +295,7 @@ label.option-card:hover {
align-items: center;
gap: 8px;
background: #2a2a2a;
border: 1px solid transparent;
color: #fff;
padding: 8px 12px;
margin: 4px;
@@ -302,6 +303,11 @@ label.option-card:hover {
font-size: 14px;
}
.player-tag.current-player {
background: rgba(14, 165, 233, 0.15);
border: 1px solid rgba(14, 165, 233, 0.4);
}
#bots-count,
#private-lobby-bots-count {
width: 80%;
+18 -5
View File
@@ -1,8 +1,21 @@
import { z } from "zod";
import { base64urlToUuid } from "./Base64";
import { ClanTagSchema } from "./Schemas";
import { BigIntStringSchema, PlayerStatsSchema } from "./StatsSchemas";
import { Difficulty, GameMode, GameType, RankedType } from "./game/Game";
function stripClanTagFromUsername(username: string): string {
return username.replace(/^\s*\[[a-zA-Z0-9]{2,5}\]\s*/u, "").trim();
}
// Historical leaderboard rows can include legacy usernames
// that predate current strict join-time validation rules.
const LeaderboardUsernameSchema = z
.string()
.transform(stripClanTagFromUsername)
.pipe(z.string().min(1).max(64));
const LeaderboardClanTagSchema = ClanTagSchema.unwrap();
export const RefreshResponseSchema = z.object({
token: z.string(),
});
@@ -114,7 +127,7 @@ export const PlayerProfileSchema = z.object({
export type PlayerProfile = z.infer<typeof PlayerProfileSchema>;
export const ClanLeaderboardEntrySchema = z.object({
clanTag: z.string(),
clanTag: LeaderboardClanTagSchema,
games: z.number(),
wins: z.number(),
losses: z.number(),
@@ -137,8 +150,8 @@ export type ClanLeaderboardResponse = z.infer<
export const PlayerLeaderboardEntrySchema = z.object({
rank: z.number(),
playerId: z.string(),
username: z.string(),
clanTag: z.string().optional(),
username: LeaderboardUsernameSchema,
clanTag: LeaderboardClanTagSchema.nullable().optional(),
flag: z.string().optional(),
elo: z.number(),
games: z.number(),
@@ -166,8 +179,8 @@ export const RankedLeaderboardEntrySchema = z.object({
total: z.number(),
public_id: z.string(),
user: DiscordUserSchema.nullable().optional(),
username: z.string(),
clanTag: z.string().nullable().optional(),
username: LeaderboardUsernameSchema,
clanTag: LeaderboardClanTagSchema.nullable().optional(),
});
export type RankedLeaderboardEntry = z.infer<
typeof RankedLeaderboardEntrySchema
+14 -16
View File
@@ -5,9 +5,7 @@ import { RecomputeRailClusterExecution } from "./execution/RecomputeRailClusterE
import { WinCheckExecution } from "./execution/WinCheckExecution";
import {
AllPlayers,
Attack,
BuildableUnit,
Cell,
Game,
GameUpdates,
NameViewData,
@@ -52,6 +50,7 @@ export async function createGameRunner(
p.clientID,
random.nextID(),
p.isLobbyCreator ?? false,
p.clanTag,
);
});
@@ -254,24 +253,23 @@ export class GameRunner {
} as PlayerBorderTiles;
}
public attackAveragePosition(
public attackClusteredPositions(
playerID: number,
attackID: string,
): Cell | null {
attackID?: string,
): { id: string; positions: { x: number; y: number }[] }[] {
const player = this.game.playerBySmallID(playerID);
if (!player.isPlayer()) {
if (!player.isPlayer())
throw new Error(`player with id ${playerID} not found`);
}
const all = [...player.outgoingAttacks(), ...player.incomingAttacks()];
const attacks = attackID ? all.filter((a) => a.id() === attackID) : all;
const condition = (a: Attack) => a.id() === attackID;
const attack =
player.outgoingAttacks().find(condition) ??
player.incomingAttacks().find(condition);
if (attack === undefined) {
return null;
}
return attack.averagePosition();
return attacks.map((a) => ({
id: a.id(),
positions: a.clusteredPositions().map((tile) => ({
x: this.game.map().x(tile),
y: this.game.map().y(tile),
})),
}));
}
public bestTransportShipSpawn(
+16 -7
View File
@@ -141,9 +141,21 @@ export type PublicGameType = z.infer<typeof PublicGameTypeSchema>;
export const PublicGameTypeSchema = z.enum(["ffa", "team", "special"]);
export const UsernameSchema = z
.string()
.regex(/^(?=.*\S)[a-zA-Z0-9_ üÜ.]+$/u)
.min(3)
.max(27);
export const ClanTagSchema = z
.string()
.regex(/^[a-zA-Z0-9]{2,5}$/)
.nullable();
const ClientInfoSchema = z.object({
clientID: z.string(),
username: z.string(),
username: UsernameSchema,
clanTag: ClanTagSchema,
});
export const GameInfoSchema = z.object({
@@ -179,6 +191,7 @@ export class LobbyInfoEvent implements GameEvent {
export interface ClientInfo {
clientID: ClientID;
username: string;
clanTag: string | null;
}
export enum LogSeverity {
Debug = "DEBUG",
@@ -279,11 +292,6 @@ export const ID = z.string().regex(GAME_ID_REGEX);
export const AllPlayersStatsSchema = z.record(ID, PlayerStatsSchema);
export const UsernameSchema = z
.string()
.regex(/^[a-zA-Z0-9_ [\]üÜ.]+$/u)
.min(3)
.max(27);
const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code);
export const QuickChatKeySchema = z.enum(
@@ -510,6 +518,7 @@ export const PlayerCosmeticsSchema = z.object({
export const PlayerSchema = z.object({
clientID: ID,
username: UsernameSchema,
clanTag: ClanTagSchema,
cosmetics: PlayerCosmeticsSchema.optional(),
isLobbyCreator: z.boolean().optional(),
});
@@ -630,6 +639,7 @@ export const ClientJoinMessageSchema = z.object({
token: TokenSchema, // WARNING: PII - server extracts persistentID from this
gameID: ID,
username: UsernameSchema,
clanTag: ClanTagSchema,
// Server replaces the refs with the actual cosmetic data.
cosmetics: PlayerCosmeticRefsSchema.optional(),
turnstileToken: z.string().nullable(),
@@ -659,7 +669,6 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [
export const PlayerRecordSchema = PlayerSchema.extend({
persistentID: PersistentIdSchema.nullable(), // WARNING: PII
clanTag: z.string().optional(),
stats: PlayerStatsSchema,
});
export type PlayerRecord = z.infer<typeof PlayerRecordSchema>;
+5 -17
View File
@@ -340,29 +340,17 @@ export function sigmoid(
return 1 / (1 + Math.exp(-decayRate * (value - midpoint)));
}
// Compute clan from name
export function getClanTag(name: string): string | null {
const clanTag = clanMatch(name);
return clanTag ? clanTag[1].toUpperCase() : null;
}
export function getClanTagOriginalCase(name: string): string | null {
const clanTag = clanMatch(name);
return clanTag ? clanTag[1] : null;
export function formatPlayerDisplayName(
username: string,
clanTag?: string | null,
): string {
return clanTag ? `[${clanTag}] ${username}` : username;
}
const CLAN_TAG_CHARS = "a-zA-Z0-9";
const CLAN_TAG_INVALID_CHARS = new RegExp(`[^${CLAN_TAG_CHARS}]`, "g");
const CLAN_TAG_REGEX = new RegExp(`\\[([${CLAN_TAG_CHARS}]{2,5})\\]`);
export function sanitizeClanTag(tag: string): string {
return tag.replace(CLAN_TAG_INVALID_CHARS, "").substring(0, 5).toUpperCase();
}
function clanMatch(name: string): RegExpMatchArray | null {
if (!name.includes("[") || !name.includes("]")) {
return null;
}
return name.match(CLAN_TAG_REGEX);
}
+6 -1
View File
@@ -136,6 +136,9 @@ export abstract class DefaultServerConfig implements ServerConfig {
}
}
/** SAM launcher construction duration in ticks (non-instant-build). */
export const SAM_CONSTRUCTION_TICKS = 30 * 10;
export class DefaultConfig implements Config {
private pastelTheme: PastelTheme = new PastelTheme();
private pastelThemeDark: PastelThemeDark = new PastelThemeDark();
@@ -430,7 +433,9 @@ export class DefaultConfig implements Config {
Math.min(3_000_000, (numUnits + 1) * 1_500_000),
UnitType.SAMLauncher,
),
constructionDuration: this.instantBuild() ? 0 : 30 * 10,
constructionDuration: this.instantBuild()
? 0
: SAM_CONSTRUCTION_TICKS,
upgradable: true,
};
break;
+1 -1
View File
@@ -90,7 +90,7 @@ export class MirvExecution implements Execution {
this.mg.displayIncomingUnit(
this.nuke.id(),
// TODO TranslateText
`⚠️⚠️⚠️ ${this.player.name()} - MIRV INBOUND ⚠️⚠️⚠️`,
`⚠️⚠️⚠️ ${this.player.displayName()} - MIRV INBOUND ⚠️⚠️⚠️`,
MessageType.MIRV_INBOUND,
this.targetPlayer.id(),
);
+2 -2
View File
@@ -150,7 +150,7 @@ export class NukeExecution implements Execution {
this.mg.displayIncomingUnit(
this.nuke.id(),
// TODO TranslateText
`${this.player.name()} - atom bomb inbound`,
`${this.player.displayName()} - atom bomb inbound`,
MessageType.NUKE_INBOUND,
target.id(),
);
@@ -158,7 +158,7 @@ export class NukeExecution implements Execution {
this.mg.displayIncomingUnit(
this.nuke.id(),
// TODO TranslateText
`${this.player.name()} - hydrogen bomb inbound`,
`${this.player.displayName()} - hydrogen bomb inbound`,
MessageType.HYDROGEN_BOMB_INBOUND,
target.id(),
);
+23 -4
View File
@@ -97,8 +97,11 @@ export class PlayerExecution implements Execution {
}
}
if (ticks - this.lastCalc > this.ticksPerClusterCalc) {
if (this.player.lastTileChange() > this.lastCalc) {
if (
ticks - this.lastCalc > this.ticksPerClusterCalc ||
this.player.numTilesOwned() < 100
) {
if (this.player.lastTileChange() >= this.lastCalc) {
this.lastCalc = ticks;
const start = performance.now();
this.removeClusters();
@@ -157,6 +160,12 @@ export class PlayerExecution implements Execution {
clusterBox: { min: Cell; max: Cell },
): false | Player {
const enemies = new Set<number>();
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (const tile of cluster) {
let hasUnownedNeighbor = false;
if (this.mg.isOceanShore(tile) || this.mg.isOnEdgeOfMap(tile)) {
@@ -170,6 +179,12 @@ export class PlayerExecution implements Execution {
const ownerId = this.mg.ownerID(n);
if (ownerId !== this.player.smallID()) {
enemies.add(ownerId);
const px = this.mg.x(n);
const py = this.mg.y(n);
minX = Math.min(minX, px);
minY = Math.min(minY, py);
maxX = Math.max(maxX, px);
maxY = Math.max(maxY, py);
}
});
if (hasUnownedNeighbor) {
@@ -182,9 +197,13 @@ export class PlayerExecution implements Execution {
if (enemies.size !== 1) {
return false;
}
const enemy = this.mg.playerBySmallID(Array.from(enemies)[0]) as Player;
const enemyBox = calculateBoundingBox(this.mg, enemy.borderTiles());
if (inscribed(enemyBox, clusterBox)) {
const localEnemyBox = {
min: new Cell(minX, minY),
max: new Cell(maxX, maxY),
};
if (inscribed(localEnemyBox, clusterBox)) {
return enemy;
}
return false;
+79 -18
View File
@@ -1,4 +1,4 @@
import { Attack, Cell, Player, TerraNullius } from "./Game";
import { Attack, Player, TerraNullius } from "./Game";
import { GameImpl } from "./GameImpl";
import { TileRef } from "./GameMap";
import { PlayerImpl } from "./PlayerImpl";
@@ -97,28 +97,89 @@ export class AttackImpl implements Attack {
}
}
averagePosition(): Cell | null {
// Returns the top 2 clustered positions of the attack's border.
// If the second cluster is too small, only returns the largest one.
clusteredPositions(): TileRef[] {
if (this._borderSize === 0) {
if (this.sourceTile() === null) {
// No border tiles and no source tile—return a default position or throw an error
return null;
const tile = this.sourceTile();
return tile !== null ? [tile] : [];
}
return this.clusterBorderTiles(30, 2);
}
// Partitions the attack's border tiles into disconnected segments using BFS,
// then returns one representative tile per segment.
//
// Border tiles naturally fragment when fighting across non-contiguous
// territory (e.g. islands, chokepoints).
//
// Results are sorted largest-first, small clusters below minSize are
// dropped (the largest is always kept as a fallback), and the list is capped
// at maxClusters to avoid label clutter on heavily fragmented borders.
private clusterBorderTiles(minSize: number, maxClusters: number): TileRef[] {
const map = this._mg.map();
const visited = new Set<TileRef>();
const clusters: { tile: TileRef; size: number }[] = [];
for (const startTile of this._border) {
if (visited.has(startTile)) continue;
const queue: TileRef[] = [startTile];
visited.add(startTile);
let qi = 0;
let sumX = 0;
let sumY = 0;
let count = 0;
while (qi < queue.length) {
const t = queue[qi++];
sumX += map.x(t);
sumY += map.y(t);
count++;
this._mg.forEachNeighborWithDiag(t, (neighbor) => {
if (this._border.has(neighbor) && !visited.has(neighbor)) {
visited.add(neighbor);
queue.push(neighbor);
}
});
}
// No border tiles yet—use the source tile's location
const tile: number = this.sourceTile()!;
return new Cell(this._mg.map().x(tile), this._mg.map().y(tile));
// The centroid (sumX/count, sumY/count) may not be a real border tile,
// so we pick whichever tile in the cluster is closest to it. This ensures
// the representative always sits on an actual front-line tile.
const cx = sumX / count;
const cy = sumY / count;
let best = queue[0];
let bestDist = Infinity;
for (const t of queue) {
const dx = map.x(t) - cx;
const dy = map.y(t) - cy;
const dist = dx * dx + dy * dy;
if (dist < bestDist) {
bestDist = dist;
best = t;
}
}
clusters.push({ tile: best, size: count });
}
let averageX = 0;
let averageY = 0;
clusters.sort((a, b) => b.size - a.size);
for (const t of this._border) {
averageX += this._mg.map().x(t);
averageY += this._mg.map().y(t);
switch (clusters.length) {
case 0:
return [];
case 1:
// If there's only one cluster, return it even if it's smaller than minSize.
return [clusters[0].tile];
default: {
const significant = clusters.filter((c) => c.size >= minSize);
if (significant.length === 0) {
// Always keep at least the largest cluster even if it falls below minSize.
return [clusters[0].tile];
}
return significant.slice(0, maxClusters).map((c) => c.tile);
}
}
averageX = averageX / this._borderSize;
averageY = averageY / this._borderSize;
return new Cell(averageX, averageY);
}
}
+5 -5
View File
@@ -2,7 +2,7 @@ import { Config } from "../configuration/Config";
import { AbstractGraph } from "../pathfinding/algorithms/AbstractGraph";
import { PathFinder } from "../pathfinding/types";
import { AllPlayersStats, ClientID } from "../Schemas";
import { getClanTag } from "../Util";
import { formatPlayerDisplayName } from "../Util";
import { GameMap, TileRef } from "./GameMap";
import {
GameUpdate,
@@ -470,7 +470,7 @@ export interface Attack {
removeBorderTile(tile: TileRef): void;
clearBorder(): void;
borderSize(): number;
averagePosition(): Cell | null;
clusteredPositions(): TileRef[];
}
export interface AllianceRequest {
@@ -503,7 +503,7 @@ export interface MutableAlliance extends Alliance {
}
export class PlayerInfo {
public readonly clan: string | null;
public readonly displayName: string;
constructor(
public readonly name: string,
@@ -513,8 +513,9 @@ export class PlayerInfo {
// TODO: make player id the small id
public readonly id: PlayerID,
public readonly isLobbyCreator: boolean = false,
public readonly clanTag: string | null = null,
) {
this.clan = getClanTag(name);
this.displayName = formatPlayerDisplayName(this.name, this.clanTag);
}
}
@@ -706,7 +707,6 @@ export interface Player {
// Either allied or on same team.
isFriendly(other: Player, treatAFKFriendly?: boolean): boolean;
team(): Team | null;
clan(): string | null;
incomingAllianceRequests(): AllianceRequest[];
outgoingAllianceRequests(): AllianceRequest[];
alliances(): MutableAlliance[];
+35 -29
View File
@@ -4,7 +4,7 @@ import { Config } from "../configuration/Config";
import { ColorPalette } from "../CosmeticSchemas";
import { PatternDecoder } from "../PatternDecoder";
import { ClientID, GameID, Player, PlayerCosmetics } from "../Schemas";
import { createRandomName } from "../Util";
import { createRandomName, formatPlayerDisplayName } from "../Util";
import { WorkerClient } from "../worker/WorkerClient";
import {
BuildableUnit,
@@ -453,11 +453,10 @@ export class PlayerView {
return this.data.incomingAttacks;
}
async attackAveragePosition(
playerID: number,
attackID: string,
): Promise<Cell | null> {
return this.game.worker.attackAveragePosition(playerID, attackID);
async attackClusteredPositions(
attackID?: string,
): Promise<{ id: string; positions: Cell[] }[]> {
return this.game.worker.attackClusteredPositions(this.smallID(), attackID);
}
units(...types: UnitType[]): UnitView[] {
@@ -482,7 +481,7 @@ export class PlayerView {
displayName(): string {
return this.anonymousName !== null && userSettings.anonymousNames()
? this.anonymousName
: this.data.name;
: this.data.displayName;
}
clientID(): ClientID | null {
@@ -659,21 +658,15 @@ export class GameView implements GameMap {
private _mapData: TerrainMapData,
private _myClientID: ClientID | undefined,
private _myUsername: string,
private _myClanTag: string | null,
private _gameID: GameID,
private humans: Player[],
humans: Player[],
) {
this._map = this._mapData.gameMap;
this.lastUpdate = null;
this.unitGrid = new UnitGrid(this._map);
// Replace the local player's username with their own stored username.
// This way the user does not know they are being censored.
for (const h of this.humans) {
if (h.clientID === this._myClientID) {
h.username = this._myUsername;
}
}
this._cosmetics = new Map(
this.humans.map((h) => [h.clientID, h.cosmetics ?? {}]),
humans.map((h) => [h.clientID, h.cosmetics ?? {}]),
);
for (const nation of this._mapData.nations) {
// Nations don't have client ids, so we use their name as the key instead.
@@ -763,25 +756,38 @@ export class GameView implements GameMap {
if (gu.updates === null) {
throw new Error("lastUpdate.updates not initialized");
}
const myDisplayName = formatPlayerDisplayName(
this._myUsername,
this._myClanTag,
);
gu.updates[GameUpdateType.Player].forEach((pu) => {
// Replace the local player's name/displayName with their own stored values.
// This way the user does not know they are being censored.
if (pu.clientID === this._myClientID) {
pu.name = this._myUsername;
pu.displayName = myDisplayName;
}
this.smallIDToID.set(pu.smallID, pu.id);
const player = this._players.get(pu.id);
let player = this._players.get(pu.id);
if (player !== undefined) {
player.data = pu;
player.nameData = gu.playerNameViewData[pu.id];
const nextNameData = gu.playerNameViewData[pu.id];
if (nextNameData !== undefined) {
player.nameData = nextNameData;
}
} else {
this._players.set(
pu.id,
new PlayerView(
this,
pu,
gu.playerNameViewData[pu.id],
// First check human by clientID, then check nation by name.
this._cosmetics.get(pu.clientID ?? "") ??
this._cosmetics.get(pu.name) ??
{},
),
player = new PlayerView(
this,
pu,
gu.playerNameViewData[pu.id],
// First check human by clientID, then check nation by name.
this._cosmetics.get(pu.clientID ?? "") ??
this._cosmetics.get(pu.name) ??
{},
);
this._players.set(pu.id, player);
}
});
+6 -15
View File
@@ -84,9 +84,6 @@ export class PlayerImpl implements Player {
public _units: Unit[] = [];
public _tiles: Set<TileRef> = new Set();
private _name: string;
private _displayName: string;
public pastOutgoingAllianceRequests: AllianceRequest[] = [];
private _expiredAlliances: Alliance[] = [];
@@ -115,10 +112,8 @@ export class PlayerImpl implements Player {
startTroops: number,
private readonly _team: Team | null,
) {
this._name = playerInfo.name;
this._troops = toInt(startTroops);
this._gold = mg.config().startingGold(playerInfo);
this._displayName = this._name;
this._pseudo_random = new PseudoRandom(simpleHash(this.playerInfo.id));
}
@@ -193,10 +188,10 @@ export class PlayerImpl implements Player {
}
name(): string {
return this._name;
return this.playerInfo.name;
}
displayName(): string {
return this._displayName;
return this.playerInfo.displayName;
}
clientID(): ClientID | null {
@@ -211,10 +206,6 @@ export class PlayerImpl implements Player {
return this.playerInfo.playerType;
}
clan(): string | null {
return this.playerInfo.clan;
}
units(...types: UnitType[]): Unit[] {
const len = types.length;
if (len === 0) {
@@ -760,14 +751,14 @@ export class PlayerImpl implements Player {
MessageType.SENT_TROOPS_TO_PLAYER,
this.id(),
undefined,
{ troops: renderTroops(troops), name: recipient.name() },
{ troops: renderTroops(troops), name: recipient.displayName() },
);
this.mg.displayMessage(
"events_display.received_troops_from_player",
MessageType.RECEIVED_TROOPS_FROM_PLAYER,
recipient.id(),
undefined,
{ troops: renderTroops(troops), name: this.name() },
{ troops: renderTroops(troops), name: this.displayName() },
);
return true;
}
@@ -784,14 +775,14 @@ export class PlayerImpl implements Player {
MessageType.SENT_GOLD_TO_PLAYER,
this.id(),
undefined,
{ gold: renderNumber(gold), name: recipient.name() },
{ gold: renderNumber(gold), name: recipient.displayName() },
);
this.mg.displayMessage(
"events_display.received_gold_from_player",
MessageType.RECEIVED_GOLD_FROM_PLAYER,
recipient.id(),
gold,
{ gold: renderNumber(gold), name: this.name() },
{ gold: renderNumber(gold), name: this.displayName() },
);
return true;
}
+8 -8
View File
@@ -16,24 +16,24 @@ export function assignTeams(
// Sort players into clan groups or no-clan list
for (const player of players) {
if (player.clan) {
if (!clanGroups.has(player.clan)) {
clanGroups.set(player.clan, []);
const clanTag = player.clanTag;
if (clanTag) {
if (!clanGroups.has(clanTag)) {
clanGroups.set(clanTag, []);
}
clanGroups.get(player.clan)!.push(player);
clanGroups.get(clanTag)!.push(player);
} else {
noClanPlayers.push(player);
}
}
// Sort clans by size (largest first)
const sortedClans = Array.from(clanGroups.entries()).sort(
(a, b) => b[1].length - a[1].length,
const sortedClanPlayers = Array.from(clanGroups.values()).sort(
(a, b) => b.length - a.length,
);
// First, assign clan players
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const [_, clanPlayers] of sortedClans) {
for (const clanPlayers of sortedClanPlayers) {
// Try to keep the clan together on the team with fewer players
let team: Team | null = null;
let teamSize = 0;
+8
View File
@@ -89,6 +89,14 @@ export class UserSettings {
return this.get("settings.territoryPatterns", true);
}
attackingTroopsOverlay() {
return this.get("settings.attackingTroopsOverlay", true);
}
toggleAttackingTroopsOverlay() {
this.set("settings.attackingTroopsOverlay", !this.attackingTroopsOverlay());
}
cursorCostLabel() {
const legacy = this.get("settings.ghostPricePill", true);
return this.get("settings.cursorCostLabel", legacy);
+28 -1
View File
@@ -1,8 +1,10 @@
import { translateText } from "../../client/Utils";
import { UsernameSchema } from "../Schemas";
import { ClanTagSchema, UsernameSchema } from "../Schemas";
export const MIN_USERNAME_LENGTH = 3;
export const MAX_USERNAME_LENGTH = 27;
export const MIN_CLAN_TAG_LENGTH = 2;
export const MAX_CLAN_TAG_LENGTH = 5;
export function validateUsername(username: string): {
isValid: boolean;
@@ -44,3 +46,28 @@ export function validateUsername(username: string): {
// All checks passed
return { isValid: true };
}
export function validateClanTag(clanTag: string): {
isValid: boolean;
error?: string;
} {
if (clanTag.length === 0) {
return { isValid: true };
}
if (clanTag.length < MIN_CLAN_TAG_LENGTH) {
return { isValid: false, error: translateText("username.tag_too_short") };
}
if (clanTag.length > MAX_CLAN_TAG_LENGTH) {
return { isValid: false, error: translateText("username.tag_too_short") };
}
const parsed = ClanTagSchema.safeParse(clanTag);
if (!parsed.success) {
return {
isValid: false,
error: translateText("username.tag_invalid_chars"),
};
}
return { isValid: true };
}
+12 -9
View File
@@ -3,7 +3,7 @@ import { createGameRunner, GameRunner } from "../GameRunner";
import { FetchGameMapLoader } from "../game/FetchGameMapLoader";
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
import {
AttackAveragePositionResultMessage,
AttackClusteredPositionsResultMessage,
InitializedMessage,
MainThreadMessage,
PlayerActionsResultMessage,
@@ -243,25 +243,28 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
throw error;
}
break;
case "attack_average_position":
case "attack_clustered_positions":
if (!gameRunner) {
throw new Error("Game runner not initialized");
}
try {
const averagePosition = (await gameRunner).attackAveragePosition(
const attacks = (await gameRunner).attackClusteredPositions(
message.playerID,
message.attackID,
);
sendMessage({
type: "attack_average_position_result",
type: "attack_clustered_positions_result",
id: message.id,
x: averagePosition ? averagePosition.x : null,
y: averagePosition ? averagePosition.y : null,
} as AttackAveragePositionResultMessage);
attacks,
} as AttackClusteredPositionsResultMessage);
} catch (error) {
console.error("Failed to get attack average position:", error);
throw error;
console.error("Failed to get attack front line centers:", error);
sendMessage({
type: "attack_clustered_positions_result",
id: message.id,
attacks: [],
} as AttackClusteredPositionsResultMessage);
}
break;
case "transport_ship_spawn":
+25 -16
View File
@@ -231,10 +231,10 @@ export class WorkerClient {
});
}
attackAveragePosition(
attackClusteredPositions(
playerID: number,
attackID: string,
): Promise<Cell | null> {
attackID?: string,
): Promise<{ id: string; positions: Cell[] }[]> {
return new Promise((resolve, reject) => {
if (!this.isInitialized) {
reject(new Error("Worker not initialized"));
@@ -243,25 +243,34 @@ export class WorkerClient {
const messageId = generateID();
const timeout = setTimeout(() => {
this.messageHandlers.delete(messageId);
reject(new Error("attack_clustered_positions request timed out"));
}, 5000);
this.messageHandlers.set(messageId, (message) => {
if (
message.type === "attack_average_position_result" &&
message.x !== undefined &&
message.y !== undefined
) {
if (message.x === null || message.y === null) {
resolve(null);
} else {
resolve(new Cell(message.x, message.y));
}
clearTimeout(timeout);
if (message.type !== "attack_clustered_positions_result") {
reject(
new Error(
`Unexpected message type for attackClusteredPositions: ${message.type}`,
),
);
return;
}
resolve(
message.attacks.map((a) => ({
id: a.id,
positions: a.positions.map((c) => new Cell(c.x, c.y)),
})),
);
});
this.worker.postMessage({
type: "attack_average_position",
type: "attack_clustered_positions",
id: messageId,
playerID: playerID,
attackID: attackID,
playerID,
attackID,
});
});
}
+11 -11
View File
@@ -24,8 +24,8 @@ export type WorkerMessageType =
| "player_profile_result"
| "player_border_tiles"
| "player_border_tiles_result"
| "attack_average_position"
| "attack_average_position_result"
| "attack_clustered_positions"
| "attack_clustered_positions_result"
| "transport_ship_spawn"
| "transport_ship_spawn_result";
@@ -108,16 +108,16 @@ export interface PlayerBorderTilesResultMessage extends BaseWorkerMessage {
result: PlayerBorderTiles;
}
export interface AttackAveragePositionMessage extends BaseWorkerMessage {
type: "attack_average_position";
export interface AttackClusteredPositionsMessage extends BaseWorkerMessage {
type: "attack_clustered_positions";
playerID: number;
attackID: string;
attackID?: string;
}
export interface AttackAveragePositionResultMessage extends BaseWorkerMessage {
type: "attack_average_position_result";
x: number | null;
y: number | null;
export interface AttackClusteredPositionsResultMessage
extends BaseWorkerMessage {
type: "attack_clustered_positions_result";
attacks: { id: string; positions: { x: number; y: number }[] }[];
}
export interface TransportShipSpawnMessage extends BaseWorkerMessage {
@@ -139,7 +139,7 @@ export type MainThreadMessage =
| PlayerBuildablesMessage
| PlayerProfileMessage
| PlayerBorderTilesMessage
| AttackAveragePositionMessage
| AttackClusteredPositionsMessage
| TransportShipSpawnMessage;
// Message send from worker
@@ -151,5 +151,5 @@ export type WorkerMessage =
| PlayerBuildablesResultMessage
| PlayerProfileResultMessage
| PlayerBorderTilesResultMessage
| AttackAveragePositionResultMessage
| AttackClusteredPositionsResultMessage
| TransportShipSpawnResultMessage;
+1 -1
View File
@@ -18,7 +18,7 @@ export class Client {
public readonly flares: string[] | undefined,
public readonly ip: string,
public username: string,
public readonly uncensoredUsername: string,
public clanTag: string | null,
public ws: WebSocket,
public readonly cosmetics: PlayerCosmetics | undefined,
) {}
+22 -30
View File
@@ -3,18 +3,14 @@ import { ClientID } from "../core/Schemas";
const INTENTS_PER_SECOND = 10;
const INTENTS_PER_MINUTE = 150;
const MAX_BYTES_PER_MINUTE = 25 * 1024; // 25KB/min per client
const MAX_INTENT_BYTES = 500; // intents are stored in turns, keep them small
const MAX_INTENT_SIZE = 500;
const TOTAL_BYTES = 2 * 1024 * 1024; // 2MB per client
export type RateLimitResult = "ok" | "limit" | "kick";
// Allow 3 winner messages per client since a player can rejoin and resend.
const MAX_WINNER_MSGS = 3;
interface ClientBucket {
perSecond: RateLimiter;
perMinute: RateLimiter;
bytesPerMinute: RateLimiter;
winnerMsgCount: number;
totalBytes: number;
}
export class ClientMsgRateLimiter {
@@ -22,27 +18,27 @@ export class ClientMsgRateLimiter {
check(clientID: ClientID, type: string, bytes: number): RateLimitResult {
const bucket = this.getOrCreate(clientID);
bucket.totalBytes += bytes;
// Winner message contains stats for all players and can be large (100s of KB).
// It bypasses the byte rate limit but is strictly limited to one per client.
if (type === "winner") {
if (bucket.winnerMsgCount >= MAX_WINNER_MSGS) return "kick";
bucket.winnerMsgCount++;
return "ok";
if (bucket.totalBytes >= TOTAL_BYTES) return "kick";
if (type === "intent") {
// 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 > MAX_INTENT_SIZE) {
return "kick";
}
if (
!bucket.perSecond.tryRemoveTokens(1) ||
!bucket.perMinute.tryRemoveTokens(1)
) {
return "limit";
}
}
// Intents are stored in turn history for the duration of the game, so
// oversized intents would accumulate and fill up server RAM.
if (type === "intent" && bytes > MAX_INTENT_BYTES) return "kick";
if (!bucket.bytesPerMinute.tryRemoveTokens(bytes)) return "kick";
if (
!bucket.perSecond.tryRemoveTokens(1) ||
!bucket.perMinute.tryRemoveTokens(1)
)
return "limit";
return "ok";
}
@@ -60,11 +56,7 @@ export class ClientMsgRateLimiter {
tokensPerInterval: INTENTS_PER_MINUTE,
interval: "minute",
}),
bytesPerMinute: new RateLimiter({
tokensPerInterval: MAX_BYTES_PER_MINUTE,
interval: "minute",
}),
winnerMsgCount: 0,
totalBytes: 0,
};
this.buckets.set(clientID, bucket);
return bucket;
+2 -2
View File
@@ -46,11 +46,11 @@ export class GameManager {
persistentID: string,
gameID: GameID,
lastTurn: number = 0,
newUsername?: string,
identityUpdate?: { username: string; clanTag: string | null },
): boolean {
const game = this.games.get(gameID);
if (!game) return false;
return game.rejoinClient(ws, persistentID, lastTurn, newUsername);
return game.rejoinClient(ws, persistentID, lastTurn, identityUpdate);
}
createGame(
+11 -4
View File
@@ -1,10 +1,12 @@
import { z } from "zod";
import { GameInfo } from "../core/Schemas";
import { ClanTagSchema, GameInfo, UsernameSchema } from "../core/Schemas";
import { formatPlayerDisplayName } from "../core/Util";
import { GameMode } from "../core/game/Game";
export const PlayerInfoSchema = z.object({
clientID: z.string().optional(),
username: z.string().optional(),
username: UsernameSchema.optional(),
clanTag: ClanTagSchema,
stats: z.unknown().optional(),
});
@@ -85,7 +87,10 @@ function parseWinner(
if (!winnerArray || winnerArray.length < 2) return undefined;
const idToName = new Map(
(players ?? []).map((p) => [p.clientID, p.username]),
(players ?? []).map((p) => [
p.clientID,
p.username ? formatPlayerDisplayName(p.username, p.clanTag) : undefined,
]),
);
if (winnerArray[0] === "team" && winnerArray.length >= 3) {
@@ -228,7 +233,9 @@ export function buildPreview(
// Show host
const hostClient = lobby.clients?.[0];
if (hostClient?.username) {
sections.push(`Host: ${hostClient.username}`);
sections.push(
`Host: ${formatPlayerDisplayName(hostClient.username, hostClient.clanTag)}`,
);
}
const gameOptions: string[] = [];
+11 -12
View File
@@ -23,7 +23,7 @@ import {
StampedIntent,
Turn,
} from "../core/Schemas";
import { createPartialGameRecord, getClanTag } from "../core/Util";
import { createPartialGameRecord } from "../core/Util";
import { archive, finalizeGameRecord } from "./Archive";
import { Client } from "./Client";
import { ClientMsgRateLimiter } from "./ClientMsgRateLimiter";
@@ -266,15 +266,13 @@ export class GameServer {
}
// Attempt to reconnect a client by persistentID. Returns true if successful.
// Only the WebSocket is updated — username, cosmetics, etc. are preserved
// from the original join to maintain consistency throughout the game session.
// Exception: in the pre-game lobby, the username is updated so players can
// rename between leaving and rejoining.
// WebSocket is always updated. Optional identity updates are applied only
// before the game has started.
public rejoinClient(
ws: WebSocket,
persistentID: string,
lastTurn: number = 0,
newUsername?: string,
identityUpdate?: { username: string; clanTag: string | null },
): boolean {
const clientID = this.getClientIdForPersistentId(persistentID);
if (!clientID) return false;
@@ -294,14 +292,13 @@ export class GameServer {
(c) => c.clientID !== client.clientID,
);
this.activeClients.push(client);
if (identityUpdate && !this.hasStarted()) {
client.username = identityUpdate.username;
client.clanTag = identityUpdate.clanTag;
}
client.lastPing = Date.now();
this.markClientDisconnected(client.clientID, false);
// Allow username updates in the pre-game lobby
if (!this._hasStarted && newUsername !== undefined) {
client.username = newUsername;
}
client.ws = ws;
this.addListeners(client);
this.startLobbyInfoBroadcast();
@@ -662,6 +659,7 @@ export class GameServer {
config: this.gameConfig,
players: this.activeClients.map((c) => ({
username: c.username,
clanTag: c.clanTag ?? null,
clientID: c.clientID,
cosmetics: c.cosmetics,
isLobbyCreator: this.lobbyCreatorID === c.clientID,
@@ -873,6 +871,7 @@ export class GameServer {
gameID: this.id,
clients: this.activeClients.map((c) => ({
username: c.username,
clanTag: c.clanTag ?? null,
clientID: c.clientID,
})),
lobbyCreatorClientID: this.lobbyCreatorID,
@@ -983,11 +982,11 @@ export class GameServer {
return {
clientID: player.clientID,
username: player.username,
clanTag: player.clanTag,
persistentID:
this.allClients.get(player.clientID)?.persistentID ?? "",
stats,
cosmetics: player.cosmetics,
clanTag: getClanTag(player.username) ?? undefined,
} satisfies PlayerRecord;
},
);
+20 -77
View File
@@ -1,3 +1,4 @@
import { SAM_CONSTRUCTION_TICKS } from "../core/configuration/DefaultConfig";
import {
Difficulty,
Duos,
@@ -109,13 +110,13 @@ type ModifierKey =
// Each entry represents one "ticket" in the pool. More tickets = higher chance of selection.
const SPECIAL_MODIFIER_POOL: ModifierKey[] = [
...Array<ModifierKey>(4).fill("isRandomSpawn"),
...Array<ModifierKey>(2).fill("isRandomSpawn"),
...Array<ModifierKey>(8).fill("isCompact"),
...Array<ModifierKey>(1).fill("isCrowded"),
...Array<ModifierKey>(1).fill("isHardNations"),
...Array<ModifierKey>(8).fill("startingGold"),
...Array<ModifierKey>(1).fill("startingGoldHigh"),
...Array<ModifierKey>(1).fill("goldMultiplier"),
...Array<ModifierKey>(4).fill("goldMultiplier"),
...Array<ModifierKey>(1).fill("isAlliancesDisabled"),
];
@@ -143,84 +144,35 @@ export class MapPlaylist {
const playerTeams =
mode === GameMode.Team ? this.getTeamCount(map) : undefined;
const modifiers = this.getRandomPublicGameModifiers(playerTeams);
const { startingGold } = modifiers;
let { isCompact, isRandomSpawn, isCrowded, isHardNations } = modifiers;
// Duos, Trios, and Quads should not get random spawn (as it defeats the purpose)
if (
playerTeams === Duos ||
playerTeams === Trios ||
playerTeams === Quads
) {
isRandomSpawn = false;
}
// Hard nations modifier only applies when nations are present (not HvN, which is always hard)
if (mode === GameMode.Team) {
isHardNations = false;
}
// Check if compact map would leave every team with at least 2 players
if (
isCompact &&
mode === GameMode.Team &&
!(await this.supportsCompactMapForTeams(map, playerTeams!))
) {
isCompact = false;
}
// Crowded modifier: if the map's biggest player count (first number of calculateMapPlayerCounts) is 60 or lower (small maps),
// set player count to MAX_PLAYER_COUNT (or 60 if compact map is also enabled)
let crowdedMaxPlayers: number | undefined;
if (isCrowded) {
crowdedMaxPlayers = await this.getCrowdedMaxPlayers(map, isCompact);
if (crowdedMaxPlayers === undefined) {
isCrowded = false;
} else {
crowdedMaxPlayers = this.adjustForTeams(crowdedMaxPlayers, playerTeams);
}
}
// Create the default public game config (from your GameManager)
return {
donateGold: mode === GameMode.Team,
donateTroops: mode === GameMode.Team,
gameMap: map,
maxPlayers:
crowdedMaxPlayers ??
(await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact)),
maxPlayers: await this.lobbyMaxPlayers(map, mode, playerTeams, false),
gameType: GameType.Public,
gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal,
gameMapSize: GameMapSize.Normal,
publicGameModifiers: {
isCompact,
isRandomSpawn,
isCrowded,
isHardNations,
startingGold,
isCompact: false,
isRandomSpawn: false,
isCrowded: false,
isHardNations: false,
isAlliancesDisabled: false,
},
startingGold,
difficulty:
isHardNations || playerTeams === HumansVsNations
? Difficulty.Hard
: Difficulty.Medium,
playerTeams === HumansVsNations ? Difficulty.Hard : Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
maxTimerValue: undefined,
instantBuild: false,
randomSpawn: isRandomSpawn,
randomSpawn: false,
nations:
mode === GameMode.Team && playerTeams !== HumansVsNations
? "disabled"
: "default",
gameMode: mode,
playerTeams,
bots: isCompact ? 100 : 400,
spawnImmunityDuration: this.getSpawnImmunityDuration(
playerTeams,
startingGold,
),
bots: 400,
spawnImmunityDuration: this.getSpawnImmunityDuration(playerTeams),
disabledUnits: [],
} satisfies GameConfig;
}
@@ -233,6 +185,7 @@ export class MapPlaylist {
const excludedModifiers: ModifierKey[] = [];
// Check if compact map would leave every team with at least 2 players
const supportsCompact =
mode !== GameMode.Team ||
(await this.supportsCompactMapForTeams(map, playerTeams!));
@@ -240,6 +193,7 @@ export class MapPlaylist {
excludedModifiers.push("isCompact");
}
// Duos, Trios, and Quads should not get random spawn (as it defeats the purpose)
if (
playerTeams === Duos ||
playerTeams === Trios ||
@@ -267,6 +221,8 @@ export class MapPlaylist {
isHardNations,
} = poolResult;
// Crowded modifier: if the map's biggest player count (first number of calculateMapPlayerCounts) is 60 or lower (small maps),
// set player count to MAX_PLAYER_COUNT (or 60 if compact map is also enabled)
let crowdedMaxPlayers: number | undefined;
if (isCrowded) {
crowdedMaxPlayers = await this.getCrowdedMaxPlayers(map, isCompact);
@@ -483,19 +439,6 @@ export class MapPlaylist {
return TEAM_WEIGHTS[0].config;
}
private getRandomPublicGameModifiers(
playerTeams?: TeamCountConfig,
): PublicGameModifiers {
return {
isRandomSpawn: Math.random() < 0.05, // 5% chance
isCompact: Math.random() < 0.05, // 5% chance
isCrowded: Math.random() < 0.05, // 5% chance
startingGold: Math.random() < 0.05 ? 5_000_000 : undefined, // 5% chance
isHardNations: Math.random() < 0.025, // 2.5% chance
isAlliancesDisabled: false,
};
}
private getRandomSpecialGameModifiers(
excludedModifiers: ModifierKey[] = [],
count?: number,
@@ -606,8 +549,8 @@ export class MapPlaylist {
/**
* Centralised spawn-immunity duration logic.
* - HumansVsNations: always 5s (nations can't benefit from longer PVP immunity)
* - 25M starting gold: 2:30 (extra time to compensate for high gold)
* - 5M starting gold: 30s
* - 25M starting gold: 2:30min (extra time to compensate for high gold)
* - 5M starting gold: SAM build time + 15s (enough to build a SAM)
* - Default: 5s
*/
private getSpawnImmunityDuration(
@@ -617,7 +560,7 @@ export class MapPlaylist {
if (playerTeams === HumansVsNations) return 5 * 10;
if (startingGold !== undefined && startingGold >= 25_000_000)
return 150 * 10;
if (startingGold) return 30 * 10;
if (startingGold) return SAM_CONSTRUCTION_TICKS + 15 * 10;
return 5 * 10;
}
+32 -32
View File
@@ -18,7 +18,7 @@ import {
PlayerCosmetics,
PlayerPattern,
} from "../core/Schemas";
import { getClanTagOriginalCase, simpleHash } from "../core/Util";
import { simpleHash } from "../core/Util";
export const shadowNames = [
"UnhuggedToday",
@@ -72,7 +72,7 @@ export function createMatcher(bannedWords: string[]): RegExpMatcher {
}
/**
* Sanitizes and censors profane usernames and clan tags.
* Sanitizes and censors profane usernames and clan tags separately.
* Profane username is overwritten, profane clan tag is removed.
*
* Removing bad clan tags won't hurt existing clans nor cause desyncs:
@@ -80,36 +80,28 @@ export function createMatcher(bannedWords: string[]): RegExpMatcher {
* - only each separate local player name with a profane clan tag will remain, no clan team assignment
*
* Examples:
* - "GoodName" -> "GoodName"
* - "BadName" -> "Censored"
* - "[CLAN]GoodName" -> "[CLAN]GoodName"
* - "[CLaN]BadName" -> "[CLAN] Censored"
* - "[BAD]GoodName" -> "GoodName"
* - "[BAD]BadName" -> "Censored"
* - username="GoodName", clanTag=null -> { username: "GoodName", clanTag: null }
* - username="BadName", clanTag=null -> { username: "Censored", clanTag: null }
* - username="GoodName", clanTag="CLaN" -> { username: "GoodName", clanTag: "CLAN" }
* - username="GoodName", clanTag="BAD" -> { username: "GoodName", clanTag: null }
* - username="BadName", clanTag="BAD" -> { username: "Censored", clanTag: null }
*/
function censorUsernameWithMatcher(
username: string,
matcher: RegExpMatcher,
): string {
const clanTag = getClanTagOriginalCase(username);
const nameWithoutClan = clanTag
? username.replace(`[${clanTag}]`, "").trim()
function censorWithMatcher(
username: string,
clanTag: string | null,
matcher: RegExpMatcher,
): { username: string; clanTag: string | null } {
const usernameIsProfane = matcher.hasMatch(username);
const censoredName = usernameIsProfane
? shadowNames[simpleHash(username) % shadowNames.length]
: username;
const clanTagIsProfane = clanTag ? matcher.hasMatch(clanTag) : false;
const usernameIsProfane = matcher.hasMatch(nameWithoutClan);
const censoredClanTag =
clanTag && !clanTagIsProfane ? clanTag.toUpperCase() : null;
const censoredName = usernameIsProfane
? shadowNames[simpleHash(nameWithoutClan) % shadowNames.length]
: nameWithoutClan;
// Restore clan tag only if it's clean, otherwise remove it entirely
if (clanTag && !clanTagIsProfane) {
return `[${clanTag.toUpperCase()}] ${censoredName}`;
}
return censoredName;
return { username: censoredName, clanTag: censoredClanTag };
}
type CosmeticResult =
@@ -118,7 +110,10 @@ type CosmeticResult =
export interface PrivilegeChecker {
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult;
censorUsername(username: string): string;
censor(
username: string,
clanTag: string | null,
): { username: string; clanTag: string | null };
}
export class PrivilegeCheckerImpl implements PrivilegeChecker {
@@ -217,8 +212,11 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
return { color };
}
censorUsername(username: string): string {
return censorUsernameWithMatcher(username, this.matcher);
censor(
username: string,
clanTag: string | null,
): { username: string; clanTag: string | null } {
return censorWithMatcher(username, clanTag, this.matcher);
}
}
@@ -230,8 +228,10 @@ export class FailOpenPrivilegeChecker implements PrivilegeChecker {
return { type: "allowed", cosmetics: {} };
}
censorUsername(username: string): string {
// Fail open: use matcher with just the built-in English profanity dataset
return censorUsernameWithMatcher(username, defaultMatcher);
censor(
username: string,
clanTag: string | null,
): { username: string; clanTag: string | null } {
return censorWithMatcher(username, clanTag, defaultMatcher);
}
}
+39 -14
View File
@@ -3,6 +3,7 @@ import express, { NextFunction, Request, Response } from "express";
import rateLimit from "express-rate-limit";
import http from "http";
import ipAnonymize from "ip-anonymize";
import { RateLimiter } from "limiter";
import path from "path";
import { fileURLToPath } from "url";
import { WebSocket, WebSocketServer } from "ws";
@@ -50,7 +51,7 @@ export async function startWorker() {
const server = http.createServer(app);
const wss = new WebSocketServer({
noServer: true,
maxPayload: 2 * 1024 * 1024,
maxPayload: 1024 * 1024, // 1MB
});
const gm = new GameManager(config, log);
@@ -289,6 +290,11 @@ export async function startWorker() {
: // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
forwarded || req.socket.remoteAddress || "unknown";
if (!getWsIpLimiter(ip).tryRemoveTokens(1)) {
ws.close(1008, "Rate limit exceeded");
return;
}
try {
// Parse and handle client messages
const parsed = ClientMessageSchema.safeParse(
@@ -358,20 +364,21 @@ export async function startWorker() {
return;
}
// Normalize username and clan tag before any rejoin/join handling.
// If this connection maps to an existing lobby client, we still want
// the latest pre-join identity to be reflected.
const { clanTag: censoredClanTag, username: censoredUsername } =
privilegeRefresher
.get()
.censor(clientMsg.username, clientMsg.clanTag ?? null);
// Try to reconnect an existing client (e.g., page refresh)
// If successful, skip all authorization (but pass updated username
// so players can rename in the pre-game lobby)
const censoredUsername = privilegeRefresher
.get()
.censorUsername(clientMsg.username);
// If successful, skip all authorization
if (
gm.rejoinClient(
ws,
persistentId,
clientMsg.gameID,
0,
censoredUsername,
)
gm.rejoinClient(ws, persistentId, clientMsg.gameID, 0, {
username: censoredUsername,
clanTag: censoredClanTag,
})
) {
return;
}
@@ -463,7 +470,7 @@ export async function startWorker() {
flares,
ip,
censoredUsername,
clientMsg.username,
censoredClanTag,
ws,
cosmeticResult.cosmetics,
);
@@ -609,3 +616,21 @@ function generateGameIdForWorker(): GameID | null {
log.warn(`Failed to generate game ID for worker ${workerId}`);
return null;
}
// Per-IP rate limiter for pre-join WebSocket messages.
// Prevents unauthenticated connections from spamming messages
// (e.g. pings) before joining a game.
const wsIpLimiters = new Map<string, RateLimiter>();
function getWsIpLimiter(ip: string): RateLimiter {
let limiter = wsIpLimiters.get(ip);
if (!limiter) {
limiter = new RateLimiter({
tokensPerInterval: 5,
interval: "second",
});
wsIpLimiters.set(ip, limiter);
}
return limiter;
}
// Clean up stale IP limiters every 10 minutes
setInterval(() => wsIpLimiters.clear(), 10 * 60 * 1000);