diff --git a/packages/shared/src/ApiSchemas.ts b/packages/shared/src/ApiSchemas.ts index 9c7b42704f2..6168bb05a5d 100644 --- a/packages/shared/src/ApiSchemas.ts +++ b/packages/shared/src/ApiSchemas.ts @@ -109,6 +109,11 @@ export const RewardSchema = z.object({ amount: z.string(), reason: z.string(), note: z.string().nullable(), + // On a level reward (`level_up` / `level_milestone`): the level it was + // paid for, and the prestige run it was reached in. Absent on every other + // reward, and on an API that predates them. + level: z.number().optional(), + prestige: z.number().optional(), // 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 @@ -207,6 +212,34 @@ export const XpBreakdownSchema = z.object({ }); export type XpBreakdown = z.infer; +// 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; + +// 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; + // 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. @@ -221,6 +254,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(), @@ -246,6 +287,21 @@ export const PublicProgressSchema = z.object({ // is left out. xpInLevel: z.number().optional(), xpForNext: z.number().optional(), + // For the profile's Progression tab. Optional, and a malformed list reads + // as absent rather than failing the whole response (the level still shows). + // When each prestige rank was entered (ISO dates), ordered by rank. + prestigeHistory: z + .array(z.object({ rank: z.number(), at: z.string() })) + .optional() + .catch(undefined), + // The first time each milestone level (10/25/50/75/100) was reached in each + // prestige run (ISO dates), ordered by run then level. + milestones: z + .array( + z.object({ prestige: z.number(), level: z.number(), at: z.string() }), + ) + .optional() + .catch(undefined), }); export type PublicProgress = z.infer; diff --git a/resources/lang/en.json b/resources/lang/en.json index 3ecacbf75a4..332c00a8b49 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -126,6 +126,7 @@ "tab_clans": "Clans", "tab_friends": "Friends", "tab_games": "Games", + "tab_progression": "Progression", "tab_stats": "Stats", "title": "Account", "unclaimed_rewards": "Unclaimed Rewards", @@ -1741,12 +1742,28 @@ "troops": "Troops" }, "player_profile": { + "current_run": "Current run", + "current_run_prestige": "Current run · Prestige {prestige}", + "first_run": "First run", + "legend_run": "Legend · Prestige {prestige}", "loading": "Loading profile...", + "milestone_not_yet": "Not yet", + "milestones": "Milestones", "no_clans": "This player isn't in any clans.", - "not_found": "Player profile not found", + "no_prestige": "Hasn't prestiged yet", + "not_found_body": "We couldn't find a player at this link. It may be mistyped, or the account may have been deleted.", + "not_found_title": "Player not found", + "play": "Play OpenFront", + "prestige_history": "Prestige history", "share": "Share Profile", + "share_copy_link": "Copy link", + "share_discord": "Copy for Discord", + "share_native": "Share", + "share_text": "Check out {name}'s profile on OpenFront", + "share_x": "Share on X", "title": "Player Profile", - "view": "View Profile" + "view": "View Profile", + "view_leaderboard": "View leaderboard" }, "player_stats_table": { "arrived": "Arrived", @@ -1849,6 +1866,7 @@ "keep_holding": "Keep holding…", "load_failed": "Couldn't open Prestige. Check your connection and try again.", "ready": "Level 100! Until you prestige, XP only adds to your lifetime total.", + "share_text": "I just reached Prestige {rank} on OpenFront!", "stale": "Your level changed since this page loaded, so your account has been refreshed.", "stale_failed": "Your level changed since this page loaded. Reload the page to see where you are.", "submitting": "Prestiging…", @@ -1935,9 +1953,24 @@ "reconcile_other": "The final figure differs from the provisional one.", "reconcile_reconnect": "An opponent who had disconnected came back, so they don't count as outlasted.", "reconcile_team_win": "Your team won: the win bonus was added.", + "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_confirmed": "XP confirmed", @@ -2042,6 +2075,33 @@ "unavailable_unreachable": "The game could not be downloaded. Check your connection and try again.", "watch_classic": "Watch the old replay instead" }, + "reward_track": { + "after_level": "After level {level}", + "caps_amount": "{amount} Caps", + "claim_caps": "+{amount} Caps", + "claim_count": "{count, plural, one {# level reward to claim} other {# level rewards to claim}}", + "claim_plutonium": "+{amount} Plutonium", + "earlier": "Earlier levels", + "key_claimed": "Claimed", + "key_locked": "Locked", + "key_ready": "Ready to claim", + "later": "Later levels", + "legend_reward": "Legend reward", + "legend_sub": "The top of the track", + "levels_label": "Levels and their rewards", + "plus": "+{amount}", + "prestige_reward": "Prestige {rank} reward", + "strip_end_legend": "{level} → Legend", + "strip_end_prestige": "{level} → P{rank}", + "strip_label": "Levels 1 to {max}. Choose a level to show it on the track.", + "strip_level": "Level {level}", + "strip_start": "Lv 1", + "strip_value": "Showing levels {lo} to {hi}", + "strip_you": "You · {level}", + "title": "Reward track · Prestige {prestige}", + "title_first_run": "Reward track · First run", + "you": "You" + }, "rewards_modal": { "title": "Rewards" }, diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index 260cd3f1fc7..91beb802e5e 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -218,6 +218,7 @@ export class AccountModal extends BaseModal {
${this.renderTab(tab)}
diff --git a/src/client/GameRewards.ts b/src/client/GameRewards.ts new file mode 100644 index 00000000000..86f088a0cd6 --- /dev/null +++ b/src/client/GameRewards.ts @@ -0,0 +1,266 @@ +import { + type GameXpFlare, + type GameXpReward, + isRewardClaimable, +} from "@openfront/shared/ApiSchemas"; +import { + cosmeticSelectionLabel, + cosmeticTypeLabel, +} from "./components/CosmeticPresentation"; +import { + findPackItem, + type ResolvedCosmetic, + translateCosmetic, +} from "./Cosmetics"; +import { rewardAmount } from "./Progression"; +import { claimRewards, type RefusedHold } from "./RewardClaims"; +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). +// A held reward (isRewardClaimable) is shown apart, with why it is held, and +// is never claimed nor counted as claimed. So is one the API refused as held +// when it was claimed (withRefusedHolds): held after all. + +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 HeldRewardLine extends RewardLine { + // The label shown in place of a claim, a translation key: the one the + // rewards panel shows for a trust hold, and the generic note for any other. + labelKey: string; +} + +export interface GameRewardsSummary { + // The row's heading, a translation key and its params. + headingKey: string; + headingParams: Record; + // The rewards that can be claimed (or were): Caps first, then Plutonium; a + // currency the game didn't pay is left out. + lines: RewardLine[]; + // The unclaimed held ones, in the same order, one line per currency and + // kind of hold. + heldLines: HeldRewardLine[]; + // Whether any reward isn't held (claimed already or not): false when every + // one is, and the row offers no claim. + claimable: boolean; + // Every claimable reward was already claimed (elsewhere, or on an earlier + // view). Held ones don't count. + allClaimed: boolean; +} + +// A held reward, unclaimed: it stays pending until the API lets it go. +function isHeld(reward: GameXpReward): boolean { + return !reward.claimed && !isRewardClaimable(reward); +} + +/** + * The game's rewards as they are now known: each one the API refused as held + * (`holds`, by id, from claimGameRewards) carries its hold, as if it had + * arrived held. Unchanged when there are none. + */ +export function withRefusedHolds( + rewards: readonly GameXpReward[], + holds: ReadonlyMap, +): readonly GameXpReward[] { + if (holds.size === 0) return rewards; + return rewards.map((r) => { + const held = holds.get(r.id); + return held === undefined || r.claimed ? r : { ...r, held }; + }); +} + +function linesOf(rewards: readonly GameXpReward[]): RewardLine[] { + 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((r) => rewardAmount(r.amount)); + 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 lines; +} + +/** + * 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; + 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 open = rewards.filter((r) => !isHeld(r)); + const held = rewards.filter(isHeld); + const trust = held.filter((r) => r.held === "trust"); + const other = held.filter((r) => r.held !== "trust"); + const heldLines: HeldRewardLine[] = [ + ...linesOf(trust).map((l) => ({ + ...l, + labelKey: "account_modal.reward_held_trust", + })), + ...linesOf(other).map((l) => ({ + ...l, + labelKey: "account_modal.reward_held_other_info", + })), + ]; + return { + headingKey, + headingParams, + lines: linesOf(open), + heldLines, + claimable: open.length > 0, + allClaimed: open.every((r) => r.claimed), + }; +} + +export interface Balances { + soft: number; + hard: number; +} + +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[]; + // The ones the API refused as held (held after all): neither claimed + // nor credited. Shown as held from then on (withRefusedHolds). + held: RefusedHold[]; + } + // A claim failed. The ones before it went through (`claimedIds`), or were + // refused as held (`held`), so a retry claims only the rest. + | { ok: false; claimedIds: string[]; held: RefusedHold[] }; + +/** + * Claims this game's unclaimed rewards, one by one, and only those: never + * the player's other pending rewards, nor a held one. `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; + * one the API refuses as held is passed over, and the rest still claimed. + */ +export async function claimGameRewards( + rewards: readonly GameXpReward[], + skip: ReadonlySet = new Set(), +): Promise { + const run = await claimRewards( + rewards.filter((r) => !r.claimed && !skip.has(r.id) && !isHeld(r)), + ); + if (run.failed) { + return { ok: false, claimedIds: run.claimedIds, held: run.held }; + } + const credited: Balances = { soft: 0, hard: 0 }; + for (const reward of rewards) { + if (run.creditedIds.includes(reward.id)) { + credited[reward.currencyType] += Number(rewardAmount(reward.amount)); + } + } + return { + ok: true, + currency: run.currency, + credited, + claimedIds: run.claimedIds, + held: run.held, + }; +} + +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 = { + pattern: "territory_patterns.pattern", + skin: "territory_patterns.pattern", + flag: "flags", + crown: "crowns", + effect: "effects", +}; + +const TYPE_KEYS: Record = { + 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"), + }; +} diff --git a/src/client/Main.ts b/src/client/Main.ts index c16513763c4..2fb47e211af 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -161,6 +161,7 @@ import { installDoubleTapZoomBlocker, installSafariPinchZoomBlocker, } from "./utilities/DisableSafariPinchZoom"; +import { playerProfileRouteArgs } from "./utilities/PlayerProfileUrl"; import "./components/DesktopNavBar"; import "./components/Footer"; @@ -479,6 +480,8 @@ class Client { tag: "player-profile-modal", pageId: "page-profile", }); + // The canonical profile link, `/player/` (see playerProfileUrl). + modalRouter.registerPath("profile", playerProfileRouteArgs); modalRouter.register("help", { tag: "help-modal", pageId: "page-help" }); modalRouter.register("news", { tag: "news-modal", pageId: "page-news" }); modalRouter.register("language", { @@ -1413,6 +1416,10 @@ class Client { console.log(`${spectate ? "spectating" : "joining"} lobby ${lobbyId}`); return; } + // A shared profile link (`/player/`) opens that profile. + if (modalRouter.routeFromPath()) { + return; + } if (modalRouter.routeFromHash()) { return; } diff --git a/src/client/ModalRouter.ts b/src/client/ModalRouter.ts index c7a71dfb98b..63a87286bf7 100644 --- a/src/client/ModalRouter.ts +++ b/src/client/ModalRouter.ts @@ -7,9 +7,22 @@ * * Lobby modals (join/host) and matchmaking are intentionally not registered: * they have their own URL state (path-based) or none at all. + * + * A modal can also be opened by a path link (`/player/`, see + * registerPath). While it stays open the URL keeps that path, with tab + * changes written to `?tab=`; closing it returns the URL to the home page, so + * a reload doesn't reopen it. Opening another modal, in-app or from the hash, + * hands the URL to the hash and leaves the path the same way. */ import { loadModal } from "./LazyModals"; +import { pagePin } from "./PagePin"; + +/** Parses a page location into a modal's open() args, or null if it isn't one. */ +export type PathRouteParser = (location: { + pathname: string; + search: string; +}) => Record | null; interface RegistryEntry { /** Custom element tag, e.g. "store-modal". */ @@ -34,11 +47,57 @@ class ModalRouter { private currentName: string | null = null; /** True while we're routing from the URL (suppress modal→URL sync). */ private routingFromUrl = false; + private pathRoutes = new Map(); + /** Name of the modal the URL's path (not its hash) reflects, if any. */ + private pathRoutedName: string | null = null; register(name: string, entry: RegistryEntry): void { this.registry.set(name, entry); } + /** Let a registered modal be opened by a path link as well as the hash. */ + registerPath(name: string, parse: PathRouteParser): void { + this.pathRoutes.set(name, parse); + } + + /** + * Open the modal the current path names, if one does. Returns true when it + * did (the open itself is asynchronous, as for routeFromHash). + */ + routeFromPath(): boolean { + for (const [name, parse] of this.pathRoutes) { + const entry = this.registry.get(name); + if (entry === undefined) continue; + const args = parse(window.location); + if (args === null) continue; + this.pathRoutedName = name; + void this.openRegistered(name, entry, args); + return true; + } + return false; + } + + /** True while the named modal is open from a path link. */ + isPathRouted(name: string): boolean { + return this.pathRoutedName === name; + } + + // The path link has had its say: put the URL back to the home page. The + // home page keeps the page's version pin when it has one (see + // currentPagePath in Utils, which this mirrors without its imports). A + // `#modal=` hash stays: it's another modal's, and that modal is still open. + private leavePath(): void { + if (this.pathRoutedName === null) return; + this.pathRoutedName = null; + const commit = pagePin(); + history.replaceState( + history.state, + "", + (commit === null ? "/" : `/v/${commit}/`) + + (this.isHashRouted() ? window.location.hash : ""), + ); + } + /** * Parse `window.location.hash` for `#modal=&...`. If present and * registered, open the modal with the remaining keys as args. Returns true @@ -66,6 +125,11 @@ class ModalRouter { args[key] = value; }); + // The hash names the modal now, as when one opens in-app (syncOpened). + // Left on, the path would outlive the modal it names: the router stops + // tracking that modal, so its close wouldn't clear the path, and the next + // reload would open it again. + this.leavePath(); void this.openRegistered(name, entry, args); return true; } @@ -85,8 +149,14 @@ class ModalRouter { return; } await customElements.whenDefined(entry.tag); - // A slow load can land after the URL has moved on to another modal. - if (this.currentHashParams().get("modal") !== name) return; + // A slow load can land after the URL has moved on to another modal. The + // URL names this one by its hash, or by its path while path-routed. + if ( + this.currentHashParams().get("modal") !== name && + this.pathRoutedName !== name + ) { + return; + } this.routingFromUrl = true; try { @@ -108,6 +178,8 @@ class ModalRouter { syncOpened(name: string, args?: Record): void { if (this.routingFromUrl) return; // we're driving the modal from the URL; don't loop if (!this.registry.has(name)) return; + // Another modal (or this one again) opened in-app: the hash takes over. + this.leavePath(); this.currentName = name; this.writeHash(name, args); } @@ -115,6 +187,13 @@ class ModalRouter { /** Called by BaseModal.close() when a router-managed modal closes. */ syncClosed(name: string): void { if (this.routingFromUrl) return; + // The path names this modal whichever modal the router last recorded, so + // its close always leaves the path. + if (this.pathRoutedName === name) { + if (this.currentName === name) this.currentName = null; + this.leavePath(); + return; + } if (this.currentName !== name) return; // not the active routed modal this.currentName = null; this.replaceHash(""); @@ -124,6 +203,24 @@ class ModalRouter { syncTab(name: string, tab: string): void { if (this.routingFromUrl) return; if (this.currentName !== name) return; + if (this.pathRoutedName === name) { + // Opened from a path: the path stays, and the tab rides as `?tab=`. + const search = new URLSearchParams(window.location.search); + if (tab) { + search.set("tab", tab); + } else { + search.delete("tab"); + } + const query = search.toString(); + history.replaceState( + history.state, + "", + window.location.pathname + + (query ? `?${query}` : "") + + window.location.hash, + ); + return; + } const params = this.currentHashParams(); params.set("modal", name); if (tab) { @@ -138,6 +235,8 @@ class ModalRouter { syncArgs(name: string, args: Record): void { if (this.routingFromUrl) return; if (this.currentName !== name) return; + // The path is the state of a path-routed modal; don't add a hash to it. + if (this.pathRoutedName === name) return; const params = this.currentHashParams(); params.set("modal", name); for (const [key, value] of Object.entries(args)) { diff --git a/src/client/MomentShare.ts b/src/client/MomentShare.ts new file mode 100644 index 00000000000..3ffb1797d15 --- /dev/null +++ b/src/client/MomentShare.ts @@ -0,0 +1,99 @@ +import type { GameXpPanelState } from "./components/GameXpPanel"; +import { + isMilestoneLevel, + levelsReachedInGame, + MAX_LEVEL, + MAX_PRESTIGE, + reachedLegendThisGame, +} from "./Progression"; +import { playerProfileUrl } from "./utilities/PlayerProfileUrl"; +import { translateText } from "./Utils"; + +// A progression moment worth sharing: a milestone level, becoming a Legend, +// or a new prestige rank. Shared as the player's profile link with the moment +// named in `?moment=`, so the link unfurls into a card for it. +export type ShareMoment = + | { kind: "level"; level: number } + | { kind: "legend" } + | { kind: "prestige"; rank: number }; + +/** + * The moment a finished game is worth sharing for, or null. Only the server's + * result counts (never a provisional figure), and only a milestone level or + * becoming a Legend: an ordinary level-up isn't shared. A Legend beats any + * level, and of several milestones reached in one game the highest is shared. + */ +export function gameShareMoment(view: GameXpPanelState): ShareMoment | null { + if (view.kind !== "result" || !view.data.eligible) return null; + const data = view.data; + if (reachedLegendThisGame(data)) return { kind: "legend" }; + const milestones = levelsReachedInGame(data) + .map((l) => l.level) + .filter(isMilestoneLevel); + if (milestones.length === 0) return null; + return { kind: "level", level: Math.max(...milestones) }; +} + +// The moments the site's card route draws: a level from 2 (the first one +// reached) to the last, and a prestige rank from 1 to the last. It answers +// anything else with a 400, which unfurls as no image at all. +function inRange(n: number, min: number, max: number): boolean { + return Number.isInteger(n) && n >= min && n <= max; +} + +/** + * The `?moment=` value: `level50`, `legend`, `prestige3`. Null for a level or + * rank the card route wouldn't draw (a surprising server value), so the + * share is dropped rather than sending a link that unfurls without a card. + */ +export function momentSlug(moment: ShareMoment): string | null { + switch (moment.kind) { + case "level": + return inRange(moment.level, 2, MAX_LEVEL) + ? `level${moment.level}` + : null; + case "legend": + return "legend"; + case "prestige": + return inRange(moment.rank, 1, MAX_PRESTIGE) + ? `prestige${moment.rank}` + : null; + } +} + +/** + * The player's profile link with the moment: `/player/?moment=level50`. + * Null when the moment has no slug (see momentSlug): nothing to share. + */ +export function momentShareUrl( + publicId: string, + moment: ShareMoment, +): string | null { + const slug = momentSlug(moment); + return slug === null ? null : `${playerProfileUrl(publicId)}?moment=${slug}`; +} + +/** The line that goes with the link ("I just reached Level 50 on OpenFront!"). */ +export function momentShareText(moment: ShareMoment): string { + switch (moment.kind) { + case "level": + return translateText("progression.share_text_level", { + level: moment.level, + }); + case "legend": + return translateText("progression.share_text_legend"); + case "prestige": + return translateText("prestige.share_text", { rank: moment.rank }); + } +} + +/** The share button's label for a game's moment ("Share Level 50"). */ +export function momentShareLabel( + moment: Exclude, +): string { + return moment.kind === "legend" + ? translateText("progression.share_moment_legend") + : translateText("progression.share_moment_level", { + level: moment.level, + }); +} diff --git a/src/client/PlayerProfileModal.ts b/src/client/PlayerProfileModal.ts index 3787f4002f2..81baa5e3270 100644 --- a/src/client/PlayerProfileModal.ts +++ b/src/client/PlayerProfileModal.ts @@ -9,6 +9,7 @@ import { customElement, state } from "lit/decorators.js"; import { ClientEnv } from "src/client/ClientEnv"; import { fetchPublicPlayerProfile, getUserMe } from "./Api"; import "./ClanModal"; +import "./components/baseComponents/Button"; import "./components/baseComponents/stats/PlayerGameHistoryView"; import type { PlayerGameHistoryCache } from "./components/baseComponents/stats/PlayerGameHistoryView"; import "./components/baseComponents/stats/PlayerStatsTree"; @@ -16,17 +17,26 @@ import { BaseModal } from "./components/BaseModal"; import "./components/clan/ClanCard"; import "./components/PlayerName"; import "./components/ProfileCard"; +import "./components/ProfileProgression"; +import "./components/ProfileShare"; import { modalHeader } from "./components/ui/ModalHeader"; import { usernameText } from "./components/ui/UsernameText"; import { verifiedBadge } from "./components/ui/VerifiedBadge"; import "./GameStatsModal"; import "./LeaderboardModal"; +import { modalRouter } from "./ModalRouter"; import { fetchPublicPlayerProgress } from "./ProgressionApi"; -import { playerProfileUrl } from "./utilities/PlayerProfileUrl"; +import { + parsePlayerProfilePath, + playerProfileUrl, +} from "./utilities/PlayerProfileUrl"; import { currentPagePath, translateText } from "./Utils"; export { playerProfileUrl }; +// Every opening of a profile, numbered: keys the once-per-opening flourishes. +let profileOpenings = 0; + /** Where a profile was opened from, i.e. where its Back button leads. */ export type ProfileOrigin = "clan" | "leaderboard" | "account"; @@ -41,6 +51,9 @@ export class PlayerProfileModal extends BaseModal { // Level / XP, when progression is on and the player has any. @state() private progress: PublicProgress | null = null; @state() private loading = false; + // The link this profile was opened from (`openfront.io/player/`), when + // it was opened from one: the not-found state shows it. + @state() private openedLink: string | null = null; private openedFrom: ProfileOrigin | null = null; // Mirrors the account modal's Games tab: keep the accumulated history list + // cursor across tab switches (and the game-stats detour) so re-entering Games @@ -50,6 +63,10 @@ export class PlayerProfileModal extends BaseModal { private restoreGamesScrollAfterOpen = false; // Bumped on every profile load so a superseded in-flight response is dropped. private loadGeneration = 0; + // The Progression tab was asked for (a link, or the tab the profile was + // last left on) before this player's progress arrived. The tab only exists + // for a player with progress, so it's selected once that lands. + private wantsProgressionTab = false; // Counts fresh openings, to key the profile card's once-per-open flourish. private openCount = 0; @@ -60,6 +77,15 @@ export class PlayerProfileModal extends BaseModal { { key: "stats", label: translateText("account_modal.tab_stats") }, { key: "games", label: translateText("account_modal.tab_games") }, { key: "clans", label: translateText("account_modal.tab_clans") }, + // Only for a player with a level: there is nothing to show otherwise. + ...(this.progress === null + ? [] + : [ + { + key: "progression", + label: translateText("account_modal.tab_progression"), + }, + ]), ], }; } @@ -108,6 +134,8 @@ export class PlayerProfileModal extends BaseModal { return this.renderGames(); case "clans": return this.renderClans(); + case "progression": + return this.renderProgression(); default: return this.renderProfile(); } @@ -219,10 +247,12 @@ export class PlayerProfileModal extends BaseModal { if (this.loading) { return this.renderLoadingSpinner(translateText("player_profile.loading")); } - if (!this.profileLoaded()) { + const publicId = this.publicId; + if (!this.profileLoaded() || publicId === null) { return this.renderNotFound(); } - // The card heads the stats; games and wins stay in the stats below it. + // The card heads the stats; games and wins stay in the stats below it, + // and the ways to share the profile follow it. return html` ${this.progress === null ? nothing @@ -233,19 +263,37 @@ export class PlayerProfileModal extends BaseModal { .progress=${this.progress} .openKey=${`profile-${this.openCount}`} >`} + `; } + private renderProgression() { + // The tab is hidden without progress; should it still be selected (a new + // player opened on top), show the Stats content rather than nothing. + if (this.progress === null) return this.renderProfile(); + // Keyed like the card's flourish: the tab pops in once per opening, not + // again on a tab switch. + return html``; + } + // Everyone's level, your own included, comes from the public endpoint first: // the /users/@me copy is cached from page load and would miss the games // played since. But that endpoint 404s for a player who hides their level — // for everyone, them included — and they still see their own level. So on // your OWN profile only, no public progress falls back to your /users/@me // copy (memoised: normally no request). Nobody else's profile ever does, so a - // hidden level stays hidden from others. Missing progress hides the card. + // hidden level stays hidden from others. Missing progress hides the summary. private async loadProgress(publicId: string): Promise { try { const progress = await fetchPublicPlayerProgress(publicId); @@ -265,15 +313,95 @@ export class PlayerProfileModal extends BaseModal { private renderNotFound() { return html` -
- 📊 -

- ${translateText("player_profile.not_found")} +

+
+ +
+

+ ${translateText("player_profile.not_found_title")} +

+

+ ${translateText("player_profile.not_found_body")}

+ ${this.openedLink === null + ? nothing + : html`

+ ${this.openedLink} +

`} + +
+ this.goHome()} + > + this.openLeaderboard()} + > +
`; } + // Close the profile onto the home page (the URL follows: see ModalRouter). + private goHome(): void { + this.close(); + } + + private openLeaderboard(): void { + this.close(); + document + .querySelector("leaderboard-modal") + ?.open(); + } + + // Opening another player's profile over this one: BaseModal syncs the URL + // only when a modal first opens, which would leave it naming the last + // player (its `/player/` path, or its `#modal=` hash), with tab changes + // written onto that and a reload opening the wrong profile. Hand the URL to + // the new player as an in-app open does. The router ignores this while it + // is the one opening the modal (from a path or the hash). + public override open(args?: Record): void { + const wasOpen = this.isOpen(); + const before = this.publicId; + super.open(args); + if (wasOpen && this.isOpen() && this.publicId !== before) { + modalRouter.syncOpened(this.routerName, args); + } + } + protected onOpen(args?: Record): void { const publicId = typeof args?.publicID === "string" && args.publicID.length > 0 @@ -296,12 +424,29 @@ export class PlayerProfileModal extends BaseModal { return; } + // The Progression tab can't be selected until this player's progress is + // known (BaseModal drops a tab it doesn't list): hold the profile on + // Stats, and switch once the progress shows up. An explicit tab request + // decides on its own: when already open, BaseModal calls onOpen before it + // applies the requested tab, so activeTab may still be the remembered one. + // Only without a request does the remembered Progression tab carry over. + // Asked for while already open on another tab (Games, Clans), the profile + // holds on Stats too, so the switch below still happens. + this.wantsProgressionTab = + typeof args?.tab === "string" + ? args.tab === "progression" + : this.activeTab === "progression"; + if (this.wantsProgressionTab || this.activeTab === "progression") { + this.activeTab = "stats"; + } + // Fresh open (router/share link): clear any stale origin. The openFrom* // helpers re-set it right after open() so back() routes home; the // return-from-stats path above skips this and keeps the origin intact. this.openedFrom = null; // A new opening: the profile card plays its flourish again. - this.openCount++; + // Counted across every profile modal, so no two openings share a key. + this.openCount = ++profileOpenings; this.publicId = publicId; this.username = null; this.statsTree = null; @@ -310,6 +455,14 @@ export class PlayerProfileModal extends BaseModal { this.gameHistoryCache = null; this.gamesScrollTop = 0; this.restoreGamesScrollAfterOpen = false; + // Only while the path is this player's: the URL can still name the last + // one for the moment (see open()). + this.openedLink = + publicId !== null && + modalRouter.isPathRouted("profile") && + parsePlayerProfilePath(window.location.pathname) === publicId + ? `${window.location.host}${window.location.pathname}` + : null; this.loading = publicId !== null; if (publicId !== null) { void this.loadProfile(publicId); @@ -327,7 +480,20 @@ export class PlayerProfileModal extends BaseModal { // holds up the profile (its request can take up to its own timeout). // Only shown alongside a loaded profile (see renderProfile). void this.loadProgress(publicId).then((progress) => { - if (current()) this.progress = progress; + if (!current()) return; + this.progress = progress; + const wanted = this.wantsProgressionTab; + this.wantsProgressionTab = false; + // Unless the viewer has moved to another tab, or closed the profile + // (the tab would then be remembered for the next opening), meanwhile. + if ( + wanted && + progress !== null && + this.activeTab === "stats" && + this.isOpen() + ) { + this.setActiveTab("progression"); + } }); const profile = await fetchPublicPlayerProfile(publicId); if (!current()) return; diff --git a/src/client/Progression.ts b/src/client/Progression.ts index 2469c947647..60c96bcc728 100644 --- a/src/client/Progression.ts +++ b/src/client/Progression.ts @@ -281,3 +281,12 @@ export function levelRewardReasonKey(reason: string): string | undefined { ? LEVEL_REWARD_REASON_KEYS[reason] : undefined; } + +/** A reward's amount (a stringified bigint); 0 when it isn't one. */ +export function rewardAmount(value: string): bigint { + try { + return BigInt(value); + } catch { + return 0n; + } +} diff --git a/src/client/RewardClaims.ts b/src/client/RewardClaims.ts new file mode 100644 index 00000000000..ee9eaac7685 --- /dev/null +++ b/src/client/RewardClaims.ts @@ -0,0 +1,114 @@ +import { + type ClaimRewardResponse, + isRewardClaimable, + type Reward, +} from "@openfront/shared/ApiSchemas"; +import { claimReward, invalidateUserMe } from "./Api"; + +// Claiming a list of rewards one by one: the profile's Claim all and the +// end-of-game reveal's Claim both go through here. A held reward (see +// isRewardClaimable) is never claimed: it stays pending until the API lets it +// go, so it is left out of the run, as if it weren't in the list. One held +// that this client's copy didn't know about (the API refuses its claim with a +// `{ held }` answer) is noted in `held`, and the run moves on to the rest. + +export interface ClaimRun { + // Claimed by this run, or found claimed already ("not_found": a second + // device, another tab, the rewards panel), in the order given. + claimedIds: string[]; + // The ones this run itself credited: claimedIds less the not_found ones. + creditedIds: string[]; + // The balances after the last claim this run credited; null when it + // credited none. + currency: ClaimRewardResponse["currency"] | null; + // The ones the API refused as held (held after all: this client's copy was + // stale), each with its hold, in the order given. Neither claimed nor a + // failure: they stay pending until the hold lifts, so a caller shows them + // as held from then on, and doesn't claim them again. + held: RefusedHold[]; + // A claim failed, and the run stopped there. The ones before it went + // through (`claimedIds`), so a retry claims only the rest. + failed: boolean; +} + +export interface RefusedHold { + id: string; + // The hold, as RewardSchema.held has it: "trust", or one this client + // doesn't know yet. + held: string; +} + +export interface ClaimRunOptions { + // Replaceable for tests and previews. + claim?: (rewardId: string) => Promise; + // Checked before each claim: true stops the run (its caller has gone). + stopped?: () => boolean; +} + +// Only the success shape counts as claimed. Anything but that or a `{ held }` +// answer (false, or a result this client doesn't know) stops the run without +// reading balances from it. +function isClaimed(result: unknown): result is ClaimRewardResponse { + return ( + typeof result === "object" && + result !== null && + "currency" in result && + typeof result.currency === "object" && + result.currency !== null + ); +} + +// The hold in the API's answer for a reward held after all +// (ClaimRewardHeldResponseSchema), or null when the answer isn't one. +function refusedHold(result: unknown): string | null { + if (typeof result !== "object" || result === null || !("held" in result)) { + return null; + } + const held = result.held; + return typeof held === "string" && held.length > 0 ? held : null; +} + +/** + * Claims `rewards` one by one, in order, skipping the held ones, and stops + * at the first failure. A reward the API refuses as held is noted (`held`) + * and the run goes on. When anything was claimed, or refused as held, the + * cached /users/@me is dropped (its balances and pending rewards changed, or + * it doesn't know the hold); telling the page (`rewards-changed`) is the + * caller's, which knows what changed. + */ +export async function claimRewards( + rewards: readonly Pick[], + { claim = claimReward, stopped = () => false }: ClaimRunOptions = {}, +): Promise { + const run: ClaimRun = { + claimedIds: [], + creditedIds: [], + currency: null, + held: [], + failed: false, + }; + for (const reward of rewards) { + if (!isRewardClaimable(reward)) continue; + if (stopped()) break; + const id = reward.id; + const result = await claim(id); + if (result === "not_found") { + run.claimedIds.push(id); + continue; + } + const held = refusedHold(result); + if (held !== null) { + run.held.push({ id, held }); + continue; + } + if (!isClaimed(result)) { + run.failed = true; + break; + } + run.claimedIds.push(id); + run.creditedIds.push(id); + run.currency = result.currency; + } + if (run.claimedIds.length > 0 || run.held.length > 0) invalidateUserMe(); + return run; +} diff --git a/src/client/components/FlareCosmetic.ts b/src/client/components/FlareCosmetic.ts index fe809a2d5e1..5027b1b98e5 100644 --- a/src/client/components/FlareCosmetic.ts +++ b/src/client/components/FlareCosmetic.ts @@ -72,6 +72,23 @@ function fallbackPreview(url: string | null | undefined): TemplateResult { `; } +/** + * What the flare itself says about its cosmetic, before (or without) the + * catalog: the name from the flare, no type, and its own image or a sparkle. + * Null when the flare isn't a cosmetic. + */ +export function plainFlareCosmetic( + flare: TrackFlare, +): FlareCosmeticView | null { + const cosmetic = flare.cosmetic ?? null; + if (cosmetic === null) return null; + return { + name: plainName(cosmetic.name), + typeLabel: "", + preview: fallbackPreview(cosmetic.url), + }; +} + /** * Describes the cosmetic a flare unlocks, from the cosmetics catalog when it * has the item, else from what the flare itself says. Null when the flare @@ -93,11 +110,7 @@ export async function describeFlareCosmetic( console.warn("describeFlareCosmetic: catalog unavailable", err); } if (resolved === null) { - return { - name: plainName(cosmetic.name), - typeLabel: "", - preview: fallbackPreview(cosmetic.url), - }; + return plainFlareCosmetic(flare); } return { name: cosmeticSelectionLabel(resolved), diff --git a/src/client/components/FriendsList.ts b/src/client/components/FriendsList.ts index 54a60ea9d6a..4efc5df89d5 100644 --- a/src/client/components/FriendsList.ts +++ b/src/client/components/FriendsList.ts @@ -10,14 +10,16 @@ import { sendFriendRequest, } from "../FriendsApi"; import { showInGameConfirm } from "../InGameModal"; +import { parsePlayerProfilePath } from "../utilities/PlayerProfileUrl"; import { showToast, translateText } from "../Utils"; import { playerNameLink } from "./ui/PlayerNameLink"; const PAGE_LIMIT = 20; /** - * Accept a pasted profile share link (`…#modal=profile&publicID=abc12345`) and - * keep only the id, mirroring the private-lobby join box. Only strings that are + * Accept a pasted profile share link (`…/player/abc12345`, or the older + * `…#modal=profile&publicID=abc12345`) and keep only the id, mirroring the + * private-lobby join box. Only strings that are * actually URLs are touched: usernames render as `wonder #5005`, so a bare `#` * must be left alone. */ @@ -32,6 +34,9 @@ export function extractPublicIdFromUrl(input: string): string { // so don't log; just hand the raw text back for the server to reject. return input; } + // The canonical link names the player in its path. + const fromPath = parsePlayerProfilePath(url.pathname); + if (fromPath !== null) return fromPath; // Modals are hash-routed (`…/#modal=profile&publicID=abc12345`), so the id is // in the fragment, not the query string — `url.searchParams` is always empty // here. Parse the fragment's own `key=value&…` pairs instead. diff --git a/src/client/components/GameXpPanel.ts b/src/client/components/GameXpPanel.ts index 818f9c686d8..97ba0c7763a 100644 --- a/src/client/components/GameXpPanel.ts +++ b/src/client/components/GameXpPanel.ts @@ -2,10 +2,27 @@ import type { GameType } from "@openfront/engine-api/game/GameTypes"; import type { GameXpEligible, GameXpResponse, + GameXpReward, } from "@openfront/shared/ApiSchemas"; import { html, LitElement, nothing, PropertyValues, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { keyed } from "lit/directives/keyed.js"; +import { getUserMe } from "../Api"; +import { + fetchCosmetics, + resolveCosmetics, + type ResolvedCosmetic, +} from "../Cosmetics"; +import { + type Balances, + claimGameRewards, + type FlareUnlock, + flareUnlock, + type GameRewardsSummary, + type RewardLine, + summarizeGameRewards, + withRefusedHolds, +} from "../GameRewards"; import { apportionXp, ineligibleReasonKey, @@ -26,7 +43,12 @@ import type { XpReconciliation, } from "../ProvisionalXpAtDeath"; import { translateText } from "../Utils"; +import "./baseComponents/Button"; +import "./CapIcon"; +import "./CosmeticPreview"; import "./LevelBadge"; +import "./PlutoniumIcon"; +import type { RewardsChangedDetail } from "./RewardsPanel"; import { formatXp, xpBonusText, xpProgressText } from "./XpBar"; // What the XP section of a finished game is showing. The caller owns the @@ -106,6 +128,20 @@ function wipeStart(i: number, n: number): number { } const MULTIPLIER_HOLD_MS = 400; const PAYOFF_MS = 900; +// The level rewards, after the last level-up of the game: a cosmetic it +// unlocked wipes into the milestone card once that has landed, then the +// rewards row rises in (later when it follows a card), and its amounts +// count up. +const UNLOCK_DELAY_MS = 700; +const REWARDS_DELAY_MS = 300; +const REWARDS_AFTER_CARD_MS = 900; +const REWARDS_COUNT_LEAD_MS = 360; +const REWARDS_COUNT_MS = 500; +// The balance after a claim counts up from the old one. +const BALANCE_LEAD_MS = 120; +const BALANCE_COUNT_MS = 600; + +const easeOut = (f: number) => 1 - Math.pow(1 - f, 3); // The caption step the panel rests on once the reveal is over. const REST_STEP = -1; @@ -155,12 +191,26 @@ interface RevealFrame { lineValues: number[]; // A multiplier being applied to the cards (its wipe): a boost or a cut. applying: "boost" | "cut" | null; + // The level rewards row has risen in, and how far its amounts have + // counted up, 0 to 1. + rewardsShown: boolean; + rewardsCount: number; + // The cosmetics the game unlocked have wiped in. + unlockShown: boolean; // The last level, and whether the Legend moment, a provisional reveal // already celebrated: their cards stay put rather than landing again. celebrated: number; celebratedLegend: boolean; } +// The rewards row's Claim button. +type ClaimState = + | { status: "idle"; failed: boolean } + | { status: "claiming" } + // `to` is the balance after the claim (null if unknown), `from` the one + // before it, which the shown balance counts up from. + | { status: "claimed"; from: Balances | null; to: Balances | null }; + // Bar position of a level/progress pair, in level units. Level 100 has no // next level and reads as a full bar. A full level stays just short of the // next, so the position never reads as a level the player hasn't reached. @@ -213,6 +263,23 @@ export class GameXpPanel extends LitElement { // The Legend result already announced (see announceLegend). private announcedLegend: GameXpEligible | null = null; private timers: ReturnType[] = []; + // The result last announced as settled (see updated()). + private settledView: GameXpPanelState | null = null; + + // Claiming this game's level rewards. Reset with every new view. + @state() private claim: ClaimState = { status: "idle", failed: false }; + @state() private claimPressed = false; + // How far the balance shown after a claim has counted up, 0 to 1. + @state() private balanceCount = 1; + private claimedIds = new Set(); + // The ones the API refused as held when claimed from this view, with the + // hold: shown as held from then on (see rewardsOf). + private refusedHolds = new Map(); + private claimToken = 0; + private claimTimers: ReturnType[] = []; + // The store's catalog, for the previews of what a game unlocked. + @state() private catalog: readonly ResolvedCosmetic[] = []; + private catalogRequested = false; createRenderRoot() { return this; @@ -222,12 +289,22 @@ export class GameXpPanel extends LitElement { // Jump to the end: with its timers gone, a reveal left in place would // stay half-done (and aria-busy) if the panel were added back. this.skipReveal(); + this.clearClaimTimers(); + this.balanceCount = 1; super.disconnectedCallback(); } protected willUpdate(changed: PropertyValues): void { if (changed.has("view")) { + this.resetClaim(); const s = this.view; + if ( + s.kind === "result" && + s.data.eligible && + (s.data.flares ?? []).length > 0 + ) { + this.loadCatalog(); + } const prev = changed.get("view"); // The same provisional figure, now being confirmed: a reveal still // playing carries on. @@ -357,6 +434,33 @@ export class GameXpPanel extends LitElement { const positionAfterXp = (xp: number) => total <= 0 ? start : start + ((end - start) * xp) / total; + // The rewards and unlocks come after the game's last level-up. + const reachedLevels = levelsReachedInGame(data); + const lastLevel = + reachedLevels.length > 0 + ? reachedLevels[reachedLevels.length - 1].level + : null; + // Only the server's confirmed result has rewards or unlocks. + const confirmed = this.view.kind === "result"; + const rewards = confirmed ? summarizeGameRewards(data.rewards ?? []) : null; + const unlocks = confirmed && (data.flares ?? []).length > 0; + const card = + reachedLegendThisGame(data) || this.milestoneOf(data) !== undefined; + const scheduleRewards = (t0: number) => { + if (unlocks) at(t0 + UNLOCK_DELAY_MS, () => patch({ unlockShown: true })); + if (rewards === null) return; + const rowAt = + t0 + (unlocks || card ? REWARDS_AFTER_CARD_MS : REWARDS_DELAY_MS); + at(rowAt, () => patch({ rewardsShown: true })); + const countAt = rowAt + REWARDS_COUNT_LEAD_MS; + const steps = Math.max(2, Math.round(REWARDS_COUNT_MS / 40)); + for (let i = 1; i <= steps; i++) { + at(countAt + (REWARDS_COUNT_MS * i) / steps, () => + patch({ rewardsCount: easeOut(i / steps) }), + ); + } + }; + this.barAnimate = false; this.reveal = { step: 0, @@ -376,6 +480,9 @@ export class GameXpPanel extends LitElement { multipliersShown: celebrate ? multipliers.length : 0, lineValues: celebrate ? finalLineValues(data) : [...faces], applying: null, + rewardsShown: false, + rewardsCount: 0, + unlockShown: false, celebrated: celebrate ? mode.celebrated : 0, celebratedLegend: celebrate && mode.celebratedLegend, }; @@ -399,6 +506,11 @@ export class GameXpPanel extends LitElement { .map((l) => l.level) .filter((l) => l > passed && l <= MAX_LEVEL) .sort((a, b) => a - b); + // A celebration whose last level the provisional reveal already reached + // has no level-up to bring the rewards in after: they come in at once. + if (celebrate && lastLevel !== null && lastLevel <= passed) { + scheduleRewards(START_MS); + } const climb = ( t: number, to: number, @@ -462,6 +574,7 @@ export class GameXpPanel extends LitElement { legend, }); }); + if (reached === lastLevel) scheduleRewards(t); t += legend ? LEGEND_MS : LEVEL_UP_MS; // Empty the bar and carry on with the rest of this step. const resume = stepCount + 1; @@ -611,6 +724,21 @@ export class GameXpPanel extends LitElement { }); } + // Tells the host once a result is showing in full: its reveal has ended or + // been skipped, or there was none. Once per result (`detail` is the view). + private announceSettled(): void { + const view = this.view; + if (view.kind !== "result" || this.reveal !== null) return; + if (this.settledView === view) return; + this.settledView = view; + this.dispatchEvent( + new CustomEvent("xp-reveal-settled", { + detail: view, + bubbles: true, + }), + ); + } + // Tells the page this result made the player a Legend, once per result: // `xp-legend` (detail: the result), cancelable. A listener that takes the // moment (plays the Legend ceremony) cancels it. Only ever for a result the @@ -629,6 +757,7 @@ export class GameXpPanel extends LitElement { protected updated(changed: PropertyValues): void { super.updated(changed); + this.announceSettled(); // A Legend result shown without its reveal reaching level 100 (reduced // motion, a skip, or it arrived while the popup was hidden) still gets // its moment, once it's on screen. @@ -756,7 +885,7 @@ export class GameXpPanel extends LitElement { return this.frame( html`
${translateText("progression.calculating")} @@ -878,7 +1007,7 @@ export class GameXpPanel extends LitElement { data-xp-bar-fill class="h-full bg-yellow-400 shadow-[0_0_10px_rgba(250,204,21,0.7)] ${this .barAnimate - ? "transition-[width] ease-linear" + ? "xp-bar-filling transition-[width] ease-linear" : ""}" style="width: ${fillPercent}%; transition-duration: ${this .barDurationMs}ms" @@ -1186,6 +1315,54 @@ export class GameXpPanel extends LitElement { .xp-bar-flash { animation: xp-bar-flash 450ms ease-in-out 2; } + /* While the bar fills, its leading edge glows white-hot and a sheen + runs along the fill. Both are pseudo-elements clipped to the fill; + the sheen moves by transform, one tile per loop. */ + [data-xp-panel] [data-xp-bar-fill] { + position: relative; + overflow: hidden; + } + [data-xp-panel] [data-xp-bar-fill]::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + right: 0; + width: 42px; + pointer-events: none; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0), + rgba(255, 250, 210, 0.95) + ); + opacity: 0; + transition: opacity 160ms ease-out; + } + [data-xp-panel] .xp-bar-filling::after { + opacity: 1; + } + [data-xp-panel] .xp-bar-filling::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: -48px; + right: 0; + pointer-events: none; + background-image: repeating-linear-gradient( + 115deg, + rgba(255, 255, 255, 0) 0 10px, + rgba(255, 255, 255, 0.28) 10px 14px, + rgba(255, 255, 255, 0) 14px 24px + ); + background-size: 48px 100%; + animation: xp-bar-sheen 420ms linear infinite; + } + @keyframes xp-bar-sheen { + to { + transform: translateX(48px); + } + } @keyframes xp-badge-pop { 0% { transform: scale(1); @@ -1434,6 +1611,55 @@ export class GameXpPanel extends LitElement { .xp-card-in.xp-after-slide.xp-shine::after { animation-delay: 410ms; } + /* A cosmetic unlocked: the divider draws, the half wipes in from it, + its preview pops. The rewards row's claimed state rises in. */ + @keyframes xp-unlock-sep { + from { + transform: scaleY(0); + } + } + .xp-unlock-sep { + animation: xp-unlock-sep 220ms ease-out both; + } + @keyframes xp-unlock-in { + from { + clip-path: inset(0 100% 0 0); + } + to { + clip-path: inset(0 0 0 0); + } + } + .xp-unlock-in { + animation: xp-unlock-in 480ms cubic-bezier(0.2, 0.8, 0.2, 1) 120ms both; + } + @keyframes xp-unlock-pop { + from { + transform: scale(0.4); + opacity: 0; + } + } + .xp-unlock-pop { + animation: xp-unlock-pop 420ms cubic-bezier(0.2, 0.9, 0.3, 1.35) 300ms + both; + } + @keyframes xp-claimed-in { + from { + opacity: 0; + transform: translateY(6px); + } + } + .xp-claimed-in { + animation: xp-claimed-in 280ms ease-out both; + } + @keyframes xp-check-in { + from { + transform: scale(0.3); + opacity: 0; + } + } + .xp-check-in { + animation: xp-check-in 380ms cubic-bezier(0.2, 0.9, 0.3, 1.5) both; + } .xp-legend-text { background: linear-gradient(90deg, #fde047, #f0abfc, #fde047); background-size: 200% 100%; @@ -1443,6 +1669,10 @@ export class GameXpPanel extends LitElement { filter: drop-shadow(0 0 10px rgba(250, 204, 21, 0.8)); } @media (prefers-reduced-motion: reduce) { + [data-xp-panel] .xp-bar-filling::before, + [data-xp-panel] .xp-bar-filling::after { + display: none; + } .xp-caption-in, .xp-levelup-in, .xp-bar-flash, @@ -1455,7 +1685,12 @@ export class GameXpPanel extends LitElement { .xp-card-wipe::before, .xp-card-in, .xp-card-in.xp-shine::after, - .xp-card-in .xp-shine::after { + .xp-card-in .xp-shine::after, + .xp-unlock-sep, + .xp-unlock-in, + .xp-unlock-pop, + .xp-claimed-in, + .xp-check-in { animation: none; } } @@ -1470,7 +1705,8 @@ export class GameXpPanel extends LitElement { private renderRecap(data: GameXpEligible): TemplateResult { const r = this.reveal; return html` - ${this.renderLevelUp(data)} ${this.renderBreakdown(data)} + ${this.renderLevelUp(data)} ${this.renderRewards(data)} + ${this.renderBreakdown(data)} ${data.breakdown.leftEarly ? this.collapsible( r === null, @@ -1522,7 +1758,7 @@ export class GameXpPanel extends LitElement { html`
@@ -1544,6 +1780,7 @@ export class GameXpPanel extends LitElement { })}
+ ${this.renderUnlocks(data, true)}
`, ); } @@ -1553,12 +1790,19 @@ export class GameXpPanel extends LitElement { // end of the bar say it. During the reveal its space slides open when the // bar reaches the milestone, and the card pops in; one the provisional // reveal already celebrated stays put. + // The highest milestone level reached this game, if any. + private milestoneOf(data: GameXpEligible) { + return [...levelsReachedInGame(data)] + .reverse() + .find((lvl) => isMilestoneLevel(lvl.level)); + } + private renderLevelUp(data: GameXpEligible): TemplateResult | typeof nothing { if (reachedLegendThisGame(data)) return this.renderLegendMoment(data); const milestones = levelsReachedInGame(data).filter((lvl) => isMilestoneLevel(lvl.level), ); - if (milestones.length === 0) return nothing; + if (milestones.length === 0) return this.renderUnlockCard(data); const r = this.reveal; // The latest milestone the bar has reached, or (closed until then) the // one it is heading for. @@ -1577,7 +1821,7 @@ export class GameXpPanel extends LitElement { data-xp-levelup data-xp-milestone=${milestone.level} data-xp-slot-hidden=${shown ? nothing : "true"} - class="xp-shine mt-3 flex items-center justify-center gap-3 rounded-xl border border-yellow-400/30 bg-gradient-to-r from-transparent via-yellow-400/15 to-transparent px-3 py-2 ${settled + class="xp-shine mt-3 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 rounded-xl border border-yellow-400/30 bg-gradient-to-r from-transparent via-yellow-400/15 to-transparent px-3 py-2 ${settled ? "" : this.slideSlotClass(shown)}" > @@ -1606,10 +1850,410 @@ export class GameXpPanel extends LitElement { ${translateText("progression.level", { level: milestone.level })} + ${this.renderUnlocks(data, true)} + `, + ); + } + + // What the game unlocked, resolved against the store's catalog. + // Only for the server's confirmed result, like the rewards. + private unlocks(data: GameXpEligible): FlareUnlock[] { + if (this.view.kind !== "result") return []; + return (data.flares ?? []).map((f) => flareUnlock(f, this.catalog)); + } + + private loadCatalog(): void { + if (this.catalogRequested) return; + this.catalogRequested = true; + void fetchCosmetics() + .then((cosmetics) => { + this.catalog = resolveCosmetics(cosmetics, false, null); + }) + .catch(() => { + // Named from the flare alone, without a preview. + }); + } + + // The "Unlocked" half of a milestone (or Legend) card, one per cosmetic + // the game unlocked: after the card has landed, a divider draws and the + // half wipes in from it, its preview popping in. Its space is kept from + // the start, so the card never changes size. On a phone it wraps under + // the milestone. + private renderUnlocks( + data: GameXpEligible, + divider: boolean, + ): TemplateResult | typeof nothing { + const unlocks = this.unlocks(data); + if (unlocks.length === 0) return nothing; + const r = this.reveal; + const shown = r === null || r.unlockShown; + const animate = r !== null && shown; + const hidden = shown ? "" : "invisible"; + return html`${unlocks.map( + (u) => + html`${divider + ? html`` + : nothing} +
+ + ${u.resolved !== null + ? html`` + : html``} + +
+
+ ${translateText("progression.unlocked")} +
+
+ ${u.name} +
+
+ ${translateText("progression.unlock_in_locker", { + type: u.typeLabel, + })} +
+
+
`, + )}`; + } + + // A cosmetic unlocked at a level with no milestone card of its own: the + // "Unlocked" half on a card by itself, its space sliding open. + private renderUnlockCard( + data: GameXpEligible, + ): TemplateResult | typeof nothing { + if ((data.flares ?? []).length === 0) return nothing; + const r = this.reveal; + const shown = r === null || r.unlockShown; + return this.collapsible( + shown, + html`
+ ${this.renderUnlocks(data, false)} +
`, + ); + } + + // --------------------------------------------------------------------------- + // Level rewards: the row under the level-up, and claiming it. + // --------------------------------------------------------------------------- + + // The rewards row, once the reveal has reached the game's last level-up: + // its space slides open and it rises in like a source card, its amounts + // counting up. Only for the server's result (never a provisional figure), + // and not in the past-game summary. + private renderRewards(data: GameXpEligible): TemplateResult | typeof nothing { + const summary = summarizeGameRewards(this.rewardsOf(data)); + if (summary === null) return nothing; + const r = this.reveal; + const shown = r === null || r.rewardsShown; + const count = r === null ? 1 : r.rewardsCount; + return this.collapsible( + shown, + html`
+
+
+ ${translateText(summary.headingKey, summary.headingParams)} +
+
+ ${summary.lines.map((line) => this.renderRewardLine(line, count))} + ${summary.heldLines.map((line) => + this.renderRewardLine(line, count, line.labelKey), + )} +
+
+
+ ${this.renderClaim(data, summary)} +
`, ); } + // A held line (`heldKey`) also says why it can't be claimed yet, as the + // rewards panel does; the Claim button leaves it out. + private renderRewardLine( + line: RewardLine, + count: number, + heldKey?: string, + ): TemplateResult { + const soft = line.currency === "soft"; + const amount = + count >= 1 + ? line.total.toLocaleString() + : Math.round(Number(line.total) * count).toLocaleString(); + return html`
+ ${soft + ? html`` + : html``} + +${amount} + ${translateText(soft ? "cosmetics.soft" : "cosmetics.hard")} + ${line.each !== null + ? html`${translateText("progression.rewards_each", { + count: line.count, + amount: line.each.toLocaleString(), + })}` + : nothing} + ${heldKey !== undefined + ? html`${translateText(heldKey)}` + : nothing} +
`; + } + + private renderClaim( + data: GameXpEligible, + summary: GameRewardsSummary, + ): TemplateResult | typeof nothing { + const claim = this.claim; + // Every reward held: nothing to claim, and nothing was. + if (!summary.claimable) return nothing; + if ( + claim.status === "claimed" || + (claim.status === "idle" && summary.allClaimed) + ) { + const to = claim.status === "claimed" ? claim.to : null; + const from = claim.status === "claimed" ? claim.from : null; + return this.renderClaimed(summary, from, to); + } + const claiming = claim.status === "claiming"; + return html`
+ { + e.stopPropagation(); + this.claimPressed = true; + }} + @pointerup=${() => (this.claimPressed = false)} + @pointerleave=${() => (this.claimPressed = false)} + @pointercancel=${() => (this.claimPressed = false)} + @click=${(e: Event) => { + // The rest of the panel skips the reveal; this button doesn't. + e.stopPropagation(); + void this.claimRewards(data); + }} + > + ` + : undefined} + > + + ${claim.status === "idle" && claim.failed + ? html`${translateText("account_modal.claim_failed")}` + : nothing} +
`; + } + + private renderClaimed( + summary: GameRewardsSummary, + from: Balances | null, + to: Balances | null, + ): TemplateResult { + const f = this.balanceCount; + const at = (key: keyof Balances) => + to === null + ? "" + : Math.round( + (from?.[key] ?? to[key]) + (to[key] - (from?.[key] ?? to[key])) * f, + ).toLocaleString(); + const plutonium = summary.lines.some((l) => l.currency === "hard"); + return html`
+ + + ${translateText("progression.rewards_claimed")} + + ${to !== null + ? html`${plutonium + ? translateText("progression.rewards_balance", { + caps: at("soft"), + plutonium: at("hard"), + }) + : translateText("progression.rewards_balance_caps", { + caps: at("soft"), + })}` + : nothing} +
`; + } + + private resetClaim(): void { + this.claimToken++; + this.clearClaimTimers(); + this.claim = { status: "idle", failed: false }; + this.claimPressed = false; + this.balanceCount = 1; + this.claimedIds = new Set(); + this.refusedHolds = new Map(); + } + + // The game's rewards, those refused as held since arriving marked held: + // the row then shows them with why, and neither claims nor counts them. + // Only the server's confirmed result has rewards: a provisional figure + // never shows (or claims) any. + private rewardsOf(data: GameXpEligible): readonly GameXpReward[] { + if (this.view.kind !== "result") return []; + return withRefusedHolds(data.rewards ?? [], this.refusedHolds); + } + + private clearClaimTimers(): void { + for (const t of this.claimTimers) clearTimeout(t); + this.claimTimers = []; + } + + // Claims this game's rewards, and only these: never the player's other + // pending rewards. Works mid-reveal too. + private async claimRewards(data: GameXpEligible): Promise { + if (this.claim.status !== "idle") return; + const token = this.claimToken; + this.claim = { status: "claiming" }; + const outcome = await claimGameRewards( + this.rewardsOf(data), + this.claimedIds, + ); + if (token !== this.claimToken) return; + for (const id of outcome.claimedIds) this.claimedIds.add(id); + if (outcome.held.length > 0) { + const holds = new Map(this.refusedHolds); + for (const { id, held } of outcome.held) holds.set(id, held); + this.refusedHolds = holds; + } + if (!outcome.ok) { + this.claim = { status: "idle", failed: true }; + return; + } + // Read afresh: the claims dropped the cached copy. + const me = await getUserMe(); + if (token !== this.claimToken) return; + const to = + outcome.currency ?? (me === false ? null : (me.player.currency ?? null)); + const from = + to === null || outcome.currency === null + ? to + : { + soft: to.soft - outcome.credited.soft, + hard: to.hard - outcome.credited.hard, + }; + this.claim = { status: "claimed", from, to }; + this.countBalance(); + if (me !== false) { + this.dispatchEvent( + new CustomEvent("rewards-changed", { + detail: { currency: to, rewards: me.player.rewards ?? [] }, + bubbles: true, + composed: true, + }), + ); + } + } + + private countBalance(): void { + this.clearClaimTimers(); + if (prefersReducedMotion()) { + this.balanceCount = 1; + return; + } + this.balanceCount = 0; + const steps = Math.max(2, Math.round(BALANCE_COUNT_MS / 30)); + for (let i = 1; i <= steps; i++) { + this.claimTimers.push( + setTimeout( + () => (this.balanceCount = easeOut(i / steps)), + BALANCE_LEAD_MS + (BALANCE_COUNT_MS * i) / steps, + ), + ); + } + } + // One small card per XP source and multiplier, like the stats page tiles: // a label over a big number. // The slot (entrance) and the card (the multiplier's wipe) are separate diff --git a/src/client/components/LegendCeremony.ts b/src/client/components/LegendCeremony.ts index 8d0eacc29a4..131a6296e8d 100644 --- a/src/client/components/LegendCeremony.ts +++ b/src/client/components/LegendCeremony.ts @@ -7,6 +7,7 @@ import { TemplateResult, } from "lit"; import { customElement, state } from "lit/decorators.js"; +import { momentShareText, momentShareUrl } from "../MomentShare"; import { MAX_PRESTIGE } from "../Progression"; import { translateText } from "../Utils"; import { @@ -15,6 +16,7 @@ import { renderHoneycomb, } from "./Ceremony"; import "./LevelBadge"; +import "./ProfileShare"; import { formatXp } from "./XpBar"; // Becoming a Legend (Prestige 10, level 99 to 100): the whole screen, once @@ -22,8 +24,9 @@ import { formatXp } from "./XpBar"; // it and the honeycomb pulsing in toward it, implodes to a point of light, and // the Legend crown drops in and lands hard: a gold flash, two shockwaves, the // screen shaking and two gold waves running out across the honeycomb. LEGEND -// slams in and a gold sweep runs through it, then a line on what it took, and -// Continue. At rest the crown breathes and the rays turn slowly. +// slams in and a gold sweep runs through it, then a line on what it took, a +// row to share it, and Continue. At rest the crown breathes and the rays turn +// slowly. // // Any click or key skips to the end; Escape (or Continue) then closes it. // Emits `legend-ceremony-closed` when it closes. @@ -59,6 +62,8 @@ export interface LegendMoment { lifetimeXp: number; // When it happened. at: Date; + // Whose it is, for the share link. Empty: no share row. + publicId: string; } const SEEN_PREFIX = "legendCeremonySeen:"; @@ -215,7 +220,8 @@ export class LegendCeremony extends LitElement { } // Every key while it's up stays with it. Before the end any key skips; - // at the end Escape closes and Tab stays on Continue. + // at the end Escape closes and Tab moves between the share buttons and + // Continue without leaving the ceremony. private onKeyDown = (e: KeyboardEvent): void => { if (this.moment === null) return; e.stopPropagation(); @@ -229,7 +235,19 @@ export class LegendCeremony extends LitElement { this.close(); } else if (e.key === "Tab") { e.preventDefault(); - this.continueButton()?.focus(); + const buttons = [ + ...(this.root()?.querySelectorAll( + "button:not([disabled])", + ) ?? []), + ]; + if (buttons.length === 0) return; + // From anywhere else, back to Continue (the last). + const at = buttons.indexOf(document.activeElement as HTMLButtonElement); + const next = + at === -1 + ? buttons.length - 1 + : (at + (e.shiftKey ? -1 : 1) + buttons.length) % buttons.length; + buttons[next].focus(); } }; @@ -371,6 +389,29 @@ export class LegendCeremony extends LitElement { `; } + // Sharing it, between the line and Continue, the same as the prestige + // ceremony's row. The ceremony skips (or, done, does nothing) on any + // click; the row keeps its clicks. It is simply there, without the rise + // the Continue button has. + private renderShare(moment: LegendMoment): TemplateResult | typeof nothing { + if (moment.publicId === "") return nothing; + const share = { kind: "legend" } as const; + const url = momentShareUrl(moment.publicId, share); + if (url === null) return nothing; + return html``; + } + private renderCeremony(moment: LegendMoment): TemplateResult { const done = this.beat === "done"; const shaking = this.reached("impact") && !this.landed; @@ -407,6 +448,7 @@ export class LegendCeremony extends LitElement { ` : nothing} ${this.reached("line") ? this.renderLine(moment) : nothing} + ${done ? this.renderShare(moment) : nothing} ${done ? html`
transform: translateY(12px); } } + .lc-share { + margin-top: 24px; + padding: 0 16px; + } .lc-actions { - margin-top: 26px; + margin-top: 24px; display: flex; flex-direction: column; align-items: center; diff --git a/src/client/components/NavAccountMenu.ts b/src/client/components/NavAccountMenu.ts index df8e2acf277..4669c4bb61f 100644 --- a/src/client/components/NavAccountMenu.ts +++ b/src/client/components/NavAccountMenu.ts @@ -1,5 +1,12 @@ import { Progress, UserMeResponse } from "@openfront/shared/ApiSchemas"; -import { html, LitElement, nothing, render, TemplateResult } from "lit"; +import { + html, + LitElement, + nothing, + PropertyValues, + render, + TemplateResult, +} from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { hasLinkedIdentity } from "../AccountIdentity"; @@ -19,6 +26,43 @@ import "./LevelBadge"; import { levelBadgeLabel } from "./LevelBadge"; import { xpBar, xpProgressText } from "./XpBar"; +// The account bar catching up after a game: how long the fill takes to the +// end of the old level and then to the new progress, and the badge's pop as +// the level ticks over. +export const NAV_CATCH_UP_MS = 700; +export const NAV_LEVEL_POP_MS = 560; + +type NavCatchUp = "snap" | "grow" | "level_up"; + +/** + * How the trigger moves from the progress it shows to a refreshed one: it + * animates only when the new one is higher (more lifetime XP) and someone + * can see it happen: this menu on screen, the page visible, motion allowed. + * Within a level the bar grows; across one it fills, the level ticks over + * and it grows again. Everything else (the first progress, a lower or equal + * one, a prestige) just snaps. + */ +export function navCatchUp( + shown: Progress | null, + next: Progress | null, + env: { onScreen: boolean; pageVisible: boolean; reducedMotion: boolean }, +): NavCatchUp { + if (shown === null || next === null) return "snap"; + if (!(next.lifetimeXp > shown.lifetimeXp)) return "snap"; + if (!env.onScreen || !env.pageVisible || env.reducedMotion) return "snap"; + return next.level === shown.level && next.prestige === shown.prestige + ? "grow" + : "level_up"; +} + +function prefersReducedMotion(): boolean { + try { + return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + } catch { + return false; + } +} + type MenuItem = { key: string; labelKey: string; @@ -52,6 +96,19 @@ export class NavAccountMenu extends LitElement { // CrazyGames identities come from the SDK, not our user object. @state() private crazyGamesUser: CrazyGamesUser | null = null; + // The progress the trigger shows. It trails a refreshed /users/@me while + // the bar catches up to it (see navCatchUp). + @state() private shownProgress: Progress | null = null; + // While catching up: the fill's width in place of the progress's (100 to + // finish the old level, 0 to start the new one), and its transition. + @state() private barOverride: number | null = null; + @state() private barMs = 0; + @state() private levelPop = false; + private catchUpToken = 0; + private catchUpTimers: ReturnType[] = []; + // What the running step waits for: the fill's transition to end. + private onBarSettled: (() => void) | null = null; + // The panel is rendered into document.body, not into the nav: the nav bar // owns a stacking context, so anything inside it can be painted under the // page's overlays (modals) or paint over ones that should win (toasts). @@ -98,9 +155,107 @@ export class NavAccountMenu extends LitElement { window.removeEventListener("showPage", this.closeMenu); window.removeEventListener("resize", this.closeMenu); this.removePanel(); + this.stopCatchUp(); super.disconnectedCallback(); } + protected willUpdate(changed: PropertyValues): void { + if (!changed.has("userMeResponse") && !changed.has("crazyGamesUser")) { + return; + } + const next = this.progress(); + const shown = this.shownProgress; + if (next === shown) return; + // A catch-up still running is overtaken: carry on from what it shows. + this.stopCatchUp(); + const mode = navCatchUp(shown, next, { + onScreen: this.isOnScreen(), + pageVisible: document.visibilityState === "visible", + reducedMotion: prefersReducedMotion(), + }); + if (mode === "snap" || next === null) { + this.shownProgress = next; + return; + } + void this.catchUp(mode, next); + } + + /** Whether this instance is laid out (the other trigger is hidden). */ + protected isOnScreen(): boolean { + if (typeof this.checkVisibility === "function") { + return this.checkVisibility(); + } + return this.getClientRects().length > 0; + } + + // The bar catching up to new progress, as the player watches: within a + // level it grows to the new width; across one it fills to the end, the + // level ticks over with a pop, it empties without a transition and grows + // to the new width. + private async catchUp(mode: NavCatchUp, next: Progress): Promise { + const token = ++this.catchUpToken; + if (mode === "level_up") { + this.barMs = NAV_CATCH_UP_MS; + this.barOverride = 100; + await this.barSettled(token); + if (token !== this.catchUpToken) return; + this.shownProgress = next; + this.levelPop = true; + this.later(NAV_LEVEL_POP_MS, () => (this.levelPop = false)); + this.barMs = 0; + this.barOverride = 0; + await this.updateComplete; + if (token !== this.catchUpToken) return; + // Lay the empty bar out, so the grow below starts from it. + this.querySelector( + "[data-account-xp-bar] [data-xp-bar-fill]", + )?.getBoundingClientRect(); + } + this.barMs = NAV_CATCH_UP_MS; + this.barOverride = null; + this.shownProgress = next; + await this.barSettled(token); + if (token !== this.catchUpToken) return; + this.barMs = 0; + } + + // Resolves when the fill's transition ends; or a little after it should + // have, if it never reports (an unchanged width, a hidden bar). + private barSettled(token: number): Promise { + return new Promise((resolve) => { + let done = false; + const settle = () => { + if (done) return; + done = true; + if (this.onBarSettled === settle) this.onBarSettled = null; + resolve(); + }; + this.onBarSettled = settle; + this.later(NAV_CATCH_UP_MS + 150, () => { + if (token === this.catchUpToken) settle(); + }); + }); + } + + private handleBarTransitionEnd = (e: TransitionEvent) => { + if (e.propertyName !== "width") return; + this.onBarSettled?.(); + }; + + private later(ms: number, fn: () => void): void { + this.catchUpTimers.push(setTimeout(fn, ms)); + } + + private stopCatchUp(): void { + this.catchUpToken++; + for (const t of this.catchUpTimers) clearTimeout(t); + this.catchUpTimers = []; + this.onBarSettled = null; + this.barOverride = null; + this.barMs = 0; + this.levelPop = false; + } + protected updated(): void { if (!this.menuOpen) { this.removePanel(); @@ -406,7 +561,7 @@ export class NavAccountMenu extends LitElement { private renderLevel( variant: "desktop" | "mobile", ): TemplateResult | typeof nothing { - const progress = this.progress(); + const progress = this.shownProgress; if (progress === null) return nothing; const label = levelBadgeLabel( progress.level, @@ -414,12 +569,39 @@ export class NavAccountMenu extends LitElement { progress.legend, ); const title = `${label} · ${xpProgressText(progress.xpInLevel, progress.xpForNext)}`; - const percent = levelFraction(progress.xpInLevel, progress.xpForNext) * 100; const mobile = variant === "mobile"; return html` + ${this.levelPop + ? html`` + : nothing} + `; + } + + // The thin XP bar under the trigger's level badge and avatar. Desktop: in + // the row's bottom padding, so the avatar never overhangs it. Mobile: just + // under the avatar. + private renderXpBar( + variant: "desktop" | "mobile", + ): TemplateResult | typeof nothing { + const progress = this.shownProgress; + if (progress === null) return nothing; + const percent = + this.barOverride ?? + levelFraction(progress.xpInLevel, progress.xpForNext) * 100; + return html` `; } @@ -521,12 +724,25 @@ export class NavAccountMenu extends LitElement { aria-label=${this.triggerLabel()} data-i18n-title="main.account" > - ${this.renderLevel("desktop")} - ${this.renderIdentityIcons({ - ids: true, - iconClass: "w-5 h-5", - badgeClass: "absolute bottom-1 right-1", - })} + + + ${this.renderLevel("desktop")} + ${this.renderIdentityIcons({ + ids: true, + iconClass: "w-5 h-5", + badgeClass: "absolute -top-1 -right-1.5", + })} + ${this.renderXpBar("desktop")} + -
+ is the main one, on the right. Sharing a milestone sits between + them, or on its own row above where three don't fit. --> +
${this.actionButton( "quiet", translateText("win_modal.exit"), () => this._handleExit(), "exit", )} + ${this.renderShare()} ${this.isRankedGame ? this.actionButton( "main", @@ -181,7 +200,11 @@ export class WinModal extends LitElement implements Controller { if (ceremony === null) return; e.preventDefault(); legend.markLegendCeremonySeen(publicId); - ceremony.show({ lifetimeXp: e.detail.after.lifetimeXp, at: new Date() }); + ceremony.show({ + lifetimeXp: e.detail.after.lifetimeXp, + at: new Date(), + publicId, + }); }; // Fetches the Legend ceremony ahead of its moment: once the account is @@ -236,6 +259,33 @@ export class WinModal extends LitElement implements Controller { `; } + // Sharing a milestone or becoming a Legend, once the reveal has shown it. + // Only for the server's result (gameShareMoment), and only for a signed-in + // player with a profile to link to. + private renderShare(): TemplateResult | null { + if (this.sharePublicId === "" || this.xpSettled !== this.xpView) { + return null; + } + const moment = gameShareMoment(this.xpView); + if (moment === null || moment.kind === "prestige") return null; + const url = momentShareUrl(this.sharePublicId, moment); + if (url === null) return null; + // Three labelled buttons fit side by side from md up; below that (and + // beside a ranked game's requeue button) it takes a row of its own. + const placement = this.isRankedGame + ? "order-first basis-full" + : "order-first basis-full md:order-none md:basis-0 md:flex-1"; + return html``; + } + private renderActionStyles(): TemplateResult { return html`