Merge branch 'v29'

This commit is contained in:
evanpelle
2026-02-14 12:14:19 -08:00
15 changed files with 571 additions and 85 deletions
+7
View File
@@ -860,6 +860,13 @@
"pattern": {
"default": "Default"
},
"try_me": "Try me!",
"trial_remaining": "remaining",
"trial_granted": "Skin trial granted!",
"trial_cooldown": "Only one trial per 24 hours. Please try again later.",
"trial_login_required": "Must be logged in to trial a skin",
"reward_countdown": "Reward in {seconds} seconds...",
"steam_wishlist_prompt": "Support OpenFront by adding it to your Steam wishlist",
"select_skin": "Select Skin",
"selected": "selected"
},
+25
View File
@@ -125,6 +125,31 @@ export async function createCheckoutSession(
}
}
export async function grantTemporaryFlare(flare: string): Promise<boolean> {
try {
const response = await fetch(`${getApiBase()}/flares_granted/temporary`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: await getAuthHeader(),
},
body: JSON.stringify({ flare }),
});
if (!response.ok) {
console.error(
"grantTemporaryFlare: request failed",
response.status,
response.statusText,
);
return false;
}
return true;
} catch (e) {
console.error("grantTemporaryFlare: request failed", e);
return false;
}
}
export function getApiBase() {
const domainname = getAudience();
+13
View File
@@ -10,6 +10,7 @@ export type UserAuth = { jwt: string; claims: TokenPayload } | false;
const PERSISTENT_ID_KEY = "player_persistent_id";
let __jwt: string | null = null;
let __refreshPromise: Promise<void> | null = null;
export function discordLogin() {
const redirectUri = encodeURIComponent(window.location.href);
@@ -138,6 +139,18 @@ export async function userAuth(
}
async function refreshJwt(): Promise<void> {
if (__refreshPromise) {
return __refreshPromise;
}
__refreshPromise = doRefreshJwt();
try {
await __refreshPromise;
} finally {
__refreshPromise = null;
}
}
async function doRefreshJwt(): Promise<void> {
try {
console.log("Refreshing jwt");
const response = await fetch(getApiBase() + "/auth/refresh", {
+98 -3
View File
@@ -5,7 +5,15 @@ import {
CosmeticsSchema,
Pattern,
} from "../core/CosmeticSchemas";
import { createCheckoutSession, getApiBase } from "./Api";
import {
PlayerCosmeticRefs,
PlayerCosmetics,
PlayerPattern,
} from "../core/Schemas";
import { UserSettings } from "../core/game/UserSettings";
import { createCheckoutSession, getApiBase, getUserMe } from "./Api";
export const TEMP_FLARE_OFFSET = 1 * 60 * 1000; // 1 minute
export async function handlePurchase(
pattern: Pattern,
@@ -77,14 +85,19 @@ export async function getCosmeticsHash(): Promise<string | null> {
return __cosmeticsHash;
}
// When a number is returned it signifies when the pattern expires.
export function patternRelationship(
pattern: Pattern,
colorPalette: { name: string; isArchived?: boolean } | null,
userMeResponse: UserMeResponse | false,
affiliateCode: string | null,
): "owned" | "purchasable" | "blocked" {
): "owned" | "purchasable" | "purchasable_no_trial" | "blocked" | number {
const flares =
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
const expirations: Record<string, number> =
userMeResponse === false
? {}
: (userMeResponse.player.flareExpiration ?? {});
if (flares.includes("pattern:*")) {
return "owned";
}
@@ -100,6 +113,14 @@ export function patternRelationship(
const requiredFlare = `pattern:${pattern.name}:${colorPalette.name}`;
if (flares.includes(requiredFlare)) {
const expiresAt = expirations[requiredFlare];
if (expiresAt) {
if (expiresAt - Date.now() <= TEMP_FLARE_OFFSET) {
// Already expired or about to expire so just show it as purchasable.
return "purchasable";
}
return expiresAt;
}
return "owned";
}
@@ -118,6 +139,80 @@ export function patternRelationship(
return "blocked";
}
// Patterns is for sale, and it's the right store to show it on.
// --- Patterns is for sale, and it's the right store to show it on. ---
if (pattern.name === "custom") {
// Don't allow trying a custom pattern.
return "purchasable_no_trial";
}
return "purchasable";
}
export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
const userSettings = new UserSettings();
const cosmetics = await fetchCosmetics();
let pattern: PlayerPattern | null =
userSettings.getSelectedPatternName(cosmetics);
if (pattern) {
const userMe = await getUserMe();
if (userMe) {
const flareName =
pattern.colorPalette?.name === undefined
? `pattern:${pattern.name}`
: `pattern:${pattern.name}:${pattern.colorPalette.name}`;
const flares = userMe.player.flares ?? [];
const expirations = userMe.player.flareExpiration ?? {};
const hasWildcard = flares.includes("pattern:*");
if (!hasWildcard) {
if (!flares.includes(flareName)) {
pattern = null;
} else if (expirations[flareName]) {
if (expirations[flareName]! - Date.now() <= TEMP_FLARE_OFFSET) {
pattern = null;
}
}
}
}
if (pattern === null) {
userSettings.setSelectedPatternName(undefined);
}
}
return {
flag: userSettings.getFlag(),
color: userSettings.getSelectedColor() ?? undefined,
patternName: pattern?.name ?? undefined,
patternColorPaletteName: pattern?.colorPalette?.name ?? undefined,
};
}
export async function getPlayerCosmetics(): Promise<PlayerCosmetics> {
const refs = await getPlayerCosmeticsRefs();
const cosmetics = await fetchCosmetics();
const result: PlayerCosmetics = {};
if (refs.flag) {
result.flag = refs.flag;
}
if (refs.color) {
result.color = { color: refs.color };
}
if (refs.patternName && cosmetics) {
const pattern = cosmetics.patterns[refs.patternName];
if (pattern) {
result.pattern = {
name: refs.patternName,
patternData: pattern.pattern,
colorPalette: refs.patternColorPaletteName
? cosmetics.colorPalettes?.[refs.patternColorPaletteName]
: undefined,
};
}
}
return result;
}
+8 -14
View File
@@ -10,7 +10,7 @@ import "./AccountModal";
import { getUserMe } from "./Api";
import { userAuth } from "./Auth";
import { joinLobby } from "./ClientGameRunner";
import { fetchCosmetics } from "./Cosmetics";
import { getPlayerCosmeticsRefs } from "./Cosmetics";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import "./FlagInput";
import { FlagInput } from "./FlagInput";
@@ -182,6 +182,12 @@ declare global {
onPlayerReady: (() => void) | null;
addUnits: (units: Array<{ type: string }>) => Promise<void>;
displayUnits: () => void;
// Rewarded video ad methods
manuallyCreateRewardUi?: (options: {
skipConfirmation?: boolean;
watchAdId?: string;
closeId?: string;
}) => Promise<void> | void;
};
Bolt: {
on: (unitType: string, event: string, callback: () => void) => void;
@@ -798,24 +804,12 @@ class Client {
this.updateJoinUrlForShare(lobby.gameID, config);
}
const pattern = this.userSettings.getSelectedPatternName(
await fetchCosmetics(),
);
this.gameStop = joinLobby(
this.eventBus,
{
gameID: lobby.gameID,
serverConfig: config,
cosmetics: {
color: this.userSettings.getSelectedColor() ?? undefined,
patternName: pattern?.name ?? undefined,
patternColorPaletteName: pattern?.colorPalette?.name ?? undefined,
flag:
this.flagInput === null || this.flagInput.getCurrentFlag() === "xx"
? ""
: this.flagInput.getCurrentFlag(),
},
cosmetics: await getPlayerCosmeticsRefs(),
turnstileToken: await this.getTurnstileToken(lobby),
playerName:
this.usernameInput?.getCurrentUsername() ?? genAnonUsername(),
+8 -21
View File
@@ -1,10 +1,8 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { Cosmetics } from "../core/CosmeticSchemas";
import { UserSettings } from "../core/game/UserSettings";
import { PlayerPattern } from "../core/Schemas";
import { renderPatternPreview } from "./components/PatternButton";
import { fetchCosmetics } from "./Cosmetics";
import { getPlayerCosmetics } from "./Cosmetics";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { translateText } from "./Utils";
@@ -17,24 +15,14 @@ export class PatternInput extends LitElement {
@property({ type: Boolean, attribute: "show-select-label" })
public showSelectLabel: boolean = false;
private userSettings = new UserSettings();
private cosmetics: Cosmetics | null = null;
private _abortController: AbortController | null = null;
private _onPatternSelected = () => {
this.updateFromSettings();
private _onPatternSelected = async () => {
const cosmetics = await getPlayerCosmetics();
this.selectedColor = cosmetics.color?.color ?? null;
this.pattern = cosmetics.pattern ?? null;
};
private updateFromSettings() {
this.selectedColor = this.userSettings.getSelectedColor() ?? null;
if (this.cosmetics) {
this.pattern = this.userSettings.getSelectedPatternName(this.cosmetics);
} else {
this.pattern = null;
}
}
private onInputClick(e: Event) {
e.preventDefault();
e.stopPropagation();
@@ -50,10 +38,9 @@ export class PatternInput extends LitElement {
super.connectedCallback();
this._abortController = new AbortController();
this.isLoading = true;
const cosmetics = await fetchCosmetics();
if (!this.isConnected) return;
this.cosmetics = cosmetics;
this.updateFromSettings();
const cosmetics = await getPlayerCosmetics();
this.selectedColor = cosmetics.color?.color ?? null;
this.pattern = cosmetics.pattern ?? null;
if (!this.isConnected) return;
this.isLoading = false;
window.addEventListener("pattern-selected", this._onPatternSelected, {
+179
View File
@@ -0,0 +1,179 @@
let rewardedUnitRegistered = false;
let rewardedAdReady = false;
// Listen for when rewarded ad becomes available
if (typeof window !== "undefined") {
window.addEventListener("rewardedAdVideoRewardReady", () => {
console.log("[RewardedVideoPromo] Rewarded ad is ready");
rewardedAdReady = true;
});
}
const AD_READY_TIMEOUT_MS = 3000;
function ensureRewardedUnitRegistered(): Promise<void> {
console.log("[ensureRewardedUnitRegistered] Called", {
rewardedUnitRegistered,
rewardedAdReady,
hasSpaAddAds: !!window.ramp?.spaAddAds,
});
return new Promise((resolve, reject) => {
// Check for real SDK (not just stub from index.html)
if (!window.ramp?.spaAddAds) {
console.log(
"[ensureRewardedUnitRegistered] Rejecting: spaAddAds not available",
);
reject(new Error("Ramp SDK not available"));
return;
}
// If already registered and ready, resolve immediately
if (rewardedUnitRegistered && rewardedAdReady) {
console.log(
"[ensureRewardedUnitRegistered] Already registered and ready",
);
resolve();
return;
}
// Register the unit if not already registered
if (!rewardedUnitRegistered) {
try {
window.ramp.spaAddAds([{ type: "rewarded_ad_video", selectorId: "" }]);
rewardedUnitRegistered = true;
console.log("[RewardedVideoPromo] Rewarded unit registered");
} catch (e) {
reject(e);
return;
}
}
// If ad is already ready, resolve
if (rewardedAdReady) {
console.log("[ensureRewardedUnitRegistered] Ad already ready");
resolve();
return;
}
// Wait for the rewardedAdVideoRewardReady event or no-fill event
console.log("[ensureRewardedUnitRegistered] Waiting for ad to be ready...");
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
if (timeoutId) clearTimeout(timeoutId);
window.removeEventListener("rewardedAdVideoRewardReady", onReady);
window.removeEventListener("rewardedVideoNoFill", onNoFill);
window.removeEventListener("rewardedAdNoFill", onNoFill);
window.removeEventListener("pwNoFillEvent", onNoFill);
};
const onReady = () => {
console.log("[ensureRewardedUnitRegistered] Ad is now ready");
cleanup();
resolve();
};
const onNoFill = () => {
console.log("[ensureRewardedUnitRegistered] No fill event received");
cleanup();
reject(new Error("No rewarded ad available"));
};
timeoutId = setTimeout(() => {
cleanup();
console.log("[ensureRewardedUnitRegistered] Timeout waiting for ad");
reject(new Error("Ad timeout"));
}, AD_READY_TIMEOUT_MS);
window.addEventListener("rewardedAdVideoRewardReady", onReady);
window.addEventListener("rewardedVideoNoFill", onNoFill);
window.addEventListener("rewardedAdNoFill", onNoFill);
window.addEventListener("pwNoFillEvent", onNoFill);
});
}
export function showRewardedAd(): Promise<void> {
console.log("[showRewardedAd] Called", {
rewardedUnitRegistered,
});
return new Promise((resolve, reject) => {
console.log("[showRewardedAd] Calling ensureRewardedUnitRegistered...");
ensureRewardedUnitRegistered()
.then(() => {
console.log("[showRewardedAd] ensureRewardedUnitRegistered resolved");
if (!window.ramp?.manuallyCreateRewardUi) {
reject(new Error("Ramp SDK manuallyCreateRewardUi not available"));
return;
}
// Set up event listeners before triggering the ad
const cleanup = () => {
window.removeEventListener(
"rewardedAdRewardGranted",
onRewardGranted,
);
window.removeEventListener("rewardedAdCompleted", onCompleted);
window.removeEventListener("rewardedCloseButtonTriggered", onClosed);
window.removeEventListener("rejectAdCloseCta", onRejected);
// Destroy old unit and reset state so next ad attempt will re-register
try {
window.ramp?.destroyUnits?.("rewarded_ad_video");
} catch (e) {
console.error("[showRewardedAd] Failed to destroy unit:", e);
}
rewardedUnitRegistered = false;
rewardedAdReady = false;
};
const onRewardGranted = () => {
console.log("[showRewardedAd] Reward granted");
cleanup();
resolve();
};
const onCompleted = () => {
console.log("[showRewardedAd] Ad completed without reward");
// Don't resolve here - wait for rewardedAdRewardGranted
};
const onClosed = () => {
console.log("[showRewardedAd] User closed ad early");
cleanup();
reject(new Error("User closed ad early"));
};
const onRejected = () => {
console.log("[showRewardedAd] User rejected ad");
cleanup();
reject(new Error("User rejected ad"));
};
window.addEventListener("rewardedAdRewardGranted", onRewardGranted);
window.addEventListener("rewardedAdCompleted", onCompleted);
window.addEventListener("rewardedCloseButtonTriggered", onClosed);
window.addEventListener("rejectAdCloseCta", onRejected);
// Trigger the ad
const result = window.ramp.manuallyCreateRewardUi({
skipConfirmation: true,
});
// If it returns a promise that rejects, handle that too
if (result && typeof result.then === "function") {
result.catch((error: unknown) => {
cleanup();
reject(error);
});
}
})
.catch((err) => {
console.log(
"[showRewardedAd] ensureRewardedUnitRegistered rejected:",
err,
);
reject(err);
});
});
}
+2 -25
View File
@@ -11,7 +11,6 @@ import {
HumansVsNations,
UnitType,
} from "../core/game/Game";
import { UserSettings } from "../core/game/UserSettings";
import { TeamCountConfig } from "../core/Schemas";
import { generateID } from "../core/Util";
import { hasLinkedAccount } from "./Api";
@@ -21,9 +20,8 @@ import { BaseModal } from "./components/BaseModal";
import "./components/GameConfigSettings";
import "./components/ToggleInputCard";
import { modalHeader } from "./components/ui/ModalHeader";
import { fetchCosmetics } from "./Cosmetics";
import { getPlayerCosmetics } from "./Cosmetics";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { FlagInput } from "./FlagInput";
import { JoinLobbyEvent } from "./Main";
import { UsernameInput } from "./UsernameInput";
import {
@@ -90,8 +88,6 @@ export class SinglePlayerModal extends BaseModal {
...DEFAULT_OPTIONS.disabledUnits,
];
private userSettings: UserSettings = new UserSettings();
connectedCallback() {
super.connectedCallback();
document.addEventListener(
@@ -624,18 +620,6 @@ export class SinglePlayerModal extends BaseModal {
console.warn("Username input element not found");
}
const flagInput = document.querySelector("flag-input") as FlagInput;
if (!flagInput) {
console.warn("Flag input element not found");
}
const cosmetics = await fetchCosmetics();
let selectedPattern = this.userSettings.getSelectedPatternName(cosmetics);
selectedPattern ??= cosmetics
? (this.userSettings.getDevOnlyPattern() ?? null)
: null;
const selectedColor = this.userSettings.getSelectedColor();
await crazyGamesSDK.requestMidgameAd();
this.dispatchEvent(
@@ -648,14 +632,7 @@ export class SinglePlayerModal extends BaseModal {
{
clientID,
username: usernameInput.getCurrentUsername(),
cosmetics: {
flag:
flagInput.getCurrentFlag() === "xx"
? ""
: flagInput.getCurrentFlag(),
pattern: selectedPattern ?? undefined,
color: selectedColor ? { color: selectedColor } : undefined,
},
cosmetics: await getPlayerCosmetics(),
},
],
config: {
+26 -18
View File
@@ -12,8 +12,10 @@ import "./components/PatternButton";
import { modalHeader } from "./components/ui/ModalHeader";
import {
fetchCosmetics,
getPlayerCosmetics,
handlePurchase,
patternRelationship,
TEMP_FLARE_OFFSET,
} from "./Cosmetics";
import { translateText } from "./Utils";
@@ -37,8 +39,8 @@ export class TerritoryPatternsModal extends BaseModal {
private userMeResponse: UserMeResponse | false = false;
private _onPatternSelected = () => {
this.updateFromSettings();
private _onPatternSelected = async () => {
await this.updateFromSettings();
this.refresh();
};
@@ -62,24 +64,16 @@ export class TerritoryPatternsModal extends BaseModal {
window.removeEventListener("pattern-selected", this._onPatternSelected);
}
private updateFromSettings() {
this.selectedPattern =
this.cosmetics !== null
? this.userSettings.getSelectedPatternName(this.cosmetics)
: null;
this.selectedColor = this.userSettings.getSelectedColor() ?? null;
private async updateFromSettings() {
const cosmetics = await getPlayerCosmetics();
this.selectedPattern = cosmetics.pattern ?? null;
this.selectedColor = cosmetics.color?.color ?? null;
}
async onUserMe(userMeResponse: UserMeResponse | false) {
if (!hasLinkedAccount(userMeResponse)) {
this.userSettings.setSelectedPatternName(undefined);
this.userSettings.setSelectedColor(undefined);
this.selectedPattern = null;
this.selectedColor = null;
}
this.userMeResponse = userMeResponse;
this.cosmetics = await fetchCosmetics();
this.updateFromSettings();
await this.updateFromSettings();
this.refresh();
}
@@ -130,7 +124,7 @@ export class TerritoryPatternsModal extends BaseModal {
? [...(pattern.colorPalettes ?? []), null]
: [null];
for (const colorPalette of colorPalettes) {
let rel = "owned";
let rel: string | number = "owned";
if (pattern) {
rel = patternRelationship(
pattern,
@@ -142,8 +136,9 @@ export class TerritoryPatternsModal extends BaseModal {
if (rel === "blocked") {
continue;
}
const isTrial = typeof rel === "number";
if (this.showOnlyOwned) {
if (rel !== "owned") continue;
if (rel !== "owned" && !isTrial) continue;
} else {
// Store mode: hide owned items
if (rel === "owned") continue;
@@ -163,7 +158,20 @@ export class TerritoryPatternsModal extends BaseModal {
.colorPalette=${this.cosmetics?.colorPalettes?.[
colorPalette?.name ?? ""
] ?? null}
.requiresPurchase=${rel === "purchasable"}
.requiresPurchase=${rel === "purchasable" ||
rel === "purchasable_no_trial"}
.allowTrial=${rel === "purchasable"}
.hasLinkedAccount=${hasLinkedAccount(this.userMeResponse)}
.trialCooldown=${this.userMeResponse !== false &&
this.userMeResponse.player.tempFlaresCooldown}
.trialTimeRemaining=${isTrial
? Math.max(
0,
Math.floor(
((rel as number) - TEMP_FLARE_OFFSET - Date.now()) / 1000,
),
)
: 0}
.selected=${isSelected}
.onSelect=${(p: PlayerPattern | null) => this.selectPattern(p)}
.onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) =>
+195 -3
View File
@@ -1,14 +1,17 @@
import { Colord } from "colord";
import { base64url } from "jose";
import { html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { customElement, property, state } from "lit/decorators.js";
import {
ColorPalette,
DefaultPattern,
Pattern,
} from "../../core/CosmeticSchemas";
import { UserSettings } from "../../core/game/UserSettings";
import { PatternDecoder } from "../../core/PatternDecoder";
import { PlayerPattern } from "../../core/Schemas";
import { grantTemporaryFlare } from "../Api";
import { showRewardedAd } from "../RewardedVideoPromo";
import { translateText } from "../Utils";
export const BUTTON_WIDTH = 150;
@@ -26,16 +29,64 @@ export class PatternButton extends LitElement {
@property({ type: Boolean })
requiresPurchase: boolean = false;
@property({ type: Number })
trialTimeRemaining: number = 0;
@property({ type: Boolean })
allowTrial: boolean = true;
@property({ type: Boolean })
trialCooldown: boolean = false;
@property({ type: Boolean })
hasLinkedAccount: boolean = false;
@property({ type: Function })
onSelect?: (pattern: PlayerPattern | null) => void;
@property({ type: Function })
onPurchase?: (pattern: Pattern, colorPalette: ColorPalette | null) => void;
private _countdownInterval: ReturnType<typeof setInterval> | null = null;
@state()
private _adLoading: boolean = false;
createRenderRoot() {
return this;
}
updated(changedProperties: Map<string, unknown>) {
if (changedProperties.has("trialTimeRemaining")) {
this.setupCountdown();
}
}
disconnectedCallback() {
super.disconnectedCallback();
this.clearCountdown();
}
private setupCountdown() {
this.clearCountdown();
if (this.trialTimeRemaining > 0) {
this._countdownInterval = setInterval(() => {
this.trialTimeRemaining--;
if (this.trialTimeRemaining <= 0) {
this.trialTimeRemaining = 0;
this.clearCountdown();
}
}, 1000);
}
}
private clearCountdown() {
if (this._countdownInterval !== null) {
clearInterval(this._countdownInterval);
this._countdownInterval = null;
}
}
private translateCosmetic(prefix: string, patternName: string): string {
const translation = translateText(`${prefix}.${patternName}`);
if (translation.startsWith(prefix)) {
@@ -60,6 +111,104 @@ export class PatternButton extends LitElement {
} satisfies PlayerPattern);
}
private async grantTrial() {
const flare =
this.colorPalette?.name === undefined
? `pattern:${this.pattern!.name}`
: `pattern:${this.pattern!.name}:${this.colorPalette.name}`;
await grantTemporaryFlare(flare);
new UserSettings().setSelectedPatternName(flare);
alert(translateText("territory_patterns.trial_granted"));
window.location.reload();
}
private showSteamModal(): Promise<void> {
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.className =
"fixed inset-0 bg-black/80 flex items-center justify-center z-[9999]";
let secondsLeft = 10;
const updateContent = () => {
overlay.innerHTML = `
<div class="bg-slate-900 border border-white/20 rounded-xl p-8 max-w-md text-center">
<h2 class="text-2xl font-bold text-white mb-4">Wishlist on Steam!</h2>
<p class="text-white/70 mb-6">${translateText("territory_patterns.steam_wishlist_prompt")}</p>
<a
href="https://store.steampowered.com/app/3560670"
target="_blank"
rel="noopener noreferrer"
class="inline-block px-6 py-3 bg-[#1b2838] hover:bg-[#2a475e] text-white font-bold rounded-lg mb-6 transition-colors"
>
<span class="flex items-center gap-2">
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658a3.387 3.387 0 0 1 1.912-.59c.064 0 .128.003.191.006l2.866-4.158v-.058c0-2.495 2.03-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.091 2.921c0 .054.003.108.003.163 0 1.871-1.523 3.393-3.394 3.393-1.646 0-3.02-1.179-3.33-2.74L.453 15.406C1.727 20.279 6.228 24 11.979 24 18.627 24 24 18.627 24 12S18.627 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.455-.397.957-1.497 1.41-2.454 1.012zm11.415-9.303a3.015 3.015 0 0 0-3.015-3.015 3.015 3.015 0 1 0 3.015 3.015zm-5.273-.005c0-1.248 1.013-2.26 2.262-2.26a2.26 2.26 0 1 1 0 4.52 2.261 2.261 0 0 1-2.262-2.26z"/>
</svg>
Wishlist on Steam
</span>
</a>
<div class="text-white/50 text-sm">
${translateText("territory_patterns.reward_countdown", { seconds: secondsLeft.toString() })}
</div>
</div>
`;
};
updateContent();
document.body.appendChild(overlay);
const interval = setInterval(() => {
secondsLeft--;
if (secondsLeft <= 0) {
clearInterval(interval);
overlay.remove();
resolve();
} else {
updateContent();
}
}, 1000);
});
}
private async handleTryMe(e: Event) {
e.stopPropagation();
if (this.pattern === null || this._adLoading) return;
if (!this.hasLinkedAccount) {
alert(translateText("territory_patterns.trial_login_required"));
return;
}
if (this.trialCooldown) {
alert(translateText("territory_patterns.trial_cooldown"));
return;
}
console.log("[PatternButton] handleTryMe called");
this._adLoading = true;
try {
console.log("[PatternButton] Calling showRewardedAd...");
await showRewardedAd();
console.log("[PatternButton] showRewardedAd resolved");
await this.grantTrial();
} catch (error) {
console.error("[PatternButton] Rewarded ad failed:", error);
// Show Steam wishlist modal with countdown
await this.showSteamModal();
await this.grantTrial();
} finally {
this._adLoading = false;
}
}
private formatTimeRemaining(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
private handlePurchase(e: Event) {
e.stopPropagation();
if (this.pattern?.product) {
@@ -137,9 +286,52 @@ export class PatternButton extends LitElement {
</div>
</button>
${this.requiresPurchase && this.pattern?.product
${(this.requiresPurchase || this.trialTimeRemaining > 0) &&
this.pattern?.product
? html`
<div class="w-full mt-2">
<div class="w-full mt-2 flex flex-col gap-2">
${this.trialTimeRemaining > 0
? html`
<div
class="w-full px-4 py-2 bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 rounded-lg text-xs font-bold uppercase tracking-wider text-center"
>
${this.formatTimeRemaining(this.trialTimeRemaining)}
${translateText("territory_patterns.trial_remaining")}
</div>
`
: this.allowTrial
? html`
<button
class="w-full px-4 py-2 bg-blue-500/20 text-blue-400 border border-blue-500/30 rounded-lg text-xs font-bold uppercase tracking-wider cursor-pointer transition-all duration-200
hover:bg-blue-500/30 hover:shadow-[0_0_15px_rgba(59,130,246,0.2)] flex items-center justify-center gap-2"
@click=${this.handleTryMe}
?disabled=${this._adLoading}
>
${this._adLoading
? html`<svg
class="animate-spin h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>`
: translateText("territory_patterns.try_me")}
</button>
`
: null}
<button
class="w-full px-4 py-2 bg-green-500/20 text-green-400 border border-green-500/30 rounded-lg text-xs font-bold uppercase tracking-wider cursor-pointer transition-all duration-200
hover:bg-green-500/30 hover:shadow-[0_0_15px_rgba(74,222,128,0.2)]"
@@ -4,7 +4,7 @@ import { crazyGamesSDK } from "src/client/CrazyGamesSDK";
import { getGamesPlayed } from "src/client/Utils";
import { GameType } from "src/core/game/Game";
import { GameView } from "../../../core/game/GameView";
import "../../components/VideoReward";
import "../../components/VideoPromo";
import { Layer } from "./Layer";
@customElement("spawn-video-ad")
+1
View File
@@ -218,6 +218,7 @@ export class WinModal extends LitElement implements Layer {
.pattern=${pattern}
.colorPalette=${colorPalette}
.requiresPurchase=${true}
.allowTrial=${false}
.onSelect=${(p: Pattern | null) => {}}
.onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) =>
handlePurchase(p, colorPalette)}
+2
View File
@@ -56,6 +56,8 @@ export const UserMeResponseSchema = z.object({
publicId: z.string(),
roles: z.string().array().optional(),
flares: z.string().array().optional(),
flareExpiration: z.record(z.string(), z.number()).optional(),
tempFlaresCooldown: z.boolean(),
achievements: z
.array(
z.object({
+6
View File
@@ -192,6 +192,12 @@ export class UserSettings {
}
}
getFlag(): string | undefined {
const flag = localStorage.getItem("flag");
if (!flag || flag === "xx") return undefined;
return flag;
}
backgroundMusicVolume(): number {
return this.getFloat("settings.backgroundMusicVolume", 0);
}