Merge branch 'main' into team-names

This commit is contained in:
Mattia Migliorini
2026-03-19 11:27:15 +01:00
committed by GitHub
78 changed files with 1328 additions and 1179 deletions
+2 -3
View File
@@ -284,7 +284,7 @@
class="w-full pointer-events-auto order-1 sm:order-none"
></attacks-display>
<div
class="pointer-events-auto bg-gray-800/70 backdrop-blur-xs sm:rounded-tr-lg lg:rounded-t-lg min-[1200px]:rounded-lg shadow-lg order-3 sm:order-none"
class="pointer-events-auto bg-gray-800/92 backdrop-blur-sm sm:rounded-tr-lg lg:rounded-t-lg min-[1200px]:rounded-lg shadow-lg order-3 sm:order-none"
>
<control-panel class="w-full"></control-panel>
<unit-display class="hidden lg:block w-full"></unit-display>
@@ -319,8 +319,7 @@
<player-panel></player-panel>
<spawn-timer></spawn-timer>
<immunity-timer></immunity-timer>
<in-game-header-ad></in-game-header-ad>
<spawn-video-ad></spawn-video-ad>
<in-game-promo></in-game-promo>
<game-info-modal></game-info-modal>
<alert-frame></alert-frame>
<chat-modal></chat-modal>
+17 -1
View File
@@ -56,5 +56,21 @@
"name": "Listvyanka",
"flag": "ru"
}
]
],
"teamGameSpawnAreas": {
"2": [
{
"height": 1564,
"width": 1330,
"x": 0,
"y": 0
},
{
"height": 1564,
"width": 1070,
"x": 1430,
"y": 0
}
]
}
}

Before

Width:  |  Height:  |  Size: 334 KiB

After

Width:  |  Height:  |  Size: 334 KiB

+12 -1
View File
@@ -24,6 +24,7 @@
"copied": "Copied!",
"click_to_copy": "Click to copy",
"enabled": "Enabled",
"disabled": "Disabled",
"map_default": "Map default"
},
"main": {
@@ -97,6 +98,8 @@
"action_build": "Open build menu",
"action_emote": "Open emote menu",
"action_center": "Center camera on player",
"action_pause_game": "Pause / Resume game",
"action_game_speed": "Game speed down / up (single player)",
"action_zoom": "Zoom out/in",
"action_move_camera": "Move camera",
"action_ratio_change": "Decrease/Increase attack ratio",
@@ -486,8 +489,10 @@
"crowded": "Crowded",
"hard_nations": "Hard Nations",
"starting_gold": "{amount}M Starting Gold",
"starting_gold_label": "Starting Gold",
"gold_multiplier": "x{amount} Gold Multiplier",
"disable_alliances": "Alliances Disabled"
"disable_alliances": "Alliances Disabled",
"disable_alliances_label": "Alliances"
},
"select_lang": {
"title": "Select Language"
@@ -571,6 +576,12 @@
"build_menu_modifier_desc": "Hold this key while clicking to open the build menu.",
"emoji_menu_modifier": "Emoji Menu Modifier",
"emoji_menu_modifier_desc": "Hold this key while clicking to open the emoji menu.",
"pause_game": "Pause",
"pause_game_desc": "Pause or resume the game (single player and custom games for host).",
"game_speed_up": "Game Speed Up",
"game_speed_up_desc": "Cycle to next game speed (0.5, 1, 2, max). Single player only.",
"game_speed_down": "Game Speed Down",
"game_speed_down_desc": "Cycle to previous game speed. Single player only.",
"attack_ratio_controls": "Attack Ratio Controls",
"attack_ratio_up": "Increase Attack Ratio",
"attack_ratio_up_desc": "Increase attack ratio by {amount}%",
+17 -1
View File
@@ -71,5 +71,21 @@
"flag": "ru",
"name": "Listvyanka"
}
]
],
"teamGameSpawnAreas": {
"2": [
{
"height": 1564,
"width": 1330,
"x": 0,
"y": 0
},
{
"height": 1564,
"width": 1070,
"x": 1430,
"y": 0
}
]
}
}
+7 -7
View File
@@ -1,20 +1,20 @@
{
"map": {
"height": 1202,
"num_land_tiles": 1258353,
"height": 1200,
"num_land_tiles": 1255327,
"width": 1800
},
"map16x": {
"height": 300,
"num_land_tiles": 77228,
"num_land_tiles": 77229,
"width": 450
},
"map4x": {
"height": 601,
"num_land_tiles": 313011,
"height": 600,
"num_land_tiles": 312219,
"width": 900
},
"name": "straitofhormuz",
"name": "Strait of Hormuz",
"nations": [
{
"coordinates": [837, 356],
@@ -99,7 +99,7 @@
{
"coordinates": [159, 756],
"flag": "",
"name": "Ar Rayy\u0101n"
"name": "Ar Rayyān"
},
{
"coordinates": [1103, 647],
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

+32 -20
View File
@@ -56,6 +56,7 @@ export interface LobbyConfig {
serverConfig: ServerConfig;
cosmetics: PlayerCosmeticRefs;
playerName: string;
playerClanTag: string | null;
gameID: GameID;
turnstileToken: string | null;
// GameStartInfo only exists when playing a singleplayer game.
@@ -64,15 +65,24 @@ export interface LobbyConfig {
gameRecord?: GameRecord;
}
export interface JoinLobbyResult {
stop: (force?: boolean) => boolean;
prestart: Promise<void>;
join: Promise<void>;
}
export function joinLobby(
eventBus: EventBus,
lobbyConfig: LobbyConfig,
onPrestart: () => void,
onJoin: () => void,
): (force?: boolean) => boolean {
): JoinLobbyResult {
// Mutable clientID state — assigned by server (multiplayer) or derived from gameStartInfo (singleplayer)
let clientID: ClientID | undefined;
let resolvePrestart: () => void;
let resolveJoin: () => void;
const prestartPromise = new Promise<void>((r) => (resolvePrestart = r));
const joinPromise = new Promise<void>((r) => (resolveJoin = r));
console.log(`joining lobby: gameID: ${lobbyConfig.gameID}`);
const userSettings: UserSettings = new UserSettings();
@@ -105,17 +115,17 @@ export function joinLobby(
message.gameMapSize,
terrainMapFileLoader,
);
onPrestart();
resolvePrestart();
}
if (message.type === "start") {
// Trigger prestart for singleplayer games
onPrestart();
resolvePrestart();
console.log(
`lobby: game started: ${JSON.stringify(message, replacer, 2)}`,
);
// Server tells us our assigned clientID (also sent on start for late joins)
clientID = message.myClientID;
onJoin();
resolveJoin();
// For multiplayer games, GameStartInfo is not known until game starts.
lobbyConfig.gameStartInfo = message.gameStartInfo;
createClientGame(
@@ -157,7 +167,7 @@ export function joinLobby(
if (message.error === "full-lobby") {
document.dispatchEvent(
new CustomEvent("leave-lobby", {
detail: { lobby: lobbyConfig.gameID },
detail: { lobby: lobbyConfig.gameID, cause: "full-lobby" },
bubbles: true,
composed: true,
}),
@@ -176,19 +186,19 @@ export function joinLobby(
}
};
transport.connect(onconnect, onmessage);
return (force: boolean = false) => {
if (!force && currentGameRunner?.shouldPreventWindowClose()) {
console.log("Player is active, prevent leaving game");
return false;
}
console.log("leaving game");
currentGameRunner = null;
transport.leaveGame();
return true;
return {
stop: (force: boolean = false) => {
if (!force && currentGameRunner?.shouldPreventWindowClose()) {
console.log("Player is active, prevent leaving game");
return false;
}
console.log("leaving game");
currentGameRunner = null;
transport.leaveGame();
return true;
},
prestart: prestartPromise,
join: joinPromise,
};
}
@@ -228,6 +238,7 @@ async function createClientGame(
gameMap,
clientID,
lobbyConfig.playerName,
lobbyConfig.playerClanTag,
lobbyConfig.gameStartInfo.gameID,
lobbyConfig.gameStartInfo.players,
);
@@ -301,6 +312,7 @@ export class ClientGameRunner {
{
persistentID: getPersistentID(),
username: this.lobby.playerName,
clanTag: this.lobby.playerClanTag ?? null,
clientID: this.clientID,
stats: update.allPlayersStats[this.clientID],
},
+4 -15
View File
@@ -4,7 +4,6 @@ import { GameEndInfo } from "../core/Schemas";
import { GameMapType } from "../core/game/Game";
import { fetchGameById } from "./Api";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import { UsernameInput } from "./UsernameInput";
import { renderDuration, translateText } from "./Utils";
import {
PlayerInfo,
@@ -28,7 +27,7 @@ export class GameInfoModal extends LitElement {
@property({ type: String }) gameId: string | null = null;
@property({ type: String }) rankType = RankType.Lifetime;
@state() private username: string | null = null;
@state() private currentClientID: string | null = null;
@state() private isLoadingGame: boolean = true;
private ranking: Ranking | null = null;
@@ -152,7 +151,7 @@ export class GameInfoModal extends LitElement {
.score=${this.ranking?.score(player, this.rankType) ?? 0}
.rankType=${this.rankType}
.bestScore=${bestScore}
.currentPlayer=${this.username === player.rawUsername}
.currentPlayer=${this.currentClientID === player.id}
></player-row>
`,
)}
@@ -183,26 +182,16 @@ export class GameInfoModal extends LitElement {
}
}
public loadUserName() {
const usernameInput = document.querySelector(
"username-input",
) as UsernameInput;
if (usernameInput) {
this.username = usernameInput.getCurrentUsername();
}
}
public async loadGame(gameId: string) {
public async loadGame(gameId: string, currentClientID: string | null = null) {
try {
this.isLoadingGame = true;
this.loadUserName();
this.currentClientID = currentClientID;
const session = await fetchGameById(gameId);
if (!session) return;
this.gameInfo = session.info;
this.ranking = new Ranking(session);
this.updateRanking();
this.isLoadingGame = false;
await this.loadMapImage(session.info.config.gameMap);
} catch (err) {
console.error("Failed to load game:", err);
+9 -21
View File
@@ -17,9 +17,12 @@ import { PublicLobbySocket } from "./LobbySocket";
import { JoinLobbyEvent } from "./Main";
import { SinglePlayerModal } from "./SinglePlayerModal";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import { UsernameInput } from "./UsernameInput";
import {
calculateServerTimeOffset,
getMapName,
getModifierLabels,
getSecondsUntilServerTimestamp,
renderDuration,
translateText,
} from "./Utils";
@@ -46,20 +49,10 @@ export class GameModeSelector extends LitElement {
* Returns true if valid, false otherwise.
*/
private validateUsername(): boolean {
const usernameInput = document.querySelector("username-input") as any;
if (usernameInput?.isValid?.() === false) {
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: usernameInput.validationError,
color: "red",
duration: 3000,
},
}),
);
return false;
}
return true;
const usernameInput = document.querySelector(
"username-input",
) as UsernameInput | null;
return usernameInput ? usernameInput.validateOrShowError() : true;
}
connectedCallback() {
@@ -81,7 +74,7 @@ export class GameModeSelector extends LitElement {
private handleLobbiesUpdate(lobbies: PublicGames) {
this.lobbies = lobbies;
this.serverTimeOffset = lobbies.serverTime - Date.now();
this.serverTimeOffset = calculateServerTimeOffset(lobbies.serverTime);
document.dispatchEvent(
new CustomEvent("public-lobbies-update", {
detail: { payload: lobbies },
@@ -279,12 +272,7 @@ export class GameModeSelector extends LitElement {
const useContain =
aspectRatio !== undefined && (aspectRatio > 4 || aspectRatio < 0.25);
const timeRemaining = lobby.startsAt
? Math.max(
0,
Math.floor(
(lobby.startsAt - this.serverTimeOffset - Date.now()) / 1000,
),
)
? getSecondsUntilServerTimestamp(lobby.startsAt, this.serverTimeOffset)
: undefined;
let timeDisplay: string = "";
+24
View File
@@ -58,6 +58,9 @@ export class HelpModal extends BaseModal {
modifierKey: isMac ? "MetaLeft" : "ControlLeft",
altKey: "AltLeft",
resetGfx: "KeyR",
pauseGame: "KeyP",
gameSpeedUp: "Period",
gameSpeedDown: "Comma",
...saved,
};
}
@@ -81,6 +84,8 @@ export class HelpModal extends BaseModal {
ArrowDown: "↓",
ArrowLeft: "←",
ArrowRight: "→",
Period: ">",
Comma: "<",
};
if (specialLabels[code]) return specialLabels[code];
@@ -372,6 +377,25 @@ export class HelpModal extends BaseModal {
${translateText("help_modal.action_center")}
</td>
</tr>
<tr class="hover:bg-white/5 transition-colors">
<td class="py-3 pl-4 border-b border-white/5">
${this.renderKey(keybinds.pauseGame)}
</td>
<td class="py-3 border-b border-white/5 text-white/70">
${translateText("help_modal.action_pause_game")}
</td>
</tr>
<tr class="hover:bg-white/5 transition-colors">
<td class="py-3 pl-4 border-b border-white/5">
<div class="flex flex-wrap gap-2">
${this.renderKey(keybinds.gameSpeedDown)}
${this.renderKey(keybinds.gameSpeedUp)}
</div>
</td>
<td class="py-3 border-b border-white/5 text-white/70">
${translateText("help_modal.action_game_speed")}
</td>
</tr>
<tr class="hover:bg-white/5 transition-colors">
<td class="py-3 pl-4 border-b border-white/5">
<div class="flex flex-wrap gap-2">
+22 -1
View File
@@ -123,6 +123,12 @@ export class ReplaySpeedChangeEvent implements GameEvent {
constructor(public readonly replaySpeedMultiplier: ReplaySpeedMultiplier) {}
}
export class TogglePauseIntentEvent implements GameEvent {}
export class GameSpeedUpIntentEvent implements GameEvent {}
export class GameSpeedDownIntentEvent implements GameEvent {}
export class CenterCameraEvent implements GameEvent {
constructor() {}
}
@@ -236,6 +242,9 @@ export class InputHandler {
buildAtomBomb: "Digit8",
buildHydrogenBomb: "Digit9",
buildMIRV: "Digit0",
pauseGame: "KeyP",
gameSpeedUp: "Period",
gameSpeedDown: "Comma",
...saved,
};
@@ -433,8 +442,20 @@ export class InputHandler {
this.eventBus.emit(new SwapRocketDirectionEvent(nextDirection));
}
if (!e.repeat && e.code === this.keybinds.pauseGame) {
e.preventDefault();
this.eventBus.emit(new TogglePauseIntentEvent());
}
if (!e.repeat && e.code === this.keybinds.gameSpeedUp) {
e.preventDefault();
this.eventBus.emit(new GameSpeedUpIntentEvent());
}
if (!e.repeat && e.code === this.keybinds.gameSpeedDown) {
e.preventDefault();
this.eventBus.emit(new GameSpeedDownIntentEvent());
}
// Shift-D to toggle performance overlay
console.log(e.code, e.shiftKey, e.ctrlKey, e.altKey, e.metaKey);
if (e.code === "KeyD" && e.shiftKey) {
e.preventDefault();
console.log("TogglePerformanceOverlayEvent");
+14 -2
View File
@@ -1,9 +1,12 @@
import { html, TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import {
calculateServerTimeOffset,
getActiveModifiers,
getGameModeLabel,
getMapName,
getSecondsUntilServerTimestamp,
getServerNow,
renderDuration,
renderNumber,
translateText,
@@ -44,6 +47,7 @@ export class JoinLobbyModal extends BaseModal {
@state() private currentClientID: string = "";
@state() private nationCount: number = 0;
@state() private lobbyStartAt: number | null = null;
@state() private serverTimeOffset: number = 0;
@state() private isConnecting: boolean = true;
@state() private lobbyCreatorClientID: string | null = null;
@@ -77,7 +81,10 @@ export class JoinLobbyModal extends BaseModal {
// Post-join state: show lobby info (identical for public & private)
const secondsRemaining =
this.lobbyStartAt !== null
? Math.max(0, Math.floor((this.lobbyStartAt - Date.now()) / 1000))
? getSecondsUntilServerTimestamp(
this.lobbyStartAt,
this.serverTimeOffset,
)
: null;
const statusLabel =
secondsRemaining === null
@@ -328,6 +335,7 @@ export class JoinLobbyModal extends BaseModal {
this.players = [];
this.nationCount = 0;
this.lobbyStartAt = null;
this.serverTimeOffset = 0;
this.lobbyCreatorClientID = null;
this.isConnecting = true;
this.handledJoinTimeout = false;
@@ -377,6 +385,7 @@ export class JoinLobbyModal extends BaseModal {
this.currentClientID = "";
this.nationCount = 0;
this.lobbyStartAt = null;
this.serverTimeOffset = 0;
this.lobbyCreatorClientID = null;
this.isConnecting = true;
this.leaveLobbyOnClose = true;
@@ -513,6 +522,9 @@ export class JoinLobbyModal extends BaseModal {
private updateFromLobby(lobby: GameInfo | PublicGameInfo) {
this.players = "clients" in lobby ? (lobby.clients ?? []) : [];
if ("serverTime" in lobby && typeof lobby.serverTime === "number") {
this.serverTimeOffset = calculateServerTimeOffset(lobby.serverTime);
}
this.lobbyStartAt = lobby.startsAt ?? null;
this.syncCountdownTimer();
if (lobby.gameConfig) {
@@ -577,7 +589,7 @@ export class JoinLobbyModal extends BaseModal {
) {
return;
}
if (Date.now() < this.lobbyStartAt) {
if (getServerNow(this.serverTimeOffset) < this.lobbyStartAt) {
return;
}
this.handledJoinTimeout = true;
+34 -3
View File
@@ -15,17 +15,28 @@ import {
import {
createPartialGameRecord,
decompressGameRecord,
getClanTag,
replacer,
} from "../core/Util";
import { getPersistentID } from "./Auth";
import { LobbyConfig } from "./ClientGameRunner";
import { ReplaySpeedChangeEvent } from "./InputHandler";
import {
GameSpeedDownIntentEvent,
GameSpeedUpIntentEvent,
ReplaySpeedChangeEvent,
} from "./InputHandler";
import {
defaultReplaySpeedMultiplier,
ReplaySpeedMultiplier,
} from "./utilities/ReplaySpeedMultiplier";
// Order: 0.5, 1, 2, max (same as ReplayPanel)
const SPEED_ORDER: ReplaySpeedMultiplier[] = [
ReplaySpeedMultiplier.slow,
ReplaySpeedMultiplier.normal,
ReplaySpeedMultiplier.fast,
ReplaySpeedMultiplier.fastest,
];
// build a small backlog so MAX can catch up.
const MAX_REPLAY_BACKLOG_TURNS = 60;
@@ -94,6 +105,26 @@ export class LocalServer {
this.replaySpeedMultiplier = event.replaySpeedMultiplier;
});
if (!this.isReplay) {
this.eventBus.on(GameSpeedUpIntentEvent, () => {
const idx = SPEED_ORDER.indexOf(this.replaySpeedMultiplier);
if (idx < 0 || idx >= SPEED_ORDER.length - 1) return;
this.replaySpeedMultiplier = SPEED_ORDER[idx + 1];
this.eventBus.emit(
new ReplaySpeedChangeEvent(this.replaySpeedMultiplier),
);
});
this.eventBus.on(GameSpeedDownIntentEvent, () => {
const idx = SPEED_ORDER.indexOf(this.replaySpeedMultiplier);
if (idx <= 0) return;
this.replaySpeedMultiplier = SPEED_ORDER[idx - 1];
this.eventBus.emit(
new ReplaySpeedChangeEvent(this.replaySpeedMultiplier),
);
});
}
this.startedAt = Date.now();
this.clientConnect();
if (this.lobbyConfig.gameRecord) {
@@ -241,10 +272,10 @@ export class LocalServer {
{
persistentID: getPersistentID(),
username: this.lobbyConfig.playerName,
clanTag: this.lobbyConfig.playerClanTag ?? null,
clientID: this.clientID!,
stats: this.allPlayersStats[this.clientID!],
cosmetics: this.lobbyConfig.gameStartInfo?.players[0].cosmetics,
clanTag: getClanTag(this.lobbyConfig.playerName) ?? undefined,
},
];
if (this.lobbyConfig.gameStartInfo === undefined) {
+132 -109
View File
@@ -15,7 +15,7 @@ import { UserSettings } from "../core/game/UserSettings";
import "./AccountModal";
import { getUserMe } from "./Api";
import { userAuth } from "./Auth";
import { joinLobby } from "./ClientGameRunner";
import { joinLobby, type JoinLobbyResult } from "./ClientGameRunner";
import { getPlayerCosmeticsRefs } from "./Cosmetics";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import "./FlagInput";
@@ -180,12 +180,17 @@ declare global {
ramp: {
que: Array<() => void>;
passiveMode: boolean;
spaAddAds: (ads: Array<{ type: string; selectorId: string }>) => void;
destroyUnits: (adType: string) => void;
spaAddAds: (ads: Array<{ type: string; selectorId?: string }>) => void;
destroyUnits: (adType: string | string[]) => Promise<void>;
settings?: {
slots?: any;
};
spaNewPage: (url?: string) => void;
spaAds: (config?: {
ads?: Array<{ type: string; selectorId?: string }>;
countPageview?: boolean;
path?: string;
}) => void;
// Video ad methods
onPlayerReady: (() => void) | null;
addUnits: (units: Array<{ type: string }>) => Promise<void>;
@@ -230,7 +235,7 @@ export interface JoinLobbyEvent {
}
class Client {
private gameStop: ((force?: boolean) => boolean) | null = null;
private lobbyHandle: JoinLobbyResult | null = null;
private eventBus: EventBus = new EventBus();
private currentUrl: string | null = null;
@@ -300,8 +305,8 @@ class Client {
window.addEventListener("beforeunload", async () => {
console.log("Browser is closing");
if (this.gameStop !== null) {
this.gameStop(true);
if (this.lobbyHandle !== null) {
this.lobbyHandle.stop(true);
await crazyGamesSDK.gameplayStop();
}
});
@@ -521,10 +526,10 @@ class Client {
};
const onPopState = () => {
if (this.currentUrl !== null && this.gameStop !== null) {
if (this.currentUrl !== null && this.lobbyHandle !== null) {
console.info("Game is active");
if (!this.gameStop()) {
if (!this.lobbyHandle.stop()) {
console.info("Player is active, ask before leaving game");
const isConfirmed = confirm(
@@ -552,7 +557,7 @@ class Client {
};
const onJoinChanged = () => {
if (this.gameStop !== null) {
if (this.lobbyHandle !== null) {
this.handleLeaveLobby();
}
@@ -642,7 +647,7 @@ class Client {
return;
}
const patternName = params.get("pattern");
const patternName = params.get("cosmetic");
if (!patternName) {
alert("Something went wrong. Please contact support.");
console.error("purchase-completed but no pattern name");
@@ -732,10 +737,14 @@ class Client {
private async handleJoinLobby(event: CustomEvent<JoinLobbyEvent>) {
const lobby = event.detail;
if (this.usernameInput && !this.usernameInput.validateOrShowError()) {
return;
}
console.log(`joining lobby ${lobby.gameID}`);
if (this.gameStop !== null) {
if (this.lobbyHandle !== null) {
console.log("joining lobby, stopping existing game");
this.gameStop(true);
this.lobbyHandle.stop(true);
document.body.classList.remove("in-game");
}
if (lobby.source === "public") {
@@ -746,106 +755,105 @@ class Client {
if (lobby.source !== "public") {
this.updateJoinUrlForShare(lobby.gameID, config);
}
this.gameStop = joinLobby(
this.eventBus,
{
gameID: lobby.gameID,
serverConfig: config,
cosmetics: await getPlayerCosmeticsRefs(),
turnstileToken: await this.getTurnstileToken(lobby),
playerName:
this.usernameInput?.getCurrentUsername() ?? genAnonUsername(),
gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info,
gameRecord: lobby.gameRecord,
},
() => {
console.log("Closing modals");
document.getElementById("settings-button")?.classList.add("hidden");
if (this.usernameInput) {
// fix edge case where username-validation-error is re-rendered and hidden tag removed
this.usernameInput.validationError = "";
this.lobbyHandle = joinLobby(this.eventBus, {
gameID: lobby.gameID,
serverConfig: config,
cosmetics: await getPlayerCosmeticsRefs(),
turnstileToken: await this.getTurnstileToken(lobby),
playerName: this.usernameInput?.getUsername() ?? genAnonUsername(),
playerClanTag: this.usernameInput?.getClanTag() ?? null,
gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info,
gameRecord: lobby.gameRecord,
});
this.lobbyHandle.prestart.then(() => {
console.log("Closing modals");
document.getElementById("settings-button")?.classList.add("hidden");
if (this.usernameInput) {
// fix edge case where username-validation-error is re-rendered and hidden tag removed
this.usernameInput.validationError = "";
}
document
.getElementById("username-validation-error")
?.classList.add("hidden");
this.joinModal?.closeWithoutLeaving();
[
"single-player-modal",
"host-lobby-modal",
"game-starting-modal",
"game-top-bar",
"help-modal",
"user-setting",
"troubleshooting-modal",
"territory-patterns-modal",
"language-modal",
"news-modal",
"flag-input-modal",
"account-button",
"leaderboard-button",
"token-login",
"matchmaking-modal",
"lang-selector",
"gutter-ads",
].forEach((tag) => {
const modal = document.querySelector(tag) as HTMLElement & {
close?: () => void;
isModalOpen?: boolean;
};
if (modal?.close) {
modal.close();
} else if (modal && "isModalOpen" in modal) {
modal.isModalOpen = false;
}
document
.getElementById("username-validation-error")
?.classList.add("hidden");
this.joinModal?.closeWithoutLeaving();
[
"single-player-modal",
"host-lobby-modal",
"game-starting-modal",
"game-top-bar",
"help-modal",
"user-setting",
"troubleshooting-modal",
"territory-patterns-modal",
"language-modal",
"news-modal",
"flag-input-modal",
"account-button",
"leaderboard-button",
"token-login",
"matchmaking-modal",
"lang-selector",
"gutter-ads",
].forEach((tag) => {
const modal = document.querySelector(tag) as HTMLElement & {
close?: () => void;
isModalOpen?: boolean;
};
if (modal?.close) {
modal.close();
} else if (modal && "isModalOpen" in modal) {
modal.isModalOpen = false;
}
});
this.gameModeSelector.stop();
document.querySelectorAll(".ad").forEach((ad) => {
(ad as HTMLElement).style.display = "none";
});
});
this.gameModeSelector.stop();
document.querySelectorAll(".ad").forEach((ad) => {
(ad as HTMLElement).style.display = "none";
});
crazyGamesSDK.loadingStart();
crazyGamesSDK.loadingStart();
// show when the game loads
const startingModal = document.querySelector(
"game-starting-modal",
) as GameStartingModal;
if (startingModal && startingModal instanceof GameStartingModal) {
startingModal.show();
}
},
() => {
this.joinModal?.closeWithoutLeaving();
this.gameModeSelector.stop();
incrementGamesPlayed();
// show when the game loads
const startingModal = document.querySelector(
"game-starting-modal",
) as GameStartingModal;
if (startingModal && startingModal instanceof GameStartingModal) {
startingModal.show();
}
});
document.querySelectorAll(".ad").forEach((ad) => {
(ad as HTMLElement).style.display = "none";
});
this.lobbyHandle.join.then(() => {
this.joinModal?.closeWithoutLeaving();
this.gameModeSelector.stop();
incrementGamesPlayed();
if (window.PageOS?.session?.newPageView) {
window.PageOS.session.newPageView();
}
crazyGamesSDK.loadingStop();
crazyGamesSDK.gameplayStart();
document.body.classList.add("in-game");
document.querySelectorAll(".ad").forEach((ad) => {
(ad as HTMLElement).style.display = "none";
});
// Ensure there's a homepage entry in history before adding the lobby entry
if (window.location.hash === "" || window.location.hash === "#") {
history.replaceState(null, "", window.location.origin + "#refresh");
}
const lobbyIdHidden = !this.userSettings.lobbyIdVisibility();
history.pushState(
null,
"",
lobbyIdHidden
? "/streamer-mode"
: `/${config.workerPath(lobby.gameID)}/game/${lobby.gameID}?live`,
);
if (window.PageOS?.session?.newPageView) {
window.PageOS.session.newPageView();
}
crazyGamesSDK.loadingStop();
crazyGamesSDK.gameplayStart();
document.body.classList.add("in-game");
// Store current URL for popstate confirmation
this.currentUrl = window.location.href;
},
);
// Ensure there's a homepage entry in history before adding the lobby entry
if (window.location.hash === "" || window.location.hash === "#") {
history.replaceState(null, "", window.location.origin + "#refresh");
}
const lobbyIdHidden = !this.userSettings.lobbyIdVisibility();
history.pushState(
null,
"",
lobbyIdHidden
? "/streamer-mode"
: `/${config.workerPath(lobby.gameID)}/game/${lobby.gameID}?live`,
);
// Store current URL for popstate confirmation
this.currentUrl = window.location.href;
});
}
private updateJoinUrlForShare(
@@ -863,13 +871,13 @@ class Client {
}
}
private async handleLeaveLobby(/* event: CustomEvent */) {
if (this.gameStop === null) {
private async handleLeaveLobby(event?: CustomEvent) {
if (this.lobbyHandle === null) {
return;
}
console.log("leaving lobby, cancelling game");
this.gameStop(true);
this.gameStop = null;
this.lobbyHandle.stop(true);
this.lobbyHandle = null;
this.currentUrl = null;
try {
@@ -880,6 +888,21 @@ class Client {
document.body.classList.remove("in-game");
if (this.joinModal.isOpen()) {
this.joinModal.close();
if (event?.detail.cause === "full-lobby") {
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: translateText("public_lobby.join_timeout"),
color: "red",
duration: 3500,
},
}),
);
}
}
crazyGamesSDK.gameplayStop();
}
+2 -4
View File
@@ -654,9 +654,6 @@ export class SinglePlayerModal extends BaseModal {
const usernameInput = document.querySelector(
"username-input",
) as UsernameInput;
if (!usernameInput) {
console.warn("Username input element not found");
}
await crazyGamesSDK.requestMidgameAd();
@@ -669,7 +666,8 @@ export class SinglePlayerModal extends BaseModal {
players: [
{
clientID,
username: usernameInput.getCurrentUsername(),
username: usernameInput.getUsername(),
clanTag: usernameInput.getClanTag() ?? null,
cosmetics: await getPlayerCosmetics(),
},
],
+1
View File
@@ -399,6 +399,7 @@ export class Transport {
gameID: this.lobbyConfig.gameID,
// Note: clientID is not sent - server assigns it based on persistentID
username: this.lobbyConfig.playerName,
clanTag: this.lobbyConfig.playerClanTag ?? null,
cosmetics: this.lobbyConfig.cosmetics,
turnstileToken: this.lobbyConfig.turnstileToken,
token: await getPlayToken(),
+33
View File
@@ -47,6 +47,9 @@ const DefaultKeybinds: Record<string, string> = {
moveRight: "KeyD",
modifierKey: isMac ? "MetaLeft" : "ControlLeft",
altKey: "AltLeft",
pauseGame: "KeyP",
gameSpeedUp: "Period",
gameSpeedDown: "Comma",
};
@customElement("user-setting")
@@ -634,6 +637,36 @@ export class UserSettingModal extends BaseModal {
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="pauseGame"
label=${translateText("user_setting.pause_game")}
description=${translateText("user_setting.pause_game_desc")}
.defaultKey=${DefaultKeybinds.pauseGame}
.value=${this.getKeyValue("pauseGame")}
.display=${this.getKeyChar("pauseGame")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="gameSpeedUp"
label=${translateText("user_setting.game_speed_up")}
description=${translateText("user_setting.game_speed_up_desc")}
.defaultKey=${DefaultKeybinds.gameSpeedUp}
.value=${this.getKeyValue("gameSpeedUp")}
.display=${this.getKeyChar("gameSpeedUp")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="gameSpeedDown"
label=${translateText("user_setting.game_speed_down")}
description=${translateText("user_setting.game_speed_down_desc")}
.defaultKey=${DefaultKeybinds.gameSpeedDown}
.value=${this.getKeyValue("gameSpeedDown")}
.display=${this.getKeyChar("gameSpeedDown")}
@change=${this.handleKeybindChange}
></setting-keybind>
<h2
class="text-blue-200 text-xl font-bold mt-8 mb-3 border-b border-white/10 pb-2"
>
+59 -62
View File
@@ -2,15 +2,19 @@ import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { v4 as uuidv4 } from "uuid";
import { translateText } from "../client/Utils";
import { getClanTagOriginalCase, sanitizeClanTag } from "../core/Util";
import { sanitizeClanTag } from "../core/Util";
import {
MAX_CLAN_TAG_LENGTH,
MAX_USERNAME_LENGTH,
MIN_CLAN_TAG_LENGTH,
MIN_USERNAME_LENGTH,
validateClanTag,
validateUsername,
} from "../core/validations/username";
import { crazyGamesSDK } from "./CrazyGamesSDK";
const usernameKey: string = "username";
const clanTagKey: string = "clanTag";
@customElement("username-input")
export class UsernameInput extends LitElement {
@@ -27,46 +31,45 @@ export class UsernameInput extends LitElement {
return this;
}
public getCurrentUsername(): string {
return this.constructFullUsername();
public getUsername(): string {
return this.baseUsername.trim();
}
private constructFullUsername(): string {
if (this.clanTag.length >= 2) {
return `[${this.clanTag}] ${this.baseUsername}`;
}
return this.baseUsername;
public getClanTag(): string | null {
return this.clanTag.length >= MIN_CLAN_TAG_LENGTH &&
this.clanTag.length <= MAX_CLAN_TAG_LENGTH &&
validateClanTag(this.clanTag).isValid
? this.clanTag
: null;
}
connectedCallback() {
super.connectedCallback();
const stored = this.getUsername();
this.parseAndSetUsername(stored);
this.loadStoredUsername();
crazyGamesSDK.getUsername().then((username) => {
if (username) {
this.parseAndSetUsername(username ?? genAnonUsername());
this.requestUpdate();
this.baseUsername = username;
this.validateAndStore();
}
});
crazyGamesSDK.addAuthListener((user) => {
if (user) {
this.parseAndSetUsername(user?.username);
this.baseUsername = user.username;
this.validateAndStore();
}
this.requestUpdate();
});
}
private parseAndSetUsername(fullUsername: string) {
const tag = getClanTagOriginalCase(fullUsername);
if (tag) {
this.clanTag = tag.toUpperCase();
this.baseUsername = fullUsername.replace(`[${tag}]`, "").trim();
private loadStoredUsername() {
const storedUsername = localStorage.getItem(usernameKey);
if (storedUsername) {
this.clanTag = localStorage.getItem(clanTagKey) ?? "";
this.baseUsername = storedUsername;
this.validateAndStore();
} else {
this.clanTag = "";
this.baseUsername = fullUsername;
this.baseUsername = genAnonUsername();
this.validateAndStore();
}
this.validateAndStore();
}
render() {
@@ -77,7 +80,8 @@ export class UsernameInput extends LitElement {
.value=${this.clanTag}
@input=${this.handleClanTagChange}
placeholder="${translateText("username.tag")}"
maxlength="5"
minlength="${MIN_CLAN_TAG_LENGTH}"
maxlength="${MAX_CLAN_TAG_LENGTH}"
class="w-[6rem] text-xl font-medium tracking-wider text-center uppercase shrink-0 bg-transparent text-white placeholder-white/70 focus:placeholder-transparent border-0 border-b border-white/40 focus:outline-none focus:border-white/60"
/>
<input
@@ -85,6 +89,7 @@ export class UsernameInput extends LitElement {
.value=${this.baseUsername}
@input=${this.handleUsernameChange}
placeholder="${translateText("username.enter_username")}"
minlength="${MIN_USERNAME_LENGTH}"
maxlength="${MAX_USERNAME_LENGTH}"
class="flex-1 min-w-0 border-0 text-2xl font-medium tracking-wider text-left text-white placeholder-white/70 focus:outline-none focus:ring-0 overflow-x-auto whitespace-nowrap text-ellipsis pr-2 bg-transparent"
/>
@@ -147,59 +152,51 @@ export class UsernameInput extends LitElement {
}
private validateAndStore() {
// Prevent empty username even if clan tag is present
const trimmedBase = this.baseUsername.trim();
if (!trimmedBase || trimmedBase.length < MIN_USERNAME_LENGTH) {
const trimmedBase = this.getUsername();
const clanTagResult = validateClanTag(this.clanTag);
if (!clanTagResult.isValid) {
this._isValid = false;
this.validationError = translateText("username.too_short", {
min: MIN_USERNAME_LENGTH,
});
this.validationError = clanTagResult.error ?? "";
return;
}
// Validate clan tag if present
if (this.clanTag.length > 0 && this.clanTag.length < 2) {
this._isValid = false;
this.validationError = translateText("username.tag_too_short");
return;
}
const full = this.constructFullUsername();
const trimmedFull = full.trim();
const result = validateUsername(trimmedFull);
const result = validateUsername(trimmedBase);
this._isValid = result.isValid;
if (result.isValid) {
this.storeUsername(trimmedFull);
localStorage.setItem(usernameKey, trimmedBase);
localStorage.setItem(clanTagKey, this.getClanTag() ?? "");
this.validationError = "";
} else {
this.validationError = result.error ?? "";
}
}
private getUsername(): string {
const storedUsername = localStorage.getItem(usernameKey);
if (storedUsername) {
return storedUsername;
}
return this.generateNewUsername();
}
private storeUsername(username: string) {
if (username) {
localStorage.setItem(usernameKey, username);
}
}
private generateNewUsername(): string {
const newUsername = genAnonUsername();
this.storeUsername(newUsername);
return newUsername;
}
public isValid(): boolean {
return this._isValid;
}
public showValidationFeedback(): void {
const message =
this.validationError || translateText("username.invalid_chars");
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message,
color: "red",
duration: 2500,
},
}),
);
}
public validateOrShowError(): boolean {
if (this.isValid()) {
return true;
}
this.showValidationFeedback();
return false;
}
}
export function genAnonUsername(): string {
+30 -2
View File
@@ -159,7 +159,7 @@ export function getActiveModifiers(
(modifiers.startingGold / 1_000_000).toPrecision(12),
);
result.push({
labelKey: "host_modal.starting_gold",
labelKey: "public_game_modifier.starting_gold_label",
badgeKey: "public_game_modifier.starting_gold",
badgeParams: {
amount: millions,
@@ -181,8 +181,9 @@ export function getActiveModifiers(
}
if (modifiers.isAlliancesDisabled) {
result.push({
labelKey: "host_modal.disable_alliances",
labelKey: "public_game_modifier.disable_alliances_label",
badgeKey: "public_game_modifier.disable_alliances",
formattedValue: translateText("common.disabled"),
});
}
return result;
@@ -628,3 +629,30 @@ export function getDiscordAvatarUrl(user: {
return null;
}
export function calculateServerTimeOffset(
serverTimeMs: number,
localNowMs: number = Date.now(),
): number {
return serverTimeMs - localNowMs;
}
export function getServerNow(
serverTimeOffsetMs: number,
localNowMs: number = Date.now(),
): number {
return localNowMs + serverTimeOffsetMs;
}
export function getSecondsUntilServerTimestamp(
targetServerTimestampMs: number,
serverTimeOffsetMs: number,
localNowMs: number = Date.now(),
): number {
return Math.max(
0,
Math.floor(
(targetServerTimestampMs - getServerNow(serverTimeOffsetMs, localNowMs)) /
1000,
),
);
}
+4
View File
@@ -35,6 +35,10 @@ export abstract class BaseModal extends LitElement {
return this;
}
public isOpen(): boolean {
return this.isModalOpen;
}
protected firstUpdated(): void {
if (this.modalEl) {
this.modalEl.onClose = () => {
+20 -13
View File
@@ -16,7 +16,7 @@ import {
import { assignTeamsLobbyPreview } from "../../core/game/TeamAssignment";
import { UserSettings } from "../../core/game/UserSettings";
import { ClientInfo, TeamCountConfig } from "../../core/Schemas";
import { createRandomName } from "../../core/Util";
import { createRandomName, formatPlayerDisplayName } from "../../core/Util";
import { getTranslatedPlayerTeamLabel, translateText } from "../Utils";
export interface TeamPreviewData {
@@ -122,7 +122,7 @@ 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"
>
@@ -167,7 +167,7 @@ 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`<span class="player-tag">
<span class="text-white">${displayName}</span>
${client.clientID === this.lobbyCreatorClientID
@@ -226,7 +226,7 @@ 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"
>
@@ -318,7 +318,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 +365,17 @@ export class LobbyTeamView extends LitElement {
}));
}
private displayUsername(client: ClientInfo): string {
private getClientDisplayName(client: ClientInfo): string {
const full = formatPlayerDisplayName(client.username, client.clanTag);
if (!this.userSettings.anonymousNames()) {
return client.username;
return full;
}
if (this.currentClientID && client.clientID === this.currentClientID) {
return client.username;
return full;
}
return (
createRandomName(client.username, PlayerType.Human) ?? client.username
);
// Keep clan tag visible while anonymizing only the username.
const anonymizedUsername =
createRandomName(client.username, PlayerType.Human) ?? client.username;
return formatPlayerDisplayName(anonymizedUsername, client.clanTag);
}
}
-213
View File
@@ -1,213 +0,0 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
const VIDEO_AD_UNIT_TYPE = "precontent_ad_video";
@customElement("video-ad")
export class VideoAd extends LitElement {
@state()
private isVisible: boolean = true;
@property({ attribute: false })
onComplete?: () => void;
@property({ attribute: false })
onMidpoint?: () => void;
@property({ attribute: false })
onAdBlocked?: () => void;
private adLoadTimeout: ReturnType<typeof setTimeout> | null = null;
private rampCheckInterval: ReturnType<typeof setInterval> | null = null;
private rampWaitTimeout: ReturnType<typeof setTimeout> | null = null;
private adStarted = false;
// How long to wait for ad to start before assuming it's blocked
private static readonly AD_LOAD_TIMEOUT_MS = 8000;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
// Set dimensions on the custom element itself (required by Playwire)
// Playwire requires explicit pixel dimensions, use max-width for responsiveness
this.style.display = "block";
this.style.width = "100%";
this.style.maxWidth = "800px";
this.style.aspectRatio = "16/9";
this.showVideoAd();
}
disconnectedCallback() {
super.disconnectedCallback();
// Clean up timeout if component is removed
if (this.adLoadTimeout) {
clearTimeout(this.adLoadTimeout);
this.adLoadTimeout = null;
}
if (this.rampCheckInterval) {
clearInterval(this.rampCheckInterval);
this.rampCheckInterval = null;
}
if (this.rampWaitTimeout) {
clearTimeout(this.rampWaitTimeout);
this.rampWaitTimeout = null;
}
}
public showVideoAd(): void {
if (!window.ramp) {
// Wait for ramp to be available, but give up after timeout
this.rampCheckInterval = setInterval(() => {
if (window.ramp && window.ramp.que) {
if (this.rampCheckInterval) {
clearInterval(this.rampCheckInterval);
this.rampCheckInterval = null;
}
if (this.rampWaitTimeout) {
clearTimeout(this.rampWaitTimeout);
this.rampWaitTimeout = null;
}
this.loadVideoAd();
}
}, 100);
// Stop polling after timeout (e.g. adblocker preventing ramp from loading)
this.rampWaitTimeout = setTimeout(() => {
if (this.rampCheckInterval) {
clearInterval(this.rampCheckInterval);
this.rampCheckInterval = null;
}
console.log("[VideoAd] Ramp SDK never loaded - possible adblocker");
this.handleAdBlocked();
}, VideoAd.AD_LOAD_TIMEOUT_MS);
return;
}
this.loadVideoAd();
}
private loadVideoAd(): void {
// Start timeout to detect if ad doesn't load (e.g., due to adblocker)
this.adLoadTimeout = setTimeout(() => {
if (!this.adStarted) {
console.log("[VideoAd] Ad load timeout - possible adblocker detected");
this.handleAdBlocked();
}
}, VideoAd.AD_LOAD_TIMEOUT_MS);
// Set up event listeners when player is ready, chaining any existing handler
const prevOnPlayerReady = window.ramp.onPlayerReady;
window.ramp.onPlayerReady = () => {
if (prevOnPlayerReady) prevOnPlayerReady();
if (window.Bolt) {
// Listen for ad start to know ad is loading successfully
window.Bolt.on(
VIDEO_AD_UNIT_TYPE,
window.Bolt.BOLT_AD_STARTED ?? "boltAdStarted",
() => {
console.log("[VideoAd] Ad started");
this.adStarted = true;
// Clear the timeout since ad is playing
if (this.adLoadTimeout) {
clearTimeout(this.adLoadTimeout);
this.adLoadTimeout = null;
}
},
);
window.Bolt.on(VIDEO_AD_UNIT_TYPE, window.Bolt.BOLT_AD_COMPLETE, () => {
console.log("[VideoAd] Ad completed");
this.hideElement();
});
window.Bolt.on(VIDEO_AD_UNIT_TYPE, window.Bolt.BOLT_AD_ERROR, () => {
console.log("[VideoAd] Ad error/no fill");
this.handleAdBlocked();
});
window.Bolt.on(VIDEO_AD_UNIT_TYPE, window.Bolt.BOLT_MIDPOINT, () => {
console.log("[VideoAd] Ad midpoint");
if (this.onMidpoint) {
this.onMidpoint();
}
});
window.Bolt.on(
VIDEO_AD_UNIT_TYPE,
window.Bolt.SHOW_HIDDEN_CONTAINER ?? "showHiddenContainer",
() => {
console.log("[VideoAd] Ad finished");
this.hideElement();
},
);
}
};
// Queue the video ad initialization
window.ramp.que.push(() => {
const pwUnits = [{ type: VIDEO_AD_UNIT_TYPE }];
window.ramp
.addUnits(pwUnits)
.then(() => {
window.ramp.displayUnits();
})
.catch((e: Error) => {
console.error("[VideoAd] Error adding units:", e);
window.ramp.displayUnits();
});
});
}
private handleAdBlocked(): void {
// Clear timeout if still pending
if (this.adLoadTimeout) {
clearTimeout(this.adLoadTimeout);
this.adLoadTimeout = null;
}
// Call the callback if provided
if (this.onAdBlocked) {
this.onAdBlocked();
}
}
private hideElement(): void {
this.style.display = "none";
this.isVisible = false;
// Call the callback if provided
if (this.onComplete) {
this.onComplete();
}
// Also dispatch event for backwards compatibility
this.dispatchEvent(
new CustomEvent("ad-complete", {
bubbles: true,
composed: true,
}),
);
}
render() {
if (!this.isVisible) {
return html``;
}
// Provide a container for the Playwire video player to render into
// Structure matches Playwire example: wrapper > game-video-ad > precontent-video-location
return html`
<div
class="game-video-ad"
style="width: 100%; height: 100%; overflow: hidden;"
>
<div
id="precontent-video-location"
style="width: 100%; height: 100%;"
></div>
</div>
`;
}
}
@@ -26,9 +26,8 @@ export enum RankType {
export interface PlayerInfo {
id: string;
rawUsername: string;
username: string;
tag?: string;
clanTag: string | null;
killedAt?: number;
gold: bigint[];
conquests: bigint[];
@@ -77,18 +76,12 @@ export class Ranking {
for (const player of session.info.players) {
if (player === undefined || !hasPlayed(player)) continue;
const stats = player.stats!;
const match = player.username.match(/^\[(.*?)\]\s*(.*)$/);
let username = player.username;
if (player.clanTag && match) {
username = match[2];
}
const gold = (stats.gold ?? []).map((v) => BigInt(v ?? 0));
const conquests = (stats.conquests ?? []).map((v) => BigInt(v ?? 0));
players[player.clientID] = {
id: player.clientID,
rawUsername: player.username,
username,
tag: player.clanTag,
username: player.username,
clanTag: player.clanTag,
conquests,
flag: player.cosmetics?.flag ?? undefined,
killedAt: stats.killedAt !== null ? Number(stats.killedAt) : undefined,
@@ -220,7 +220,7 @@ export class PlayerRow extends LitElement {
private renderPlayerName() {
return html`
<div class="flex gap-1 items-center w-50 shrink-0">
${this.player.tag ? this.renderTag(this.player.tag) : ""}
${this.player.clanTag ? this.renderTag(this.player.clanTag) : ""}
<div
class="text-xs sm:text-sm font-bold tracking-wide text-white/80 text-ellipsis w-37.5 shrink-0 overflow-hidden whitespace-nowrap"
>
@@ -249,9 +249,7 @@ export class LeaderboardPlayerList extends LitElement {
</div>`
: ""}
<span class="font-bold text-blue-300 truncate text-base"
>${player.clanTag
? player.username.replace(/^\[.*?\]\s*/, "")
: player.username}</span
>${player.username}</span
>
</div>
</td>
@@ -434,14 +432,18 @@ export class LeaderboardPlayerList extends LitElement {
"leaderboard_modal.your_ranking",
)}</span
>
<span class="font-bold text-white text-base"
>${this.currentUserEntry.clanTag
? this.currentUserEntry.username.replace(
/^\[.*?\]\s*/,
"",
)
: this.currentUserEntry.username}</span
>
<div class="flex items-center gap-2">
${this.currentUserEntry.clanTag
? html`<div
class="px-2 py-0.5 rounded bg-blue-500/10 border border-blue-300/40 text-[10px] font-bold text-blue-100 shrink-0"
>
${this.currentUserEntry.clanTag}
</div>`
: ""}
<span class="font-bold text-white text-base"
>${this.currentUserEntry.username}</span
>
</div>
</div>
<div class="flex flex-col items-end w-20">
<div class="font-mono text-white font-bold text-lg">
+6 -16
View File
@@ -21,7 +21,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 +36,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 +261,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.
@@ -321,8 +312,7 @@ export function createRenderer(
playerPanel,
headsUpMessage,
multiTabModal,
inGameHeaderAd,
spawnVideoAd,
inGamePromo,
alertFrame,
performanceOverlay,
];
+1 -1
View File
@@ -52,7 +52,7 @@ export function placeName(game: Game, player: Player): NameViewData {
),
);
const fontSize = calculateFontSize(largestRectangle, player.name());
const fontSize = calculateFontSize(largestRectangle, player.displayName());
center = new Cell(center.x, center.y - fontSize / 3);
return {
+9 -9
View File
@@ -222,7 +222,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 +235,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 +269,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 +282,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 +310,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 +346,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 +365,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 +401,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 +409,7 @@ export class AttacksDisplay extends LitElement implements Layer {
>${renderTroops(boat.troops())}</span
>
<span class="truncate text-xs ml-1"
>${boat.owner()?.name()}</span
>${boat.owner()?.displayName()}</span
>`,
onClick: () => this.eventBus.emit(new GoToUnitEvent(boat)),
className:
+6 -5
View File
@@ -147,7 +147,7 @@ export class ChatModal extends LitElement {
.toHex()};"
@click=${() => this.selectPlayer(player)}
>
${player.name()}
${player.displayName()}
</button>
`,
)}
@@ -216,7 +216,8 @@ export class ChatModal extends LitElement {
private selectPlayer(player: PlayerView) {
if (this.previewText) {
this.previewText =
this.selectedPhraseTemplate?.replace("[P1]", player.name()) ?? null;
this.selectedPhraseTemplate?.replace("[P1]", player.displayName()) ??
null;
this.selectedPlayer = player;
this.requiresPlayerSelection = false;
this.requestUpdate();
@@ -255,13 +256,13 @@ export class ChatModal extends LitElement {
private getSortedFilteredPlayers(): PlayerView[] {
const sorted = [...this.players].sort((a, b) =>
a.name().localeCompare(b.name()),
a.displayName().localeCompare(b.displayName()),
);
const filtered = sorted.filter((p) =>
p.name().toLowerCase().includes(this.playerSearchQuery),
p.displayName().toLowerCase().includes(this.playerSearchQuery),
);
const others = sorted.filter(
(p) => !p.name().toLowerCase().includes(this.playerSearchQuery),
(p) => !p.displayName().toLowerCase().includes(this.playerSearchQuery),
);
return [...filtered, ...others];
}
+12 -12
View File
@@ -283,7 +283,7 @@ export class EventsDisplay extends LitElement implements Layer {
this.addEvent({
description: translateText("events_display.about_to_expire", {
name: other.name(),
name: other.displayName(),
}),
type: MessageType.RENEW_ALLIANCE,
duration: this.game.config().allianceExtensionPromptOffset() - 3 * 10, // 3 second buffer
@@ -296,7 +296,7 @@ export class EventsDisplay extends LitElement implements Layer {
},
{
text: translateText("events_display.renew_alliance", {
name: other.name(),
name: other.displayName(),
}),
className: "btn",
action: () =>
@@ -460,7 +460,7 @@ export class EventsDisplay extends LitElement implements Layer {
this.addEvent({
description: translateText("events_display.request_alliance", {
name: requestor.name(),
name: requestor.displayName(),
}),
buttons: [
{
@@ -525,7 +525,7 @@ export class EventsDisplay extends LitElement implements Layer {
) as PlayerView;
this.addEvent({
description: translateText("events_display.alliance_request_status", {
name: recipient.name(),
name: recipient.displayName(),
status: update.accepted
? translateText("events_display.alliance_accepted")
: translateText("events_display.alliance_rejected"),
@@ -569,7 +569,7 @@ export class EventsDisplay extends LitElement implements Layer {
this.addEvent({
description: translateText("events_display.betrayal_description", {
name: betrayed.name(),
name: betrayed.displayName(),
malusPercent: malusPercent,
durationText: durationText,
}),
@@ -589,7 +589,7 @@ export class EventsDisplay extends LitElement implements Layer {
];
this.addEvent({
description: translateText("events_display.betrayed_you", {
name: traitor.name(),
name: traitor.displayName(),
}),
type: MessageType.ALLIANCE_BROKEN,
highlight: true,
@@ -616,7 +616,7 @@ export class EventsDisplay extends LitElement implements Layer {
this.addEvent({
description: translateText("events_display.alliance_expired", {
name: other.name(),
name: other.displayName(),
}),
type: MessageType.ALLIANCE_EXPIRED,
highlight: true,
@@ -641,8 +641,8 @@ export class EventsDisplay extends LitElement implements Layer {
this.addEvent({
description: translateText("events_display.attack_request", {
name: other.name(),
target: target.name(),
name: other.displayName(),
target: target.displayName(),
}),
type: MessageType.ATTACK_REQUEST,
highlight: true,
@@ -809,7 +809,7 @@ export class EventsDisplay extends LitElement implements Layer {
`,
onClick: this.toggleHidden,
className:
"text-white cursor-pointer pointer-events-auto w-fit p-2 lg:p-3 min-[1200px]:rounded-lg sm:rounded-tl-lg bg-gray-800/70 backdrop-blur-xs",
"text-white cursor-pointer pointer-events-auto w-fit p-2 lg:p-3 min-[1200px]:rounded-lg sm:rounded-tl-lg bg-gray-800/92 backdrop-blur-sm",
})}
</div>
`
@@ -820,7 +820,7 @@ export class EventsDisplay extends LitElement implements Layer {
>
<!-- Button Bar -->
<div
class="w-full p-2 lg:p-3 bg-gray-800/70 sm:rounded-tl-lg min-[1200px]:rounded-t-lg"
class="w-full p-2 lg:p-3 bg-gray-800/92 backdrop-blur-sm sm:rounded-tl-lg min-[1200px]:rounded-t-lg"
>
<div class="flex justify-between items-center gap-3">
<div class="flex gap-4">
@@ -864,7 +864,7 @@ export class EventsDisplay extends LitElement implements Layer {
<!-- Content Area -->
<div
class="bg-gray-800/70 max-h-[15vh] lg:max-h-[30vh] overflow-y-auto w-full h-full min-[1200px]:rounded-b-xl events-container"
class="bg-gray-800/92 backdrop-blur-sm max-h-[15vh] lg:max-h-[30vh] overflow-y-auto w-full h-full min-[1200px]:rounded-b-xl events-container"
>
<div>
<table
@@ -102,7 +102,7 @@ export class GameLeftSidebar extends LitElement implements Layer {
render() {
return html`
<aside
class=${`fixed top-0 min-[1200px]:top-4 left-0 min-[1200px]:left-4 z-900 flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg rounded-br-lg ${this.isLeaderboardShow || this.isTeamLeaderboardShow ? "max-[400px]:w-full max-[400px]:rounded-none" : ""} transition-all duration-300 ease-out transform ${
class=${`fixed top-0 min-[1200px]:top-4 left-0 min-[1200px]:left-4 z-900 flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg rounded-br-lg ${this.isLeaderboardShow || this.isTeamLeaderboardShow ? "max-[400px]:w-full max-[400px]:rounded-none" : ""} transition-all duration-300 ease-out transform ${
this.isVisible ? "translate-x-0" : "hidden"
}`}
style="margin-top: ${this.barOffset}px;"
+10 -1
View File
@@ -4,6 +4,7 @@ import { EventBus } from "../../../core/EventBus";
import { GameType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { crazyGamesSDK } from "../../CrazyGamesSDK";
import { TogglePauseIntentEvent } from "../../InputHandler";
import { PauseGameIntentEvent, SendWinnerEvent } from "../../Transport";
import { translateText } from "../../Utils";
import { ImmunityBarVisibleEvent } from "./ImmunityTimer";
@@ -67,6 +68,14 @@ export class GameRightSidebar extends LitElement implements Layer {
this.requestUpdate();
});
this.eventBus.on(TogglePauseIntentEvent, () => {
const isReplayOrSingleplayer =
this._isSinglePlayer || this.game?.config()?.isReplay();
if (isReplayOrSingleplayer || this.isLobbyCreator) {
this.onPauseButtonClick();
}
});
this.requestUpdate();
}
@@ -175,7 +184,7 @@ export class GameRightSidebar extends LitElement implements Layer {
return html`
<aside
class=${`w-fit flex flex-row items-center gap-3 py-2 px-3 bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg rounded-bl-lg transition-transform duration-300 ease-out transform text-white ${
class=${`w-fit flex flex-row items-center gap-3 py-2 px-3 bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg rounded-bl-lg transition-transform duration-300 ease-out transform text-white ${
this._isVisible ? "translate-x-0" : "translate-x-full"
}`}
@contextmenu=${(e: Event) => e.preventDefault()}
@@ -1,119 +0,0 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { GameView } from "../../../core/game/GameView";
import { Layer } from "./Layer";
const AD_SHOW_TICKS = 10 * 60 * 10; // 2 minutes
const HEADER_AD_TYPE = "standard_iab_head1";
const HEADER_AD_CONTAINER_ID = "header-ad-container";
const TWO_XL_BREAKPOINT = 1536;
@customElement("in-game-header-ad")
export class InGameHeaderAd extends LitElement implements Layer {
public game: GameView;
private isHidden: boolean = false;
private adLoaded: boolean = false;
private shouldShow: boolean = false;
createRenderRoot() {
return this;
}
init() {
// TODO: move ad and re-enable.
// this.showHeaderAd();
}
private showHeaderAd(): void {
// Don't show header ad on screens smaller than 2xl
if (window.innerWidth < TWO_XL_BREAKPOINT) {
return;
}
if (!window.adsEnabled) {
return;
}
this.shouldShow = true;
this.requestUpdate();
// Wait for the element to render before loading the ad
this.updateComplete.then(() => {
this.loadAd();
});
}
private loadAd(): void {
if (!window.ramp) {
console.warn("Playwire RAMP not available for header ad");
return;
}
try {
window.ramp.que.push(() => {
try {
window.ramp.spaAddAds([
{
type: HEADER_AD_TYPE,
selectorId: HEADER_AD_CONTAINER_ID,
},
]);
this.adLoaded = true;
console.log("Header ad loaded:", HEADER_AD_TYPE);
} catch (e) {
console.error("Failed to add header ad:", e);
}
});
} catch (error) {
console.error("Failed to load header ad:", error);
}
}
private hideHeaderAd(): void {
this.shouldShow = false;
this.adLoaded = false;
try {
window.ramp.destroyUnits(HEADER_AD_TYPE);
console.log("successfully destroyed in game header ad");
} catch (e) {
console.error("error destroying in game header ad", e);
}
this.requestUpdate();
}
public tick() {
if (this.isHidden) {
return;
}
const gameTicks =
this.game.ticks() - this.game.config().numSpawnPhaseTurns();
if (gameTicks > AD_SHOW_TICKS) {
console.log("destroying header ad and refreshing PageOS");
this.hideHeaderAd();
this.isHidden = true;
if (window.PageOS?.session?.newPageView) {
window.PageOS.session.newPageView();
}
return;
}
}
shouldTransform(): boolean {
return false;
}
render() {
if (!this.shouldShow) {
return html``;
}
return html`
<div
id="${HEADER_AD_CONTAINER_ID}"
class="hidden 2xl:flex fixed top-0 left-1/2 -translate-x-1/2 z-[100] justify-center items-center pointer-events-auto p-0 -mt-[20px]"
></div>
`;
}
}
+146
View File
@@ -0,0 +1,146 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { GameView } from "../../../core/game/GameView";
import { Layer } from "./Layer";
const AD_TYPE = "standard_iab_left1";
const AD_CONTAINER_ID = "in-game-bottom-left-ad";
const BOTTOM_RAIL_TYPE = "bottom_rail";
@customElement("in-game-promo")
export class InGamePromo extends LitElement implements Layer {
public game: GameView;
private shouldShow: boolean = false;
private bottomRailActive: boolean = false;
private cornerAdShown: boolean = false;
createRenderRoot() {
return this;
}
init() {
this.showBottomRail();
}
tick() {
if (!this.game.inSpawnPhase()) {
if (this.bottomRailActive) {
this.destroyBottomRail();
}
if (!this.cornerAdShown) {
this.cornerAdShown = true;
this.showAd();
}
}
}
private showBottomRail(): void {
if (!window.adsEnabled) return;
if (!this.game.inSpawnPhase()) return;
if (!window.ramp) {
console.warn("Playwire RAMP not available for bottom_rail ad");
return;
}
this.bottomRailActive = true;
try {
window.ramp.que.push(() => {
try {
window.ramp.spaAddAds([{ type: BOTTOM_RAIL_TYPE }]);
console.log("Bottom rail ad loaded during spawn phase");
} catch (e) {
console.error("Failed to add bottom_rail ad:", e);
}
});
} catch (error) {
console.error("Failed to load bottom_rail ad:", error);
}
}
private destroyBottomRail(): void {
if (!this.bottomRailActive) return;
this.bottomRailActive = false;
if (!window.ramp) return;
try {
window.ramp.spaAds({ ads: [], countPageview: false });
console.log("Bottom rail ad destroyed via spaAds after spawn phase");
} catch (e) {
console.error("Error destroying bottom_rail ad:", e);
}
}
private showAd(): void {
if (!window.adsEnabled) return;
if (window.innerWidth < 1100) return;
if (window.innerHeight < 750) return;
this.shouldShow = true;
this.requestUpdate();
this.updateComplete.then(() => {
this.loadAd();
});
}
private loadAd(): void {
if (!window.ramp) {
console.warn("Playwire RAMP not available for in-game ad");
return;
}
try {
window.ramp.que.push(() => {
try {
window.ramp.spaAddAds([
{
type: AD_TYPE,
selectorId: AD_CONTAINER_ID,
},
]);
console.log("In-game bottom-left ad loaded:", AD_TYPE);
} catch (e) {
console.error("Failed to add in-game ad:", e);
}
});
} catch (error) {
console.error("Failed to load in-game ad:", error);
}
}
public hideAd(): void {
this.destroyBottomRail();
if (!window.ramp) {
console.warn("Playwire RAMP not available for in-game ad");
return;
}
this.shouldShow = false;
try {
window.ramp.destroyUnits(AD_TYPE);
console.log("successfully destroyed in-game bottom-left ad");
} catch (e) {
console.error("error destroying in-game ad:", e);
}
this.requestUpdate();
}
shouldTransform(): boolean {
return false;
}
render() {
if (!this.shouldShow) {
return html``;
}
return html`
<div
id="${AD_CONTAINER_ID}"
class="fixed left-0 z-[100] pointer-events-auto"
style="bottom: -0.7cm"
></div>
`;
}
}
+2 -2
View File
@@ -239,7 +239,7 @@ export class NameLayer implements Layer {
const nameSpan = document.createElement("span");
nameSpan.className = "player-name-span";
nameSpan.innerHTML = player.name();
nameSpan.textContent = player.displayName();
nameDiv.appendChild(nameSpan);
element.appendChild(nameDiv);
@@ -338,7 +338,7 @@ export class NameLayer implements Layer {
nameDiv.style.color = render.fontColor;
const span = nameDiv.querySelector(".player-name-span");
if (span) {
span.innerHTML = render.player.name();
span.textContent = render.player.displayName();
}
if (flagDiv) {
flagDiv.style.height = `${render.fontSize}px`;
@@ -380,7 +380,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
src=${"/flags/" + player.cosmetics.flag! + ".svg"}
/>`
: html``}
<span>${player.name()}</span>
<span>${player.displayName()}</span>
${playerTeam !== "" && player.type() !== PlayerType.Bot
? html`<div class="flex flex-col leading-tight">
<span class="text-gray-400 text-xs font-normal"
@@ -488,7 +488,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
return html`
<div class="p-2">
<div class="font-bold mb-1 ${isAlly ? "text-green-500" : "text-white"}">
${unit.owner().name()}
${unit.owner().displayName()}
</div>
<div class="mt-1">
<div class="text-sm opacity-80">${unit.type()}</div>
@@ -524,7 +524,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
>
<div
class="bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg sm:rounded-b-lg shadow-lg text-white text-lg lg:text-base w-full sm:w-[500px] overflow-hidden ${containerClasses}"
class="bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg sm:rounded-b-lg shadow-lg text-white text-lg lg:text-base w-full sm:w-[500px] overflow-hidden ${containerClasses}"
>
${this.player !== null ? this.renderPlayerInfo(this.player) : ""}
${this.unit !== null ? this.renderUnitInfo(this.unit) : ""}
@@ -65,7 +65,7 @@ export class PlayerModerationModal extends LitElement {
if (!targetClientID || targetClientID.length === 0) return;
const confirmed = confirm(
translateText("player_panel.kick_confirm", { name: other.name() }),
translateText("player_panel.kick_confirm", { name: other.displayName() }),
);
if (!confirmed) return;
@@ -142,9 +142,9 @@ export class PlayerModerationModal extends LitElement {
>
<div
class="text-sm font-semibold text-zinc-100 truncate"
title=${other.name()}
title=${other.displayName()}
>
${other.name()}
${other.displayName()}
</div>
</div>
+5 -5
View File
@@ -505,9 +505,9 @@ export class PlayerPanel extends LitElement implements Layer {
<div class="flex-1 min-w-0">
<h2
class="text-xl font-bold tracking-[-0.01em] text-zinc-50 truncate"
title=${other.name()}
title=${other.displayName()}
>
${other.name()}
${other.displayName()}
</h2>
</div>
${chip
@@ -626,7 +626,7 @@ export class PlayerPanel extends LitElement implements Layer {
const nameCollator = new Intl.Collator(undefined, { sensitivity: "base" });
const alliesSorted = [...allies].sort((a, b) =>
nameCollator.compare(a.name(), b.name()),
nameCollator.compare(a.displayName(), b.displayName()),
);
return html`
@@ -669,9 +669,9 @@ export class PlayerPanel extends LitElement implements Layer {
rounded-md border border-white/10 bg-white/5
px-2.5 py-1 text-[14px] text-zinc-100
hover:bg-white/8 active:scale-[0.99] transition"
title=${p.name()}
title=${p.displayName()}
>
<span class="truncate">${p.name()}</span>
<span class="truncate">${p.displayName()}</span>
</li>`,
)}
</ul>
+8 -1
View File
@@ -41,6 +41,13 @@ export class ReplayPanel extends LitElement implements Layer {
this.visible = event.visible;
this.isSingleplayer = event.isSingleplayer;
});
this.eventBus.on(
ReplaySpeedChangeEvent,
(event: ReplaySpeedChangeEvent) => {
this._replaySpeedMultiplier = event.replaySpeedMultiplier;
this.requestUpdate();
},
);
}
}
@@ -68,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">
@@ -1,67 +0,0 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { crazyGamesSDK } from "src/client/CrazyGamesSDK";
import { Platform } from "src/client/Platform";
import { getGamesPlayed } from "src/client/Utils";
import { GameType } from "src/core/game/Game";
import { GameView } from "../../../core/game/GameView";
import "../../components/VideoPromo";
import { Layer } from "./Layer";
@customElement("spawn-video-ad")
export class SpawnVideoAd extends LitElement implements Layer {
public game: GameView;
@state() private shouldShow = false;
@state() private adComplete = false;
createRenderRoot() {
return this;
}
init() {
if (
!window.adsEnabled ||
Platform.isMobileWidth ||
crazyGamesSDK.isOnCrazyGames() ||
this.game.config().gameConfig().gameType === GameType.Singleplayer ||
getGamesPlayed() < 3 // Don't show to new players
) {
return;
}
this.shouldShow = true;
}
tick() {
if (this.adComplete) return;
// Hide when spawn phase ends
if (this.shouldShow && !this.game.inSpawnPhase()) {
this.shouldShow = false;
this.requestUpdate();
}
}
private handleComplete = () => {
this.adComplete = true;
this.shouldShow = false;
};
shouldTransform(): boolean {
return false;
}
render() {
if (!this.shouldShow || this.adComplete) {
return html``;
}
return html`
<div class="fixed bottom-0 left-0 z-[9999] pointer-events-auto">
<video-ad
style="width: 400px; max-width: 400px; height: 225px; aspect-ratio: auto;"
.onComplete="${this.handleComplete}"
></video-ad>
</div>
`;
}
}
+1 -1
View File
@@ -334,7 +334,7 @@ export class WinModal extends LitElement implements Layer {
crazyGamesSDK.happytime();
} else {
this._title = translateText("win_modal.other_won", {
player: winner.name(),
player: winner.displayName(),
});
this.isWin = false;
}
+18 -5
View File
@@ -1,8 +1,21 @@
import { z } from "zod";
import { base64urlToUuid } from "./Base64";
import { ClanTagSchema } from "./Schemas";
import { BigIntStringSchema, PlayerStatsSchema } from "./StatsSchemas";
import { Difficulty, GameMode, GameType, RankedType } from "./game/Game";
function stripClanTagFromUsername(username: string): string {
return username.replace(/^\s*\[[a-zA-Z0-9]{2,5}\]\s*/u, "").trim();
}
// Historical leaderboard rows can include legacy usernames
// that predate current strict join-time validation rules.
const LeaderboardUsernameSchema = z
.string()
.transform(stripClanTagFromUsername)
.pipe(z.string().min(1).max(64));
const LeaderboardClanTagSchema = ClanTagSchema.unwrap();
export const RefreshResponseSchema = z.object({
token: z.string(),
});
@@ -114,7 +127,7 @@ export const PlayerProfileSchema = z.object({
export type PlayerProfile = z.infer<typeof PlayerProfileSchema>;
export const ClanLeaderboardEntrySchema = z.object({
clanTag: z.string(),
clanTag: LeaderboardClanTagSchema,
games: z.number(),
wins: z.number(),
losses: z.number(),
@@ -137,8 +150,8 @@ export type ClanLeaderboardResponse = z.infer<
export const PlayerLeaderboardEntrySchema = z.object({
rank: z.number(),
playerId: z.string(),
username: z.string(),
clanTag: z.string().optional(),
username: LeaderboardUsernameSchema,
clanTag: LeaderboardClanTagSchema.nullable().optional(),
flag: z.string().optional(),
elo: z.number(),
games: z.number(),
@@ -166,8 +179,8 @@ export const RankedLeaderboardEntrySchema = z.object({
total: z.number(),
public_id: z.string(),
user: DiscordUserSchema.nullable().optional(),
username: z.string(),
clanTag: z.string().nullable().optional(),
username: LeaderboardUsernameSchema,
clanTag: LeaderboardClanTagSchema.nullable().optional(),
});
export type RankedLeaderboardEntry = z.infer<
typeof RankedLeaderboardEntrySchema
+1
View File
@@ -52,6 +52,7 @@ export async function createGameRunner(
p.clientID,
random.nextID(),
p.isLobbyCreator ?? false,
p.clanTag,
);
});
+16 -7
View File
@@ -141,9 +141,21 @@ export type PublicGameType = z.infer<typeof PublicGameTypeSchema>;
export const PublicGameTypeSchema = z.enum(["ffa", "team", "special"]);
export const UsernameSchema = z
.string()
.regex(/^(?=.*\S)[a-zA-Z0-9_ üÜ.]+$/u)
.min(3)
.max(27);
export const ClanTagSchema = z
.string()
.regex(/^[a-zA-Z0-9]{2,5}$/)
.nullable();
const ClientInfoSchema = z.object({
clientID: z.string(),
username: z.string(),
username: UsernameSchema,
clanTag: ClanTagSchema,
});
export const GameInfoSchema = z.object({
@@ -179,6 +191,7 @@ export class LobbyInfoEvent implements GameEvent {
export interface ClientInfo {
clientID: ClientID;
username: string;
clanTag: string | null;
}
export enum LogSeverity {
Debug = "DEBUG",
@@ -279,11 +292,6 @@ export const ID = z.string().regex(GAME_ID_REGEX);
export const AllPlayersStatsSchema = z.record(ID, PlayerStatsSchema);
export const UsernameSchema = z
.string()
.regex(/^[a-zA-Z0-9_ [\]üÜ.]+$/u)
.min(3)
.max(27);
const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code);
export const QuickChatKeySchema = z.enum(
@@ -510,6 +518,7 @@ export const PlayerCosmeticsSchema = z.object({
export const PlayerSchema = z.object({
clientID: ID,
username: UsernameSchema,
clanTag: ClanTagSchema,
cosmetics: PlayerCosmeticsSchema.optional(),
isLobbyCreator: z.boolean().optional(),
});
@@ -630,6 +639,7 @@ export const ClientJoinMessageSchema = z.object({
token: TokenSchema, // WARNING: PII - server extracts persistentID from this
gameID: ID,
username: UsernameSchema,
clanTag: ClanTagSchema,
// Server replaces the refs with the actual cosmetic data.
cosmetics: PlayerCosmeticRefsSchema.optional(),
turnstileToken: z.string().nullable(),
@@ -659,7 +669,6 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [
export const PlayerRecordSchema = PlayerSchema.extend({
persistentID: PersistentIdSchema.nullable(), // WARNING: PII
clanTag: z.string().optional(),
stats: PlayerStatsSchema,
});
export type PlayerRecord = z.infer<typeof PlayerRecordSchema>;
+5 -17
View File
@@ -340,29 +340,17 @@ export function sigmoid(
return 1 / (1 + Math.exp(-decayRate * (value - midpoint)));
}
// Compute clan from name
export function getClanTag(name: string): string | null {
const clanTag = clanMatch(name);
return clanTag ? clanTag[1].toUpperCase() : null;
}
export function getClanTagOriginalCase(name: string): string | null {
const clanTag = clanMatch(name);
return clanTag ? clanTag[1] : null;
export function formatPlayerDisplayName(
username: string,
clanTag?: string | null,
): string {
return clanTag ? `[${clanTag}] ${username}` : username;
}
const CLAN_TAG_CHARS = "a-zA-Z0-9";
const CLAN_TAG_INVALID_CHARS = new RegExp(`[^${CLAN_TAG_CHARS}]`, "g");
const CLAN_TAG_REGEX = new RegExp(`\\[([${CLAN_TAG_CHARS}]{2,5})\\]`);
export function sanitizeClanTag(tag: string): string {
return tag.replace(CLAN_TAG_INVALID_CHARS, "").substring(0, 5).toUpperCase();
}
function clanMatch(name: string): RegExpMatchArray | null {
if (!name.includes("[") || !name.includes("]")) {
return null;
}
return name.match(CLAN_TAG_REGEX);
}
+13 -2
View File
@@ -136,6 +136,9 @@ export abstract class DefaultServerConfig implements ServerConfig {
}
}
/** SAM launcher construction duration in ticks (non-instant-build). */
export const SAM_CONSTRUCTION_TICKS = 30 * 10;
export class DefaultConfig implements Config {
private pastelTheme: PastelTheme = new PastelTheme();
private pastelThemeDark: PastelThemeDark = new PastelThemeDark();
@@ -430,7 +433,9 @@ export class DefaultConfig implements Config {
Math.min(3_000_000, (numUnits + 1) * 1_500_000),
UnitType.SAMLauncher,
),
constructionDuration: this.instantBuild() ? 0 : 30 * 10,
constructionDuration: this.instantBuild()
? 0
: SAM_CONSTRUCTION_TICKS,
upgradable: true,
};
break;
@@ -545,7 +550,13 @@ export class DefaultConfig implements Config {
return 3;
}
numSpawnPhaseTurns(): number {
return this._gameConfig.gameType === GameType.Singleplayer ? 100 : 300;
if (this._gameConfig.gameType === GameType.Singleplayer) {
return 100;
}
if (this.isRandomSpawn()) {
return 150;
}
return 300;
}
numBots(): number {
return this.bots();
+1 -1
View File
@@ -90,7 +90,7 @@ export class MirvExecution implements Execution {
this.mg.displayIncomingUnit(
this.nuke.id(),
// TODO TranslateText
`⚠️⚠️⚠️ ${this.player.name()} - MIRV INBOUND ⚠️⚠️⚠️`,
`⚠️⚠️⚠️ ${this.player.displayName()} - MIRV INBOUND ⚠️⚠️⚠️`,
MessageType.MIRV_INBOUND,
this.targetPlayer.id(),
);
+2 -2
View File
@@ -150,7 +150,7 @@ export class NukeExecution implements Execution {
this.mg.displayIncomingUnit(
this.nuke.id(),
// TODO TranslateText
`${this.player.name()} - atom bomb inbound`,
`${this.player.displayName()} - atom bomb inbound`,
MessageType.NUKE_INBOUND,
target.id(),
);
@@ -158,7 +158,7 @@ export class NukeExecution implements Execution {
this.mg.displayIncomingUnit(
this.nuke.id(),
// TODO TranslateText
`${this.player.name()} - hydrogen bomb inbound`,
`${this.player.displayName()} - hydrogen bomb inbound`,
MessageType.HYDROGEN_BOMB_INBOUND,
target.id(),
);
+23 -4
View File
@@ -97,8 +97,11 @@ export class PlayerExecution implements Execution {
}
}
if (ticks - this.lastCalc > this.ticksPerClusterCalc) {
if (this.player.lastTileChange() > this.lastCalc) {
if (
ticks - this.lastCalc > this.ticksPerClusterCalc ||
this.player.numTilesOwned() < 100
) {
if (this.player.lastTileChange() >= this.lastCalc) {
this.lastCalc = ticks;
const start = performance.now();
this.removeClusters();
@@ -157,6 +160,12 @@ export class PlayerExecution implements Execution {
clusterBox: { min: Cell; max: Cell },
): false | Player {
const enemies = new Set<number>();
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (const tile of cluster) {
let hasUnownedNeighbor = false;
if (this.mg.isOceanShore(tile) || this.mg.isOnEdgeOfMap(tile)) {
@@ -170,6 +179,12 @@ export class PlayerExecution implements Execution {
const ownerId = this.mg.ownerID(n);
if (ownerId !== this.player.smallID()) {
enemies.add(ownerId);
const px = this.mg.x(n);
const py = this.mg.y(n);
minX = Math.min(minX, px);
minY = Math.min(minY, py);
maxX = Math.max(maxX, px);
maxY = Math.max(maxY, py);
}
});
if (hasUnownedNeighbor) {
@@ -182,9 +197,13 @@ export class PlayerExecution implements Execution {
if (enemies.size !== 1) {
return false;
}
const enemy = this.mg.playerBySmallID(Array.from(enemies)[0]) as Player;
const enemyBox = calculateBoundingBox(this.mg, enemy.borderTiles());
if (inscribed(enemyBox, clusterBox)) {
const localEnemyBox = {
min: new Cell(minX, minY),
max: new Cell(maxX, maxY),
};
if (inscribed(localEnemyBox, clusterBox)) {
return enemy;
}
return false;
+4 -4
View File
@@ -2,7 +2,7 @@ import { Config } from "../configuration/Config";
import { AbstractGraph } from "../pathfinding/algorithms/AbstractGraph";
import { PathFinder } from "../pathfinding/types";
import { AllPlayersStats, ClientID } from "../Schemas";
import { getClanTag } from "../Util";
import { formatPlayerDisplayName } from "../Util";
import { GameMap, TileRef } from "./GameMap";
import {
GameUpdate,
@@ -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[];
+31 -24
View File
@@ -4,7 +4,7 @@ import { Config } from "../configuration/Config";
import { ColorPalette } from "../CosmeticSchemas";
import { PatternDecoder } from "../PatternDecoder";
import { ClientID, GameID, Player, PlayerCosmetics } from "../Schemas";
import { createRandomName } from "../Util";
import { createRandomName, formatPlayerDisplayName } from "../Util";
import { WorkerClient } from "../worker/WorkerClient";
import {
BuildableUnit,
@@ -482,7 +482,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 +659,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 +757,38 @@ export class GameView implements GameMap {
if (gu.updates === null) {
throw new Error("lastUpdate.updates not initialized");
}
const myDisplayName = formatPlayerDisplayName(
this._myUsername,
this._myClanTag,
);
gu.updates[GameUpdateType.Player].forEach((pu) => {
// Replace the local player's name/displayName with their own stored values.
// This way the user does not know they are being censored.
if (pu.clientID === this._myClientID) {
pu.name = this._myUsername;
pu.displayName = myDisplayName;
}
this.smallIDToID.set(pu.smallID, pu.id);
const player = this._players.get(pu.id);
let player = this._players.get(pu.id);
if (player !== undefined) {
player.data = pu;
player.nameData = gu.playerNameViewData[pu.id];
const nextNameData = gu.playerNameViewData[pu.id];
if (nextNameData !== undefined) {
player.nameData = nextNameData;
}
} else {
this._players.set(
pu.id,
new PlayerView(
this,
pu,
gu.playerNameViewData[pu.id],
// First check human by clientID, then check nation by name.
this._cosmetics.get(pu.clientID ?? "") ??
this._cosmetics.get(pu.name) ??
{},
),
player = new PlayerView(
this,
pu,
gu.playerNameViewData[pu.id],
// First check human by clientID, then check nation by name.
this._cosmetics.get(pu.clientID ?? "") ??
this._cosmetics.get(pu.name) ??
{},
);
this._players.set(pu.id, player);
}
});
+6 -15
View File
@@ -84,9 +84,6 @@ export class PlayerImpl implements Player {
public _units: Unit[] = [];
public _tiles: Set<TileRef> = new Set();
private _name: string;
private _displayName: string;
public pastOutgoingAllianceRequests: AllianceRequest[] = [];
private _expiredAlliances: Alliance[] = [];
@@ -115,10 +112,8 @@ export class PlayerImpl implements Player {
startTroops: number,
private readonly _team: Team | null,
) {
this._name = playerInfo.name;
this._troops = toInt(startTroops);
this._gold = mg.config().startingGold(playerInfo);
this._displayName = this._name;
this._pseudo_random = new PseudoRandom(simpleHash(this.playerInfo.id));
}
@@ -193,10 +188,10 @@ export class PlayerImpl implements Player {
}
name(): string {
return this._name;
return this.playerInfo.name;
}
displayName(): string {
return this._displayName;
return this.playerInfo.displayName;
}
clientID(): ClientID | null {
@@ -211,10 +206,6 @@ export class PlayerImpl implements Player {
return this.playerInfo.playerType;
}
clan(): string | null {
return this.playerInfo.clan;
}
units(...types: UnitType[]): Unit[] {
const len = types.length;
if (len === 0) {
@@ -760,14 +751,14 @@ export class PlayerImpl implements Player {
MessageType.SENT_TROOPS_TO_PLAYER,
this.id(),
undefined,
{ troops: renderTroops(troops), name: recipient.name() },
{ troops: renderTroops(troops), name: recipient.displayName() },
);
this.mg.displayMessage(
"events_display.received_troops_from_player",
MessageType.RECEIVED_TROOPS_FROM_PLAYER,
recipient.id(),
undefined,
{ troops: renderTroops(troops), name: this.name() },
{ troops: renderTroops(troops), name: this.displayName() },
);
return true;
}
@@ -784,14 +775,14 @@ export class PlayerImpl implements Player {
MessageType.SENT_GOLD_TO_PLAYER,
this.id(),
undefined,
{ gold: renderNumber(gold), name: recipient.name() },
{ gold: renderNumber(gold), name: recipient.displayName() },
);
this.mg.displayMessage(
"events_display.received_gold_from_player",
MessageType.RECEIVED_GOLD_FROM_PLAYER,
recipient.id(),
gold,
{ gold: renderNumber(gold), name: this.name() },
{ gold: renderNumber(gold), name: this.displayName() },
);
return true;
}
+8 -8
View File
@@ -16,24 +16,24 @@ export function assignTeams(
// Sort players into clan groups or no-clan list
for (const player of players) {
if (player.clan) {
if (!clanGroups.has(player.clan)) {
clanGroups.set(player.clan, []);
const clanTag = player.clanTag;
if (clanTag) {
if (!clanGroups.has(clanTag)) {
clanGroups.set(clanTag, []);
}
clanGroups.get(player.clan)!.push(player);
clanGroups.get(clanTag)!.push(player);
} else {
noClanPlayers.push(player);
}
}
// Sort clans by size (largest first)
const sortedClans = Array.from(clanGroups.entries()).sort(
(a, b) => b[1].length - a[1].length,
const sortedClanPlayers = Array.from(clanGroups.values()).sort(
(a, b) => b.length - a.length,
);
// First, assign clan players
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const [_, clanPlayers] of sortedClans) {
for (const clanPlayers of sortedClanPlayers) {
// Try to keep the clan together on the team with fewer players
let team: Team | null = null;
let teamSize = 0;
+28 -1
View File
@@ -1,8 +1,10 @@
import { translateText } from "../../client/Utils";
import { UsernameSchema } from "../Schemas";
import { ClanTagSchema, UsernameSchema } from "../Schemas";
export const MIN_USERNAME_LENGTH = 3;
export const MAX_USERNAME_LENGTH = 27;
export const MIN_CLAN_TAG_LENGTH = 2;
export const MAX_CLAN_TAG_LENGTH = 5;
export function validateUsername(username: string): {
isValid: boolean;
@@ -44,3 +46,28 @@ export function validateUsername(username: string): {
// All checks passed
return { isValid: true };
}
export function validateClanTag(clanTag: string): {
isValid: boolean;
error?: string;
} {
if (clanTag.length === 0) {
return { isValid: true };
}
if (clanTag.length < MIN_CLAN_TAG_LENGTH) {
return { isValid: false, error: translateText("username.tag_too_short") };
}
if (clanTag.length > MAX_CLAN_TAG_LENGTH) {
return { isValid: false, error: translateText("username.tag_too_short") };
}
const parsed = ClanTagSchema.safeParse(clanTag);
if (!parsed.success) {
return {
isValid: false,
error: translateText("username.tag_invalid_chars"),
};
}
return { isValid: true };
}
+1 -1
View File
@@ -18,7 +18,7 @@ export class Client {
public readonly flares: string[] | undefined,
public readonly ip: string,
public username: string,
public readonly uncensoredUsername: string,
public clanTag: string | null,
public ws: WebSocket,
public readonly cosmetics: PlayerCosmetics | undefined,
) {}
+22 -30
View File
@@ -3,18 +3,14 @@ import { ClientID } from "../core/Schemas";
const INTENTS_PER_SECOND = 10;
const INTENTS_PER_MINUTE = 150;
const MAX_BYTES_PER_MINUTE = 25 * 1024; // 25KB/min per client
const MAX_INTENT_BYTES = 500; // intents are stored in turns, keep them small
const MAX_INTENT_SIZE = 500;
const TOTAL_BYTES = 2 * 1024 * 1024; // 2MB per client
export type RateLimitResult = "ok" | "limit" | "kick";
// Allow 3 winner messages per client since a player can rejoin and resend.
const MAX_WINNER_MSGS = 3;
interface ClientBucket {
perSecond: RateLimiter;
perMinute: RateLimiter;
bytesPerMinute: RateLimiter;
winnerMsgCount: number;
totalBytes: number;
}
export class ClientMsgRateLimiter {
@@ -22,27 +18,27 @@ export class ClientMsgRateLimiter {
check(clientID: ClientID, type: string, bytes: number): RateLimitResult {
const bucket = this.getOrCreate(clientID);
bucket.totalBytes += bytes;
// Winner message contains stats for all players and can be large (100s of KB).
// It bypasses the byte rate limit but is strictly limited to one per client.
if (type === "winner") {
if (bucket.winnerMsgCount >= MAX_WINNER_MSGS) return "kick";
bucket.winnerMsgCount++;
return "ok";
if (bucket.totalBytes >= TOTAL_BYTES) return "kick";
if (type === "intent") {
// Intents are stored in turn history for the duration of the game, so
// oversized intents would accumulate and fill up server RAM.
// Intents are also sent to all players, so it increase outgoing
// data.
// Intents should never be larger than MAX_INTENT_SIZE, so we assume the client is malicious.
if (bytes > MAX_INTENT_SIZE) {
return "kick";
}
if (
!bucket.perSecond.tryRemoveTokens(1) ||
!bucket.perMinute.tryRemoveTokens(1)
) {
return "limit";
}
}
// Intents are stored in turn history for the duration of the game, so
// oversized intents would accumulate and fill up server RAM.
if (type === "intent" && bytes > MAX_INTENT_BYTES) return "kick";
if (!bucket.bytesPerMinute.tryRemoveTokens(bytes)) return "kick";
if (
!bucket.perSecond.tryRemoveTokens(1) ||
!bucket.perMinute.tryRemoveTokens(1)
)
return "limit";
return "ok";
}
@@ -60,11 +56,7 @@ export class ClientMsgRateLimiter {
tokensPerInterval: INTENTS_PER_MINUTE,
interval: "minute",
}),
bytesPerMinute: new RateLimiter({
tokensPerInterval: MAX_BYTES_PER_MINUTE,
interval: "minute",
}),
winnerMsgCount: 0,
totalBytes: 0,
};
this.buckets.set(clientID, bucket);
return bucket;
+2 -2
View File
@@ -46,11 +46,11 @@ export class GameManager {
persistentID: string,
gameID: GameID,
lastTurn: number = 0,
newUsername?: string,
identityUpdate?: { username: string; clanTag: string | null },
): boolean {
const game = this.games.get(gameID);
if (!game) return false;
return game.rejoinClient(ws, persistentID, lastTurn, newUsername);
return game.rejoinClient(ws, persistentID, lastTurn, identityUpdate);
}
createGame(
+11 -4
View File
@@ -1,10 +1,12 @@
import { z } from "zod";
import { GameInfo } from "../core/Schemas";
import { ClanTagSchema, GameInfo, UsernameSchema } from "../core/Schemas";
import { formatPlayerDisplayName } from "../core/Util";
import { GameMode } from "../core/game/Game";
export const PlayerInfoSchema = z.object({
clientID: z.string().optional(),
username: z.string().optional(),
username: UsernameSchema.optional(),
clanTag: ClanTagSchema,
stats: z.unknown().optional(),
});
@@ -85,7 +87,10 @@ function parseWinner(
if (!winnerArray || winnerArray.length < 2) return undefined;
const idToName = new Map(
(players ?? []).map((p) => [p.clientID, p.username]),
(players ?? []).map((p) => [
p.clientID,
p.username ? formatPlayerDisplayName(p.username, p.clanTag) : undefined,
]),
);
if (winnerArray[0] === "team" && winnerArray.length >= 3) {
@@ -228,7 +233,9 @@ export function buildPreview(
// Show host
const hostClient = lobby.clients?.[0];
if (hostClient?.username) {
sections.push(`Host: ${hostClient.username}`);
sections.push(
`Host: ${formatPlayerDisplayName(hostClient.username, hostClient.clanTag)}`,
);
}
const gameOptions: string[] = [];
+11 -12
View File
@@ -23,7 +23,7 @@ import {
StampedIntent,
Turn,
} from "../core/Schemas";
import { createPartialGameRecord, getClanTag } from "../core/Util";
import { createPartialGameRecord } from "../core/Util";
import { archive, finalizeGameRecord } from "./Archive";
import { Client } from "./Client";
import { ClientMsgRateLimiter } from "./ClientMsgRateLimiter";
@@ -266,15 +266,13 @@ export class GameServer {
}
// Attempt to reconnect a client by persistentID. Returns true if successful.
// Only the WebSocket is updated — username, cosmetics, etc. are preserved
// from the original join to maintain consistency throughout the game session.
// Exception: in the pre-game lobby, the username is updated so players can
// rename between leaving and rejoining.
// WebSocket is always updated. Optional identity updates are applied only
// before the game has started.
public rejoinClient(
ws: WebSocket,
persistentID: string,
lastTurn: number = 0,
newUsername?: string,
identityUpdate?: { username: string; clanTag: string | null },
): boolean {
const clientID = this.getClientIdForPersistentId(persistentID);
if (!clientID) return false;
@@ -294,14 +292,13 @@ export class GameServer {
(c) => c.clientID !== client.clientID,
);
this.activeClients.push(client);
if (identityUpdate && !this.hasStarted()) {
client.username = identityUpdate.username;
client.clanTag = identityUpdate.clanTag;
}
client.lastPing = Date.now();
this.markClientDisconnected(client.clientID, false);
// Allow username updates in the pre-game lobby
if (!this._hasStarted && newUsername !== undefined) {
client.username = newUsername;
}
client.ws = ws;
this.addListeners(client);
this.startLobbyInfoBroadcast();
@@ -662,6 +659,7 @@ export class GameServer {
config: this.gameConfig,
players: this.activeClients.map((c) => ({
username: c.username,
clanTag: c.clanTag ?? null,
clientID: c.clientID,
cosmetics: c.cosmetics,
isLobbyCreator: this.lobbyCreatorID === c.clientID,
@@ -873,6 +871,7 @@ export class GameServer {
gameID: this.id,
clients: this.activeClients.map((c) => ({
username: c.username,
clanTag: c.clanTag ?? null,
clientID: c.clientID,
})),
lobbyCreatorClientID: this.lobbyCreatorID,
@@ -983,11 +982,11 @@ export class GameServer {
return {
clientID: player.clientID,
username: player.username,
clanTag: player.clanTag,
persistentID:
this.allClients.get(player.clientID)?.persistentID ?? "",
stats,
cosmetics: player.cosmetics,
clanTag: getClanTag(player.username) ?? undefined,
} satisfies PlayerRecord;
},
);
+4 -3
View File
@@ -1,3 +1,4 @@
import { SAM_CONSTRUCTION_TICKS } from "../core/configuration/DefaultConfig";
import {
Difficulty,
Duos,
@@ -606,8 +607,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 +618,7 @@ export class MapPlaylist {
if (playerTeams === HumansVsNations) return 5 * 10;
if (startingGold !== undefined && startingGold >= 25_000_000)
return 150 * 10;
if (startingGold) return 30 * 10;
if (startingGold) return SAM_CONSTRUCTION_TICKS + 15 * 10;
return 5 * 10;
}
+32 -32
View File
@@ -18,7 +18,7 @@ import {
PlayerCosmetics,
PlayerPattern,
} from "../core/Schemas";
import { getClanTagOriginalCase, simpleHash } from "../core/Util";
import { simpleHash } from "../core/Util";
export const shadowNames = [
"UnhuggedToday",
@@ -72,7 +72,7 @@ export function createMatcher(bannedWords: string[]): RegExpMatcher {
}
/**
* Sanitizes and censors profane usernames and clan tags.
* Sanitizes and censors profane usernames and clan tags separately.
* Profane username is overwritten, profane clan tag is removed.
*
* Removing bad clan tags won't hurt existing clans nor cause desyncs:
@@ -80,36 +80,28 @@ export function createMatcher(bannedWords: string[]): RegExpMatcher {
* - only each separate local player name with a profane clan tag will remain, no clan team assignment
*
* Examples:
* - "GoodName" -> "GoodName"
* - "BadName" -> "Censored"
* - "[CLAN]GoodName" -> "[CLAN]GoodName"
* - "[CLaN]BadName" -> "[CLAN] Censored"
* - "[BAD]GoodName" -> "GoodName"
* - "[BAD]BadName" -> "Censored"
* - username="GoodName", clanTag=null -> { username: "GoodName", clanTag: null }
* - username="BadName", clanTag=null -> { username: "Censored", clanTag: null }
* - username="GoodName", clanTag="CLaN" -> { username: "GoodName", clanTag: "CLAN" }
* - username="GoodName", clanTag="BAD" -> { username: "GoodName", clanTag: null }
* - username="BadName", clanTag="BAD" -> { username: "Censored", clanTag: null }
*/
function censorUsernameWithMatcher(
username: string,
matcher: RegExpMatcher,
): string {
const clanTag = getClanTagOriginalCase(username);
const nameWithoutClan = clanTag
? username.replace(`[${clanTag}]`, "").trim()
function censorWithMatcher(
username: string,
clanTag: string | null,
matcher: RegExpMatcher,
): { username: string; clanTag: string | null } {
const usernameIsProfane = matcher.hasMatch(username);
const censoredName = usernameIsProfane
? shadowNames[simpleHash(username) % shadowNames.length]
: username;
const clanTagIsProfane = clanTag ? matcher.hasMatch(clanTag) : false;
const usernameIsProfane = matcher.hasMatch(nameWithoutClan);
const censoredClanTag =
clanTag && !clanTagIsProfane ? clanTag.toUpperCase() : null;
const censoredName = usernameIsProfane
? shadowNames[simpleHash(nameWithoutClan) % shadowNames.length]
: nameWithoutClan;
// Restore clan tag only if it's clean, otherwise remove it entirely
if (clanTag && !clanTagIsProfane) {
return `[${clanTag.toUpperCase()}] ${censoredName}`;
}
return censoredName;
return { username: censoredName, clanTag: censoredClanTag };
}
type CosmeticResult =
@@ -118,7 +110,10 @@ type CosmeticResult =
export interface PrivilegeChecker {
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult;
censorUsername(username: string): string;
censor(
username: string,
clanTag: string | null,
): { username: string; clanTag: string | null };
}
export class PrivilegeCheckerImpl implements PrivilegeChecker {
@@ -217,8 +212,11 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
return { color };
}
censorUsername(username: string): string {
return censorUsernameWithMatcher(username, this.matcher);
censor(
username: string,
clanTag: string | null,
): { username: string; clanTag: string | null } {
return censorWithMatcher(username, clanTag, this.matcher);
}
}
@@ -230,8 +228,10 @@ export class FailOpenPrivilegeChecker implements PrivilegeChecker {
return { type: "allowed", cosmetics: {} };
}
censorUsername(username: string): string {
// Fail open: use matcher with just the built-in English profanity dataset
return censorUsernameWithMatcher(username, defaultMatcher);
censor(
username: string,
clanTag: string | null,
): { username: string; clanTag: string | null } {
return censorWithMatcher(username, clanTag, defaultMatcher);
}
}
+39 -14
View File
@@ -3,6 +3,7 @@ import express, { NextFunction, Request, Response } from "express";
import rateLimit from "express-rate-limit";
import http from "http";
import ipAnonymize from "ip-anonymize";
import { RateLimiter } from "limiter";
import path from "path";
import { fileURLToPath } from "url";
import { WebSocket, WebSocketServer } from "ws";
@@ -50,7 +51,7 @@ export async function startWorker() {
const server = http.createServer(app);
const wss = new WebSocketServer({
noServer: true,
maxPayload: 2 * 1024 * 1024,
maxPayload: 1024 * 1024, // 1MB
});
const gm = new GameManager(config, log);
@@ -289,6 +290,11 @@ export async function startWorker() {
: // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
forwarded || req.socket.remoteAddress || "unknown";
if (!getWsIpLimiter(ip).tryRemoveTokens(1)) {
ws.close(1008, "Rate limit exceeded");
return;
}
try {
// Parse and handle client messages
const parsed = ClientMessageSchema.safeParse(
@@ -358,20 +364,21 @@ export async function startWorker() {
return;
}
// Normalize username and clan tag before any rejoin/join handling.
// If this connection maps to an existing lobby client, we still want
// the latest pre-join identity to be reflected.
const { clanTag: censoredClanTag, username: censoredUsername } =
privilegeRefresher
.get()
.censor(clientMsg.username, clientMsg.clanTag ?? null);
// Try to reconnect an existing client (e.g., page refresh)
// If successful, skip all authorization (but pass updated username
// so players can rename in the pre-game lobby)
const censoredUsername = privilegeRefresher
.get()
.censorUsername(clientMsg.username);
// If successful, skip all authorization
if (
gm.rejoinClient(
ws,
persistentId,
clientMsg.gameID,
0,
censoredUsername,
)
gm.rejoinClient(ws, persistentId, clientMsg.gameID, 0, {
username: censoredUsername,
clanTag: censoredClanTag,
})
) {
return;
}
@@ -463,7 +470,7 @@ export async function startWorker() {
flares,
ip,
censoredUsername,
clientMsg.username,
censoredClanTag,
ws,
cosmeticResult.cosmetics,
);
@@ -609,3 +616,21 @@ function generateGameIdForWorker(): GameID | null {
log.warn(`Failed to generate game ID for worker ${workerId}`);
return null;
}
// Per-IP rate limiter for pre-join WebSocket messages.
// Prevents unauthenticated connections from spamming messages
// (e.g. pings) before joining a game.
const wsIpLimiters = new Map<string, RateLimiter>();
function getWsIpLimiter(ip: string): RateLimiter {
let limiter = wsIpLimiters.get(ip);
if (!limiter) {
limiter = new RateLimiter({
tokensPerInterval: 5,
interval: "second",
});
wsIpLimiters.set(ip, limiter);
}
return limiter;
}
// Clean up stale IP limiters every 10 minutes
setInterval(() => wsIpLimiters.clear(), 10 * 60 * 1000);
+32
View File
@@ -5,7 +5,9 @@ vi.mock("../src/client/Utils", () => ({
}));
import {
MAX_CLAN_TAG_LENGTH,
MAX_USERNAME_LENGTH,
validateClanTag,
validateUsername,
} from "../src/core/validations/username";
@@ -39,4 +41,34 @@ describe("username.ts functions", () => {
expect(res.isValid).toBe(true);
});
});
describe("validateClanTag", () => {
test("accepts empty clan tag", () => {
const res = validateClanTag("");
expect(res.isValid).toBe(true);
});
test("rejects too short clan tag", () => {
const res = validateClanTag("A");
expect(res.isValid).toBe(false);
expect(res.error).toBe("username.tag_too_short");
});
test("rejects invalid clan tag characters", () => {
const res = validateClanTag("A!");
expect(res.isValid).toBe(false);
expect(res.error).toBe("username.tag_invalid_chars");
});
test("rejects too long clan tag", () => {
const res = validateClanTag("A".repeat(MAX_CLAN_TAG_LENGTH + 1));
expect(res.isValid).toBe(false);
expect(res.error).toBe("username.tag_too_short");
});
test("accepts valid clan tag", () => {
const res = validateClanTag("AB12");
expect(res.isValid).toBe(true);
});
});
});
+6 -2
View File
@@ -179,16 +179,20 @@ describe("Disconnected", () => {
beforeEach(async () => {
const player1Info = new PlayerInfo(
"[CLAN]Player1",
"Player1",
PlayerType.Human,
null,
"player_1_id",
false,
"CLAN",
);
const player2Info = new PlayerInfo(
"[CLAN]Player2",
"Player2",
PlayerType.Human,
null,
"player_2_id",
false,
"CLAN",
);
game = await setup(
+3 -1
View File
@@ -51,7 +51,7 @@ describe("Ranking class", () => {
players: [
{
clientID: "p1",
username: "[X] Alice",
username: "Alice",
clanTag: "X",
cosmetics: { flag: "USA" },
stats: {
@@ -69,6 +69,7 @@ describe("Ranking class", () => {
{
clientID: "p2",
username: "Bob",
clanTag: null,
stats: {
units: { city: [2n, 0n, 0n, 2n] },
conquests: [8n],
@@ -84,6 +85,7 @@ describe("Ranking class", () => {
{
clientID: "p3",
username: "Charlie",
clanTag: null,
stats: {
// no units, but has conquests/killedAt to count as played
conquests: [8n],
+12 -4
View File
@@ -141,28 +141,36 @@ describe("Counter Warship Infestation", () => {
test("rich nation sends counter-warship in Team game when enemy team has too many warships", async () => {
// Create players with team setup - use clan tags to group players
const nationInfo = new PlayerInfo(
"[ALPHA]defender_nation",
"defender_nation",
PlayerType.Nation,
null,
"nation_id",
false,
"ALPHA",
);
const allyInfo = new PlayerInfo(
"[ALPHA]ally_player",
"ally_player",
PlayerType.Human,
null,
"ally_id",
false,
"ALPHA",
);
const enemy1Info = new PlayerInfo(
"[BETA]enemy_player_1",
"enemy_player_1",
PlayerType.Human,
null,
"enemy1_id",
false,
"BETA",
);
const enemy2Info = new PlayerInfo(
"[BETA]enemy_player_2",
"enemy_player_2",
PlayerType.Human,
null,
"enemy2_id",
false,
"BETA",
);
const game = await setup(
+6 -2
View File
@@ -602,16 +602,20 @@ describe("Nation MIRV Retaliation", () => {
test("nation launches MIRV to prevent team victory when team approaches victory denial threshold (targets biggest team member)", async () => {
// Setup game
const teamPlayer1Info = new PlayerInfo(
"[ALPHA]team_player_1",
"team_player_1",
PlayerType.Human,
null,
"team1_id",
false,
"ALPHA",
);
const teamPlayer2Info = new PlayerInfo(
"[ALPHA]team_player_2",
"team_player_2",
PlayerType.Human,
null,
"team2_id",
false,
"ALPHA",
);
const nationInfo = new PlayerInfo(
"defender_nation",
+59 -175
View File
@@ -1,215 +1,99 @@
import { PlayerInfo, PlayerType } from "../src/core/game/Game";
describe("PlayerInfo", () => {
describe("clan", () => {
test("should extract clan from name when format contains [XX]", () => {
describe("clanTag from explicit clanTag parameter", () => {
test("should set clanTag from clanTag parameter", () => {
const playerInfo = new PlayerInfo(
"[CL]PlayerName",
"PlayerName",
PlayerType.Human,
null,
"player_id",
false,
"abc",
);
expect(playerInfo.clan).toBe("CL");
expect(playerInfo.clanTag).toBe("abc");
});
test("should extract clan from name when format contains [XXX]", () => {
test("should preserve already-uppercase clan tag", () => {
const playerInfo = new PlayerInfo(
"[ABC]PlayerName",
"PlayerName",
PlayerType.Human,
null,
"player_id",
false,
"CLAN",
);
expect(playerInfo.clan).toBe("ABC");
expect(playerInfo.clanTag).toBe("CLAN");
});
test("should extract clan from name when format contains [XXXX]", () => {
const playerInfo = new PlayerInfo(
"[ABCD]PlayerName",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBe("ABCD");
});
test("should extract clan from name when format contains [XXXXX]", () => {
const playerInfo = new PlayerInfo(
"[ABCDE]PlayerName",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBe("ABCDE");
});
test("should extract uppercase clan from name when format contains [xxxxx]", () => {
const playerInfo = new PlayerInfo(
"[abcde]PlayerName",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBe("ABCDE");
});
test("should extract uppercase clan from name when format contains [XxXxX]", () => {
const playerInfo = new PlayerInfo(
"[AbCdE]PlayerName",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBe("ABCDE");
});
test("should extract uppercase clan from name when format contains [Xx#xX]", () => {
const playerInfo = new PlayerInfo(
"[Ab1cD]PlayerName",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBe("AB1CD");
});
test("should return null when name doesn't contain [", () => {
test("should set clan to null when clanTag is not provided", () => {
const playerInfo = new PlayerInfo(
"PlayerName",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBeNull();
expect(playerInfo.clanTag).toBeNull();
});
test("should return null when name doesn't contain ]", () => {
test("should set clan to null when clanTag is null", () => {
const playerInfo = new PlayerInfo(
"[ABCPlayerName",
"PlayerName",
PlayerType.Human,
null,
"player_id",
false,
null,
);
expect(playerInfo.clanTag).toBeNull();
});
test("should set clan to null when clanTag is undefined", () => {
const playerInfo = new PlayerInfo(
"PlayerName",
PlayerType.Human,
null,
"player_id",
false,
undefined,
);
expect(playerInfo.clanTag).toBeNull();
});
});
describe("displayName", () => {
test("should construct display name with clan tag", () => {
const playerInfo = new PlayerInfo(
"PlayerName",
PlayerType.Human,
null,
"player_id",
false,
"CLAN",
);
expect(playerInfo.displayName).toBe("[CLAN] PlayerName");
});
test("should return just name when no clan tag", () => {
const playerInfo = new PlayerInfo(
"PlayerName",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBeNull();
expect(playerInfo.displayName).toBe("PlayerName");
});
test("should return null when clan tag is not 2-5 alphanumeric letters", () => {
test("should preserve clan tag casing in display name", () => {
const playerInfo = new PlayerInfo(
"[A]PlayerName",
"PlayerName",
PlayerType.Human,
null,
"player_id",
false,
"abc",
);
expect(playerInfo.clan).toBeNull();
});
test("should return null when clan tag contains non alphanumeric characters", () => {
const playerInfo = new PlayerInfo(
"[A?c]PlayerName",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBeNull();
});
test("should return null when clan tag is too long", () => {
const playerInfo = new PlayerInfo(
"[ABCDEF]PlayerName",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBeNull();
});
test("should extract uppercase clan name from any location in the player name", () => {
const playerInfo = new PlayerInfo(
"Player[aa]Name",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBe("AA");
});
test("should extract only the first occurrence of a clan name match", () => {
const playerInfo = new PlayerInfo(
"[Ab1cD]Player[aa]Name",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBe("AB1CD");
});
test("should extract only the first occurrence of a valid clan name match and extract as uppercase", () => {
const playerInfo = new PlayerInfo(
"[Ab1cDEF]Player[aa]Name",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBe("AA");
});
test("should extract numeric-only clan names", () => {
const playerInfo = new PlayerInfo(
"[012]PlayerName",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBe("012");
});
test("should extract numeric-only clan names and only the first valid clan name", () => {
const playerInfo = new PlayerInfo(
"[012]Player[aa]Name",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBe("012");
});
test("should extract numeric-only clan names from anywhere within the name", () => {
const playerInfo = new PlayerInfo(
"Player[012]Name",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBe("012");
});
test("should extract numeric-only clan names from the end of the name", () => {
const playerInfo = new PlayerInfo(
"PlayerName[012]",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBe("012");
});
test("should extract uppercase alphanumeric clan names from anywhere within the name", () => {
const playerInfo = new PlayerInfo(
"Player[0a1B2]Name",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBe("0A1B2");
});
test("should extract uppercase alphanumeric clan names from the end of the name", () => {
const playerInfo = new PlayerInfo(
"PlayerName[0a1B2]",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBe("0A1B2");
expect(playerInfo.displayName).toBe("[abc] PlayerName");
});
});
});
+40 -31
View File
@@ -17,7 +17,7 @@ const bannedWords = [
const matcher = createMatcher(bannedWords);
// Create a minimal PrivilegeCheckerImpl for testing censorUsername
// Create a minimal PrivilegeCheckerImpl for testing censor
const mockCosmetics = { patterns: {}, colorPalettes: {} };
const mockDecoder = () => new Uint8Array();
const checker = new PrivilegeCheckerImpl(
@@ -75,73 +75,82 @@ describe("UsernameCensor", () => {
});
});
describe("censorUsername", () => {
describe("censor", () => {
test("returns clean usernames unchanged", () => {
expect(checker.censorUsername("CoolPlayer")).toBe("CoolPlayer");
expect(checker.censorUsername("GameMaster")).toBe("GameMaster");
expect(checker.censor("CoolPlayer", null).username).toBe("CoolPlayer");
expect(checker.censor("GameMaster", null).username).toBe("GameMaster");
});
test("replaces profane usernames with a shadow name", () => {
const result = checker.censorUsername("hitler");
expect(shadowNames).toContain(result);
const result = checker.censor("hitler", null);
expect(shadowNames).toContain(result.username);
});
test("replaces leet speak profane usernames with a shadow name", () => {
const result = checker.censorUsername("h1tl3r");
expect(shadowNames).toContain(result);
const result = checker.censor("h1tl3r", null);
expect(shadowNames).toContain(result.username);
});
test("preserves clean clan tag when username is profane", () => {
const result = checker.censorUsername("[COOL]hitler");
expect(result).toMatch(/^\[COOL\] /);
const nameAfterTag = result.replace("[COOL] ", "");
expect(shadowNames).toContain(nameAfterTag);
const result = checker.censor("hitler", "COOL");
expect(result.clanTag).toBe("COOL");
expect(shadowNames).toContain(result.username);
});
test("removes profane clan tag but keeps clean username", () => {
expect(checker.censorUsername("[NAZI]CoolPlayer")).toBe("CoolPlayer");
const result = checker.censor("CoolPlayer", "NAZI");
expect(result.username).toBe("CoolPlayer");
expect(result.clanTag).toBeNull();
});
test("removes clan tag with leet speak profanity", () => {
expect(checker.censorUsername("[N4Z1]CoolPlayer")).toBe("CoolPlayer");
const result = checker.censor("CoolPlayer", "N4Z1");
expect(result.username).toBe("CoolPlayer");
expect(result.clanTag).toBeNull();
});
test("removes clan tag with uppercased banned word", () => {
expect(checker.censorUsername("[ADOLF]CoolPlayer")).toBe("CoolPlayer");
const result = checker.censor("CoolPlayer", "ADOLF");
expect(result.username).toBe("CoolPlayer");
expect(result.clanTag).toBeNull();
});
test("removes clan tag containing banned word substring", () => {
expect(checker.censorUsername("[JEWS]CoolPlayer")).toBe("CoolPlayer");
const result = checker.censor("CoolPlayer", "JEWS");
expect(result.username).toBe("CoolPlayer");
expect(result.clanTag).toBeNull();
});
test("removes profane clan tag and censors profane username", () => {
const result = checker.censorUsername("[NAZI]hitler");
// No clan tag prefix, just a shadow name
expect(shadowNames).toContain(result);
const result = checker.censor("hitler", "NAZI");
expect(result.clanTag).toBeNull();
expect(shadowNames).toContain(result.username);
});
test("removes leet speak profane clan tag and censors leet speak username", () => {
const result = checker.censorUsername("[N4Z1]h1tl3r");
// No clan tag prefix, just a shadow name
expect(shadowNames).toContain(result);
const result = checker.censor("h1tl3r", "N4Z1");
expect(result.clanTag).toBeNull();
expect(shadowNames).toContain(result.username);
});
test("returns deterministic shadow name for same input", () => {
const a = checker.censorUsername("hitler");
const b = checker.censorUsername("hitler");
expect(a).toBe(b);
const a = checker.censor("hitler", null);
const b = checker.censor("hitler", null);
expect(a.username).toBe(b.username);
});
test("handles username with no clan tag", () => {
expect(checker.censorUsername("NormalPlayer")).toBe("NormalPlayer");
expect(checker.censor("NormalPlayer", null).username).toBe(
"NormalPlayer",
);
});
test("empty banned words list still catches englishDataset profanity", () => {
// The emptyChecker still uses englishDataset, so common profanity is caught
expect(emptyChecker.censorUsername("CoolPlayer")).toBe("CoolPlayer");
// Verify a known english profanity gets censored even without custom banned words
const result = emptyChecker.censorUsername("fuck");
expect(shadowNames).toContain(result);
expect(emptyChecker.censor("CoolPlayer", null).username).toBe(
"CoolPlayer",
);
const result = emptyChecker.censor("fuck", null);
expect(shadowNames).toContain(result.username);
});
});
});
+3 -2
View File
@@ -8,12 +8,13 @@ const teams = [ColoredTeams.Red, ColoredTeams.Blue];
describe("assignTeams", () => {
const createPlayer = (id: string, clan?: string): PlayerInfo => {
const name = clan ? `[${clan}]Player ${id}` : `Player ${id}`;
return new PlayerInfo(
name,
`Player ${id}`,
PlayerType.Human,
null, // clientID (null for testing)
id,
false,
clan,
);
};
+74
View File
@@ -0,0 +1,74 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { JoinLobbyModal } from "../../src/client/JoinLobbyModal";
describe("JoinLobbyModal server time offset", () => {
let nowMs = 0;
beforeEach(() => {
vi.spyOn(Date, "now").mockImplementation(() => nowMs);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("updates serverTimeOffset from lobby serverTime", () => {
const modal = new JoinLobbyModal();
(modal as any).syncCountdownTimer = vi.fn();
nowMs = 220_000;
(modal as any).updateFromLobby({
gameID: "g1",
serverTime: 200_000,
startsAt: 230_000,
clients: [],
});
expect((modal as any).serverTimeOffset).toBe(-20_000);
expect((modal as any).lobbyStartAt).toBe(230_000);
});
it("does not trigger join timeout early when local clock is ahead", () => {
const modal = new JoinLobbyModal();
const closeSpy = vi
.spyOn(modal, "closeAndLeave")
.mockImplementation(() => undefined);
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
(modal as any).isModalOpen = true;
(modal as any).isConnecting = true;
(modal as any).handledJoinTimeout = false;
// Local clock is +60s ahead of server clock.
nowMs = 160_000;
(modal as any).lobbyStartAt = 105_000;
(modal as any).serverTimeOffset = -60_000;
(modal as any).checkForJoinTimeout();
expect(closeSpy).not.toHaveBeenCalled();
expect(dispatchSpy).not.toHaveBeenCalled();
expect((modal as any).handledJoinTimeout).toBe(false);
});
it("triggers join timeout once adjusted server time reaches lobbyStartAt", () => {
const modal = new JoinLobbyModal();
const closeSpy = vi
.spyOn(modal, "closeAndLeave")
.mockImplementation(() => undefined);
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
(modal as any).isModalOpen = true;
(modal as any).isConnecting = true;
(modal as any).handledJoinTimeout = false;
(modal as any).lobbyStartAt = 105_000;
(modal as any).serverTimeOffset = -60_000;
nowMs = 165_000;
(modal as any).checkForJoinTimeout();
expect(closeSpy).toHaveBeenCalledTimes(1);
expect(dispatchSpy).toHaveBeenCalledTimes(1);
expect((modal as any).handledJoinTimeout).toBe(true);
});
});
@@ -53,6 +53,7 @@ describe("PlayerPanel - kick player moderation", () => {
const other = {
id: () => 2,
name: () => "Other",
displayName: () => "[TAG] Other",
type: () => PlayerType.Human,
clientID: () => "client-2",
} as unknown as PlayerView;
@@ -84,6 +85,7 @@ describe("PlayerPanel - kick player moderation", () => {
const other = {
id: () => 2,
name: () => "Other",
displayName: () => "[TAG] Other",
type: () => PlayerType.Human,
clientID: () => "client-2",
} as unknown as PlayerView;
@@ -119,6 +121,7 @@ describe("PlayerModerationModal - kick confirmation", () => {
const other = {
id: () => 2,
name: () => "Other",
displayName: () => "[TAG] Other",
type: () => PlayerType.Human,
clientID: () => "client-2",
} as unknown as PlayerView;
@@ -151,6 +154,7 @@ describe("PlayerModerationModal - kick confirmation", () => {
const other = {
id: () => 2,
name: () => "Other",
displayName: () => "[TAG] Other",
type: () => PlayerType.Human,
clientID: () => "client-2",
} as unknown as PlayerView;
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, test } from "vitest";
import { PlayerExecution } from "../../../src/core/execution/PlayerExecution";
import {
Game,
Player,
PlayerInfo,
PlayerType,
} from "../../../src/core/game/Game";
import { setup } from "../../util/Setup";
import { executeTicks } from "../../util/utils";
let game: Game;
let largePlayer: Player;
let smallPlayer: Player;
describe("PlayerExecution Annexation Bug", () => {
beforeEach(async () => {
game = await setup(
"big_plains",
{
infiniteGold: true,
instantBuild: true,
},
[
new PlayerInfo("large", PlayerType.Human, "client1", "large_id"),
new PlayerInfo("small", PlayerType.Human, "client2", "small_id"),
],
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
largePlayer = game.player("large_id");
smallPlayer = game.player("small_id");
game.addExecution(new PlayerExecution(largePlayer));
game.addExecution(new PlayerExecution(smallPlayer));
});
test("A large player is not reverse-annexed by surrounded smaller player", () => {
// Cluster A
smallPlayer.conquer(game.ref(50, 50));
smallPlayer.conquer(game.ref(50, 51));
smallPlayer.conquer(game.ref(51, 50));
smallPlayer.conquer(game.ref(51, 51));
// Cluster B
smallPlayer.conquer(game.ref(10, 10));
smallPlayer.conquer(game.ref(90, 90));
// Larger player gets the rest
game.map().forEachTile((tile) => {
if (game.ownerID(tile) !== smallPlayer.smallID()) {
largePlayer.conquer(tile);
}
});
const initialLargeTiles = largePlayer.numTilesOwned();
expect(largePlayer.numTilesOwned()).toBe(initialLargeTiles);
expect(smallPlayer.numTilesOwned()).toBeGreaterThan(0);
// Keep ticksPerClusterCalc and lastTileChange in mind
executeTicks(game, 20);
largePlayer.conquer(game.ref(49, 49));
smallPlayer.conquer(game.ref(50, 50));
// Annexation happens here
executeTicks(game, 50);
expect(largePlayer.numTilesOwned()).toBeGreaterThan(initialLargeTiles);
expect(smallPlayer.numTilesOwned()).toBe(0);
});
});
+31 -25
View File
@@ -5,7 +5,6 @@ const CLIENT_A = "clientA" as any;
const CLIENT_B = "clientB" as any;
const SMALL = 100;
const LARGE = 501; // over MAX_INTENT_BYTES
describe("ClientMsgRateLimiter", () => {
describe("intent messages", () => {
@@ -14,11 +13,6 @@ describe("ClientMsgRateLimiter", () => {
expect(limiter.check(CLIENT_A, "intent", SMALL)).toBe("ok");
});
it("kicks on oversized intent", () => {
const limiter = new ClientMsgRateLimiter();
expect(limiter.check(CLIENT_A, "intent", LARGE)).toBe("kick");
});
it("limits when per-second count exceeded", () => {
const limiter = new ClientMsgRateLimiter();
for (let i = 0; i < 10; i++) {
@@ -36,34 +30,46 @@ describe("ClientMsgRateLimiter", () => {
});
});
describe("winner messages", () => {
it("allows first winner message", () => {
describe("non-intent messages", () => {
it("does not rate-limit non-intent messages", () => {
const limiter = new ClientMsgRateLimiter();
expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("ok");
for (let i = 0; i < 20; i++) {
expect(limiter.check(CLIENT_A, "winner", 50)).toBe("ok");
}
});
it("allows up to 3 winner messages", () => {
it("does not rate-limit ping messages", () => {
const limiter = new ClientMsgRateLimiter();
expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("ok");
expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("ok");
expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("ok");
expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("kick");
});
it("winner does not consume intent rate limit", () => {
const limiter = new ClientMsgRateLimiter();
limiter.check(CLIENT_A, "winner", 50000);
expect(limiter.check(CLIENT_A, "intent", SMALL)).toBe("ok");
for (let i = 0; i < 20; i++) {
expect(limiter.check(CLIENT_A, "ping", 50)).toBe("ok");
}
});
});
describe("other messages", () => {
it("applies rate limiting to other message types", () => {
describe("total bytes limit", () => {
it("kicks when cumulative bytes reach 2MB", () => {
const limiter = new ClientMsgRateLimiter();
for (let i = 0; i < 10; i++) {
expect(limiter.check(CLIENT_A, "ping", 50)).toBe("ok");
const chunkSize = 512 * 1024; // 512KB
// Send 3 chunks = 1.5MB, should be ok
for (let i = 0; i < 3; i++) {
expect(limiter.check(CLIENT_A, "other", chunkSize)).toBe("ok");
}
expect(limiter.check(CLIENT_A, "ping", 50)).toBe("limit");
// 4th chunk pushes to 2MB, should kick
expect(limiter.check(CLIENT_A, "other", chunkSize)).toBe("kick");
});
it("byte tracking is per client", () => {
const limiter = new ClientMsgRateLimiter();
const almostFull = 2 * 1024 * 1024 - 1;
expect(limiter.check(CLIENT_A, "other", almostFull)).toBe("ok");
// CLIENT_B should still be fine
expect(limiter.check(CLIENT_B, "other", 100)).toBe("ok");
});
it("kicks on bytes regardless of message type", () => {
const limiter = new ClientMsgRateLimiter();
const twoMB = 2 * 1024 * 1024;
expect(limiter.check(CLIENT_A, "intent", twoMB)).toBe("kick");
});
});
});