Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ebce76c
Draw a colour and outline for every prestige rank
iiamlewis Oct 4, 2026
c184385
Light the XP bar's leading edge while it fills
iiamlewis Oct 4, 2026
782aa96
Read a game's level rewards and unlocks from the XP result
iiamlewis Oct 4, 2026
c1695b0
Show and claim a game's level rewards in the end-of-game reveal
iiamlewis Oct 4, 2026
169fc46
Let the nav's XP bar catch up to new progress
iiamlewis Oct 4, 2026
47e7900
Keep the nav's XP bar clear of the avatar
iiamlewis Oct 4, 2026
a78bcfb
Merge profile-page into levels-polish
iiamlewis Oct 4, 2026
f09b2aa
Draw each level badge look once and clone it per badge
iiamlewis Oct 5, 2026
87cacb8
Merge profile-page (with profile-progression's lazy loading) into lev…
iiamlewis Oct 5, 2026
bf3e8d0
Merge profile-page: src/core split into packages
iiamlewis Oct 6, 2026
0349cde
Merge remote-tracking branch 'origin/profile-page' into levels-polish
iiamlewis Oct 6, 2026
e8e6126
Merge remote-tracking branch 'origin/profile-page' into levels-polish
iiamlewis Oct 8, 2026
ce7814a
Merge profile-page (review fixes)
iiamlewis Oct 8, 2026
c12cadd
Merge profile-page (main sync)
iiamlewis Oct 9, 2026
0babc7a
Merge profile-page: show prestige ranks past 10, clamp the XP bar
iiamlewis Oct 9, 2026
13072e6
Merge branch 'profile-page' into levels-polish
iiamlewis Oct 9, 2026
cc5b916
Merge branch 'profile-page' into levels-polish
iiamlewis Oct 9, 2026
70bc26e
Merge profile-page (closed-profile tab fix)
iiamlewis Oct 9, 2026
51c5cbc
Merge profile-page into levels-polish
iiamlewis Oct 11, 2026
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
36 changes: 36 additions & 0 deletions packages/shared/src/ApiSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,34 @@ export const XpBreakdownSchema = z.object({
});
export type XpBreakdown = z.infer<typeof XpBreakdownSchema>;

// A level reward one game earned (GET /users/@me/xp/:gameId `rewards`).
export const GameXpRewardSchema = RewardSchema.extend({
prestige: z.number(),
level: z.number(),
claimed: z.boolean(),
});
export type GameXpReward = z.infer<typeof GameXpRewardSchema>;

// A flare one game unlocked from the level track: a level's (`prestige` and
// `level` are the run and level crossed) or Legend's (both null). `kind`
// stays a string so a new kind never fails the parse.
export const GameXpFlareSchema = z.object({
kind: z.string(),
prestige: z.number().nullable(),
level: z.number().nullable(),
flareId: z.string(),
flareName: z.string(),
// The cosmetic behind the flare, when it is one.
cosmetic: z
.object({
type: z.string(),
name: z.string(),
url: z.string().nullable(),
})
.nullable(),
});
export type GameXpFlare = z.infer<typeof GameXpFlareSchema>;

// GET /users/@me/xp/:gameId — the XP a finished game awarded the caller. 404
// until the game has been processed. `reason` is open-ended server-side, so it
// stays a string: an unknown reason shows a generic line, never a parse error.
Expand All @@ -182,6 +210,14 @@ export const GameXpEligibleSchema = z.object({
.array(z.object({ prestige: z.number(), level: z.number() }))
.optional()
.default([]),
// What the levels this game crossed paid: one entry per grant, in level
// order, Caps before Plutonium. `id` is the reward to claim while
// `claimed` is false. Optional (an API without it still parses), and a
// malformed list reads as absent rather than failing the result.
rewards: z.array(GameXpRewardSchema).optional().catch(undefined),
// The cosmetics this game unlocked from the level track (already granted:
// nothing to claim). Same leniency as `rewards`.
flares: z.array(GameXpFlareSchema).optional().catch(undefined),
});
export const GameXpIneligibleSchema = z.object({
gameId: z.string(),
Expand Down
11 changes: 11 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1921,13 +1921,24 @@
"penalty_line": "−{percent}% XP ({source})",
"prestige": "Prestige {prestige}",
"prestige_short": "P{prestige}",
"rewards_balance": "Balance {caps} Caps · {plutonium} Plutonium",
"rewards_balance_caps": "Balance {caps} Caps",
"rewards_claimed": "Claimed",
"rewards_claiming": "Claiming",
"rewards_each": "({count} × {amount})",
"rewards_level_many": "Level {level} rewards",
"rewards_level_one": "Level {level} reward",
"rewards_levels": "Levels {from}–{to}",
"share_moment_legend": "Share Legend",
"share_moment_level": "Share Level {level}",
"share_text_legend": "I just became a Legend on OpenFront!",
"share_text_level": "I just reached Level {level} on OpenFront!",
"sign_in_to_earn": "Sign in to earn XP and level up.",
"skip_reveal": "Skip",
"tap_to_skip": "Tap to skip",
"unlock_in_locker": "{type} · in your Locker",
"unlocked": "Unlocked",
"unlocked_cosmetic": "Cosmetic",
"xp_bar_label": "Level progress",
"xp_before_levels": "Played before levels existed, so there's no XP for this game.",
"xp_heading": "XP earned",
Expand Down
201 changes: 201 additions & 0 deletions src/client/GameRewards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import type { GameXpFlare, GameXpReward } from "@openfront/shared/ApiSchemas";
import { claimReward } from "./Api";
import {
cosmeticSelectionLabel,
cosmeticTypeLabel,
} from "./components/CosmeticPresentation";
import {
findPackItem,
type ResolvedCosmetic,
translateCosmetic,
} from "./Cosmetics";
import { translateText } from "./Utils";

// The level rewards and unlocks one game earned, as the end-of-game reveal
// shows them: summed per currency, and claimed together (only this game's).

export type Currency = "soft" | "hard";

export interface RewardLine {
currency: Currency;
// Sum of this game's rewards in the currency (amounts are stringified
// bigints; summed exactly).
total: bigint;
count: number;
// The amount of each, when several rewards in this currency are all the
// same ("3 × 100"); null otherwise.
each: bigint | null;
}

export interface GameRewardsSummary {
// The row's heading, a translation key and its params.
headingKey: string;
headingParams: Record<string, number>;
// Caps first, then Plutonium; a currency the game didn't pay is left out.
lines: RewardLine[];
// Every reward was already claimed (elsewhere, or on an earlier view).
allClaimed: boolean;
}

function amountOf(reward: GameXpReward): bigint {
try {
return BigInt(reward.amount);
} catch {
return 0n;
}
}

/**
* The rewards row for one game: "Level 47 reward +100 Caps", "Levels 45–47
* +300 Caps (3 × 100)", "Level 50 rewards +100 Caps +25 Plutonium". Null
* when the game paid nothing.
*/
export function summarizeGameRewards(
rewards: readonly GameXpReward[],
): GameRewardsSummary | null {
if (rewards.length === 0) return null;
const levels = [...new Set(rewards.map((r) => r.level))].sort(
(a, b) => a - b,
);
let headingKey: string;
let headingParams: Record<string, number>;
if (levels.length > 1) {
headingKey = "progression.rewards_levels";
headingParams = { from: levels[0], to: levels[levels.length - 1] };
} else {
headingKey =
rewards.length > 1
? "progression.rewards_level_many"
: "progression.rewards_level_one";
headingParams = { level: levels[0] };
}
const lines: RewardLine[] = [];
for (const currency of ["soft", "hard"] as const) {
const these = rewards.filter((r) => r.currencyType === currency);
if (these.length === 0) continue;
const amounts = these.map(amountOf);
const total = amounts.reduce((a, b) => a + b, 0n);
const each =
amounts.length > 1 && amounts.every((a) => a === amounts[0])
? amounts[0]
: null;
lines.push({ currency, total, count: these.length, each });
}
return {
headingKey,
headingParams,
lines,
allClaimed: rewards.every((r) => r.claimed),
};
}

export interface Balances {
soft: number;
hard: number;
}

export type ClaimOutcome =
| {
ok: true;
// The balances after the last claim this call made, or null when it
// made none (all were claimed elsewhere first).
currency: Balances | null;
// What this call's claims added to the balances.
credited: Balances;
claimedIds: string[];
}
// A claim failed. The ones before it went through (`claimedIds`), so a
// retry claims only the rest.
| { ok: false; claimedIds: string[] };

/**
* Claims this game's unclaimed rewards, one by one, and only those: never
* the player's other pending rewards. `skip` holds ids already claimed from
* this view. A reward someone else claimed first (a second device, the
* rewards panel) answers not_found and counts as claimed.
*/
export async function claimGameRewards(
rewards: readonly GameXpReward[],
skip: ReadonlySet<string> = new Set(),
): Promise<ClaimOutcome> {
const claimedIds: string[] = [];
const credited: Balances = { soft: 0, hard: 0 };
let currency: Balances | null = null;
for (const reward of rewards) {
if (reward.claimed || skip.has(reward.id)) continue;
const result = await claimReward(reward.id);
if (result === false) return { ok: false, claimedIds };
claimedIds.push(reward.id);
if (result === "not_found") continue;
currency = result.currency;
credited[reward.currencyType] += Number(amountOf(reward));
}
return { ok: true, currency, credited, claimedIds };
}

export interface FlareUnlock {
flare: GameXpFlare;
// The catalog entry, for its preview; null while the catalog is unknown or
// when it no longer lists the cosmetic.
resolved: ResolvedCosmetic | null;
name: string;
// "Skin", "Boat Trail Effect", …
typeLabel: string;
}

const NAME_PREFIXES: Record<string, string> = {
pattern: "territory_patterns.pattern",
skin: "territory_patterns.pattern",
flag: "flags",
crown: "crowns",
effect: "effects",
};

const TYPE_KEYS: Record<string, string> = {
pattern: "cosmetics.type_skin",
skin: "cosmetics.type_skin",
flag: "cosmetics.type_flag",
crown: "cosmetics.type_crown",
};

/**
* What a flare unlocked, for the reveal's "Unlocked" card: its catalog entry
* where `catalog` lists it (flares spell cosmetics "type:name", or
* "pattern:name:palette"), named the way the store names it.
*/
export function flareUnlock(
flare: GameXpFlare,
catalog: readonly ResolvedCosmetic[],
): FlareUnlock {
const [type = "", name = "", palette] = flare.flareName.split(":");
const known = ["pattern", "flag", "skin", "crown", "effect"].includes(type);
const resolved = known
? (findPackItem(
{
type: type as "pattern" | "flag" | "skin" | "crown" | "effect",
name,
colorPalette: palette,
},
catalog,
) ?? null)
: null;
if (resolved !== null) {
return {
flare,
resolved,
name: cosmeticSelectionLabel(resolved),
typeLabel: cosmeticTypeLabel(resolved),
};
}
const prefix = NAME_PREFIXES[type];
const typeKey = TYPE_KEYS[type];
return {
flare,
resolved: null,
name:
prefix !== undefined && name !== ""
? translateCosmetic(prefix, name)
: flare.flareName,
typeLabel: translateText(typeKey ?? "progression.unlocked_cosmetic"),
};
}
Loading
Loading