mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-21 14:15:14 +00:00
Merge branch 'main' into player-text-opacity
This commit is contained in:
@@ -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,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);
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user