From 5fbdea3c3965a2a8263093be22035aca02058f0e Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sat, 22 Nov 2025 12:23:33 -0800 Subject: [PATCH 01/14] Show enzo tutorial video on death screen if played less than 3 games --- resources/lang/en.json | 3 ++- src/client/graphics/layers/WinModal.ts | 31 +++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 691f188ea..ffc857a62 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -530,7 +530,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/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`
From d88a6ba88753413f03665ed055dbf58fdd7efde3 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sun, 23 Nov 2025 12:38:50 -0800 Subject: [PATCH 02/14] remove human vs nation from public game playlist --- src/server/MapPlaylist.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 4413f2049..85095524a 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -67,7 +67,6 @@ const TEAM_COUNTS = [ Duos, Trios, Quads, - HumansVsNations, ] as const satisfies TeamCountConfig[]; export class MapPlaylist { From c8fb8e7f04d64973b18d08a4bcbf70621c2ccfc2 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Tue, 25 Nov 2025 15:47:12 -0800 Subject: [PATCH 03/14] record stats for factory build and capture --- src/core/game/UnitImpl.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index c4f3c00e0..c6b8f68da 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -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; From e99bf6360cbd6574b8176fda726aac29b76f6737 Mon Sep 17 00:00:00 2001 From: VariableVince <24507472+VariableVince@users.noreply.github.com> Date: Wed, 26 Nov 2025 05:08:30 +0100 Subject: [PATCH 04/14] Fix: prevent desync after clan team assignment for profane username (#2511) ## Description: Clan tag was removed when overwriting profane username. The local player still sees the name they put in though, and are assigned to a team based on the clan tag. Other player's browsers don't assign them to their team because the clan tag isn't visible to them. **Fixes:** - GameRunner.ts > username.ts: fetch clan tag before potentially overwriting bad username. Prepend non-profane clan tag back to the name string afterwards. - Util.ts: added getClanTagOriginalCase; we can't use getClanTag in censorNameWithClanTag because it returns all caps and we needed to retain the orginal capitalization. Explained in code comment. - Game.ts: no changes. By keeping the getClanTag in PlayerInfo contructor, TeamAssignment still gets clan param to correctly assign clan teams, other players get to see the clan tag of the "BeNicer" player, and GameServer archivegame() and LocalServer endGame() can still do getClanTag to have the same data at the end of the game, and test files keep working. **Screencap after fix:** https://github.com/user-attachments/assets/564c0ffd-577e-4653-ba33-155d2135a9f0 ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: tryout33 --- src/core/GameRunner.ts | 23 +++++++------- src/core/Util.ts | 13 ++++++-- src/core/validations/username.ts | 51 +++++++++++++++++++++++++++++++- 3 files changed, 72 insertions(+), 15 deletions(-) diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index a78e39699..e1b9afb5f 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -30,7 +30,7 @@ import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader"; import { PseudoRandom } from "./PseudoRandom"; import { ClientID, GameStartInfo, Turn } from "./Schemas"; import { sanitize, simpleHash } from "./Util"; -import { fixProfaneUsername } from "./validations/username"; +import { censorNameWithClanTag } from "./validations/username"; export async function createGameRunner( gameStart: GameStartInfo, @@ -46,17 +46,16 @@ export async function createGameRunner( ); const random = new PseudoRandom(simpleHash(gameStart.gameID)); - const humans = gameStart.players.map( - (p) => - new PlayerInfo( - p.clientID === clientID - ? sanitize(p.username) - : fixProfaneUsername(sanitize(p.username)), - PlayerType.Human, - p.clientID, - random.nextID(), - ), - ); + const humans = gameStart.players.map((p) => { + return new PlayerInfo( + p.clientID === clientID + ? sanitize(p.username) + : censorNameWithClanTag(p.username), + PlayerType.Human, + p.clientID, + random.nextID(), + ); + }); const nations = gameStart.config.disableNPCs ? [] diff --git a/src/core/Util.ts b/src/core/Util.ts index d060b7c67..3a6395e49 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -339,9 +339,18 @@ export function sigmoid( // Compute clan from name export function getClanTag(name: string): string | null { + const clanTag = clanMatch(name); + return clanTag ? clanTag[1].toUpperCase() : null; +} + +export function getClanTagOriginalCase(name: string): string | null { + const clanTag = clanMatch(name); + return clanTag ? clanTag[1] : null; +} + +function clanMatch(name: string): RegExpMatchArray | null { if (!name.includes("[") || !name.includes("]")) { return null; } - const clanMatch = name.match(/\[([a-zA-Z0-9]{2,5})\]/); - return clanMatch ? clanMatch[1].toUpperCase() : null; + return name.match(/\[([a-zA-Z0-9]{2,5})\]/); } diff --git a/src/core/validations/username.ts b/src/core/validations/username.ts index a7fe4f9dd..b9d50ccc1 100644 --- a/src/core/validations/username.ts +++ b/src/core/validations/username.ts @@ -8,7 +8,7 @@ import { skipNonAlphabeticTransformer, } from "obscenity"; import { translateText } from "../../client/Utils"; -import { simpleHash } from "../Util"; +import { getClanTagOriginalCase, sanitize, simpleHash } from "../Util"; const matcher = new RegExpMatcher({ ...englishDataset.build(), @@ -45,6 +45,55 @@ export function isProfaneUsername(username: string): boolean { return matcher.hasMatch(username); } +/** + * Sanitizes and censors profane usernames and clan tags. + * Profane username is overwritten, profane clan tag is removed. + * + * Preserves non-profane clan tag: + * prevents desync after clan team assignment because local player's own clan tag and name aren't overwritten + * + * 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 + * + * Examples: + * - "GoodName" -> "GoodName" + * - "Good$Name" -> "GoodName" + * - "BadName" -> "Censored" + * - "[CLAN]GoodName" -> "[CLAN]GoodName" + * - "[CLaN]BadName" -> "[CLaN] Censored" + * - "[BAD]GoodName" -> "GoodName" + * - "[BAD]BadName" -> "Censored" + */ +export function censorNameWithClanTag(username: string): string { + const sanitizedUsername = sanitize(username); + + // Don't use getClanTag because that returns upperCase and if original isn't, str replace `[{$clanTag}]` won't match + const clanTag = getClanTagOriginalCase(sanitizedUsername); + + const nameWithoutClan = clanTag + ? sanitizedUsername.replace(`[${clanTag}]`, "").trim() + : sanitizedUsername; + + const clanTagIsProfane = clanTag ? isProfaneUsername(clanTag) : false; + const usernameIsProfane = isProfaneUsername(nameWithoutClan); + + const censoredNameWithoutClan = usernameIsProfane + ? fixProfaneUsername(nameWithoutClan) + : nameWithoutClan; + + // Restore clan tag if it existed and is not profane + if (clanTag && !clanTagIsProfane) { + if (usernameIsProfane) { + return `[${clanTag}] ${censoredNameWithoutClan}`; + } + return sanitizedUsername; + } + + // Don't restore profane or nonexistent clan tag + return censoredNameWithoutClan; +} + export function validateUsername(username: string): { isValid: boolean; error?: string; From d694f615178bd143775f94676cd097b349a3582b Mon Sep 17 00:00:00 2001 From: DevelopingTom Date: Thu, 27 Nov 2025 01:39:52 +0100 Subject: [PATCH 05/14] Add factory & train emojis (#2522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Add 🏭 & 🚂 emojis, which is commonly requested. Also 3 other emojis to fill the new emoji line: 🫴✋🙏 To be discussed: this adds a new line in the emoji list: is it too much? Eventually we could use categories to filter the emojis. image ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: IngloriousTom --- src/core/Util.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/core/Util.ts b/src/core/Util.ts index 3a6395e49..ff71c1a3d 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -307,15 +307,16 @@ export function createRandomName( export const emojiTable = [ ["😀", "😊", "🥰", "😇", "😎"], ["😞", "🥺", "😭", "😱", "😡"], - ["😈", "🤡", "🖕", "🥱", "🤦‍♂️"], - ["👋", "👏", "🤌", "💪", "🫡"], - ["👍", "👎", "❓", "🐔", "🐀"], + ["😈", "🤡", "🥱", "🫡", "🖕"], + ["👋", "👏", "✋", "🙏", "💪"], + ["👍", "👎", "🫴", "🤌", "🤦‍♂️"], ["🤝", "🆘", "🕊️", "🏳️", "⏳"], ["🔥", "💥", "💀", "☢️", "⚠️"], ["↖️", "⬆️", "↗️", "👑", "🥇"], ["⬅️", "🎯", "➡️", "🥈", "🥉"], ["↙️", "⬇️", "↘️", "❤️", "💔"], ["💰", "⚓", "⛵", "🏡", "🛡️"], + ["🏭", "🚂", "❓", "🐔", "🐀"], ] as const; // 2d to 1d array export const flattenedEmojiTable = emojiTable.flat(); From 38145254af7fe2305b72f37a0e910eb9cb2c7738 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Thu, 27 Nov 2025 04:50:58 +0100 Subject: [PATCH 06/14] =?UTF-8?q?Alliance=20icon=20does=20no=20longer=20st?= =?UTF-8?q?retch/disappear=20=F0=9F=96=8C=EF=B8=8F=20(#2527)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #2521 ## Description: Small CSS fix so the new alliance icon does not stretch when there are multiple icons. ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/client/graphics/PlayerIcons.ts | 1 + src/client/graphics/layers/NameLayer.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/client/graphics/PlayerIcons.ts b/src/client/graphics/PlayerIcons.ts index 928e54351..8c2510827 100644 --- a/src/client/graphics/PlayerIcons.ts +++ b/src/client/graphics/PlayerIcons.ts @@ -169,6 +169,7 @@ export function createAllianceProgressIcon( wrapper.style.width = `${size}px`; wrapper.style.height = `${size}px`; wrapper.style.display = "inline-block"; + wrapper.style.flexShrink = "0"; // Base faded icon (full) const base = document.createElement("img"); diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index f87ccd0a1..eb82e3a11 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -439,6 +439,7 @@ export class NameLayer implements Layer { // Update existing alliance icon allianceWrapper.style.width = `${iconSize}px`; allianceWrapper.style.height = `${iconSize}px`; + allianceWrapper.style.flexShrink = "0"; const overlay = allianceWrapper.querySelector( ".alliance-progress-overlay", From 2b606f280b86933338904dc0b7b6142afae44dbd Mon Sep 17 00:00:00 2001 From: VariableVince <24507472+VariableVince@users.noreply.github.com> Date: Wed, 26 Nov 2025 22:09:52 +0100 Subject: [PATCH 07/14] Fix: 'Mini Map' to 'Compact Map' for Private Lobby Modal (#2520) ## Description: Fix for v27. In commit https://github.com/openfrontio/OpenFrontIO/commit/91ff1c0e538b80825fd4374b936763923905cb1d, the name of Mini Map was changed to Compact Map. But it was only done for the Single Player modal by mistake. This PR also changes it for the Host Lobby Modal. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: tryout33 --- resources/lang/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index ffc857a62..ec27601e8 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -267,7 +267,7 @@ "donate_gold": "Donate gold", "infinite_troops": "Infinite troops", "donate_troops": "Donate troops", - "compact_map": "Mini Map", + "compact_map": "Compact Map", "automatic_difficulty": "Automatic Difficulty", "enables_title": "Enable Settings", "player": "Player", From cb4cf091ffd0b4a462770d22ad6773e3533c3ede Mon Sep 17 00:00:00 2001 From: evanpelle Date: Fri, 5 Dec 2025 08:47:48 -0800 Subject: [PATCH 08/14] update ads --- src/client/graphics/layers/AdTimer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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; From 075c232d8a05036732fd2888ddd8cc5fd270bb43 Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 8 Dec 2025 14:07:07 -0800 Subject: [PATCH 09/14] improve game websockt (re)connection (#2584) Previously, the connection and reconnection logic were identical in Worker.ts, so clients would need to be re-authorized for cosmetics etc even when reconnecting. Now, on reconnect, Worker.ts only does authentication - verifying the jwt is valid. This will allow clients to require a valid turnstile token when first connecting, and not when reconnecting after a broken ws connection. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- src/client/ClientGameRunner.ts | 27 +++++---- src/client/Transport.ts | 22 ++++++- src/core/Schemas.ts | 12 +++- src/server/Client.ts | 3 +- src/server/GameManager.ts | 20 ++++++- src/server/GameServer.ts | 105 +++++++++++++++++++++++---------- src/server/Worker.ts | 25 ++++++-- 7 files changed, 158 insertions(+), 56 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 575977f9a..f9d4be0b5 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -79,9 +79,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; @@ -198,7 +206,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; @@ -322,13 +329,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 = () => { @@ -403,10 +409,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) { @@ -425,7 +427,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/Transport.ts b/src/client/Transport.ts index 98b8bde16..6c9d34bfe 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -17,6 +17,7 @@ import { ClientJoinMessage, ClientMessage, ClientPingMessage, + ClientRejoinMessage, ClientSendWinnerMessage, Intent, ServerMessage, @@ -287,6 +288,14 @@ export class Transport { } } + public updateCallback( + onconnect: () => void, + onmessage: (message: ServerMessage) => void, + ) { + this.onconnect = onconnect; + this.onmessage = onmessage; + } + private connectLocal( onconnect: () => void, onmessage: (message: ServerMessage) => void, @@ -376,18 +385,27 @@ 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, } 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/core/Schemas.ts b/src/core/Schemas.ts index acedd062a..fdc3bd4ef 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -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; export type ClientPingMessage = z.infer; export type ClientIntentMessage = z.infer; export type ClientJoinMessage = z.infer; +export type ClientRejoinMessage = z.infer; export type ClientLogMessage = z.infer; export type ClientHashMessage = z.infer; @@ -529,17 +531,25 @@ 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(), }); +export const ClientRejoinMessageSchema = z.object({ + type: z.literal("rejoin"), + gameID: ID, + clientID: ID, + lastTurn: z.number(), + token: TokenSchema, +}); + export const ClientMessageSchema = z.discriminatedUnion("type", [ ClientSendWinnerSchema, ClientPingMessageSchema, ClientIntentMessageSchema, ClientJoinMessageSchema, + ClientRejoinMessageSchema, ClientLogMessageSchema, ClientHashSchema, ]); diff --git a/src/server/Client.ts b/src/server/Client.ts index c0052529f..9f879dddd 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -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, ) {} } diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index 0cd4420da..966867b33 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -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; diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 530391604..dcc214e32 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -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,14 @@ export class GameServer { }); return; } + + if (this.allClients.has(client.clientID)) { + this.log.warn("cannot add client, already in game", { + clientID: client.clientID, + }); + return; + } + // Log when lobby creator joins private game if (client.clientID === this.lobbyCreatorID) { this.log.info("Lobby creator joined", { @@ -144,11 +153,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 +193,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 +275,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 +379,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 { diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 212f1bcf0..a39dafb12 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -317,7 +317,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 +342,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; @@ -402,11 +419,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}`); From 3314ca16cebfc2c09e6e2ac90d77b7e23a1209fd Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 8 Dec 2025 16:16:31 -0800 Subject: [PATCH 10/14] Turnstile: require token before joining a multiplayer game (#2572) When user tries to join either a public or private multiplayer game, the turnstile callback is triggered, and the turnstile token is passed to the server when joining a game. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- .github/workflows/deploy.yml | 1 + .github/workflows/release.yml | 4 ++ deploy.sh | 1 + src/client/ClientGameRunner.ts | 1 + src/client/Main.ts | 73 +++++++++++++++++++++++++ src/client/Transport.ts | 1 + src/client/index.html | 12 +++- src/core/Schemas.ts | 1 + src/core/configuration/Config.ts | 2 + src/core/configuration/DefaultConfig.ts | 4 ++ src/core/configuration/DevConfig.ts | 36 +++--------- src/core/configuration/PreprodConfig.ts | 3 + src/core/configuration/ProdConfig.ts | 3 + src/server/Turnstile.ts | 73 +++++++++++++++++++++++++ src/server/Worker.ts | 27 +++++++++ tests/util/TestServerConfig.ts | 6 ++ 16 files changed, 219 insertions(+), 29 deletions(-) create mode 100644 src/server/Turnstile.ts 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/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index f9d4be0b5..bc7a2a31d 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. diff --git a/src/client/Main.ts b/src/client/Main.ts index ade475838..68bd7a407 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,16 @@ class Client { private gutterAds: GutterAds; + private turnstileTokenPromise: Promise<{ + token: string; + createdAt: number; + }> | null = null; + constructor() {} initialize(): void { + this.turnstileTokenPromise = getTurnstileToken(); + const gameVersion = document.getElementById( "game-version", ) as HTMLDivElement; @@ -484,6 +494,7 @@ class Client { ? "" : this.flagInput.getCurrentFlag(), }, + turnstileToken: await this.getTurnstileToken(lobby), playerName: this.usernameInput?.getCurrentUsername() ?? "", token: getPlayToken(), clientID: lobby.clientID, @@ -596,6 +607,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; + } + + const token = await this.turnstileTokenPromise; + if (token === null) { + return null; + } + + const tokenTTL = 3 * 60 * 1000; + // If token is still valid, use it and kick off new one for next time + if (Date.now() < token.createdAt + tokenTTL) { + this.turnstileTokenPromise = getTurnstileToken(); // Prefetch for next join + return token.token; + } + + // Token expired, get new one immediately + console.log("Turnstile token expired, getting new token"); + const newToken = await getTurnstileToken(); + this.turnstileTokenPromise = getTurnstileToken(); // Prefetch for next time + + if (newToken === null) { + return null; + } + return newToken.token; + } } // Initialize the client when the DOM is loaded @@ -642,3 +687,31 @@ function getPersistentIDFromCookie(): string { return newID; } + +async function getTurnstileToken(): Promise<{ + token: string; + createdAt: number; +}> { + 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": () => { + window.turnstile.remove(widgetId); + alert("Something went wrong, please refresh the page and try again."); + reject(new Error("Turnstile failed")); + }, + }); + }); +} diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 6c9d34bfe..8c0f9a434 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -393,6 +393,7 @@ export class Transport { token: this.lobbyConfig.token, username: this.lobbyConfig.playerName, cosmetics: this.lobbyConfig.cosmetics, + turnstileToken: this.lobbyConfig.turnstileToken, } satisfies ClientJoinMessage); } 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"; + + +