mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:40:43 +00:00
Merge branch 'v27'
This commit is contained in:
@@ -120,6 +120,7 @@ jobs:
|
||||
R2_ACCESS_KEY: ${{ secrets.R2_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }}
|
||||
TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }}
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
SERVER_HOST_MASTERS: ${{ secrets.SERVER_HOST_MASTERS }}
|
||||
SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }}
|
||||
|
||||
@@ -78,6 +78,7 @@ jobs:
|
||||
R2_ACCESS_KEY: ${{ secrets.R2_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }}
|
||||
TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }}
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
SERVER_HOST_STAGING: ${{ secrets.SERVER_HOST_STAGING }}
|
||||
SSH_KEY: ~/.ssh/id_rsa
|
||||
@@ -135,6 +136,7 @@ jobs:
|
||||
R2_ACCESS_KEY: ${{ secrets.R2_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }}
|
||||
TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }}
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }}
|
||||
SSH_KEY: ~/.ssh/id_rsa
|
||||
@@ -192,6 +194,7 @@ jobs:
|
||||
R2_ACCESS_KEY: ${{ secrets.R2_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }}
|
||||
TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }}
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }}
|
||||
SSH_KEY: ~/.ssh/id_rsa
|
||||
@@ -249,6 +252,7 @@ jobs:
|
||||
R2_ACCESS_KEY: ${{ secrets.R2_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }}
|
||||
TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }}
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }}
|
||||
SSH_KEY: ~/.ssh/id_rsa
|
||||
|
||||
@@ -176,6 +176,7 @@ R2_ACCESS_KEY=$R2_ACCESS_KEY
|
||||
R2_SECRET_KEY=$R2_SECRET_KEY
|
||||
R2_BUCKET=$R2_BUCKET
|
||||
CF_API_TOKEN=$CF_API_TOKEN
|
||||
TURNSTILE_SECRET_KEY=$TURNSTILE_SECRET_KEY
|
||||
API_KEY=$API_KEY
|
||||
DOMAIN=$DOMAIN
|
||||
SUBDOMAIN=$SUBDOMAIN
|
||||
|
||||
@@ -539,7 +539,8 @@
|
||||
"join_tournament": "Join Tournament",
|
||||
"join_discord": "Join Our Discord Community!",
|
||||
"discord_description": "Connect with other players, get updates, and share strategies",
|
||||
"join_server": "Join Server"
|
||||
"join_server": "Join Server",
|
||||
"youtube_tutorial": "Need some help?"
|
||||
},
|
||||
"leaderboard": {
|
||||
"title": "Leaderboard",
|
||||
|
||||
@@ -58,6 +58,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.
|
||||
@@ -79,9 +80,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;
|
||||
|
||||
@@ -208,7 +217,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;
|
||||
@@ -332,13 +340,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 = () => {
|
||||
@@ -413,10 +420,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) {
|
||||
@@ -435,7 +438,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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
@@ -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}`));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+26
-4
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
+11
-1
@@ -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 -->
|
||||
|
||||
+12
-1
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -27,6 +27,8 @@ export enum GameEnv {
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
turnstileSiteKey(): string;
|
||||
turnstileSecretKey(): string;
|
||||
turnIntervalMs(): number;
|
||||
gameCreationRate(): number;
|
||||
lobbyMaxPlayers(
|
||||
|
||||
@@ -83,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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -11,4 +11,7 @@ export const prodConfig = new (class extends DefaultServerConfig {
|
||||
jwtAudience(): string {
|
||||
return "openfront.io";
|
||||
}
|
||||
turnstileSiteKey(): string {
|
||||
return "0x4AAAAAACFLkaecN39lS8sk";
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+89
-49
@@ -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`, {
|
||||
@@ -138,6 +139,30 @@ 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", {
|
||||
@@ -145,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 (
|
||||
@@ -186,53 +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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -254,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(
|
||||
@@ -351,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 {
|
||||
|
||||
@@ -69,7 +69,6 @@ const TEAM_COUNTS = [
|
||||
Duos,
|
||||
Trios,
|
||||
Quads,
|
||||
HumansVsNations,
|
||||
] as const satisfies TeamCountConfig[];
|
||||
|
||||
export class MapPlaylist {
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
|
||||
@@ -4,6 +4,12 @@ import { GameMapType } from "../../src/core/game/Game";
|
||||
import { GameID } from "../../src/core/Schemas";
|
||||
|
||||
export class TestServerConfig implements ServerConfig {
|
||||
turnstileSiteKey(): string {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
turnstileSecretKey(): string {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
enableMatchmaking(): boolean {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user