Files
OpenFrontIO/src/client/Cosmetics.ts
T
Evan 97d0a05d58 Rewarded videos ads to test a skin (#3120)
## Description:

Added rewarded video ads for skin trials via Playwire's
manuallyCreateRewardUi API. Users can now click "Try me" to watch a
video ad and receive a temporary skin trial. Upon completion a temporary
flare is granted to the player so they have ~5 minutes to use the skin.

added getPlayerCosmeticsRefs and getPlayerCosmetics to Cosmetics.ts to
centralize cosmetic retrieval & validation.

<img width="801" height="534" alt="Screenshot 2026-02-10 at 7 58 14 PM"
src="https://github.com/user-attachments/assets/51cc378c-2feb-4692-8cf2-20ee54cea3b8"
/>

## 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
2026-02-11 20:52:48 -08:00

219 lines
6.1 KiB
TypeScript

import { UserMeResponse } from "../core/ApiSchemas";
import {
ColorPalette,
Cosmetics,
CosmeticsSchema,
Pattern,
} from "../core/CosmeticSchemas";
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,
colorPalette: ColorPalette | null,
) {
if (pattern.product === null) {
alert("This pattern is not available for purchase.");
return;
}
const url = await createCheckoutSession(
pattern.product.priceId,
colorPalette?.name ?? null,
);
if (url === false) {
alert("Failed to create checkout session.");
return;
}
// Redirect to Stripe checkout
window.location.href = url;
}
let __cosmetics: Promise<Cosmetics | null> | null = null;
let __cosmeticsHash: string | null = null;
function simpleHash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return hash.toString(36);
}
export async function fetchCosmetics(): Promise<Cosmetics | null> {
if (__cosmetics !== null) {
return __cosmetics;
}
__cosmetics = (async () => {
try {
const response = await fetch(`${getApiBase()}/cosmetics.json`);
if (!response.ok) {
console.error(`HTTP error! status: ${response.status}`);
return null;
}
const result = CosmeticsSchema.safeParse(await response.json());
if (!result.success) {
console.error(`Invalid cosmetics: ${result.error.message}`);
return null;
}
const patternKeys = Object.keys(result.data.patterns).sort();
const hashInput = patternKeys
.map((k) => k + (result.data.patterns[k].product ? "sale" : ""))
.join(",");
__cosmeticsHash = simpleHash(hashInput);
return result.data;
} catch (error) {
console.error("Error getting cosmetics:", error);
return null;
}
})();
return __cosmetics;
}
export async function getCosmeticsHash(): Promise<string | null> {
await fetchCosmetics();
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" | "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";
}
if (colorPalette === null) {
// For backwards compatibility only show non-colored patterns if they are owned.
if (flares.includes(`pattern:${pattern.name}`)) {
return "owned";
}
return "blocked";
}
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";
}
if (pattern.product === null) {
// We don't own it and it's not for sale, so don't show it.
return "blocked";
}
if (colorPalette?.isArchived) {
// We don't own the color palette, and it's archived, so don't show it.
return "blocked";
}
if (affiliateCode !== pattern.affiliateCode) {
// Pattern is for sale, but it's not the right store to show it on.
return "blocked";
}
// --- 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;
}