diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8e4df3623..ddd5fdc78 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa02920a1..0a65e7b8f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/deploy.sh b/deploy.sh index bbacb85a0..cc5b0ac35 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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 diff --git a/resources/lang/en.json b/resources/lang/en.json index 2953d7011..a737b52d8 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index c3c093426..ecfb4c6ea 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -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 | 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() { diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index c21114911..d813c52a3 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -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 diff --git a/src/client/Main.ts b/src/client/Main.ts index 6425c137f..1cf5ff543 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -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 { + 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}`)); + }, + }); + }); +} diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 98b8bde16..9f4f1f5a7 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -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(); diff --git a/src/client/graphics/layers/AdTimer.ts b/src/client/graphics/layers/AdTimer.ts index eaa46c6e3..2d5462275 100644 --- a/src/client/graphics/layers/AdTimer.ts +++ b/src/client/graphics/layers/AdTimer.ts @@ -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; diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts index 27a9f3047..14a94d8fd 100644 --- a/src/client/graphics/layers/WinModal.ts +++ b/src/client/graphics/layers/WinModal.ts @@ -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` +
+

+ ${translateText("win_modal.youtube_tutorial")} +

+
+ +
+
+ `; + } + renderPatternButton() { return html`
diff --git a/src/client/index.html b/src/client/index.html index af9109cb2..341773620 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -90,6 +90,13 @@ document.documentElement.className = "preload"; + + +