Merge branch 'main' into canbuildtransport-perf

This commit is contained in:
VariableVince
2025-12-10 21:08:31 +01:00
committed by GitHub
84 changed files with 2097 additions and 368 deletions
+36 -20
View File
@@ -62,6 +62,7 @@ export interface LobbyConfig {
clientID: ClientID;
gameID: GameID;
token: string;
turnstileToken: string | null;
// GameStartInfo only exists when playing a singleplayer game.
gameStartInfo?: GameStartInfo;
// GameRecord exists when replaying an archived game.
@@ -83,9 +84,17 @@ export function joinLobby(
const transport = new Transport(lobbyConfig, eventBus);
let hasJoined = false;
const onconnect = () => {
console.log(`Joined game lobby ${lobbyConfig.gameID}`);
transport.joinGame(0);
if (hasJoined) {
console.log("rejoining game");
transport.rejoinGame(0);
} else {
hasJoined = true;
console.log(`Joining game lobby ${lobbyConfig.gameID}`);
transport.joinGame();
}
};
let terrainLoad: Promise<TerrainMapData> | null = null;
@@ -120,15 +129,25 @@ export function joinLobby(
).then((r) => r.start());
}
if (message.type === "error") {
showErrorModal(
message.error,
message.message,
lobbyConfig.gameID,
lobbyConfig.clientID,
true,
false,
"error_modal.connection_error",
);
if (message.error === "full-lobby") {
document.dispatchEvent(
new CustomEvent("leave-lobby", {
detail: { lobby: lobbyConfig.gameID },
bubbles: true,
composed: true,
}),
);
} else {
showErrorModal(
message.error,
message.message,
lobbyConfig.gameID,
lobbyConfig.clientID,
true,
false,
"error_modal.connection_error",
);
}
}
};
transport.connect(onconnect, onmessage);
@@ -202,7 +221,6 @@ export class ClientGameRunner {
private isActive = false;
private turnsSeen = 0;
private hasJoined = false;
private lastMousePosition: { x: number; y: number } | null = null;
private lastMessageTime: number = 0;
@@ -326,13 +344,12 @@ export class ClientGameRunner {
const onconnect = () => {
console.log("Connected to game server!");
this.transport.joinGame(this.turnsSeen);
this.transport.rejoinGame(this.turnsSeen);
};
const onmessage = (message: ServerMessage) => {
this.lastMessageTime = Date.now();
if (message.type === "start") {
this.hasJoined = true;
console.log("starting game!");
console.log("starting game! in client game runner");
if (this.gameView.config().isRandomSpawn()) {
const goToPlayer = () => {
@@ -407,10 +424,6 @@ export class ClientGameRunner {
);
}
if (message.type === "turn") {
if (!this.hasJoined) {
this.transport.joinGame(0);
return;
}
// Track when we receive the turn to calculate delay
const now = Date.now();
if (this.lastTickReceiveTime > 0) {
@@ -429,7 +442,10 @@ export class ClientGameRunner {
}
}
};
this.transport.connect(onconnect, onmessage);
this.transport.updateCallback(onconnect, onmessage);
console.log("sending join game");
// Rejoin game from the start so we don't miss any turns.
this.transport.rejoinGame(0);
}
public stop() {
+14
View File
@@ -8,6 +8,19 @@ export class GameStartingModal extends LitElement {
isVisible = false;
static styles = css`
.overlay {
display: none;
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
z-index: 9998;
}
.overlay.visible {
display: block;
}
.modal {
display: none;
position: fixed;
@@ -117,6 +130,7 @@ export class GameStartingModal extends LitElement {
render() {
return html`
<div class="overlay ${this.isVisible ? "visible" : ""}"></div>
<div class="modal ${this.isVisible ? "visible" : ""}">
<div class="copyright">© OpenFront and Contributors</div>
<a
+26 -5
View File
@@ -28,8 +28,8 @@ import "./components/Difficulties";
import "./components/LobbyTeamView";
import "./components/Maps";
import { JoinLobbyEvent } from "./Main";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions";
@customElement("host-lobby-modal")
export class HostLobbyModal extends LitElement {
@query("o-modal") private modalEl!: HTMLElement & {
@@ -58,11 +58,13 @@ export class HostLobbyModal extends LitElement {
@state() private disabledUnits: UnitType[] = [];
@state() private lobbyCreatorClientID: string = "";
@state() private lobbyIdVisible: boolean = true;
@state() private nationCount: number = 0;
private playersInterval: NodeJS.Timeout | null = null;
// Add a new timer for debouncing bot changes
private botsUpdateTimer: number | null = null;
private userSettings: UserSettings = new UserSettings();
private mapLoader = terrainMapFileLoader;
connectedCallback() {
super.connectedCallback();
@@ -553,6 +555,13 @@ export class HostLobbyModal extends LitElement {
? translateText("host_modal.player")
: translateText("host_modal.players")
}
<span style="margin: 0 8px;">•</span>
${this.nationCount}
${
this.nationCount === 1
? translateText("host_modal.nation_player")
: translateText("host_modal.nation_players")
}
</div>
<lobby-team-view
@@ -560,6 +569,7 @@ export class HostLobbyModal extends LitElement {
.clients=${this.clients}
.lobbyCreatorClientID=${this.lobbyCreatorClientID}
.teamCount=${this.teamCount}
.nationCount=${this.disableNPCs ? 0 : this.nationCount}
.onKickPlayer=${(clientID: string) => this.kickPlayer(clientID)}
></lobby-team-view>
</div>
@@ -613,6 +623,7 @@ export class HostLobbyModal extends LitElement {
});
this.modalEl?.open();
this.playersInterval = setInterval(() => this.pollPlayers(), 1000);
this.loadNationCount();
}
public close() {
@@ -631,12 +642,15 @@ export class HostLobbyModal extends LitElement {
private async handleRandomMapToggle() {
this.useRandomMap = true;
this.selectedMap = this.getRandomMap();
await this.loadNationCount();
this.putGameConfig();
}
private async handleMapSelection(value: GameMapType) {
this.selectedMap = value;
this.useRandomMap = false;
await this.loadNationCount();
this.putGameConfig();
}
@@ -794,10 +808,6 @@ export class HostLobbyModal extends LitElement {
}
private async startGame() {
if (this.useRandomMap) {
this.selectedMap = this.getRandomMap();
}
await this.putGameConfig();
console.log(
`Starting private game with map: ${GameMapType[this.selectedMap as keyof typeof GameMapType]} ${this.useRandomMap ? " (Randomly selected)" : ""}`,
@@ -857,6 +867,17 @@ export class HostLobbyModal extends LitElement {
}),
);
}
private async loadNationCount() {
try {
const mapData = this.mapLoader.getMapData(this.selectedMap);
const manifest = await mapData.manifest();
this.nationCount = manifest.nations.length;
} catch (error) {
console.warn("Failed to load nation count", error);
this.nationCount = 0;
}
}
}
async function createLobby(creatorClientID: string): Promise<GameInfo> {
+19 -2
View File
@@ -41,16 +41,25 @@ export class LocalServer {
private turnStartTime = 0;
private turnCheckInterval: NodeJS.Timeout;
private clientConnect: () => void;
private clientMessage: (message: ServerMessage) => void;
constructor(
private lobbyConfig: LobbyConfig,
private clientConnect: () => void,
private clientMessage: (message: ServerMessage) => void,
private isReplay: boolean,
private eventBus: EventBus,
) {}
public updateCallback(
clientConnect: () => void,
clientMessage: (message: ServerMessage) => void,
) {
this.clientConnect = clientConnect;
this.clientMessage = clientMessage;
}
start() {
console.log("local server starting");
this.turnCheckInterval = setInterval(() => {
const turnIntervalMs =
this.lobbyConfig.serverConfig.turnIntervalMs() *
@@ -97,6 +106,14 @@ export class LocalServer {
}
onMessage(clientMsg: ClientMessage) {
if (clientMsg.type === "rejoin") {
this.clientMessage({
type: "start",
gameStartInfo: this.lobbyConfig.gameStartInfo!,
turns: this.turns,
lobbyCreatedAt: this.lobbyConfig.gameStartInfo!.lobbyCreatedAt,
} satisfies ServerStartGameMessage);
}
if (clientMsg.type === "intent") {
if (this.lobbyConfig.gameRecord) {
// If we are replaying a game, we don't want to process intents
+88 -1
View File
@@ -2,7 +2,9 @@ import version from "../../resources/version.txt";
import { UserMeResponse } from "../core/ApiSchemas";
import { EventBus } from "../core/EventBus";
import { GameRecord, GameStartInfo, ID } from "../core/Schemas";
import { GameEnv } from "../core/configuration/Config";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { GameType } from "../core/game/Game";
import { UserSettings } from "../core/game/UserSettings";
import "./AccountModal";
import { joinLobby } from "./ClientGameRunner";
@@ -46,6 +48,7 @@ import "./styles.css";
declare global {
interface Window {
turnstile: any;
enableAds: boolean;
PageOS: {
session: {
@@ -105,9 +108,18 @@ class Client {
private gutterAds: GutterAds;
private turnstileTokenPromise: Promise<{
token: string;
createdAt: number;
}> | null = null;
constructor() {}
initialize(): void {
// Prefetch turnstile token so it is available when
// the user joins a lobby.
this.turnstileTokenPromise = getTurnstileToken();
const gameVersion = document.getElementById(
"game-version",
) as HTMLDivElement;
@@ -484,6 +496,7 @@ class Client {
? ""
: this.flagInput.getCurrentFlag(),
},
turnstileToken: await this.getTurnstileToken(lobby),
playerName: this.usernameInput?.getCurrentUsername() ?? "",
token: getPlayToken(),
clientID: lobby.clientID,
@@ -548,7 +561,7 @@ class Client {
// Ensure there's a homepage entry in history before adding the lobby entry
if (window.location.hash === "" || window.location.hash === "#") {
history.pushState(null, "", window.location.origin + "#refresh");
history.replaceState(null, "", window.location.origin + "#refresh");
}
history.pushState(null, "", `#join=${lobby.gameID}`);
},
@@ -596,6 +609,40 @@ class Client {
}
}, 100);
}
private async getTurnstileToken(
lobby: JoinLobbyEvent,
): Promise<string | null> {
const config = await getServerConfigFromClient();
if (
config.env() === GameEnv.Dev ||
lobby.gameStartInfo?.config.gameType === GameType.Singleplayer
) {
return null;
}
if (this.turnstileTokenPromise === null) {
console.log("No prefetched turnstile token, getting new token");
return (await getTurnstileToken())?.token ?? null;
}
const token = await this.turnstileTokenPromise;
// Clear promise so a new token is fetched next time
this.turnstileTokenPromise = null;
if (!token) {
console.log("No turnstile token");
return null;
}
const tokenTTL = 3 * 60 * 1000;
if (Date.now() < token.createdAt + tokenTTL) {
console.log("Prefetched turnstile token is valid");
return token.token;
} else {
console.log("Turnstile token expired, getting new token");
return (await getTurnstileToken())?.token ?? null;
}
}
}
// Initialize the client when the DOM is loaded
@@ -642,3 +689,43 @@ function getPersistentIDFromCookie(): string {
return newID;
}
async function getTurnstileToken(): Promise<{
token: string;
createdAt: number;
}> {
// Wait for Turnstile script to load (handles slow connections)
let attempts = 0;
while (typeof window.turnstile === "undefined" && attempts < 100) {
await new Promise((resolve) => setTimeout(resolve, 100));
attempts++;
}
if (typeof window.turnstile === "undefined") {
throw new Error("Failed to load Turnstile script");
}
const config = await getServerConfigFromClient();
const widgetId = window.turnstile.render("#turnstile-container", {
sitekey: config.turnstileSiteKey(),
size: "normal",
appearance: "interaction-only",
theme: "light",
});
return new Promise((resolve, reject) => {
window.turnstile.execute(widgetId, {
callback: (token: string) => {
window.turnstile.remove(widgetId);
console.log(`Turnstile token received: ${token}`);
resolve({ token, createdAt: Date.now() });
},
"error-callback": (errorCode: string) => {
window.turnstile.remove(widgetId);
console.error(`Turnstile error: ${errorCode}`);
alert(`Turnstile error: ${errorCode}. Please refresh and try again.`);
reject(new Error(`Turnstile failed: ${errorCode}`));
},
});
});
}
+93 -11
View File
@@ -1,7 +1,14 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { renderDuration, translateText } from "../client/Utils";
import { GameMapType, GameMode, HumansVsNations } from "../core/game/Game";
import {
Duos,
GameMapType,
GameMode,
HumansVsNations,
Quads,
Trios,
} from "../core/game/Game";
import { GameID, GameInfo } from "../core/Schemas";
import { generateID } from "../core/Util";
import { JoinLobbyEvent } from "./Main";
@@ -122,6 +129,20 @@ export class PublicLobby extends LitElement {
? (lobby.gameConfig.playerTeams ?? 0)
: null;
const maxPlayers = lobby.gameConfig.maxPlayers ?? 0;
const teamSize = this.getTeamSize(teamCount, maxPlayers);
const teamTotal = this.getTeamTotal(teamCount, teamSize, maxPlayers);
const modeLabel = this.getModeLabel(
lobby.gameConfig.gameMode,
teamCount,
teamTotal,
);
const teamDetailLabel = this.getTeamDetailLabel(
lobby.gameConfig.gameMode,
teamCount,
teamTotal,
teamSize,
);
const mapImageSrc = this.mapImages.get(lobby.gameID);
return html`
@@ -158,17 +179,16 @@ export class PublicLobby extends LitElement {
class="text-sm ${this.isLobbyHighlighted
? "text-green-600"
: "text-blue-600"} bg-white rounded-sm px-1"
>${modeLabel}</span
>
${lobby.gameConfig.gameMode === GameMode.Team
? typeof teamCount === "string"
? teamCount === HumansVsNations
? translateText("public_lobby.teams_hvn")
: translateText(`public_lobby.teams_${teamCount}`)
: translateText("public_lobby.teams", {
num: teamCount ?? 0,
})
: translateText("game_mode.ffa")}</span
>
${teamDetailLabel
? html`<span
class="text-sm ${this.isLobbyHighlighted
? "text-green-600"
: "text-blue-600"} bg-white rounded-sm px-1 ml-1"
>${teamDetailLabel}</span
>`
: ""}
<span
>${translateText(
`map.${lobby.gameConfig.gameMap.toLowerCase().replace(/\s+/g, "")}`,
@@ -193,6 +213,68 @@ export class PublicLobby extends LitElement {
this.currLobby = null;
}
private getTeamSize(
teamCount: number | string | null,
maxPlayers: number,
): number | undefined {
if (typeof teamCount === "string") {
if (teamCount === Duos) return 2;
if (teamCount === Trios) return 3;
if (teamCount === Quads) return 4;
if (teamCount === HumansVsNations) return Math.floor(maxPlayers / 2);
return undefined;
}
if (typeof teamCount === "number" && teamCount > 0) {
return Math.floor(maxPlayers / teamCount);
}
return undefined;
}
private getTeamTotal(
teamCount: number | string | null,
teamSize: number | undefined,
maxPlayers: number,
): number | undefined {
if (typeof teamCount === "number") return teamCount;
if (teamCount === HumansVsNations) return 2;
if (teamSize && teamSize > 0) return Math.floor(maxPlayers / teamSize);
return undefined;
}
private getModeLabel(
gameMode: GameMode,
teamCount: number | string | null,
teamTotal: number | undefined,
): string {
if (gameMode !== GameMode.Team) return translateText("game_mode.ffa");
if (teamCount === HumansVsNations)
return translateText("public_lobby.teams_hvn");
const totalTeams =
teamTotal ?? (typeof teamCount === "number" ? teamCount : 0);
return translateText("public_lobby.teams", { num: totalTeams });
}
private getTeamDetailLabel(
gameMode: GameMode,
teamCount: number | string | null,
teamTotal: number | undefined,
teamSize: number | undefined,
): string | null {
if (gameMode !== GameMode.Team) return null;
if (typeof teamCount === "string" && teamCount !== HumansVsNations) {
const teamKey = `public_lobby.teams_${teamCount}`;
const maybeTranslated = translateText(teamKey);
if (maybeTranslated !== teamKey) return maybeTranslated;
}
if (teamTotal !== undefined && teamSize !== undefined) {
return translateText("public_lobby.players_per_team", { num: teamSize });
}
return null;
}
private lobbyClicked(lobby: GameInfo) {
if (this.isButtonDebounced) {
return;
+7 -1
View File
@@ -134,6 +134,9 @@ export class StatsModal extends LitElement {
<table class="min-w-full text-xs md:text-sm">
<thead>
<tr class="border-b border-gray-700 text-gray-300">
<th class="py-2 pr-3 text-left">
${translateText("stats_modal.rank")}
</th>
<th class="py-2 pr-3 text-left">
${translateText("stats_modal.clan")}
</th>
@@ -153,8 +156,11 @@ export class StatsModal extends LitElement {
</thead>
<tbody>
${clans.map(
(clan) => html`
(clan, index) => html`
<tr class="border-b border-gray-800 last:border-b-0">
<td class="py-2 pr-3 text-center">
${(index + 1).toLocaleString()}
</td>
<td class="py-2 pr-3 font-semibold text-left">
${clan.clanTag}
</td>
+26 -4
View File
@@ -17,6 +17,7 @@ import {
ClientJoinMessage,
ClientMessage,
ClientPingMessage,
ClientRejoinMessage,
ClientSendWinnerMessage,
Intent,
ServerMessage,
@@ -287,17 +288,28 @@ export class Transport {
}
}
public updateCallback(
onconnect: () => void,
onmessage: (message: ServerMessage) => void,
) {
if (this.isLocal) {
this.localServer.updateCallback(onconnect, onmessage);
} else {
this.onconnect = onconnect;
this.onmessage = onmessage;
}
}
private connectLocal(
onconnect: () => void,
onmessage: (message: ServerMessage) => void,
) {
this.localServer = new LocalServer(
this.lobbyConfig,
onconnect,
onmessage,
this.lobbyConfig.gameRecord !== undefined,
this.eventBus,
);
this.localServer.updateCallback(onconnect, onmessage);
this.localServer.start();
}
@@ -376,18 +388,28 @@ export class Transport {
}
}
joinGame(numTurns: number) {
joinGame() {
this.sendMsg({
type: "join",
gameID: this.lobbyConfig.gameID,
clientID: this.lobbyConfig.clientID,
lastTurn: numTurns,
token: this.lobbyConfig.token,
username: this.lobbyConfig.playerName,
cosmetics: this.lobbyConfig.cosmetics,
turnstileToken: this.lobbyConfig.turnstileToken,
} satisfies ClientJoinMessage);
}
rejoinGame(lastTurn: number) {
this.sendMsg({
type: "rejoin",
gameID: this.lobbyConfig.gameID,
clientID: this.lobbyConfig.clientID,
lastTurn: lastTurn,
token: this.lobbyConfig.token,
} satisfies ClientRejoinMessage);
}
leaveGame() {
if (this.isLocal) {
this.localServer.endGame();
+39 -20
View File
@@ -13,7 +13,7 @@ import {
Team,
Trios,
} from "../../core/game/Game";
import { assignTeams } from "../../core/game/TeamAssignment";
import { assignTeamsLobbyPreview } from "../../core/game/TeamAssignment";
import { ClientInfo, TeamCountConfig } from "../../core/Schemas";
import { translateText } from "../Utils";
@@ -31,19 +31,23 @@ export class LobbyTeamView extends LitElement {
@property({ type: String }) lobbyCreatorClientID: string = "";
@property({ attribute: "team-count" }) teamCount: TeamCountConfig = 2;
@property({ type: Function }) onKickPlayer?: (clientID: string) => void;
@property({ type: Number }) nationCount: number = 0;
private theme: PastelTheme = new PastelTheme();
@state() private showTeamColors: boolean = false;
willUpdate(changedProperties: Map<string, any>) {
// Recompute team preview when relevant properties change
// clients is 'changed' every 1s from pollPlayers, chose to not compare for actual change
if (
changedProperties.has("gameMode") ||
changedProperties.has("clients") ||
changedProperties.has("teamCount")
changedProperties.has("teamCount") ||
changedProperties.has("nationCount")
) {
this.computeTeamPreview();
this.showTeamColors = this.getTeamList().length <= 7;
const teamsList = this.getTeamList();
this.computeTeamPreview(teamsList);
this.showTeamColors = teamsList.length <= 7;
}
}
@@ -60,8 +64,12 @@ export class LobbyTeamView extends LitElement {
}
private renderTeamMode() {
const active = this.teamPreview.filter((t) => t.players.length > 0);
const empty = this.teamPreview.filter((t) => t.players.length === 0);
const active = this.teamPreview.filter(
(t) => t.players.length > 0 || t.team === ColoredTeams.Nations,
);
const empty = this.teamPreview.filter(
(t) => t.players.length === 0 && t.team !== ColoredTeams.Nations,
);
return html` <div
class="flex flex-col md:flex-row gap-3 md:gap-4 items-stretch max-h-[65vh]"
>
@@ -96,9 +104,11 @@ export class LobbyTeamView extends LitElement {
</div>
</div>
<div>
<div class="font-semibold text-gray-200 mb-1 text-sm">
${translateText("host_modal.empty_teams")}
</div>
${empty.length > 0
? html`<div class="font-semibold text-gray-200 mb-1 text-sm">
${translateText("host_modal.empty_teams")}
</div>`
: ""}
<div class="w-full grid grid-cols-1 sm:grid-cols-2 gap-2 md:gap-3">
${repeat(
empty,
@@ -136,6 +146,16 @@ export class LobbyTeamView extends LitElement {
}
private renderTeamCard(preview: TeamPreviewData, isEmpty: boolean = false) {
const displayCount =
preview.team === ColoredTeams.Nations
? this.nationCount
: preview.players.length;
const maxTeamSize =
preview.team === ColoredTeams.Nations
? this.nationCount
: this.teamMaxSize;
return html`
<div class="bg-gray-800 border border-gray-700 rounded-xl flex flex-col">
<div
@@ -148,9 +168,7 @@ export class LobbyTeamView extends LitElement {
></span>`
: null}
<span class="truncate">${preview.team}</span>
<span class="text-white/90"
>${preview.players.length}/${this.teamMaxSize}</span
>
<span class="text-white/90">${displayCount}/${maxTeamSize}</span>
</div>
<div class="p-2 ${isEmpty ? "" : "flex flex-col gap-1.5"}">
${isEmpty
@@ -190,7 +208,7 @@ export class LobbyTeamView extends LitElement {
private getTeamList(): Team[] {
if (this.gameMode !== GameMode.Team) return [];
const playerCount = this.clients.length;
const playerCount = this.clients.length + this.nationCount;
const config = this.teamCount;
if (config === HumansVsNations) {
@@ -230,13 +248,12 @@ export class LobbyTeamView extends LitElement {
}
}
private computeTeamPreview() {
private computeTeamPreview(teams: Team[] = []) {
if (this.gameMode !== GameMode.Team) {
this.teamPreview = [];
this.teamMaxSize = 0;
return;
}
const teams = this.getTeamList();
// HumansVsNations: show all clients under Humans initially
if (this.teamCount === HumansVsNations) {
@@ -252,7 +269,11 @@ export class LobbyTeamView extends LitElement {
(c) =>
new PlayerInfo(c.username, PlayerType.Human, c.clientID, c.clientID),
);
const assignment = assignTeams(players, teams);
const assignment = assignTeamsLobbyPreview(
players,
teams,
this.nationCount,
);
const buckets = new Map<Team, ClientInfo[]>();
for (const t of teams) buckets.set(t, []);
@@ -260,9 +281,7 @@ export class LobbyTeamView extends LitElement {
if (team === "kicked") continue;
const bucket = buckets.get(team);
if (!bucket) continue;
const client =
this.clients.find((c) => c.clientID === p.clientID) ??
this.clients.find((c) => c.username === p.name);
const client = this.clients.find((c) => c.clientID === p.clientID);
if (client) bucket.push(client);
}
@@ -277,7 +296,7 @@ export class LobbyTeamView extends LitElement {
// Fallback: divide players across teams; guard against 0 and empty lobbies
this.teamMaxSize = Math.max(
1,
Math.ceil(this.clients.length / teams.length),
Math.ceil((this.clients.length + this.nationCount) / teams.length),
);
}
this.teamPreview = teams.map((t) => ({
+2
View File
@@ -38,6 +38,8 @@ export const MapDescription: Record<keyof typeof GameMapType, string> = {
Achiran: "Achiran",
BaikalNukeWars: "Baikal (Nuke Wars)",
FourIslands: "Four Islands",
GulfOfStLawrence: "Gulf of St. Lawrence",
Lisbon: "Lisbon",
};
@customElement("map-display")
+5 -1
View File
@@ -311,7 +311,11 @@ export class GameRenderer {
this.eventBus.on(RedrawGraphicsEvent, () => this.redraw());
this.layers.forEach((l) => l.init?.());
document.body.appendChild(this.canvas);
// only append the canvas if it's not already in the document to avoid reparenting side-effects
if (!document.body.contains(this.canvas)) {
document.body.appendChild(this.canvas);
}
window.addEventListener("resize", () => this.resizeCanvas());
this.resizeCanvas();
+1 -1
View File
@@ -1,7 +1,7 @@
import { GameView } from "../../../core/game/GameView";
import { Layer } from "./Layer";
const AD_SHOW_TICKS = 2 * 60 * 10; // 2 minutes
const AD_SHOW_TICKS = 5 * 60 * 10; // 5 minutes
export class AdTimer implements Layer {
private isHidden: boolean = false;
+14 -2
View File
@@ -21,6 +21,8 @@ export class AlertFrame extends LitElement implements Layer {
@state()
private isActive = false;
@state()
private alertType: "betrayal" | "land-attack" = "betrayal";
private animationTimeout: number | null = null;
private seenAttackIds: Set<string> = new Set();
@@ -36,12 +38,20 @@ export class AlertFrame extends LitElement implements Layer {
width: 100%;
height: 100%;
pointer-events: none;
border: 17px solid #ee0000;
border: 17px solid;
box-sizing: border-box;
z-index: 40;
opacity: 0;
}
.alert-border.betrayal {
border-color: #ee0000;
}
.alert-border.land-attack {
border-color: #ffa500;
}
.alert-border.animate {
animation: alertBlink ${ALERT_SPEED}s ease-in-out ${ALERT_COUNT};
}
@@ -119,6 +129,7 @@ export class AlertFrame extends LitElement implements Layer {
// Only trigger alert if the current player is the betrayed one
if (betrayed === myPlayer) {
this.alertType = "betrayal";
this.activateAlert();
}
}
@@ -202,6 +213,7 @@ export class AlertFrame extends LitElement implements Layer {
// 3. The attack is too small (less than 1/5 of our troops)
if (!inCooldown && !isRetaliation && !isSmallAttack) {
this.seenAttackIds.add(attack.id);
this.alertType = "land-attack";
this.activateAlert();
} else {
// Still mark as seen so we don't alert later
@@ -237,7 +249,7 @@ export class AlertFrame extends LitElement implements Layer {
return html`
<div
class="alert-border animate"
class=${`alert-border animate ${this.alertType}`}
@animationend=${() => this.dismissAlert()}
></div>
`;
+6 -8
View File
@@ -974,32 +974,30 @@ export class EventsDisplay extends LitElement implements Layer {
<!-- Events Toggle (when hidden) -->
${this._hidden
? html`
<div class="relative w-fit lg:bottom-2.5 lg:right-2.5 z-50">
<div class="relative w-fit lg:bottom-4 lg:right-4 z-50">
${this.renderButton({
content: html`
Events
<span
class="${this.newEvents
? ""
: "hidden"} inline-block px-2 bg-red-500 rounded-xl text-sm"
: "hidden"} inline-block px-2 bg-red-500 rounded-lg text-sm"
>${this.newEvents}</span
>
`,
onClick: this.toggleHidden,
className:
"text-white cursor-pointer pointer-events-auto w-fit p-2 lg:p-3 rounded-md bg-gray-800/70 backdrop-blur",
"text-white cursor-pointer pointer-events-auto w-fit p-2 lg:p-3 rounded-lg bg-gray-800/70 backdrop-blur",
})}
</div>
`
: html`
<!-- Main Events Display -->
<div
class="relative w-full sm:bottom-2.5 sm:right-2.5 z-50 sm:w-96 backdrop-blur"
class="relative w-full sm:bottom-4 sm:right-4 z-50 sm:w-96 backdrop-blur"
>
<!-- Button Bar -->
<div
class="w-full p-2 lg:p-3 rounded-t-none md:rounded-t-md bg-gray-800/70"
>
<div class="w-full p-2 lg:p-3 bg-gray-800/70 rounded-t-lg">
<div class="flex justify-between items-center">
<div class="flex gap-4">
${this.renderToggleButton(
@@ -1042,7 +1040,7 @@ export class EventsDisplay extends LitElement implements Layer {
<!-- Content Area -->
<div
class="rounded-b-none md:rounded-b-md bg-gray-800/70 max-h-[30vh] flex flex-col-reverse overflow-y-auto w-full h-full"
class="bg-gray-800/70 max-h-[30vh] flex flex-col-reverse overflow-y-auto w-full h-full sm:rounded-b-lg"
>
<div>
<table
+1 -1
View File
@@ -129,7 +129,7 @@ export class FxLayer implements Layer {
if (gold > 0) {
const shortened = renderNumber(gold, 0);
this.addTextFx(`+ ${shortened}`, x, y);
y += 10; // increase y so the next popup starts bellow
y += 10; // increase y so the next popup starts below
}
if (troops > 0) {
@@ -87,8 +87,8 @@ export class GameLeftSidebar extends LitElement implements Layer {
render() {
return html`
<aside
class=${`fixed top-[20px] left-0 z-[1000] flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-slate-800/40 backdrop-blur-sm shadow-xs rounded-tr-lg rounded-br-lg transition-transform duration-300 ease-out transform ${
this.isVisible ? "translate-x-0" : "-translate-x-full"
class=${`fixed top-4 left-4 z-[1000] flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-slate-800/40 backdrop-blur-sm shadow-xs rounded-lg transition-transform duration-300 ease-out transform ${
this.isVisible ? "translate-x-0" : "hidden"
}`}
>
${this.isPlayerTeamLabelVisible
@@ -99,7 +99,7 @@ export class GameLeftSidebar extends LitElement implements Layer {
>
${translateText("help_modal.ui_your_team")}
<span style="color: ${this.playerColor.toRgbString()}">
${this.getTranslatedPlayerTeamLabel()} &#10687;
&nbsp;${this.getTranslatedPlayerTeamLabel()} &#10687;
</span>
</div>
`
@@ -109,7 +109,7 @@ export class GameLeftSidebar extends LitElement implements Layer {
this.isLeaderboardShow || this.isTeamLeaderboardShow ? "mb-2" : ""
}`}
>
<div class="w-6 h-6 cursor-pointer" @click=${this.toggleLeaderboard}>
<div class="cursor-pointer" @click=${this.toggleLeaderboard}>
<img
src=${this.isLeaderboardShow
? leaderboardSolidIcon
@@ -122,7 +122,7 @@ export class GameLeftSidebar extends LitElement implements Layer {
${this.isTeamGame
? html`
<div
class="w-6 h-6 cursor-pointer"
class="cursor-pointer"
@click=${this.toggleTeamLeaderboard}
>
<img
@@ -118,7 +118,7 @@ export class GameRightSidebar extends LitElement implements Layer {
return html`
<aside
class=${`flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-gray-800/70 backdrop-blur-sm shadow-xs rounded-tl-lg rounded-bl-lg transition-transform duration-300 ease-out transform ${
class=${`flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-gray-800/70 backdrop-blur-sm shadow-xs rounded-lg transition-transform duration-300 ease-out transform ${
this._isVisible ? "translate-x-0" : "translate-x-full"
}`}
@contextmenu=${(e: Event) => e.preventDefault()}
@@ -148,7 +148,7 @@ export class GameRightSidebar extends LitElement implements Layer {
<!-- Timer display below buttons -->
<div class="flex justify-center items-center mt-2">
<div
class="w-[70px] h-8 lg:w-24 lg:h-10 border border-slate-400 p-0.5 text-xs md:text-sm lg:text-base flex items-center justify-center text-white px-1"
class="w-[70px] h-8 lg:w-24 lg:h-10 p-0.5 text-xs md:text-sm lg:text-base flex items-center justify-center text-white px-1"
style="${this.game.config().gameConfig().maxTimerValue !==
undefined && this.timer < 60
? "color: #ff8080;"
@@ -15,10 +15,13 @@ export class NukeTrajectoryPreviewLayer implements Layer {
// Trajectory preview state
private mousePos = { x: 0, y: 0 };
private trajectoryPoints: TileRef[] = [];
private untargetableSegmentBounds: [number, number] = [-1, -1];
private targetedIndex = -1;
private lastTrajectoryUpdate: number = 0;
private lastTargetTile: TileRef | null = null;
private currentGhostStructure: UnitType | null = null;
private cachedSpawnTile: TileRef | null = null; // Cache spawn tile to avoid expensive player.actions() calls
// Cache spawn tile to avoid expensive player.actions() calls
private cachedSpawnTile: TileRef | null = null;
constructor(
private game: GameView,
@@ -210,6 +213,72 @@ export class NukeTrajectoryPreviewLayer implements Layer {
);
this.trajectoryPoints = pathFinder.allTiles();
// NOTE: This is a lot to do in the rendering method, naive
// But trajectory is already calculated here and needed for prediction.
// From testing, does not seem to have much effect, so I keep it this way.
// Calculate points when bomb targetability switches
const targetRangeSquared =
this.game.config().defaultNukeTargetableRange() ** 2;
// Find two switch points where bomb transitions:
// [0]: leaves spawn range, enters untargetable zone
// [1]: enters target range, becomes targetable again
this.untargetableSegmentBounds = [-1, -1];
for (let i = 0; i < this.trajectoryPoints.length; i++) {
const tile = this.trajectoryPoints[i];
if (this.untargetableSegmentBounds[0] === -1) {
if (
this.game.euclideanDistSquared(tile, this.cachedSpawnTile) >
targetRangeSquared
) {
if (
this.game.euclideanDistSquared(tile, targetTile) <
targetRangeSquared
) {
// overlapping spawn & target range
break;
} else {
this.untargetableSegmentBounds[0] = i;
}
}
} else if (
this.game.euclideanDistSquared(tile, targetTile) < targetRangeSquared
) {
this.untargetableSegmentBounds[1] = i;
break;
}
}
// Find the point where SAM can intercept
this.targetedIndex = this.trajectoryPoints.length;
// Check trajectory
for (let i = 0; i < this.trajectoryPoints.length; i++) {
const tile = this.trajectoryPoints[i];
for (const sam of this.game.nearbyUnits(
tile,
this.game.config().maxSamRange(),
UnitType.SAMLauncher,
)) {
if (
sam.unit.owner().isMe() ||
this.game.myPlayer()?.isFriendly(sam.unit.owner())
) {
continue;
}
if (
sam.distSquared <=
this.game.config().samRange(sam.unit.level()) ** 2
) {
this.targetedIndex = i;
break;
}
}
if (this.targetedIndex !== this.trajectoryPoints.length) break;
// Jump over untargetable segment
if (i === this.untargetableSegmentBounds[0])
i = this.untargetableSegmentBounds[1] - 1;
}
}
/**
@@ -230,17 +299,78 @@ export class NukeTrajectoryPreviewLayer implements Layer {
return;
}
const territoryColor = player.territoryColor();
const lineColor = territoryColor.alpha(0.7).toRgbString();
// Set of line colors, targeted is after SAM intercept is detected.
const untargetedOutlineColor = "rgba(140, 140, 140, 1)";
const targetedOutlineColor = "rgba(150, 90, 90, 1)";
const symbolOutlineColor = "rgba(0, 0, 0, 1)";
const targetedLocationColor = "rgba(255, 0, 0, 1)";
const untargetableAndUntargetedLineColor = "rgba(255, 255, 255, 1)";
const targetableAndUntargetedLineColor = "rgba(255, 255, 255, 1)";
const untargetableAndTargetedLineColor = "rgba(255, 80, 80, 1)";
const targetableAndTargetedLineColor = "rgba(255, 80, 80, 1)";
// Set of line widths
const outlineExtraWidth = 1.5; // adds onto below
const lineWidth = 1.25;
const XLineWidth = 2;
const XSize = 6;
// Set of line dashes
// Outline dashes calculated automatically
const untargetableAndUntargetedLineDash = [2, 6];
const targetableAndUntargetedLineDash = [8, 4];
const untargetableAndTargetedLineDash = [2, 6];
const targetableAndTargetedLineDash = [8, 4];
const outlineDash = (dash: number[], extra: number) => {
return [dash[0] + extra, Math.max(dash[1] - extra, 0)];
};
// Tracks the change of color and dash length throughout
let currentOutlineColor = untargetedOutlineColor;
let currentLineColor = targetableAndUntargetedLineColor;
let currentLineDash = targetableAndUntargetedLineDash;
let currentLineWidth = lineWidth;
// Take in set of "current" parameters and draw both outline and line.
const outlineAndStroke = () => {
context.lineWidth = currentLineWidth + outlineExtraWidth;
context.setLineDash(outlineDash(currentLineDash, outlineExtraWidth));
context.lineDashOffset = outlineExtraWidth / 2;
context.strokeStyle = currentOutlineColor;
context.stroke();
context.lineWidth = currentLineWidth;
context.setLineDash(currentLineDash);
context.lineDashOffset = 0;
context.strokeStyle = currentLineColor;
context.stroke();
};
const drawUntargetableCircle = (x: number, y: number) => {
context.beginPath();
context.arc(x, y, 4, 0, 2 * Math.PI, false);
currentOutlineColor = untargetedOutlineColor;
currentLineColor = targetableAndUntargetedLineColor;
currentLineDash = [1, 0];
outlineAndStroke();
};
const drawTargetedX = (x: number, y: number) => {
context.beginPath();
context.moveTo(x - XSize, y - XSize);
context.lineTo(x + XSize, y + XSize);
context.moveTo(x - XSize, y + XSize);
context.lineTo(x + XSize, y - XSize);
currentOutlineColor = symbolOutlineColor;
currentLineColor = targetedLocationColor;
currentLineDash = [1, 0];
currentLineWidth = XLineWidth;
outlineAndStroke();
};
// Calculate offset to center coordinates (same as canvas drawing)
const offsetX = -this.game.width() / 2;
const offsetY = -this.game.height() / 2;
context.save();
context.strokeStyle = lineColor;
context.lineWidth = 1.5;
context.setLineDash([8, 4]);
context.beginPath();
// Draw line connecting trajectory points
@@ -254,9 +384,46 @@ export class NukeTrajectoryPreviewLayer implements Layer {
} else {
context.lineTo(x, y);
}
if (i === this.untargetableSegmentBounds[0]) {
outlineAndStroke();
drawUntargetableCircle(x, y);
context.beginPath();
if (i >= this.targetedIndex) {
currentOutlineColor = targetedOutlineColor;
currentLineColor = untargetableAndTargetedLineColor;
currentLineDash = untargetableAndTargetedLineDash;
} else {
currentOutlineColor = untargetedOutlineColor;
currentLineColor = untargetableAndUntargetedLineColor;
currentLineDash = untargetableAndUntargetedLineDash;
}
} else if (i === this.untargetableSegmentBounds[1]) {
outlineAndStroke();
drawUntargetableCircle(x, y);
context.beginPath();
if (i >= this.targetedIndex) {
currentOutlineColor = targetedOutlineColor;
currentLineColor = targetableAndTargetedLineColor;
currentLineDash = targetableAndTargetedLineDash;
} else {
currentOutlineColor = untargetedOutlineColor;
currentLineColor = targetableAndUntargetedLineColor;
currentLineDash = targetableAndUntargetedLineDash;
}
}
if (i === this.targetedIndex) {
outlineAndStroke();
drawTargetedX(x, y);
context.beginPath();
// Always in the targetable zone by definition.
currentOutlineColor = targetedOutlineColor;
currentLineColor = targetableAndTargetedLineColor;
currentLineDash = targetableAndTargetedLineDash;
currentLineWidth = lineWidth;
}
}
context.stroke();
outlineAndStroke();
context.restore();
}
}
@@ -467,7 +467,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
return html`
<div
class="block lg:flex fixed top-[150px] right-0 w-full z-50 flex-col max-w-[180px]"
class="block lg:flex fixed top-[150px] right-4 w-full z-50 flex-col max-w-[180px]"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
>
<div
+73 -11
View File
@@ -27,6 +27,9 @@ export class RailroadLayer implements Layer {
private existingRailroads = new Map<TileRef, RailRef>();
private nextRailIndexToCheck = 0;
private railTileList: TileRef[] = [];
private railTileIndex = new Map<TileRef, number>();
private lastRailColorUpdate = 0;
private readonly railColorIntervalMs = 50;
constructor(
private game: GameView,
@@ -49,7 +52,21 @@ export class RailroadLayer implements Layer {
}
updateRailColors() {
const maxTilesPerFrame = this.railTileList.length / 60;
if (this.railTileList.length === 0) {
return;
}
// Throttle color checks so we do not re-evaluate on every frame
const now = performance.now();
if (now - this.lastRailColorUpdate < this.railColorIntervalMs) {
return;
}
this.lastRailColorUpdate = now;
// Spread work over multiple frames to avoid large bursts when many rails exist
const maxTilesPerFrame = Math.max(
1,
Math.ceil(this.railTileList.length / 120),
);
let checked = 0;
while (checked < maxTilesPerFrame && this.railTileList.length > 0) {
@@ -58,15 +75,14 @@ export class RailroadLayer implements Layer {
if (railRef) {
const currentOwner = this.game.owner(tile)?.id() ?? null;
if (railRef.lastOwnerId !== currentOwner) {
// Repaint only when the owner changed to keep colors in sync
railRef.lastOwnerId = currentOwner;
this.paintRail(railRef.tile);
}
}
this.nextRailIndexToCheck++;
if (this.nextRailIndexToCheck >= this.railTileList.length) {
this.nextRailIndexToCheck = 0;
}
this.nextRailIndexToCheck =
(this.nextRailIndexToCheck + 1) % this.railTileList.length;
checked++;
}
}
@@ -95,22 +111,49 @@ export class RailroadLayer implements Layer {
}
renderLayer(context: CanvasRenderingContext2D) {
this.updateRailColors();
const scale = this.transformHandler.scale;
if (scale <= 1) {
return;
}
if (this.existingRailroads.size === 0) {
return;
}
this.updateRailColors();
const rawAlpha = (scale - 1) / (2 - 1); // maps 1->0, 2->1
const alpha = Math.max(0, Math.min(1, rawAlpha));
const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect();
const padding = 2; // small margin so edges do not pop
const visLeft = Math.max(0, topLeft.x - padding);
const visTop = Math.max(0, topLeft.y - padding);
const visRight = Math.min(this.game.width(), bottomRight.x + padding);
const visBottom = Math.min(this.game.height(), bottomRight.y + padding);
const visWidth = Math.max(0, visRight - visLeft);
const visHeight = Math.max(0, visBottom - visTop);
if (visWidth === 0 || visHeight === 0) {
return;
}
const srcX = visLeft * 2;
const srcY = visTop * 2;
const srcW = visWidth * 2;
const srcH = visHeight * 2;
const dstX = -this.game.width() / 2 + visLeft;
const dstY = -this.game.height() / 2 + visTop;
context.save();
context.globalAlpha = alpha;
context.drawImage(
this.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height(),
srcX,
srcY,
srcW,
srcH,
dstX,
dstY,
visWidth,
visHeight,
);
context.restore();
}
@@ -139,6 +182,7 @@ export class RailroadLayer implements Layer {
numOccurence: 1,
lastOwnerId: currentOwner,
});
this.railTileIndex.set(railRoad.tile, this.railTileList.length);
this.railTileList.push(railRoad.tile);
this.paintRail(railRoad);
}
@@ -150,7 +194,7 @@ export class RailroadLayer implements Layer {
if (!ref || ref.numOccurence <= 0) {
this.existingRailroads.delete(railRoad.tile);
this.railTileList = this.railTileList.filter((t) => t !== railRoad.tile);
this.removeRailTile(railRoad.tile);
if (this.context === undefined) throw new Error("Not initialized");
if (this.game.isWater(railRoad.tile)) {
this.context.clearRect(
@@ -170,6 +214,24 @@ export class RailroadLayer implements Layer {
}
}
private removeRailTile(tile: TileRef) {
const idx = this.railTileIndex.get(tile);
if (idx === undefined) return;
const lastIndex = this.railTileList.length - 1;
const lastTile = this.railTileList[lastIndex];
this.railTileList[idx] = lastTile;
this.railTileIndex.set(lastTile, idx);
this.railTileList.pop();
this.railTileIndex.delete(tile);
if (this.nextRailIndexToCheck >= this.railTileList.length) {
this.nextRailIndexToCheck = 0;
}
}
paintRail(railRoad: RailTile) {
if (this.context === undefined) throw new Error("Not initialized");
const { tile } = railRoad;
+38 -13
View File
@@ -1,14 +1,14 @@
import type { EventBus } from "../../../core/EventBus";
import { UnitType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import type { GameView } from "../../../core/game/GameView";
import type { GameView, PlayerView } from "../../../core/game/GameView";
import { ToggleStructureEvent } from "../../InputHandler";
import { TransformHandler } from "../TransformHandler";
import { UIState } from "../UIState";
import { Layer } from "./Layer";
/**
* Layer responsible for rendering SAM launcher defense radiuses
* Layer responsible for rendering SAM launcher defense radii
*/
export class SAMRadiusLayer implements Layer {
private readonly canvas: HTMLCanvasElement;
@@ -107,8 +107,9 @@ export class SAMRadiusLayer implements Layer {
}
}
// show when in ghost mode for sam/atom/hydrogen
// show when in ghost mode for silo/sam/atom/hydrogen
this.ghostShow =
this.uiState.ghostStructure === UnitType.MissileSilo ||
this.uiState.ghostStructure === UnitType.SAMLauncher ||
this.uiState.ghostStructure === UnitType.AtomBomb ||
this.uiState.ghostStructure === UnitType.HydrogenBomb;
@@ -157,14 +158,14 @@ export class SAMRadiusLayer implements Layer {
this.samLaunchers.set(sam.id(), sam.owner().smallID()),
);
// Draw union of SAM radiuses. Collect circle data then draw union outer arcs only
// Draw union of SAM radii. Collect circle data then draw union outer arcs only
const circles = samLaunchers.map((sam) => {
const tile = sam.tile();
return {
x: this.game.x(tile),
y: this.game.y(tile),
r: this.game.config().samRange(sam.level()),
owner: sam.owner().smallID(),
owner: sam.owner(),
};
});
@@ -176,13 +177,19 @@ export class SAMRadiusLayer implements Layer {
* so overlapping circles appear as one combined shape.
*/
private drawCirclesUnion(
circles: Array<{ x: number; y: number; r: number; owner: number }>,
circles: Array<{ x: number; y: number; r: number; owner: PlayerView }>,
) {
const ctx = this.context;
if (circles.length === 0) return;
// styles
const strokeStyleOuter = "rgba(0, 0, 0, 1)";
// Line Parameters
const outlineColor = "rgba(0, 0, 0, 1)";
const lineColorSelf = "rgba(0, 255, 0, 1)";
const lineColorEnemy = "rgba(255, 0, 0, 1)";
const lineColorFriend = "rgba(255, 255, 0, 1)";
const extraOutlineWidth = 1; // adds onto below
const lineWidth = 2;
const lineDash = [12, 6];
// 1) Fill union simply by drawing all full circle paths and filling once
ctx.save();
@@ -199,10 +206,6 @@ export class SAMRadiusLayer implements Layer {
if (!this.showStroke) return;
ctx.save();
ctx.lineWidth = 2;
ctx.setLineDash([12, 6]);
ctx.lineDashOffset = this.dashOffset;
ctx.strokeStyle = strokeStyleOuter;
const TWO_PI = Math.PI * 2;
@@ -258,7 +261,8 @@ export class SAMRadiusLayer implements Layer {
// Only consider coverage from circles owned by the same player.
// This shows separate boundaries for different players' SAM coverage,
// making contested areas visually distinct.
if (a.owner !== circles[j].owner) continue;
if (a.owner.smallID() !== circles[j].owner.smallID()) continue;
const b = circles[j];
const dx = b.x - a.x;
const dy = b.y - a.y;
@@ -318,6 +322,27 @@ export class SAMRadiusLayer implements Layer {
if (e - s < 1e-3) continue;
ctx.beginPath();
ctx.arc(a.x, a.y, a.r, s, e);
// Outline
ctx.strokeStyle = outlineColor;
ctx.lineWidth = lineWidth + extraOutlineWidth;
ctx.setLineDash([
lineDash[0] + extraOutlineWidth,
Math.max(lineDash[1] - extraOutlineWidth, 0),
]);
ctx.lineDashOffset = this.dashOffset + extraOutlineWidth / 2;
ctx.stroke();
// Inline
if (a.owner.isMe()) {
ctx.strokeStyle = lineColorSelf;
} else if (this.game.myPlayer()?.isFriendly(a.owner)) {
ctx.strokeStyle = lineColorFriend;
} else {
ctx.strokeStyle = lineColorEnemy;
}
ctx.lineWidth = lineWidth;
ctx.setLineDash(lineDash);
ctx.lineDashOffset = this.dashOffset;
ctx.stroke();
}
}
@@ -8,6 +8,7 @@ import explosionIcon from "../../../../resources/images/ExplosionIconWhite.svg";
import mouseIcon from "../../../../resources/images/MouseIconWhite.svg";
import ninjaIcon from "../../../../resources/images/NinjaIconWhite.svg";
import settingsIcon from "../../../../resources/images/SettingIconWhite.svg";
import sirenIcon from "../../../../resources/images/SirenIconWhite.svg";
import treeIcon from "../../../../resources/images/TreeIconWhite.svg";
import musicIcon from "../../../../resources/images/music.svg";
import { EventBus } from "../../../core/EventBus";
@@ -130,6 +131,11 @@ export class SettingsModal extends LitElement implements Layer {
this.requestUpdate();
}
private onToggleAlertFrameButtonClick() {
this.userSettings.toggleAlertFrame();
this.requestUpdate();
}
private onToggleDarkModeButtonClick() {
this.userSettings.toggleDarkMode();
this.eventBus.emit(new RefreshGraphicsEvent());
@@ -346,6 +352,26 @@ export class SettingsModal extends LitElement implements Layer {
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded text-white transition-colors"
@click="${this.onToggleAlertFrameButtonClick}"
>
<img src=${sirenIcon} alt="alertFrame" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.alert_frame_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.alert_frame_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.alertFrame()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded text-white transition-colors"
@click="${this.onToggleStructureSpritesButtonClick}"
@@ -148,6 +148,9 @@ export class SpriteFactory {
const { type, stage } = options;
const { scale } = this.transformHandler;
this.renderSprites =
this.game.config().userSettings()?.structureSprites() ?? true;
if (type === "icon" || type === "dot") {
const texture = this.createTexture(
structureType,
+1 -1
View File
@@ -376,7 +376,7 @@ export class UnitLayer implements Layer {
);
}
// interception missle from SAM
// interception missile from SAM
private handleMissileEvent(unit: UnitView) {
this.drawSprite(unit);
}
+30 -1
View File
@@ -1,7 +1,11 @@
import { LitElement, TemplateResult, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import ofmWintersLogo from "../../../../resources/images/OfmWintersLogo.png";
import { isInIframe, translateText } from "../../../client/Utils";
import {
getGamesPlayed,
isInIframe,
translateText,
} from "../../../client/Utils";
import { ColorPalette, Pattern } from "../../../core/CosmeticSchemas";
import { EventBus } from "../../../core/EventBus";
import { GameUpdateType } from "../../../core/game/GameUpdates";
@@ -105,6 +109,9 @@ export class WinModal extends LitElement implements Layer {
return this.steamWishlist();
}
if (!this.isWin && getGamesPlayed() < 3) {
return this.renderYoutubeTutorial();
}
if (this.rand < 0.25) {
return this.steamWishlist();
} else if (this.rand < 0.5) {
@@ -116,6 +123,28 @@ export class WinModal extends LitElement implements Layer {
}
}
renderYoutubeTutorial() {
return html`
<div class="text-center mb-6 bg-black/30 p-2.5 rounded">
<h3 class="text-xl font-semibold text-white mb-3">
${translateText("win_modal.youtube_tutorial")}
</h3>
<div class="relative w-full" style="padding-bottom: 56.25%;">
<iframe
class="absolute top-0 left-0 w-full h-full rounded"
src="${this.isVisible
? "https://www.youtube.com/embed/EN2oOog3pSs"
: ""}"
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
></iframe>
</div>
</div>
`;
}
renderPatternButton() {
return html`
<div class="text-center mb-6 bg-black/30 p-2.5 rounded">
+13 -3
View File
@@ -90,6 +90,13 @@
document.documentElement.className = "preload";
</script>
<!-- Cloudflare Turnstile -->
<script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
async
defer
></script>
<!-- Publift/Fuse ads -->
<script
async
@@ -201,7 +208,10 @@
</div>
</header>
<div class="bg-image"></div>
<div
id="turnstile-container"
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[99999]"
></div>
<gutter-ads></gutter-ads>
<!-- Main container with responsive padding -->
@@ -301,7 +311,7 @@
</div>
<div
class="bottom-0 w-full flex-col-reverse sm:flex-row z-50 md:w-[320px]"
class="left-0 bottom-0 sm:left-4 sm:bottom-4 w-full flex-col-reverse sm:flex-row z-50 md:w-[320px]"
style="position: fixed; pointer-events: none"
>
<div
@@ -398,7 +408,7 @@
<game-starting-modal></game-starting-modal>
<game-top-bar></game-top-bar>
<unit-display></unit-display>
<div class="flex fixed top-[20px] right-[20px] z-[1000] items-start gap-2">
<div class="flex fixed top-4 right-4 z-[1000] items-start gap-2">
<replay-panel></replay-panel>
<game-right-sidebar></game-right-sidebar>
</div>
+12 -1
View File
@@ -88,6 +88,7 @@ export type ClientMessage =
| ClientPingMessage
| ClientIntentMessage
| ClientJoinMessage
| ClientRejoinMessage
| ClientLogMessage
| ClientHashMessage;
export type ServerMessage =
@@ -110,6 +111,7 @@ export type ClientSendWinnerMessage = z.infer<typeof ClientSendWinnerSchema>;
export type ClientPingMessage = z.infer<typeof ClientPingMessageSchema>;
export type ClientIntentMessage = z.infer<typeof ClientIntentMessageSchema>;
export type ClientJoinMessage = z.infer<typeof ClientJoinMessageSchema>;
export type ClientRejoinMessage = z.infer<typeof ClientRejoinMessageSchema>;
export type ClientLogMessage = z.infer<typeof ClientLogMessageSchema>;
export type ClientHashMessage = z.infer<typeof ClientHashSchema>;
@@ -529,10 +531,18 @@ export const ClientJoinMessageSchema = z.object({
clientID: ID,
token: TokenSchema, // WARNING: PII
gameID: ID,
lastTurn: z.number(), // The last turn the client saw.
username: UsernameSchema,
// Server replaces the refs with the actual cosmetic data.
cosmetics: PlayerCosmeticRefsSchema.optional(),
turnstileToken: z.string().nullable(),
});
export const ClientRejoinMessageSchema = z.object({
type: z.literal("rejoin"),
gameID: ID,
clientID: ID,
lastTurn: z.number(),
token: TokenSchema,
});
export const ClientMessageSchema = z.discriminatedUnion("type", [
@@ -540,6 +550,7 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [
ClientPingMessageSchema,
ClientIntentMessageSchema,
ClientJoinMessageSchema,
ClientRejoinMessageSchema,
ClientLogMessageSchema,
ClientHashSchema,
]);
+2
View File
@@ -27,6 +27,8 @@ export enum GameEnv {
}
export interface ServerConfig {
turnstileSiteKey(): string;
turnstileSecretKey(): string;
turnIntervalMs(): number;
gameCreationRate(): number;
lobbyMaxPlayers(
+6
View File
@@ -64,10 +64,12 @@ const numPlayersConfig = {
[GameMapType.FaroeIslands]: [20, 15, 10],
[GameMapType.GatewayToTheAtlantic]: [100, 70, 50],
[GameMapType.GiantWorldMap]: [100, 70, 50],
[GameMapType.GulfOfStLawrence]: [60, 40, 30],
[GameMapType.Halkidiki]: [100, 50, 40],
[GameMapType.Iceland]: [50, 40, 30],
[GameMapType.Italia]: [50, 30, 20],
[GameMapType.Japan]: [20, 15, 10],
[GameMapType.Lisbon]: [50, 40, 30],
[GameMapType.Mars]: [70, 40, 30],
[GameMapType.Mena]: [70, 50, 40],
[GameMapType.Montreal]: [60, 40, 30],
@@ -81,6 +83,10 @@ const numPlayersConfig = {
} as const satisfies Record<GameMapType, [number, number, number]>;
export abstract class DefaultServerConfig implements ServerConfig {
turnstileSecretKey(): string {
return process.env.TURNSTILE_SECRET_KEY ?? "";
}
abstract turnstileSiteKey(): string;
allowedFlares(): string[] | undefined {
return;
}
+8 -28
View File
@@ -1,10 +1,17 @@
import { UnitInfo, UnitType } from "../game/Game";
import { UserSettings } from "../game/UserSettings";
import { GameConfig } from "../Schemas";
import { GameEnv, ServerConfig } from "./Config";
import { DefaultConfig, DefaultServerConfig } from "./DefaultConfig";
export class DevServerConfig extends DefaultServerConfig {
turnstileSiteKey(): string {
return "1x00000000000000000000AA";
}
turnstileSecretKey(): string {
return "1x0000000000000000000000000000000AA";
}
adminToken(): string {
return "WARNING_DEV_ADMIN_KEY_DO_NOT_USE_IN_PRODUCTION";
}
@@ -57,31 +64,4 @@ export class DevConfig extends DefaultConfig {
) {
super(sc, gc, us, isReplay);
}
unitInfo(type: UnitType): UnitInfo {
const info = super.unitInfo(type);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const oldCost = info.cost;
// info.cost = (p: Player) => oldCost(p) / 1000000000;
return info;
}
// tradeShipSpawnRate(): number {
// return 10;
// }
// percentageTilesOwnedToWin(): number {
// return 1
// }
// boatMaxDistance(): number {
// return 5000
// }
// numBots(): number {
// return 0;
// }
// spawnNPCs(): boolean {
// return false;
// }
}
+3
View File
@@ -8,6 +8,9 @@ export const preprodConfig = new (class extends DefaultServerConfig {
numWorkers(): number {
return 2;
}
turnstileSiteKey(): string {
return "0x4AAAAAAB7QetxHwRCKw-aP";
}
jwtAudience(): string {
return "openfront.dev";
}
+3
View File
@@ -11,4 +11,7 @@ export const prodConfig = new (class extends DefaultServerConfig {
jwtAudience(): string {
return "openfront.io";
}
turnstileSiteKey(): string {
return "0x4AAAAAACFLkaecN39lS8sk";
}
})();
+48 -7
View File
@@ -4,11 +4,16 @@ import { PseudoRandom } from "../PseudoRandom";
import { GameID } from "../Schemas";
import { simpleHash } from "../Util";
import { SpawnExecution } from "./SpawnExecution";
import { BOT_NAME_PREFIXES, BOT_NAME_SUFFIXES } from "./utils/BotNames";
import {
COMMUNITY_FULL_ELF_NAMES,
COMMUNITY_PREFIXES,
SPECIAL_FULL_ELF_NAMES,
} from "./utils/BotNames";
export class BotSpawner {
private random: PseudoRandom;
private bots: SpawnExecution[] = [];
private nameIndex = 0;
constructor(
private gs: Game,
@@ -24,9 +29,13 @@ export class BotSpawner {
console.log("too many retries while spawning bots, giving up");
return this.bots;
}
const botName = this.randomBotName();
const spawn = this.spawnBot(botName);
const candidate = this.nextCandidateName();
const spawn = this.spawnBot(candidate.name);
if (spawn !== null) {
// Only use candidate name once bot successfully spawned
if (candidate.source === "list") {
this.nameIndex++;
}
this.bots.push(spawn);
} else {
tries++;
@@ -51,10 +60,42 @@ export class BotSpawner {
);
}
private randomBotName(): string {
const prefixIndex = this.random.nextInt(0, BOT_NAME_PREFIXES.length);
const suffixIndex = this.random.nextInt(0, BOT_NAME_SUFFIXES.length);
return `${BOT_NAME_PREFIXES[prefixIndex]} ${BOT_NAME_SUFFIXES[suffixIndex]}`;
private nextCandidateName(): {
name: string;
source: "list" | "random";
} {
if (this.bots.length < 20) {
//first few usually overwritten by Nation spawn
return { name: this.getRandomElf(), source: "random" };
}
if (this.nameIndex < COMMUNITY_FULL_ELF_NAMES.length) {
return {
name: COMMUNITY_FULL_ELF_NAMES[this.nameIndex],
source: "list",
};
}
const specialOffset = COMMUNITY_FULL_ELF_NAMES.length;
if (this.nameIndex < specialOffset + SPECIAL_FULL_ELF_NAMES.length) {
return {
name: SPECIAL_FULL_ELF_NAMES[this.nameIndex - specialOffset],
source: "list",
};
}
const prefixOffset = specialOffset + SPECIAL_FULL_ELF_NAMES.length;
if (this.nameIndex < prefixOffset + COMMUNITY_PREFIXES.length) {
return {
name: `${COMMUNITY_PREFIXES[this.nameIndex - prefixOffset]} the Elf`,
source: "list",
};
}
return { name: this.getRandomElf(), source: "random" };
}
private getRandomElf(): string {
const suffixNumber = this.random.nextInt(1, 10001);
return `Elf ${suffixNumber}`;
}
private randTile(): TileRef {
+1 -1
View File
@@ -82,7 +82,7 @@ export class NukeExecution implements Execution {
this.nuke.type() !== UnitType.MIRVWarhead
) {
// Resolves exploit of alliance breaking in which a pending alliance request
// was accepeted in the middle of an missle attack.
// was accepted in the middle of a missile attack.
const allianceRequest = attackedPlayer
.incomingAllianceRequests()
.find((ar) => ar.requestor() === this.player);
+181
View File
@@ -253,3 +253,184 @@ export const BOT_NAME_SUFFIXES = [
"Democracy",
"Autocracy",
];
export const COMMUNITY_FULL_ELF_NAMES = [
"evan the Creator Elf",
"iamlewis the Head Elf",
"Restart the Community Elf",
"Mr Box the Dev Elf",
"InGloriousTom the Dev Elf",
"Sheikh the First Elf",
"N0ur the Flag Elf",
"Diessel the UI Elf",
"Nikola123 the Map Elf",
"Aotumuri the Language Elf",
"Pilkey the Admin Elf",
"Mr tryout33s Elf",
"Biffeur the YT Elf",
"Enzo the YT Elf",
"Molky the YT Elf",
"FuzeIII the YT Elf",
"Node the YT Elf",
"Lumiin the YT Elf",
"youngfentanyl OFM Elf",
"Remorse the Wiki Elf",
"Lonely Millennial Twitch Elf",
"Kaizeron OFM Elf",
"Zilka OFM Elf",
"JIZK Caster Elf",
"MiraCZ the FP Elf",
"aPuddle best Elf",
"lucas the sound Elf",
];
export const SPECIAL_FULL_ELF_NAMES = [
"Santa",
"Rudolf the Red-Nosed Reindeer",
"Frosty the Snowman",
"Hermey the Elf",
"Ivan the Elf",
"Elf on the Shelf",
"Buddy the Elf",
"Legolas",
"Elrond",
"Galadriel",
"Celeborn",
"Glorfindel",
];
export const COMMUNITY_PREFIXES = [
"Baguette Bot",
"Kiwi",
"FakeNeo",
"Nash",
"1brucben",
"Toyatak",
"Readix",
"Danny",
"php",
"Redincon",
"Sachx.",
"Fuity Mctooty",
"Vimacs",
"Wraith",
"Phantom",
"Crescent",
"OF Therapist",
"Aviid",
"brunoo",
"Ezaru",
"prices",
"Santos",
"Wonder",
"Vincent",
"Smith M",
"Acer Alex",
"Controller",
"d3n0x",
"devalnor",
"FloPinguin",
"falcon",
"GlacialDrift",
"Jax",
"Killersoren",
"MiniMeTiny",
"Remissile",
"Sorikairo",
"That Otter",
"Arya",
"Nebula",
"takeser",
"Kai IL PAZZO",
"Vanon",
"Foorack",
"Abod",
"aaa4xu",
"Goblinon",
"dx",
"Pod",
"Demonessica",
"Dovg",
"Joel",
"LegitimatelyCool1",
"OxMzimzy",
"RTHOne",
"Egophobic",
"djmrFunnyMan",
"5oliloguy",
"cfsolver",
"nvm",
"Supbro",
"Mischa",
"WALMART NINJA",
"Magico",
"sidious",
"Bruny",
"Goofer",
"Backn",
"EyeSeeEm",
"TrionX",
"Theodora",
"platz1de",
"Maths Empire",
"Moha",
"SyntaxPM",
"theskeleton4393",
"juliosilvaqwerty5",
"NewHappyRabbit",
"Moki",
"Xaelor",
"NiclasWK",
"cldprv",
"r3ms",
"Tanepro193",
"gx21",
"toldinsound",
"jacks0n",
"floriankilian",
"Fibig",
"Texxter",
"pantelispantelidis",
"ap ms",
"frappa10",
"Lollosean",
"daimyo panda2",
"gafunuko",
"Jinyoon",
"Perdiccas",
"zibi",
"RinkyDinky",
"Rulfam",
"Nobody",
"Vekser",
"extraextra",
"MotivatedMonkey",
"6uzm4n",
"theangel2",
"Keevee",
"Makonede",
"grassified",
"Zjefken",
"Summers Nick",
"Marvin",
"EagleEye",
"Shahiid",
"INGSOC",
"SIG",
"Bobo",
"seekerreturns",
"SlyTy",
"Leo 21",
"FX",
"Calrathan",
"AzloD",
"SunnyBoyWTF",
"BeGj",
"tnhnblgl",
"BrunoJurkovic",
"q8gazy",
"Kipstz",
"aqw42",
"TylerHavanan",
"KerodK",
"ghisloufou",
"dxtron",
"Sii",
];
+4
View File
@@ -101,6 +101,8 @@ export enum GameMapType {
Achiran = "Achiran",
BaikalNukeWars = "Baikal (Nuke Wars)",
FourIslands = "Four Islands",
GulfOfStLawrence = "Gulf of St. Lawrence",
Lisbon = "Lisbon",
}
export type GameMapName = keyof typeof GameMapType;
@@ -134,6 +136,8 @@ export const mapCategories: Record<string, GameMapType[]> = {
GameMapType.Italia,
GameMapType.Japan,
GameMapType.Montreal,
GameMapType.GulfOfStLawrence,
GameMapType.Lisbon,
],
fantasy: [
GameMapType.Pangaea,
+11 -4
View File
@@ -213,7 +213,9 @@ export class PlayerView {
.theme()
.borderColor(defaultTerritoryColor);
const pattern = this.cosmetics.pattern;
const pattern = userSettings.territoryPatterns()
? this.cosmetics.pattern
: undefined;
if (pattern) {
pattern.colorPalette ??= {
name: "",
@@ -225,7 +227,7 @@ export class PlayerView {
if (this.team() === null) {
this._territoryColor = colord(
this.cosmetics.color?.color ??
this.cosmetics.pattern?.colorPalette?.primaryColor ??
pattern?.colorPalette?.primaryColor ??
defaultTerritoryColor.toHex(),
);
} else {
@@ -254,9 +256,9 @@ export class PlayerView {
.defendedBorderColors(this._borderColor);
this.decoder =
this.cosmetics.pattern === undefined
pattern === undefined
? undefined
: new PatternDecoder(this.cosmetics.pattern, base64url.decode);
: new PatternDecoder(pattern, base64url.decode);
}
territoryColor(tile?: TileRef): Colord {
@@ -385,10 +387,15 @@ export class PlayerView {
totalUnitLevels(type: UnitType): number {
return this.units(type)
.filter((unit) => !unit.isUnderConstruction())
.map((unit) => unit.level())
.reduce((a, b) => a + b, 0);
}
isMe(): boolean {
return this.smallID() === this.game.myPlayer()?.smallID();
}
isAlliedWith(other: PlayerView): boolean {
return this.data.allies.some((n) => other.smallID() === n);
}
+1 -1
View File
@@ -399,7 +399,7 @@ export class PlayerImpl implements Player {
if (this.isDisconnected() || other.isDisconnected()) {
// Disconnected players are marked as not-friendly even if they are allies,
// so we need to return early if either player is disconnected.
// Otherise we could end up sending an alliance request to someone
// Otherwise we could end up sending an alliance request to someone
// we are already allied with.
return false;
}
+17 -2
View File
@@ -5,6 +5,7 @@ import { PlayerInfo, PlayerType, Team } from "./Game";
export function assignTeams(
players: PlayerInfo[],
teams: Team[],
maxTeamSize: number = getMaxTeamSize(players.length, teams.length),
): Map<PlayerInfo, Team | "kicked"> {
const result = new Map<PlayerInfo, Team | "kicked">();
const teamPlayerCount = new Map<Team, number>();
@@ -25,8 +26,6 @@ export function assignTeams(
}
}
const maxTeamSize = Math.ceil(players.length / teams.length);
// Sort clans by size (largest first)
const sortedClans = Array.from(clanGroups.entries()).sort(
(a, b) => b[1].length - a[1].length,
@@ -87,3 +86,19 @@ export function assignTeams(
return result;
}
export function assignTeamsLobbyPreview(
players: PlayerInfo[],
teams: Team[],
nationCount: number,
): Map<PlayerInfo, Team | "kicked"> {
const maxTeamSize = getMaxTeamSize(
players.length + nationCount,
teams.length,
);
return assignTeams(players, teams, maxTeamSize);
}
export function getMaxTeamSize(numPlayers: number, numTeams: number): number {
return Math.ceil(numPlayers / numTeams);
}
+2
View File
@@ -75,6 +75,7 @@ export class UnitImpl implements Unit {
case UnitType.DefensePost:
case UnitType.SAMLauncher:
case UnitType.City:
case UnitType.Factory:
this.mg.stats().unitBuild(_owner, this._type);
}
}
@@ -193,6 +194,7 @@ export class UnitImpl implements Unit {
case UnitType.DefensePost:
case UnitType.SAMLauncher:
case UnitType.City:
case UnitType.Factory:
this.mg.stats().unitCapture(newOwner, this._type);
this.mg.stats().unitLose(this._owner, this._type);
break;
+1 -1
View File
@@ -75,7 +75,7 @@ export class UserSettings {
focusLocked() {
return false;
// TODO: renable when performance issues are fixed.
// TODO: re-enable when performance issues are fixed.
this.get("settings.focusLocked", true);
}
+2 -2
View File
@@ -104,7 +104,7 @@ function fixExtremes(upscaled: Cell[], cellDst: Cell, cellSrc?: Cell): Cell[] {
if (cellSrc !== undefined) {
const srcIndex = findCell(upscaled, cellSrc);
if (srcIndex === -1) {
// didnt find the start tile in the path
// didn't find the start tile in the path
upscaled.unshift(cellSrc);
} else if (srcIndex !== 0) {
// found start tile but not at the start
@@ -115,7 +115,7 @@ function fixExtremes(upscaled: Cell[], cellDst: Cell, cellSrc?: Cell): Cell[] {
const dstIndex = findCell(upscaled, cellDst);
if (dstIndex === -1) {
// didnt find the dst tile in the path
// didn't find the dst tile in the path
upscaled.push(cellDst);
} else if (dstIndex !== upscaled.length - 1) {
// found dst tile but not at the end
+1 -1
View File
@@ -54,7 +54,7 @@ export function isProfaneUsername(username: string): boolean {
*
* Removing bad clan tags won't hurt existing clans nor cause desyncs:
* - full name including clan tag was overwritten in the past, if any part of name was bad
* - only each seperate local player name with a profane clan tag will remain, no clan team assignment
* - only each separate local player name with a profane clan tag will remain, no clan team assignment
*
* Examples:
* - "GoodName" -> "GoodName"
+2 -1
View File
@@ -18,7 +18,8 @@ export class Client {
public readonly flares: string[] | undefined,
public readonly ip: string,
public readonly username: string,
public readonly ws: WebSocket,
public ws: WebSocket,
public readonly cosmetics: PlayerCosmetics | undefined,
public readonly isRejoin: boolean = false,
) {}
}
+17 -3
View File
@@ -1,4 +1,5 @@
import { Logger } from "winston";
import WebSocket from "ws";
import { ServerConfig } from "../core/configuration/Config";
import {
Difficulty,
@@ -7,7 +8,7 @@ import {
GameMode,
GameType,
} from "../core/game/Game";
import { GameConfig, GameID } from "../core/Schemas";
import { ClientRejoinMessage, GameConfig, GameID } from "../core/Schemas";
import { Client } from "./Client";
import { GamePhase, GameServer } from "./GameServer";
@@ -25,10 +26,23 @@ export class GameManager {
return this.games.get(id) ?? null;
}
addClient(client: Client, gameID: GameID, lastTurn: number): boolean {
joinClient(client: Client, gameID: GameID): boolean {
const game = this.games.get(gameID);
if (game) {
game.addClient(client, lastTurn);
game.joinClient(client);
return true;
}
return false;
}
rejoinClient(
ws: WebSocket,
persistentID: string,
msg: ClientRejoinMessage,
): boolean {
const game = this.games.get(msg.gameID);
if (game) {
game.rejoinClient(ws, persistentID, msg);
return true;
}
return false;
+90 -32
View File
@@ -7,6 +7,7 @@ import { GameType } from "../core/game/Game";
import {
ClientID,
ClientMessageSchema,
ClientRejoinMessage,
ClientSendWinnerMessage,
GameConfig,
GameInfo,
@@ -129,7 +130,7 @@ export class GameServer {
}
}
public addClient(client: Client, lastTurn: number) {
public joinClient(client: Client) {
this.websockets.add(client.ws);
if (this.kickedClients.has(client.clientID)) {
this.log.warn(`cannot add client, already kicked`, {
@@ -137,6 +138,31 @@ export class GameServer {
});
return;
}
if (this.allClients.has(client.clientID)) {
this.log.warn("cannot add client, already in game", {
clientID: client.clientID,
});
return;
}
if (
this.gameConfig.maxPlayers &&
this.activeClients.length >= this.gameConfig.maxPlayers
) {
this.log.warn(`cannot add client, game full`, {
clientID: client.clientID,
});
client.ws.send(
JSON.stringify({
type: "error",
error: "full-lobby",
} satisfies ServerErrorMessage),
);
return;
}
// Log when lobby creator joins private game
if (client.clientID === this.lobbyCreatorID) {
this.log.info("Lobby creator joined", {
@@ -144,11 +170,10 @@ export class GameServer {
creatorID: this.lobbyCreatorID,
});
}
this.log.info("client (re)joining game", {
this.log.info("client joining game", {
clientID: client.clientID,
persistentID: client.persistentID,
clientIP: ipAnonymize(client.ip),
isRejoin: lastTurn > 0,
});
if (
@@ -185,36 +210,67 @@ export class GameServer {
}
}
// Remove stale client if this is a reconnect
const existing = this.activeClients.find(
(c) => c.clientID === client.clientID,
);
if (existing !== undefined) {
if (client.persistentID !== existing.persistentID) {
this.log.error("persistent ids do not match", {
clientID: client.clientID,
clientIP: ipAnonymize(client.ip),
clientPersistentID: client.persistentID,
existingIP: ipAnonymize(existing.ip),
existingPersistentID: existing.persistentID,
});
return;
}
client.lastPing = existing.lastPing;
client.reportedWinner = existing.reportedWinner;
this.activeClients = this.activeClients.filter((c) => c !== existing);
}
// Client connection accepted
this.activeClients.push(client);
client.lastPing = Date.now();
this.markClientDisconnected(client.clientID, false);
this.allClients.set(client.clientID, client);
this.addListeners(client);
// In case a client joined the game late and missed the start message.
if (this._hasStarted) {
this.sendStartGameMsg(client.ws, 0);
}
}
public rejoinClient(
ws: WebSocket,
persistentID: string,
msg: ClientRejoinMessage,
): void {
this.websockets.add(ws);
if (this.kickedClients.has(msg.clientID)) {
this.log.warn("cannot rejoin client, client has been kicked", {
clientID: msg.clientID,
});
return;
}
const client = this.allClients.get(msg.clientID);
if (!client) {
this.log.warn("cannot rejoin client, existing client not found", {
clientID: msg.clientID,
});
return;
}
if (client.persistentID !== persistentID) {
this.log.error("persistent ids do not match", {
clientID: msg.clientID,
clientPersistentID: persistentID,
existingIP: ipAnonymize(client.ip),
existingPersistentID: client.persistentID,
});
return;
}
this.activeClients = this.activeClients.filter(
(c) => c.clientID !== msg.clientID,
);
this.activeClients.push(client);
client.lastPing = Date.now();
this.markClientDisconnected(msg.clientID, false);
client.ws = ws;
this.addListeners(client);
if (this._hasStarted) {
this.sendStartGameMsg(client.ws, msg.lastTurn);
}
}
private addListeners(client: Client) {
client.ws.removeAllListeners("message");
client.ws.on("message", async (message: string) => {
try {
@@ -236,6 +292,13 @@ export class GameServer {
}
const clientMsg = parsed.data;
switch (clientMsg.type) {
case "rejoin": {
// Client is already connected, no auth required, send start game message if game has started
if (this._hasStarted) {
this.sendStartGameMsg(client.ws, clientMsg.lastTurn);
}
break;
}
case "intent": {
if (clientMsg.intent.clientID !== client.clientID) {
this.log.warn(
@@ -333,11 +396,6 @@ export class GameServer {
client.ws.close(1002, "WS_ERR_UNEXPECTED_RSV_1");
}
});
// In case a client joined the game late and missed the start message.
if (this._hasStarted) {
this.sendStartGameMsg(client.ws, lastTurn);
}
}
public numClients(): number {
+2 -1
View File
@@ -37,10 +37,12 @@ const frequency: Partial<Record<GameMapName, number>> = {
FalklandIslands: 4,
FaroeIslands: 4,
GatewayToTheAtlantic: 5,
GulfOfStLawrence: 4,
Halkidiki: 4,
Iceland: 4,
Italia: 6,
Japan: 6,
Lisbon: 4,
Mars: 3,
Mena: 6,
Montreal: 6,
@@ -67,7 +69,6 @@ const TEAM_COUNTS = [
Duos,
Trios,
Quads,
HumansVsNations,
] as const satisfies TeamCountConfig[];
export class MapPlaylist {
+2 -2
View File
@@ -6,9 +6,9 @@ import { Cloudflare, TunnelConfig } from "./Cloudflare";
import { startMaster } from "./Master";
import { startWorker } from "./Worker";
const config = getServerConfigFromServer();
// Load environment variables before we read configuration values derived from them.
dotenv.config();
const config = getServerConfigFromServer();
// Main entry point of the application
async function main() {
+73
View File
@@ -0,0 +1,73 @@
export async function verifyTurnstileToken(
ip: string,
turnstileToken: string | null,
turnstileSecret: string,
): Promise<
| { status: "approved" }
| { status: "rejected"; reason: string }
| { status: "error"; reason: string }
> {
if (!turnstileToken) {
return { status: "rejected", reason: "No turnstile token provided" };
}
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
const response = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
secret: turnstileSecret,
response: turnstileToken,
remoteip: ip,
}),
signal: controller.signal,
},
);
clearTimeout(timeoutId);
if (!response.ok) {
return {
status: "error",
reason: `Turnstile API returned ${response.status}`,
};
}
const result = (await response.json()) as {
success: boolean;
challenge_ts?: string;
hostname?: string;
"error-codes"?: string[];
action?: string;
cdata?: string;
};
if (!result.success) {
const codes = result["error-codes"]?.join(", ") ?? "unknown";
return {
status: "rejected",
reason: `Turnstile token validation failed: ${codes}`,
};
}
return { status: "approved" };
} catch (e) {
if (e instanceof Error && e.name === "AbortError") {
return {
status: "error",
reason: "Turnstile token validation timed out after 3 seconds",
};
}
return {
status: "error",
reason: `Turnstile token validation failed, ${e}`,
};
}
}
+46 -6
View File
@@ -24,8 +24,10 @@ import { GameManager } from "./GameManager";
import { getUserMe, verifyClientToken } from "./jwt";
import { logger } from "./Logger";
import { GameEnv } from "../core/configuration/Config";
import { MapPlaylist } from "./MapPlaylist";
import { PrivilegeRefresher } from "./PrivilegeRefresher";
import { verifyTurnstileToken } from "./Turnstile";
import { initWorkerMetrics } from "./WorkerMetrics";
const config = getServerConfigFromServer();
@@ -317,7 +319,7 @@ export async function startWorker() {
if (clientMsg.type === "ping") {
// Ignore ping
return;
} else if (clientMsg.type !== "join") {
} else if (clientMsg.type !== "join" && clientMsg.type !== "rejoin") {
log.warn(
`Invalid message before join: ${JSON.stringify(clientMsg, replacer)}`,
);
@@ -342,6 +344,23 @@ export async function startWorker() {
}
const { persistentId, claims } = result;
if (clientMsg.type === "rejoin") {
log.info("rejoining game", {
gameID: clientMsg.gameID,
clientID: clientMsg.clientID,
persistentID: persistentId,
});
const wasFound = gm.rejoinClient(ws, persistentId, clientMsg);
if (!wasFound) {
log.warn(
`game ${clientMsg.gameID} not found on worker ${workerId}`,
);
ws.close(1002, "Game not found");
}
return;
}
let roles: string[] | undefined;
let flares: string[] | undefined;
@@ -389,6 +408,31 @@ export async function startWorker() {
return;
}
if (config.env() !== GameEnv.Dev) {
const turnstileResult = await verifyTurnstileToken(
ip,
clientMsg.turnstileToken,
config.turnstileSecretKey(),
);
switch (turnstileResult.status) {
case "approved":
break;
case "rejected":
log.warn("Unauthorized: Turnstile token rejected", {
clientID: clientMsg.clientID,
reason: turnstileResult.reason,
});
ws.close(1002, "Unauthorized");
return;
case "error":
// Fail open, allow the client to join.
log.error("Turnstile token error", {
clientID: clientMsg.clientID,
reason: turnstileResult.reason,
});
}
}
// Create client and add to game
const client = new Client(
clientMsg.clientID,
@@ -402,11 +446,7 @@ export async function startWorker() {
cosmeticResult.cosmetics,
);
const wasFound = gm.addClient(
client,
clientMsg.gameID,
clientMsg.lastTurn,
);
const wasFound = gm.joinClient(client, clientMsg.gameID);
if (!wasFound) {
log.info(`game ${clientMsg.gameID} not found on worker ${workerId}`);