Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/shared/src/ApiSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,32 @@ export const RewardSchema = z.object({
amount: z.string(),
reason: z.string(),
note: z.string().nullable(),
// Why the reward can't be claimed yet; absent when it can. "trust": level
// Plutonium, kept until the account is trusted. Any other non-empty value is
// a hold this client doesn't know yet, and still reads as held: an unknown
// restriction must never read as claimable, so a malformed value (a number,
// an object, "") reads as held too. Absent or null: claimable, as from
// older APIs.
held: z
.unknown()
.transform((v): string | undefined =>
v === undefined || v === null
? undefined
: typeof v === "string" && v.length > 0
? v
: "unknown",
)
.optional(),
});
export type Reward = z.infer<typeof RewardSchema>;

// The one claimability rule: a reward is claimable unless it carries a hold,
// whatever kind. Everything that offers a claim, or counts rewards to claim,
// asks this.
export function isRewardClaimable(reward: Pick<Reward, "held">): boolean {
return reward.held === undefined;
}

// Player levels / XP ("progression"). Purely cosmetic: levels never affect
// gameplay, and XP is computed server-side only — the client just displays it.
// Level 1..100, prestige 0..10; `legend` is level 100 at the last prestige.
Expand Down Expand Up @@ -293,12 +316,32 @@ export type ClaimRewardResponse = z.infer<typeof ClaimRewardResponseSchema>;

export const ClaimAllRewardsResponseSchema = z.object({
claimed: z.array(z.object({ id: z.string() })),
// The rewards still pending after the claim because they are held (each
// with `held`), in /users/@me's reward shape. Empty from older APIs, and
// when the list can't be read: the next /users/@me read brings them back.
// Read per element, so one malformed row drops only itself.
held: z
.array(z.unknown())
.default([])
.catch([])
.transform((rows) =>
rows.flatMap((row) => {
const parsed = RewardSchema.safeParse(row);
return parsed.success ? [parsed.data] : [];
}),
),
currency: CurrencyBalancesSchema,
});
export type ClaimAllRewardsResponse = z.infer<
typeof ClaimAllRewardsResponseSchema
>;

// POST /rewards/:rewardId/claim's 403 for a held reward (RewardSchema.held):
// it stays pending, and claims once the hold lifts.
export const ClaimRewardHeldResponseSchema = z.object({
held: z.string().min(1),
});

// Account-username lifecycle. `unclaimed`: no bare-name reservation (default).
// `claimed`: reservation held but subscription lapsed — the suffix shows again
// and a grace deadline runs. `premium`: subscribed, bare display. `indefinite`:
Expand Down
6 changes: 6 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@
"reactivate_subscription": "Reactivate",
"recovery_email_sent": "Recovery email sent to {email}",
"reward_daily": "Daily subscription reward",
"reward_held_other_info": "Some rewards can't be claimed yet.",
"reward_held_trust": "Trusted accounts only",
"reward_held_trust_info": "Plutonium from levels is kept for you until your account is trusted. Your account becomes trusted once it is 14 days old and you've played 25 public games, or right away with any purchase.",
"reward_held_trust_info_crazygames": "Plutonium from levels is kept for you until your account is trusted. Your account becomes trusted once it is 14 days old and you've played 25 public games.",
"reward_held_trust_info_signed_out": "Plutonium from levels is kept for you until your account is trusted. To become trusted, sign in to your account, then play more public games or make any purchase.",
"reward_held_trust_info_signed_out_crazygames": "Plutonium from levels is kept for you until your account is trusted. To become trusted, sign in to your account, then play more public games.",
"reward_level_milestone": "Level milestone reward",
"reward_level_up": "Level-up reward",
"reward_prestige": "Prestige reward",
Expand Down
7 changes: 6 additions & 1 deletion src/client/AccountModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import { assetUrl } from "@openfront/shared/AssetUrls";
import { html, nothing, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import { ClientEnv } from "src/client/ClientEnv";
import { hasLinkedIdentity } from "./AccountIdentity";
import {
hasLinkedIdentity,
responseHasLinkedIdentity,
} from "./AccountIdentity";
import { fetchPlayerById, getUserMe, invalidateUserMe } from "./Api";
import {
discordLogin,
Expand Down Expand Up @@ -446,6 +449,8 @@ export class AccountModal extends BaseModal {
if (rewards.length === 0) return "";
return html`<rewards-panel
.rewards=${rewards}
.signedIn=${responseHasLinkedIdentity(this.userMeResponse ?? false) ||
this.crazyGamesUser !== null}
@rewards-changed=${this.handleRewardsChanged}
></rewards-panel>`;
}
Expand Down
14 changes: 12 additions & 2 deletions src/client/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { NewsItem, StreamsFeed } from "@openfront/shared/ApiSchemas";
import {
ClaimAllRewardsResponse,
ClaimAllRewardsResponseSchema,
ClaimRewardHeldResponseSchema,
ClaimRewardResponse,
ClaimRewardResponseSchema,
GetMyTribeNamesResponse,
Expand Down Expand Up @@ -1392,10 +1393,13 @@ export async function purchaseCosmeticPack(
// credits the balance atomically. "not_found" covers unknown, already-claimed
// and other players' rewards (indistinguishable by design); the usual cause is
// a double-click or a second device claiming first, so callers should re-fetch
// /users/@me and re-render rather than surface an error.
// /users/@me and re-render rather than surface an error. `{ held }` is a reward
// that can't be claimed yet (e.g. "trust": level Plutonium on an account that
// isn't trusted): a 403 naming the hold, whose kind is passed on as named. It
// stays pending, so nothing is lost.
export async function claimReward(
rewardId: string,
): Promise<ClaimRewardResponse | "not_found" | false> {
): Promise<ClaimRewardResponse | "not_found" | { held: string } | false> {
try {
const response = await fetch(
`${getApiBase()}/rewards/${encodeURIComponent(rewardId)}/claim`,
Expand All @@ -1411,6 +1415,12 @@ export async function claimReward(
return false;
}
if (response.status === 404) return "not_found";
if (response.status === 403) {
const hold = ClaimRewardHeldResponseSchema.safeParse(
await response.json().catch(() => null),
);
if (hold.success) return { held: hold.data.held };
}
if (!response.ok) {
console.error(
"claimReward: request failed",
Expand Down
6 changes: 5 additions & 1 deletion src/client/BootInterrupts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@ export interface BootInterruptInputs {
* the two grant notices. Failing it lets the next interrupt take the boot.
*/
grantStringsReady: boolean;
/** How many unclaimed rewards the account is holding. */
/**
* How many of the account's unclaimed rewards can be claimed now
* (isRewardClaimable): held ones don't count, since on their own they'd
* open the popup every boot with nothing to do.
*/
rewardCount: number;
/** claimPromptDue(...) — the decay rule below. */
claimPromptDue: boolean;
Expand Down
27 changes: 21 additions & 6 deletions src/client/Main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { GAME_ID_REGEX } from "@openfront/engine-api/Schemas";
import { UserMeResponse } from "@openfront/shared/ApiSchemas";
import {
isRewardClaimable,
UserMeResponse,
} from "@openfront/shared/ApiSchemas";
import { assetUrl } from "@openfront/shared/AssetUrls";
import { EventBus } from "@openfront/shared/EventBus";
import { toWireGameStartInfo } from "@openfront/shared/SharedUtil";
Expand All @@ -14,6 +17,7 @@ import {
import { GameEnv } from "@openfront/shared/configuration/Env";
import { ClientEnv } from "src/client/ClientEnv";
import { renderNavVersion } from "src/client/GameVersion";
import { responseHasLinkedIdentity } from "./AccountIdentity";
import { adGatekeeper } from "./AdGatekeeper";
import { loadAdmiral, onAdmiralMeasured } from "./Admiral";
import { getUserMe, invalidateUserMe } from "./Api";
Expand Down Expand Up @@ -863,7 +867,9 @@ class Client {
Date.now(),
),
grantStringsReady: steamGrantStringsReady(translateText),
rewardCount: rewards.length,
// Held rewards can't be claimed yet: on their own they'd open the
// popup every boot with nothing to do.
rewardCount: rewards.filter(isRewardClaimable).length,
claimPromptDue: claimPromptDue(claimStore, Date.now(), publicId),
claimStringsReady: claimPromptStringsReady(translateText),
}),
Expand All @@ -885,10 +891,19 @@ class Client {
navigate: (hash) => {
window.location.hash = hash;
},
openRewards: () =>
whenModalLoaded("rewards-modal", () =>
this.rewardsModal?.openWithRewards(rewards),
),
openRewards: () => {
// Signed in as for trustRequiredDialog: a linked identity, or a
// CrazyGames sign-in (only the SDK knows that one).
void (async () => {
const signedIn =
responseHasLinkedIdentity(userMeResponse) ||
(crazyGamesSDK.isOnCrazyGames() &&
(await crazyGamesSDK.getUserProfile()) !== null);
whenModalLoaded("rewards-modal", () =>
this.rewardsModal?.openWithRewards(rewards, signedIn),
);
})();
},
storeClaimPrompt: (store) =>
localStorage.setItem(CLAIM_PROMPT_KEY, JSON.stringify(store)),
storeSteamGrant: (store) =>
Expand Down
18 changes: 14 additions & 4 deletions src/client/RewardsModal.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Reward } from "@openfront/shared/ApiSchemas";
import { isRewardClaimable, Reward } from "@openfront/shared/ApiSchemas";
import { html, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import { BaseModal } from "./components/BaseModal";
Expand All @@ -13,18 +13,27 @@ import { translateText } from "./Utils";
@customElement("rewards-modal")
export class RewardsModal extends BaseModal {
@state() private rewards: Reward[] = [];
// RewardsPanel.signedIn: picks the held-reward note.
@state() private signedIn = false;

protected modalConfig() {
return { maxWidth: "620px" };
}

public openWithRewards(rewards: Reward[]): void {
public openWithRewards(rewards: Reward[], signedIn: boolean): void {
this.rewards = rewards;
this.signedIn = signedIn;
this.open();
}

// Held rewards stay listed (with why), but on their own they leave the popup
// nothing to do: it opens, and stays open, only while one can be claimed.
private hasClaimable(): boolean {
return this.rewards.some(isRewardClaimable);
}

public open(args?: Record<string, unknown>): void {
if (this.rewards.length === 0) return;
if (!this.hasClaimable()) return;
super.open(args);
}

Expand All @@ -40,14 +49,15 @@ export class RewardsModal extends BaseModal {
event: CustomEvent<RewardsChangedDetail>,
): void => {
this.rewards = event.detail.rewards;
if (this.rewards.length === 0) this.close();
if (!this.hasClaimable()) this.close();
};

protected renderBody(): TemplateResult {
return html`
<div class="p-6">
<rewards-panel
.rewards=${this.rewards}
.signedIn=${this.signedIn}
@rewards-changed=${this.handleRewardsChanged}
></rewards-panel>
</div>
Expand Down
Loading
Loading