Merge branch 'v27'

This commit is contained in:
evanpelle
2025-12-09 15:39:49 -08:00
25 changed files with 466 additions and 110 deletions
+17 -11
View File
@@ -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() {
+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
+87
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,
@@ -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
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();
+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;
+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">
+11 -1
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 -->