Skip to content

Show player levels and the XP each game earns - #5726

Merged
Celant merged 15 commits into
mainfrom
player-levels-ui
Oct 9, 2026
Merged

Celant merged 15 commits into
mainfrom
player-levels-ui

Conversation

@iiamlewis

@iiamlewis iiamlewis commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Resolves #5725

Description:

The client half of player levels: a level badge across the UI, and the XP each game earns revealed on the end-of-game screen. The API half (XP ingest, /users/@me progress, GET /users/@me/xp/:gameId) is being built in the infra repo. Until it is switched on, this renders nothing: every view checks that the API returned progress first.

Level badge

  • Shape: a badge for every level, drawn in SVG, with a shape per band of ten levels.
  • Prestige: the emblem grows with prestige: ring, double ring, sunburst, then radiant at P10. Rank gems and a "P#" tab show the prestige number.
  • Legend: P10 level 100 gets a crown.
  • Where it shows: the account menu (with an XP bar), player profiles and the game stats modal.

End-of-game XP reveal (WinModal)

  • States: the panel shows "sign in to earn XP", "calculating…" (it polls until the game is scored), an ineligible reason (not spawned, too short and so on) or the result.
  • Sources: the result is revealed a source at a time. A caption rolls in over a segmented bar ("TIME PLAYED +200 XP"), the bar climbs, a counter counts up, and that source's card lands below.
  • Levels: the bar runs from the current level (left) to the next (right).
    • Level-up: each level-up pauses: the bar flashes, "LEVEL 23!" slams in, and the next-level badge pops with a burst before both badges move on.
    • Milestones: milestones (10, 25, 50, 75) get their own card, which slides in when reached. An ordinary level-up gets no card.
    • Legend: becoming a Legend is its own longer moment.
  • Bonuses: each multiplier wipes across the cards, turning each value over to its multiplied share. The cards always add up to the award. The multiplier then lands as a line under the counter, Overwatch style: "+100% XP (SOVEREIGN BONUS)".
    • Tiers: subscription tiers show in their Discord role colours: Sovereign gold (+100%, 2x), Warlord cyan (+50%) and Vanguard green (+25%). These match the tier values infra seeds (openfrontio/infra#895).
    • How the tier is known: the client recognises the tier from the multiplier the API sends (SUBSCRIBER_TIERS in Progression.ts). The infra PR seeds those values.
    • Other values: any other boost shows as a plain "Subscriber bonus", and a cut shows in blue.
  • Layout: nothing moves or resizes while the numbers change. Anything that might not appear (a milestone, a bonus) takes no space until it does, so the layout never gives it away.
  • Skip and accessibility: tapping skips to the end, and reduced motion shows the final state straight away.
  • Buttons: the modal's buttons get the store's texture and a hover (lift, glow and a light sweep). Exit Game is on the left and Keep Playing on the right. The XP panel doesn't change the promo box under it.

Media

1. Level-up (22 → 23) with a Sovereign subscriber bonus. Played over a real local game. A caption per source, the bar climbing, the level-up pause, then the +50% bonus wiping across the cards in gold. The panel starts tight: no card for an ordinary level-up.

xp-reveal-sovereign.webm

2. Milestone (24 → 25) with a Vanguard bonus, and the buttons. The milestone card slides in when the bar reaches 25. At the end the mouse hovers Exit Game, Keep Playing and Exit again (the cursor itself does not show in the recording).

milestone-vanguard-bonus-buttons.webm

3. Becoming a Legend (P10, 99 → 100).

legend.webm

The three tier bonuses, each in its Discord role colours. The screenshots below show the earlier 1.5x / 1.3x / 1.2x values; the tiers are now +100% / +50% / +25%:

Sovereign bonus Warlord bonus Vanguard bonus

The XP numbers are sample results fed to the modal; the API that computes them is not deployed yet.

Testing

  • New tests cover:
    • the badge (every band and prestige tier);
    • the progression helpers, including splitting a multiplied award across cards and recognising tiers;
    • the API schemas;
    • the account menu, the profile and stats modals, and the rewards panel;
    • the reveal: order, level-up pauses, the milestone and Legend cards, bonuses and tiers, skipping, and the button order and actions.
  • Full suite: 7,070 passed.
    • The only failures are the two UpdateRegister tests, which shell out to update.sh and fail the same way on Windows without this change.
  • Checks: tsc, oxlint, eslint and prettier are clean.

Added: XP on past games

Opening one of your own past games from your account or profile now shows what that game earned, in a card under the game summary. It replaces the compact XP panel this PR first added to the stats popup.

  • What it shows:
    • the total;
    • "Level 46 → 47" with badges when the game caused a level-up, or your level and progress when it didn't;
    • a "Milestone" chip and a glow at 10, 25, 50, 75 and 100;
    • the sources, in three columns that become one on phones;
    • the subscriber boost on its own line.
  • Boost amounts: with only the subscriber boost, its line shows the exact amount, so the lines add up to the total. When a game-type multiplier also applies, the API rounds the two together, so the split can't be known. Each line then shows its percentage without an amount, rather than a number that might be wrong.
  • Other states:
    • "Calculating XP…" with a spinner;
    • didn't earn XP, using the existing reason lines;
    • played before levels existed, from the new before_progression reason (openfrontio/infra#909).
  • When it shows:
    • The lookup only ever returns the signed-in player's own record, so any answer is shown, including ineligible ones.
    • A "not found" answer shows "Calculating" and keeps checking only for your own game (from your account history, or your own profile) that ended in the last 10 minutes. Otherwise it shows nothing.
    • Signed out: nothing.
  • Code:
    • PastGameXpCard.ts is new.
    • GameInfoView gains a slot under the summary card and an event carrying the loaded game.
    • The XP labels and the bonus-line text are shared with the reveal.
    • The reveal's unused compact mode is removed.
  • New en.json keys: progression.level_change, milestone_chip, xp_before_levels, xp_plus, xp_minus.

Screenshots from this branch at 2x. The XP results are sample data given to the card.

A level-up, and a milestone:

Past game XP card with a level-up Past game XP card reaching milestone level 50

No level-up, Calculating, didn't earn XP, and played before levels existed:

Past game XP card with no level-up Past game XP card calculating Past game that didn't earn XP Past game played before levels existed

Phone:

Past game XP card on a phone

Fixed: the reveal's counter and level progress on phones

At 390px wide, "+1,150 XP" ran into "444 / 2,41…" under the bar. When the bar's column is narrower than 16rem, the two now stack, centred. Their heights are fixed, so nothing moves during the reveal. Desktop is unchanged.

Before and after, at 390px:

Reveal on a phone before the fix Reveal on a phone after the fix

Testing for these additions:

  • PastGameXpCard.test.ts: every state, base values, the boost amount, a lone cut, both multipliers giving no amounts, the milestone chip, before_progression, and left-early.
  • ProgressionModals.test.ts: the rules for when a "not found" answer shows Calculating.
  • All 3,421 client tests pass. tsc and lint are clean.

Please complete the following:

  • I have added screenshots for all UI updates
  • I process any text displayed to the user through translateText() and I've added it to the en.json file
  • I have added relevant tests to the test directory

Please put your Discord username so you can be contacted if a bug or regression is found:

Iamlewis

🤖 Generated with Claude Code

A level badge (level bands, prestige emblems, the Legend crown) and an XP
bar for the account menu, player profiles and the game stats modal.

On the end-of-game screen, the game's XP is revealed a source at a time:
a caption per source rolls in over a segmented bar that climbs, pausing
with an animation at each level reached, while each source's card lands
below. Multipliers then wipe across the cards in the bonus's colours, a
subscription tier's own (Sovereign, Warlord, Vanguard), and a milestone
or Legend gets its own card. Tapping skips to the end. The modal's
buttons get the store's texture and a hover.

Every API state is handled (signed out, calculating, ineligible, result),
and nothing shows while the API reports progression is off.

Resolves #5725

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@iiamlewis iiamlewis added this to the v35 milestone Sep 30, 2026
@iiamlewis iiamlewis added UI/UX UI/UX changes including assets, menus, QoL, etc. Feature labels Sep 30, 2026
@iiamlewis
iiamlewis requested a review from a team as a code owner September 30, 2026 23:20
@iiamlewis iiamlewis added area:client Best-guess area: client (rendering, UI, web components) UI/UX UI/UX changes including assets, menus, QoL, etc. labels Sep 30, 2026
@iiamlewis iiamlewis added Feature area:client Best-guess area: client (rendering, UI, web components) labels Sep 30, 2026
@iiamlewis iiamlewis self-assigned this Sep 30, 2026
@coderabbitai

coderabbitai Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 71822181-ff61-4f5b-9efe-e25e0182bdd2

📥 Commits

Reviewing files that changed from the base of the PR and between 23be312 and b899cb0.

📒 Files selected for processing (16)
  • resources/lang/en.json
  • src/client/LangSelector.ts
  • src/client/PlayerProfileModal.ts
  • src/client/Progression.ts
  • src/client/ProgressionApi.ts
  • src/client/components/GameXpPanel.ts
  • src/client/components/NavAccountMenu.ts
  • src/client/components/XpBar.ts
  • src/client/hud/layers/WinModal.ts
  • src/core/ApiSchemas.ts
  • tests/client/LevelBadge.test.ts
  • tests/client/NavAccountMenuLevel.test.ts
  • tests/client/ProgressionApi.test.ts
  • tests/client/ProgressionModals.test.ts
  • tests/client/ProgressionSchemas.test.ts
  • tests/client/graphics/layers/WinModalXp.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • resources/lang/en.json

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 6 remain after this review.


Walkthrough

The client adds progression schemas and API helpers, level and XP components, and progression displays in account, profile, game-stats, and end-of-game views. It also adds English translations and tests for progression data and display behavior.

Changes

Player progression and game XP

Layer / File(s) Summary
Progression data and display rules
src/core/ApiSchemas.ts, src/client/Progression.ts, resources/lang/en.json, tests/client/ProgressionSchemas.test.ts, tests/client/LevelBadge.test.ts
Adds progression and game XP schemas, level and XP helpers, English translations, and tests for schema parsing and helper results.
Progression API access
src/client/ProgressionAccount.ts, src/client/ProgressionApi.ts, tests/client/ProgressionApi.test.ts
Adds account-state resolution, game XP fetching and polling, public-player progress fetching, and cached progression configuration fetching.
Shared level views
src/client/components/LevelBadge.ts, src/client/components/XpBar.ts, src/client/components/NavAccountMenu.ts, src/client/PlayerProfileModal.ts, src/client/LangSelector.ts, tests/client/NavAccountMenuLevel.test.ts, tests/client/ProgressionModals.test.ts
Adds level badges and XP progress to account and profile views. The navigation menu refreshes after translations are applied.
Game XP result panel
src/client/components/GameXpPanel.ts, resources/lang/en.json, tests/client/graphics/layers/WinModalXp.test.ts
Adds status and result views for game XP. Eligible results can show an animated, skippable reveal or a compact summary.
Game modal XP integration and reward labels
src/client/GameStatsModal.ts, src/client/hud/layers/WinModal.ts, src/client/components/RewardsPanel.ts, tests/client/ProgressionModals.test.ts, tests/client/graphics/layers/WinModalTick.test.ts, tests/client/graphics/layers/WinModalXp.test.ts, tests/client/RewardsPanel.test.ts
Connects the XP panel to game-stats and end-of-game flows. Recognized progression reward reasons use translated labels. The modals check progression availability and handle pending results, stale lookups, and request cancellation.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~60 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant WinModal
  participant ProgressionAccount
  participant ProgressionApi
  participant GameXpService
  participant GameXpPanel
  WinModal->>ProgressionAccount: resolveXpAccount()
  WinModal->>ProgressionApi: fetchProgressionConfig() and pollGameXp(gameId)
  ProgressionApi->>GameXpService: request game XP
  GameXpService-->>ProgressionApi: return XP response
  ProgressionApi-->>WinModal: return result or null
  WinModal->>GameXpPanel: set XP view state
Loading

Suggested reviewers: celant

Merge Risk: ⚪ Minimal · up to b899c

The two reported issues do not block merging: XP bars receive valid percentages, and transient XP request failures are retried during polling.

Security Architecture Review

Security architecture risk: 🔵 Low · up to b899c

The feature displays server-calculated XP without granting new gameplay authority. Account-specific XP can outlive a background session change within the same page, although normal logout reloads the page and limits exposure. Server-side ownership enforcement remains unverified.

Retained concerns

  • Low · security · inferred: The new account-owned XP views are not invalidated by background session changes. WinModal can apply an earlier session's asynchronous result without rechecking ownership, while GameStatsModal checks only modal generation and game identity. Already displayed results can also remain after session cleanup. This creates a limited stale-account display or attribution risk within a still-mounted page; normal logout reload and component teardown substantially constrain exposure.
Security review details

Security Blast Radius

  • inferred — The supported concern is limited to account-specific XP retained or assigned within one still-mounted browser document after a background session transition. It does not establish remote access to another account, credential theft, gameplay authority, or modification of stored XP. Normal logout reloads the document.

Security Findings and Attack Paths

  • inferred — A request can begin under account A, followed by background session cleanup or replacement while the view remains mounted. Its eventual successful result can still be assigned without checking that A owns the current session; an existing result is also not cleared by the inspected session event path. This is an inferred stale-data path, not a verified server authorization finding.

Trust Boundaries and Controls

  • observed — Caller-supplied game identifiers are encoded into a fixed /users/@me/xp route and sent with the existing Bearer header. Client sign-in and progression checks restrict presentation but cannot enforce record ownership: GameStatsModal can open another player's game. The server must select XP using the authenticated subject and requested game; its implementation is outside the supplied repository evidence.

Resilience and Maintainability Implications

  • observed — Unauthorized responses terminate polling, WinModal disconnection aborts its poll, and GameStatsModal closure invalidates pending display work. The existing authentication layer provides a session-subject check, but the new XP result paths do not use it. Presentation cleanup therefore has stronger generation controls than account ownership cleanup.

Hardening Proposals

  • proposed — Bind account-specific XP requests and retained views to an explicit session generation or authenticated owner. Clear those views and cancel pending work on session changes, and reject stale completions even when the component remains mounted.
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning WinModal.ts replaces the existing o-button controls with a new actionButton implementation and extensive custom gradient, hover, focus, active, and motion CSS. The XP panel changes in the same f… Remove the unrelated button replacement and custom button styling, or link that work to a separate issue.
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 54 functions across 21 files. (1 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Issue #5725 coding requirements are met. LevelBadge and XpBar support the account menu, profiles, and game stats. GameXpPanel covers signed-out, calculating, ineligible, hidden, and result state…
Title check ✅ Passed The title clearly summarizes the main changes: showing player levels and game-earned XP.
Description check ✅ Passed The description covers the progression UI, end-of-game XP reveal, past-game XP card, and related tests. It is directly related to the changeset.
Full details: Out of Scope Changes check

Explanation

WinModal.ts replaces the existing o-button controls with a new actionButton implementation and extensive custom gradient, hover, focus, active, and motion CSS. The XP panel changes in the same file support issue #5725, but the button replacement has no demonstrated connection to that issue.

Full details: Docstring Coverage

Explanation

Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 54 functions across 21 files. (1 skipped: 1 unsupported.)

  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Levels rise as numbers glow
XP lines trace the points they earn
Badges frame each player’s rank
A crown marks Legend at the top
Game results reveal their gains
Quiet bars settle into place

Comment @coderabbitai help to get the list of available commands.

Comment thread tests/client/LevelBadge.test.ts Fixed
Comment thread tests/client/LevelBadge.test.ts Fixed
CodeQL flags the regex (incomplete multi-character sanitization). These
tests only compare SVG markup, but removing the comment nodes through the
DOM is simpler to read and leaves nothing to flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
coderabbitai[bot]
coderabbitai Bot previously approved these changes Sep 30, 2026
- Parse a malformed /users/@me progress object as absent instead of
  failing the whole profile (which made the player look signed out).
- Decide "signed out" for the XP panel from auth state: a /users/@me
  failure with a live session now hides the panel rather than telling a
  signed-in player to sign in. The rule lives in ProgressionAccount and
  is shared by WinModal and GameStatsModal, so both accept a CrazyGames
  session.
- Show a player who was already a Legend with the Legend frame for the
  whole reveal.
- Remove each poll wait's abort listener when its timer fires.
- Load the profile's level independently of the profile itself, still
  dropping a response for a superseded load.
- Add a keyboard-reachable skip button for the reveal, and give the XP
  progress bars an accessible name and value text.
- Translate the prestige tab's "P{prestige}" label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
iiamlewis added a commit that referenced this pull request Oct 1, 2026
On this branch the public profile shows the level on <profile-card>, not
the summary #5726's tests look for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
coderabbitai[bot]
coderabbitai Bot previously approved these changes Oct 1, 2026

@evanpelle evanpelle left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 23be312 and cross-checked against the backend on infra progression-phase1. Nothing blocking in the client. There is one rollout issue that sits mostly in infra but shows up here.

What checks out

  • Contract matches field for field for /users/@me progress, /users/@me/xp/:gameId, /public/player/:publicId/progress and /public/progression/config.
    • lifetimeXp is sent as a number, so there's no bigint-as-string issue.
    • levelsReached is always present.
    • The XP route uses bearer auth, and the public routes need none.
  • Level values come from the server. The client only reads xpInLevel/xpForNext and never recomputes the curve.
  • Constants match: MAX_LEVEL 100, MAX_PRESTIGE 10 and the 1500/1300/1200 subscriber tiers all match ProgressionConfig and the seed migration.
  • No XSS: there's no unsafeHTML/innerHTML, and usernames still go through usernameText.
  • i18n: every key is in en.json, including the progression.multiplier_* keys built at runtime.
  • Async handling: the death and win updateXp calls can't double-poll. GameStatsModal and PlayerProfileModal drop stale responses, and every void async call catches its errors.
  • Checks: the full suite passes (7,410 tests, 1 skipped). tsc --noEmit and eslint on the changed files are clean.

Should fix before progression is enabled in production

  1. Some games get no XP record, so the panel shows "Calculating XP…" for 60 s and then disappears.
    • When infra requires agreed stats, awardGameXp returns without writing anything unless record.info.statsAgreed === true (infra ProgressionIngest.ts:90).
    • Singleplayer never qualifies: archive_singleplayer_game/POST.ts:104 deletes info.statsAgreed, so singleplayer XP never gets a record. The daily cap and the 0.25× rule can't apply.
    • Multiplayer may not either: I couldn't find a game-server change that writes statsAgreed into the archived record. GameServer.ts only logs the agreement.
    • What the client does: WinModal polls a 404 every 3 s for 60 s, then silently removes the panel.
    • Suggested fix: infra writes an ineligible xp_events row in this case (for example reason: "unverified", which this client already shows as the generic line), or the client skips polling for singleplayer. Either way, statsAgreed needs to reach the archive before that mode is turned on.

Nits
2. The reveal can play unseen. If the player dismisses the modal while it's still calculating, the panel stays in the DOM, hidden by CSS (WinModal.ts:81). The reveal then plays where nobody sees it, and there's no way back to the modal after Keep Playing or Spectate. Start the reveal only while the modal is visible, or show the final state on the next show().
3. The panel can get stuck half-revealed. GameXpPanel.disconnectedCallback clears its timers but leaves reveal set. A panel that is removed and re-added would then stay half-revealed with aria-busy=true. Nothing removes it today, but this.reveal = null is a cheap guard.
4. pollGameXp ignores its abort signal (ProgressionApi.ts:82). It doesn't pass the signal to the fetch, so a request already in flight runs for up to 10 s after the abort. Combining the signals with AbortSignal.any fixes that.
5. Possible: older XP rows won't parse. played was added in XP config v2. If any xp_events rows were written under v1 (staging?), XpBreakdownSchema (ApiSchemas.ts:135) rejects them and the panel or past-game summary stays hidden. Either .default(0) on the line fields or purging v1 rows would handle it.
6. Your own profile can be out of date. PlayerProfileModal.ts:272 uses the /users/@me cache from page load, so it falls behind after games played in the same session. (Dropping xpInLevel/xpForNext from PublicProgressSchema is fine here, since #5734 adds them.)
7. Dead code and scope:

  • xpBar's animate and durationMs options (XpBar.ts:28-30) are never used.
  • The MILESTONE_LEVELS comment (Progression.ts:43) mentions a share button that doesn't exist.
  • Both WinModal and GameXpPanel inject large <style> blocks on every render. GameXpPanel's are about 300 lines of keyframes, and moving them to a stylesheet would cut that file by roughly a third.
  • The WinModal button restyle is outside this PR's scope. It also swaps o-button's translationKey for a one-off translateText call.
  1. a11y: the nav level badge sits inside the account button, whose aria-label overrides it, so screen readers never hear the level.

Generated by Claude Code

…, abortable XP fetch

- Explain games recorded ineligible as "unverified" (stats the votes did
  not agree on) instead of the generic line.
- Only play the XP reveal while the win modal is on screen: a result that
  lands after it is dismissed shows in its final state, and dismissing it
  mid-reveal jumps to the end. A panel removed mid-reveal also ends it, so
  it never comes back half-revealed and aria-busy.
- Pass pollGameXp's abort signal into the request itself (combined with its
  timeout via AbortSignal.any), so aborting cancels a request in flight.
- Default the breakdown's per-source fields and leftEarly, so XP rows
  scored under the first XP rules (no `played`/`leftEarly`) still parse.
- Read your own profile's level from the public endpoint, not the
  /users/@me copy cached at page load.
- Put the level in the nav account button's accessible name, which its
  aria-label otherwise hid from screen readers.
- Drop xpBar's unused animate/durationMs options and fix the milestone
  comment that mentioned a share button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
iiamlewis added a commit that referenced this pull request Oct 5, 2026
Brings in #5734, #5774 (reward track) and #5775 (with the Legend
ceremony's share row). Conflicts resolved as in the preview merge:
- PrestigeFlow: levels-polish's prestigeAccent(rank) colours, share row
  and publicId kept; the honeycomb and reduced-motion helper come from
  the shared Ceremony module (renderStyles); the titles stay white.
- GameXpPanel: both sides' imports (RewardsPanel, PlutoniumIcon,
  xpBonusText); the claim reset and catalog load stay in willUpdate
  without the removed compact mode; one updated() announces both the
  settled reveal and the Legend moment (the second copy of
  announceSettled the merge produced is dropped).
- WinModal, PlayerProfileModal: both sides kept (wantsProgressionTab and
  openCount).

GameXpRewards.test.ts no longer sets the compact mode #5726 removed: the
past-game check is made on <past-game-xp-card>, which replaced it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main moved src/core into packages/ (#5805). Resolves the two import
conflicts (PlayerProfileModal, NavAccountMenu) by keeping this branch's
extra types on the new @openfront/shared/ApiSchemas path, and points the
levels code and tests this branch added at the new packages:
ApiSchemas -> @openfront/shared, EventBus -> @openfront/engine-lib,
GameUpdates -> @openfront/engine-api.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
iiamlewis and others added 2 commits October 6, 2026 16:50
Main moved EventBus out of engine-lib (#5806).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
iiamlewis added a commit that referenced this pull request Oct 6, 2026
Picks up main's latest (#5806, #5808) through #5726. Import conflicts
resolved keeping both sides; EventBus now comes from @openfront/shared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
iiamlewis added a commit that referenced this pull request Oct 6, 2026
Picks up main's latest (#5806, #5808) through #5726. Import conflicts
resolved keeping both sides; EventBus now comes from @openfront/shared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
iiamlewis added a commit that referenced this pull request Oct 6, 2026
Picks up main's latest (#5806, #5808) through #5726. Import conflicts
resolved keeping both sides; EventBus now comes from @openfront/shared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@Celant Celant left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coordinated review

Part of a batch for @Celant, reviewed against the backend side of the XP system. This PR's CI review was stale by 8 days — it predated the current head by a wide margin — so it has been re-run; this is an independent second pass.

Client/server XP rules are consistent, and notably this PR doesn't reimplement the formula at all — it renders what GET /users/@me/xp/:gameId returns and recomputes nothing. I checked field-for-field against the backend:

Rule Match
Breakdown keys (leftEarly, played, time, placement, win, firstGame, feats, subtotal, gamePermille, subscriberPermille, total) exact
Ineligible reasons (incl. before_progression) exact; unknown → generic
Rounding (one step, one round) correct
Tier multipliers (Sovereign 2000 / Warlord 1500 / Vanguard 1250) exact
Reward reasons (level_up, level_milestone, prestige) exact
progress shape incl. canPrestige exact

The breakdown-key rename is correctly coordinated — nothing refers to a "first win" concept; the key is firstGame and the label reads "First game of the day".

The formula gate is N/A here — there's no provisional figure in this PR to gate. I confirmed the gate, the computeXp copy and the reference-case fixture all land in the follow-up provisional-xp branch, so nothing is missing at this layer. But finding 3 is a primitive introduced here that the follow-up inherits, and it's cheapest to fix at the root.

Division-by-zero, NaN and over-full-bar cases are all covered: levelFraction clamps (tested at levelFraction(500, 200) === 1) and xpProgressText returns "Max level" rather than dividing by zero. The backend also clamps xpInLevel to xpForNext now, so an over-full bar can't reach the client from these endpoints in the first place.

The renderStyles/renderActionStyles CSS blocks are a large share of the diff and were reviewed only for the reduced-motion fallbacks, which are present and complete. No generated data files.

8 finding(s): 1 high, 5 low, 2 medium

Comment thread src/client/Progression.ts
Comment thread src/client/Progression.ts
Comment thread src/client/ProgressionApi.ts Outdated
Comment thread src/client/Progression.ts
Comment thread src/client/components/GameXpPanel.ts Outdated
Comment thread src/client/LangSelector.ts
Comment thread src/client/ProgressionApi.ts
Comment thread src/client/components/GameXpPanel.ts Outdated
- Singleplayer games that come back "unverified" (they never carry the
  end-of-game vote) now say "Singleplayer games don't earn XP." instead of
  "couldn't be verified". WinModal and GameStatsModal pass the game type to
  the panel and card; the stats modal waits for the game record before
  showing that one reason, so the wrong line never flashes.
- XP in the subtotal that no known source accounts for (e.g. a row stored
  under the old `firstWinOfDay` key) is shown as its own "Other" line instead
  of being apportioned across the known cards.
- The progression config memo expires after the server's 60s max-age, so a
  deploy that changes it reaches open tabs; failures are still not cached.
- The reveal stops on the levels the server reports in `levelsReached`, and
  a full bar stays just short of the next level, so a level-up from a full
  bar is celebrated and its milestone card isn't shown from the first frame.
- level-badge, game-xp-panel and past-game-xp-card re-render on a language
  change.
- Documented why MAX_LEVEL/MAX_PRESTIGE stay client constants (badge art)
  and that MILESTONE_LEVELS must match the server's list.
- Trimmed the narration above startReveal.
- Tests for fetchMyGameXp / fetchPublicPlayerProgress status mapping and
  fetchProgressionConfig caching, the legacy breakdown row, the full-bar
  level-up and the singleplayer copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Verdict: No issues found. 0 findings (0 high, 0 medium, 0 low).

Checked for bugs and CLAUDE.md compliance:

  • Bugs: polling and abort handling, stale results, reveal timers, the progression maths and the schemas.
  • CLAUDE.md: i18n and en.json keys, the homepage import chain and lazy modals, and layer boundaries.

@Celant
Celant merged commit b773251 into main Oct 9, 2026
16 checks passed
@github-project-automation github-project-automation Bot moved this from Triage to Complete in OpenFront Release Management Oct 9, 2026
@Celant
Celant deleted the player-levels-ui branch October 9, 2026 13:16
iiamlewis added a commit that referenced this pull request Oct 9, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
iiamlewis added a commit that referenced this pull request Oct 9, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
iiamlewis added a commit that referenced this pull request Oct 9, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Celant pushed a commit that referenced this pull request Oct 9, 2026
Resolves #5733

Stacked on #5726 (levels and the end-of-game XP reveal). The base is
that PR's branch, so this diff shows only this change. GitHub moves the
base to `main` once #5726 merges.

## Description:

Profile cards for every player, and the prestige flow for players at
level 100: a confirmation screen you hold to confirm, then a full-screen
ceremony. As with #5726, nothing shows unless the API returns progress.
The prestige endpoint (`POST /users/@me/prestige`) is being built in the
infra repo.

### Profile cards

- **Full card:** heads the Stats tab of your account and of public
player profiles. It shows the badge, clan tag, name, prestige and level,
a segmented XP bar and lifetime XP. Games and wins stay in the stats
below it, not repeated on the card.
- **Compact card:** a single row at the top of the Account tab, above
the existing account settings and creator code.
- **Prestige button:** at level 100 your own card shows a pulsing
Prestige button. It never shows on someone else's profile.
- **Public XP bar:** the public card draws the XP bar from two new
optional fields on the public progress response (`xpInLevel`,
`xpForNext`). Without them it shows the level and leaves the bar out.

### Prestige confirmation

- **Layout:** full screen on the menu's world map, with the honeycomb
shimmering, rather than a dialog over the page:
  - "ENTER PRESTIGE N" over the new rank's emblem;
- the prestige track (P1 to P10, then Legend): ranks you've earned are
lit, the one you're entering is raised, the rest are dimmed;
- unlock tiles: the new emblem, the Caps bonus, and an exclusive
cosmetic at ranks 1, 5 and 10;
- one line on what's kept ("You go back to level 1. You keep your
lifetime stats, XP and cosmetics.") and "This can't be undone".
- **Hold to prestige:** the button fills over 1.5 seconds while held,
and the label changes to "Keep holding…". Letting go early empties it
and sends nothing; a click does nothing. Space and Enter work the same
way, and a long press on mobile doesn't open the context menu.
- **Idempotent:** each opening of the confirmation gets its own
`Idempotency-Key`. A retry after a failure reuses it, and a second hold
while the first is in flight sends nothing.
- **Errors:** a failed request leaves the player where they were, with
"Couldn't prestige. Nothing has changed, so try again."

### Ceremony

- The confirmation cuts straight into it on the same background. The
cyan honeycomb, traced onto the background image's own hex lines, pulses
in toward the level-100 badge as it charges and shakes.
- The badge shatters with a flash and shockwave, and the honeycomb
bursts outward.
- The new emblem forms out of the flash, "PRESTIGE N" slams in, then
Continue.
- Clicking anywhere skips to the end. Reduced motion goes straight
there.
- The account page updates behind it: the new rank, any rewards granted,
and the cached `/users/@me` dropped.

### Media

**Prestige, start to finish (1920×1080).** Account tab at P3 level 100 →
Prestige → hold to confirm → ceremony → back on the Account tab at P4
level 1.


https://github.com/user-attachments/assets/df99b662-bca6-46fd-a1fc-b1e9e572f10a

**Account tab: the compact card.**

<img width="960" alt="Account tab with the compact profile card"
src="https://github.com/user-attachments/assets/5534f8fe-bef6-4c7c-a494-36a3810ab417"
/>

**Stats tab: the full card above the stats.**

<img width="960" alt="Stats tab with the full profile card"
src="https://github.com/user-attachments/assets/4a1a5a03-52f3-474d-a0ec-da5f63013e76"
/>

**A public profile.**

<img width="960" alt="Public profile with the full profile card"
src="https://github.com/user-attachments/assets/770abeda-421e-40c2-b2b6-09833afae2fe"
/>

**The confirmation on mobile (P4 → P5, with the cosmetic tile).**

<img width="300" alt="Prestige confirmation on mobile"
src="https://github.com/user-attachments/assets/93a52423-a1b2-4059-a9ba-77874c48b5e9"
/>

The progress values and the prestige response are sample data fed to the
page; the API is not deployed yet.

### Testing

- **New tests** cover:
- the profile card in both variants: no card without progress, the XP
bar and its absence, and the Prestige button only on your own card at
level 100;
- the confirmation: title, unlock tiles (and the cosmetic only at ranks
1, 5 and 10), the track's earned, next and locked ranks, and no lifetime
XP figure;
- holding: a click and a short press do nothing and empty the bar, a
full hold prestiges, the keyboard works, and two holds send one request;
  - failure and retry with the same idempotency key;
  - the ceremony's beats, skipping, and Continue;
  - the account and profile modals with the cards.
- **Full suite:** 7,089 passed.
- The only failures are the `UpdateRegister` tests, which shell out to
`update.sh` and fail the same way on Windows without this change.
- **Checks:** `tsc`, oxlint, eslint and prettier are clean.

### Added (2026-10-04): real prestige rewards, a smoother prestige,
profile card pulses and the Legend ceremony

All of these were approved from mockups and prototype clips. This also
merges #5726's latest work: the past-game XP card and the narrow-bar
fix.

- **The confirm screen shows the real rewards:**
- The Caps tile reads `prestige.caps` from `GET
/public/progression/config` (2,500).
- An exclusive-cosmetic tile ("Solar Corona · Nuke explosion effect · P5
only") appears only when the config's `flares[]` has a prestige reward
for the next rank. This replaces the hard-coded `COSMETIC_RANKS`.
  - Without a config, the tiles fall back as before.
- `ProgressionConfigSchema` gains `levels[].caps/plutonium`,
`levelRewards`, `prestige.caps` and `flares[]`. All are tolerant, so an
older API still parses.
- **You hold your own badge, then the new rank appears:**
- The confirm screen's emblem is the player's current badge, e.g. P4 at
level 100.
- While held, it grows, brightens and glows, through a registered
`--hold` property, and trembles past 75%.
- When the hold completes, the confirm UI slides away, and the same
badge glides to the centre still charging. The tremble becomes the
ceremony's shake, and the honeycomb carries on without restarting.
- At the flash, the old badge shatters and the new rank's emblem is
revealed. The flash lands at hold completion + 2.2 s, or when the server
answers, whichever is later.
- On failure the move reverses and the error shows. Under reduced motion
it crossfades.
- **Prestige titles are white for every rank** ("ENTER PRESTIGE 5", and
the "PRESTIGE 5" slam). The rank colour stays on the glow, shockwave and
shards.
- **Profile card opening** (full card only, once per open):
  - The badge settles, the bar fills, and a sheen crosses the card.
- Two rings of blue light run through the card's own hex pattern,
clipped to the card. Only a mask animates, and the layer is removed
afterwards.
- It's off under reduced motion, on a hidden page and inside an iframe.
- **A Legend's profile card:**
  - a thin gold edge and a gold pattern;
  - a gentle gold ring every 3.6 s, about 30 s in total, then removed;
  - the Legend line shimmers once.
- **Full-screen Legend ceremony** (`LegendCeremony.ts`), played when the
confirmed end-of-game result (never the provisional one) makes a player
a Legend:
  - **The sequence:**
    - dim;
- the P10 emblem charges, with gold rays and the honeycomb pulsing gold;
    - it implodes;
- the crown drops: flash, shockwaves, a gold wave across the honeycomb;
    - "LEGEND" slams in;
    - "The top of the track · {xp} lifetime XP · {date}";
    - Continue.
- **Controls:** any click or key skips to the end, and Continue returns
to the end-of-game popup.
- **Once per account and reduced motion:** it plays once per account (a
`localStorage` flag). Reduced motion shows the final state.
- **Shared code:** the honeycomb, backdrop, flash, shockwave and slam
now live in `Ceremony.ts`, shared by both ceremonies.
- **The Legend share row** comes with the merge into #5775, where
`<profile-share>` lives.
- **The Prestige button's glow** is now an opacity animation on a glow
layer, replacing an endless `box-shadow` repaint.

**New `en.json` keys:**
- `legend_ceremony.continue`
- `legend_ceremony.label`
- `legend_ceremony.lifetime_xp`
- `legend_ceremony.top_of_track`
- `prestige.caps`
- `prestige.exclusive`
- `prestige.exclusive_untyped`

`prestige.gain_cosmetic` is removed.

**Screenshots and clips** (sample data, Wonder / wonder01). The confirm
screen and the hold clip were captured from this branch merged with
#5786, which carries the new per-rank emblems. On this branch alone, the
badges use the older art.

<img width="1000" alt="Prestige confirm going to P5 with the Caps and
exclusive tiles"
src="https://github.com/user-attachments/assets/84de953a-52a5-488d-b769-92cb1a69cec2"
/>

The hold, handoff, shatter and P5 reveal:


https://github.com/user-attachments/assets/fead94dd-708a-4337-9f96-cfa498eb10aa

The profile card opening with the blue pulse:


https://github.com/user-attachments/assets/143e9895-bb5b-4e48-b310-b6814ecf7b7b

A Legend's profile card, then its gold pulse:

<img width="700" alt="Legend profile card, desktop"
src="https://github.com/user-attachments/assets/2eedd285-eabc-47ef-8e34-4fab35058dff"
/> <img width="300" alt="Legend profile card, phone"
src="https://github.com/user-attachments/assets/bb3ea005-cde7-4426-ba0b-8189312d34e6"
/>


https://github.com/user-attachments/assets/4a99b4a0-dd69-463d-8726-09205b9cca7d

The Legend ceremony, from the end-of-game reveal reaching level 100:


https://github.com/user-attachments/assets/9fb5fed1-2835-4735-8866-bf10a8084676

**Testing for these additions:**
- `PrestigeFlow.test.ts`:
  - tiles with and without a prestige flare, and Caps from the config;
  - which badge is held and which is revealed;
- the handoff stages, a slow answer, failure reversal and reduced
motion;
  - white titles.
- `ProfileCard.test.ts`: the pulse only on the full card, not under
reduced motion or on a hidden page, removed after it plays, once per
open, gold for a Legend.
- `LegendCeremony.test.ts`: the beats, skipping, Escape, Continue,
reduced motion and the seen flag.
- `WinModalXp.test.ts`: the ceremony only on a confirmed Legend result,
once per account.
- `FlareCosmetic.test.ts` and `ProgressionSchemas.test.ts`.

**Results:** `tsc` and lint are clean, and `tests/client` passes (270
files, 3,508 tests).

### Performance: load the levels screens on demand (2026-10-05)

Benchmarks against main showed the levels UI added 9.4% (67 KB gzipped)
to the startup download. Screens most sessions never open now load when
they're needed:

- **Prestige flow** (and the shared `Ceremony` code): downloads as soon
as the Prestige button appears. If the download fails, pressing it shows
an alert (`prestige.load_failed`) and the next press retries.
- **Legend ceremony:** downloads at game end, only for players on their
last prestige run. If it isn't ready by the level-100 moment, the
in-card Legend pop plays, and the account isn't marked as having seen
it.
- **End-of-game XP section:** downloads when the game starts, so the
reveal never waits.
- **Past-game XP card:** downloads with its XP record.
- **Reward track** (#5774): downloads when the Progression tab first
opens, in parallel with that tab's data. If it fails, the tab shows
without the track.

Two build fixes go with this:
- `FlareCosmetic.ts` now imports `Cosmetics` / `CosmeticPresentation` /
`CosmeticPreview` statically. They were in the startup bundle anyway,
and this removes three `INEFFECTIVE_DYNAMIC_IMPORT` warnings.
- `vite.config.ts` keeps everything on the startup import chain in the
entry chunk, so the bundler no longer splits extra startup files. On
main it builds byte-identical chunks.

**Results:**

| Startup JS + CSS | main | before | after |
|---|---|---|---|
| gzip | 709 KB | 775 KB (+9.4%) | 741 KB (+4.6%) |
| Startup files | 4 | 5 | 4 |
| Play ready at 20 Mbps / 40 ms | 1,122 ms | 1,137 ms | 1,130 ms |

Unthrottled page load is within noise. The prestige, Legend, level-up,
Claim and reward-track scenarios pass on the lazy production build.

**Tests:**
- `WinModalXp`: the ceremony fallback, and downloading only for possible
Legends.
- `AccountModalPrestige`: the load on press, and the failure alert.
- `RewardTrack`: no track when it fails to load, and the parallel
download.
- `tsc`, lint and `tests/client` pass.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory

## Please put your Discord username so you can be contacted if a bug or
regression is found:

Iamlewis

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Celant pushed a commit that referenced this pull request Oct 9, 2026
Resolves #5761

Stacked on #5726 (the level badge and XP reveal). The base is that PR's
branch, so this diff shows only this change. GitHub moves the base to
`main` once #5726 merges. Part of #5760.

## Description:

Players can now see each other's levels in the lobby and in game. A 24px
level badge sits in front of every name in the lobby player list, and a
28px badge sits between the flag and the name in the in-game player
panel.

### Where the level comes from

The client never sees other players' public IDs, so it can't look their
levels up. Instead the game server, which already calls the API's
`/users/@me` for each player who joins, reads their `progress` and adds
it to their lobby entry.

- **Wire format:** one optional `levelBadge: { level, prestige, legend
}` appended at the end of `ClientInfoSchema` (`src/core/Schemas.ts`).
- One object rather than three fields keeps `ClientInfo`'s zbin header
at one byte, so an entry without a badge encodes byte-for-byte as
before.
  - Older frames still decode with the new schema.
- **Stamping** (`src/server/LevelBadge.ts`, `src/server/Worker.ts`):
`levelBadgeFromProgress` returns nothing for guests, when progression is
off (no `progress`), and for malformed values. A bad value could
otherwise break the lobby broadcast for everyone.
- **Anonymised names never carry a badge**
(`NameVisibility.lobbyClients`), including the public HTTP view.
Otherwise a badge could link an anonymous name to a known player.
- **No spoofing:** `ClientInfo` is built only on the server. The join
schema strips any level fields a client sends, and that's tested.
- **The simulation never reads it.** It isn't on the start message
(`PlayerSchema`), so it can't cause a desync.
- **In game** (`src/client/LobbyRosterLevels.ts`): the lobby roster
stops arriving once the game starts, so `ClientGameRunner` remembers the
last one. The player panel reads it with `lobbyLevelBadge(gameID,
clientID)`. A player this tab never saw in the lobby gets no badge: a
late-joining spectator, a page refresh mid-game, or someone who joined
in the last second before start. That gap is accepted for now.

### On screen

- **Lobby list** (`LobbyPlayerView.ts`): a 24px `<level-badge>` in front
of the name in all four layouts: FFA pills, the team-mode player list,
team cards and spectator pills. The verified check, friend icon and host
label stay after the name.
- **Names line up:** in the column layouts (team list, team cards), a
player without a badge gets an empty, `aria-hidden` badge-sized space.
That only happens when at least one player in that list has a badge, so
nothing moves before levels launch or in a lobby of guests. The wrapped
pills never get the empty space.
- **Streamer mode:** with "anonymous names" on, other players' badges
are hidden, exactly like the verified check. Your own still shows.
- **In-game player panel** (`PlayerPanel.ts`): flag, then a 28px badge,
then the name, for human players only. Long names still truncate.
- No new text: the badge has its own accessible label.

### Screenshots

These are screenshots of the code on this branch at 2x, with sample
names and levels sent in the same shape the server sends them. In the
panel shots, a nation stands in for a human player, since a solo game
has no other humans.

**Lobby list, FFA.** A 24px badge in front of every signed-in player's
name. Guests just show their name.

<img width="705" alt="Lobby list in FFA mode with level badges in front
of names"
src="https://github.com/user-attachments/assets/f66f3fac-931c-4937-a002-433453b92f0c"
/>

**Lobby list, teams.** Badges in the Players list and both team cards.
The two guests (AnonOtter4, AnonFinch9) get an empty badge-sized space,
so every name lines up.

<img width="705" alt="Team lobby with level badges and aligned guest
names"
src="https://github.com/user-attachments/assets/603c84dd-9697-40e6-a5fc-b9128e0d7271"
/>

**In-game player panel.** Flag, then a 28px badge, then the name: a P3
player, a Legend, and a guest with no badge.

<img width="260" alt="Player panel for a prestige 3 player"
src="https://github.com/user-attachments/assets/fba8b407-eb10-4b95-ad0c-4f080b6607eb"
/> <img width="260" alt="Player panel for a Legend"
src="https://github.com/user-attachments/assets/4985d6cd-628f-48ea-951c-311a1d3fdb4c"
/> <img width="260" alt="Player panel for a guest without a badge"
src="https://github.com/user-attachments/assets/8692348d-f4f3-4b45-bfde-71f86538c194"
/>

### Testing

- **New tests:**
  - wire round-trip with and without the badge, plus old-shaped frames;
- the server stamp from `/users/@me`, with nothing for guests,
progression off or malformed values;
  - anonymised entries and spoofed join frames;
  - the in-game roster lookup;
- every lobby layout: badge before the name, the empty-space rule,
streamer mode;
- the panel: 28px badge before the name, nothing for bots, nations,
guests or another game's roster, streamer mode.
- **Results:** 743 tests pass across the level-badge, lobby, panel,
leaderboard and clan suites. `tsc`, oxlint, eslint and prettier are
clean.

### Performance: the roster badge is one packed integer (2026-10-05,
`2edf46b6f`)

Benchmarks showed the nested `levelBadge` object made lobby broadcasts
noticeably more expensive when everyone has a badge.
`ClientInfo.levelBadge` is now `zb.uint().optional()`, still the last
field:

- **Packing:** level in bits 0–6, prestige in bits 7–10, Legend in bit
11. Prestige 0 is 1 byte; everything else is 2.
- **Decoding:** `src/core/LevelBadgeWire.ts` packs and unpacks it.
Out-of-range or reserved values decode to `undefined` and never throw,
so one bad value can't break a roster parse.
- **Packed once:** the server packs at join (`Client.wireLevelBadge`),
so broadcasts do no extra work. `lobbyLevelBadge` decodes back to `{
level, prestige, legend }`, so UI callers are unchanged.
- **Compatibility:** the nested shape only ever existed on these
unmerged branches, so nothing deployed reads it.

**Timings** (all players badged; measured on a loaded machine, so
compare columns):

| | main | before | after |
|---|---|---|---|
| Server lobby broadcast, 125 players | 3.06 ms | 4.82 ms (+58%) | 3.29
ms (+8%) |
| Server lobby broadcast, 150 players | 4.29 ms | 6.50 ms (+51%) | 5.30
ms (+23%) |
| Frame bytes, 150 players | 3,437 | 3,887 (+13%) | 3,722 (+8%) |
| Roster decode, 150 players | 131 µs | 193 µs | 167 µs |

**Tests:**
- `LevelBadgeWire.test.ts`: every level × prestige × Legend round-trips,
plus edge and bad values.
- `ClientInfoLevelBadgeWire`, `RosterLevelBadge` and `LobbyRosterLevels`
updated.

### Performance: big lobbies draw their badges over a few frames
(2026-10-05, `f6bf42888`)

With every player badged, a 150-player lobby's first paint was one long
frame. Lobby badges now carry a `stagger` attribute.

- **When it applies:** if more than 8 stagger badges arrive in one
render, each one first renders an empty span of exactly its drawn size,
so nothing shifts. A shared `requestAnimationFrame` loop
(`LevelBadgeFill.ts`) then draws them nearest the viewport first, sized
to about 5 ms per frame.
- **What still draws immediately:** a single join, a badge change, or a
join during a fill. Streamer and anonymised lobbies are unchanged.
- **Cleanup:** badges removed mid-fill leave the queue, and the loop
stops when nothing is waiting.

**Timings** (Chromium production build, 150 badged players, 6 rotating
rounds; read relatively, since the machine was moderately loaded):

| | main | before | after |
|---|---|---|---|
| FFA: time to the first frame | 17.0 ms | 21.4 ms | 17.5 ms |
| Team (300 badges): time to the first frame | 17.0 ms | 41.7 ms | 17.3
ms |
| Longest frame while badges fill in | – | – | 16.8 ms max |
| Time until every badge is shown, FFA / team | – | – | 103 / 169 ms |
| Layout shift | – | – | none (0 of 48 mounts) |

**Tests:** `LobbyPlayerView.badgeFill.test.ts` (11):
- FFA and team fills complete, with 150 players and with 8;
- reserved size and classes;
- an immediate join and badge change;
- streamer and anonymised modes;
- cancel on removal, and refill on re-attach.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory

## Please put your Discord username so you can be contacted if a bug or
regression is found:

Iamlewis

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
evanpelle pushed a commit that referenced this pull request Oct 12, 2026
)

Resolves #5762

Stacked on the lobby badges PR (#5763), which is stacked on #5726. The
base is that PR's branch, so this diff shows only this change. Part of
#5760.

## Description:

Level badges on the ranked leaderboard and clan member lists, designed
so the leaderboard doesn't get cluttered.

### The data

The API adds optional `level`, `prestige` and `legend` to each player
row of `/leaderboard/ranked` and the clan member lists. Players without
a level, or any response while progression is off, have no fields.
They're one shared optional field set (`RowLevelFields`) on
`RankedLeaderboardEntrySchema`, `PlayerLeaderboardEntrySchema` and
`ClanMemberSchema`, so older responses still parse. `toPlayerEntry`
passes them through.

### On screen

- **Ranked leaderboard** (1v1 and 2v2, `LeaderboardPlayerList.ts`): a
24px `<level-badge>` in front of the name, **only for players who have
prestiged or are Legends**. Every other row is unchanged, so the badge
stays rare enough to mean something. The pinned "your ranking" row
follows the same rule.
- **Clan member lists** (`renderMemberRow` in `ClanShared.ts`): a 24px
badge in front of every member's name. At 24px the badge shows its
prestige tab ("P3"); at 20px it was hidden.
- **Names line up:** rows without a badge get an empty, `aria-hidden`
badge-sized space (`LevelBadgeSlot.ts`).
- This only happens when at least one row in the list has a badge, so
both lists look exactly as they do today until levels launch.
- Each ladder decides for itself, and the clan list decides from the
members actually shown after a search.
- **Not changed:** the tribe and clan leaderboards, and the clan manage
and transfer views, which draw their own member rows.
- No new text: the badge has its own accessible label.

### Screenshots

These are screenshots of the code on this branch at 2x, with sample
names and levels in the same shape the API sends them.

**Ranked leaderboard.** Badges only for players who have prestiged (P3
Kestrel, P1 Hollis_7, P2 Emberline) or are Legends (Marrow). Everyone
else's name sits in the same column, so nothing zigzags.

<img width="753" alt="Ranked leaderboard with badges only for prestiged
players and Legends"
src="https://github.com/user-attachments/assets/679f2781-6b29-4c93-92b1-c126eaf65718"
/>

**Clan members.** A 24px badge in front of every member, with the
prestige tab visible. The guest at the bottom gets an empty space and
still lines up.

<img width="549" alt="Clan member list with level badges on every
member"
src="https://github.com/user-attachments/assets/911244c4-57b3-4811-8d91-2e49bc895b1c"
/>

### Testing

- **New tests:**
- schema parsing with and without the fields; a malformed or
out-of-range value is dropped (that row shows no badge) rather than
failing the whole list;
- the leaderboard: badges only for prestige and Legend, none for
prestige 0, the empty-space rule per ladder, the pinned row;
- clan rows: badge, empty space, and plain rows, end to end through
`ClanDetailView` including search.
- **Results:** 743 tests pass across the level-badge, lobby, panel,
leaderboard and clan suites. `tsc`, oxlint, eslint and prettier are
clean.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory

## Please put your Discord username so you can be contacted if a bug or
regression is found:

Iamlewis

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Josh Harris <josh@openfront.io>
Celant added a commit that referenced this pull request Oct 12, 2026
Resolves #5758

The API side is openfrontio/infra#896. This PR now also carries #5874
(end-of-game XP doesn't stay hidden after the panel is detached
mid-fetch), since both rewrite the same part of `WinModal.updateXp`.

## Description:

Most players die before a game ends, so today they see "XP is awarded
when the game ends" and never see the reveal. With this change, the
moment a player is out the client works out their XP **provisionally**
and plays the full reveal: source cards, bar, level-ups, milestone cards
and Legend. When the game ends it reconciles with the server's figure.
The server stays the only authority: the client never sends its figure
anywhere, and XP is still scored once, at ingest.

### The provisional figure

- **The formula.** `src/client/ProvisionalXp.ts` is a line-for-line copy
of infra's `computeXp`.
- Every rule value comes from `/public/progression/config` (`xp`), and
nothing is hard-coded.
- It only runs when the API's `formula` is 1, the revision it
implements. Otherwise it falls back to the old "awarded when the game
ends".
- It passes infra's **263 reference cases**
(`tests/testdata/progression/xp-reference-cases.json`). These are
generated by the real `computeXp` and cover every eligibility reason,
game kind, lobby size, the first game of the day, team wins, leavers and
multipliers.
- **Inputs at death** (`src/client/ProvisionalXpAtDeath.ts`) come from a
read-only snapshot of the simulation's own stats. They are the same
per-player stats the archived record carries, and infra scores from
them:
  - time alive, from the player's death tick;
  - spawned humans, for the lobby factor;
- FFA opponents outlasted: everyone who went out before the player,
which can't change after their death;
- the first game of the day and the subscriber boost, from `/users/@me`
(`progress.daily`, `progress.subscriberPermille`). `progress.daily.day`
says which UTC day those counts are for, so a death after midnight UTC
starts the new day from none used, as the API scores it;
  - the game config.
  
For an FFA death the provisional figure is therefore exact. In team and
Humans-vs-Nations games, the team's win isn't known yet, so it shows as
pending.
- **When it doesn't show:** if any input can't be read (no formula 1, no
rules, a worker that doesn't answer, stats that never show the death),
the player sees the old "awarded when the game ends".
- **Singleplayer:** a singleplayer game never carries the end-of-game
stats vote. The API scores it anyway (0.25×, at most 5 a day) and says
so with `scoresSingleplayer: true` on the config (the API-side change is
in the infra repo), and then the figure shows at the singleplayer rate.
An API that sends neither `scoresSingleplayer` nor `requireStatsAgreed:
false` predates singleplayer scoring, so no figure is shown rather than
one the server would take away. Multiplayer games still need the vote in
production (`requireStatsAgreed`).

### On screen

- **At death:**
- the full reveal plays straight away, so most players now see their
level-ups and milestones;
- a small "Provisional — confirmed when the game ends" label sits above
it;
  - in team games, a line notes the possible team-win bonus.
- **When the game ends** the server's figure is fetched as before
("Confirming your XP…"):
- **Match:** a quiet "XP confirmed", with no second reveal or
celebration.
- **Higher** (for example a team win): the server's numbers with the
reason, and only the extra levels celebrate.
- **Lower** (rare: unverified game, daily allowances, drift): the
server's numbers with the reason and nothing replayed. If a celebrated
level wasn't reached, it says "Adjusted to level N".
- **A gap the known reasons don't explain** is logged with both totals
and the inputs (no names or IDs), so a drift between the two formula
copies shows up.
- **The server never answers:** the figure stays, still labelled
provisional.
- Reduced motion and the existing on-screen and off-screen handling are
respected.

### The simulation

`src/core/game/HumanStats.ts` adds one **read-only** query,
`humanStats()`, reached through a new worker message. It returns:
- a copy of the humans' stats;
- the current tick;
- when each disconnected human disconnected.

It writes no state, uses no randomness and adds nothing to the turn
stream. `Game.ts` and `PlayerImpl.ts` are untouched. The worker answers
it between batches of ticks, so it always sees a whole tick.

## Performance and determinism

**Tick speed.** The full-game benchmark (`npm run perf:game`) was run
three times on each branch: world map, 400 bots, 3,000 ticks.

| | Before (#5726) | This branch |
|---|---|---|
| Mean tick | 1.98–2.15 ms | 1.93–2.16 ms |
| p95 / p99 | 4.0–4.5 / 6.9–7.5 ms | 3.8–4.4 / 6.7–7.2 ms |
| Over the 100 ms budget | 0 | 0 |

There is no measurable change, which is expected: nothing is added to
the tick loop.

**The snapshot**, taken once at the player's death: 0.03 ms with 10
humans, 0.16 ms with 50, and 0.37 ms with 125.

**Determinism:**
- Every benchmark run on both branches ended on the **same final
game-state hash**.
- A new core test plays two identical games, one snapshotting after
every tick, and checks that every tick and the final stats match.

### Media

**Dying with a milestone.** A 9-minute FFA, the first public game of the
day: +366 XP, from level 9 to 11 through the level 10 milestone. At
death the full reveal plays under the provisional label. When the game
ends it is quietly confirmed.


https://github.com/user-attachments/assets/f5f059b9-8cc6-42b8-8753-e090ef8fde93

At death, with the milestone card:

<img width="960" alt="The full XP reveal at death with the level 10
milestone card, under the provisional label"
src="https://github.com/user-attachments/assets/8f24e288-01e8-4b45-8bb4-c6b3b8b41fc4"
/>

Confirmed when the game ends:

<img width="960" alt="XP confirmed when the game ends"
src="https://github.com/user-attachments/assets/63544d27-1361-4bba-a51c-87166cda2205"
/>

The game behind is a real local solo game. The XP is sample data fed to
the end screen, because the local dev API doesn't serve the new fields
yet. Every state is covered by tests.

### Testing

- **New tests:**
  - the formula against all 263 reference cases;
- inputs at death: FFA placement from deaths so far, team wins left
pending, leavers, the first game of the day, fallbacks;
- the end screen: the reveal at death including a milestone card, a
match with no second celebration, a higher figure celebrating only the
new levels, a lower figure with the adjustment line, unverified, drift,
and the fallback without formula 1;
  - `humanStats`, including the determinism test;
  - the worker message and the new schema fields.
- **Full suite:** 7,728 passed, 1 skipped. The only failures are the two
`UpdateRegister` tests, which shell out to `update.sh` and fail the same
way on Windows without this change.
- **Checks:** `tsc`, oxlint, eslint and prettier are clean.

---

## Also included: the end-of-game XP fetch survives a detach (was #5874)

### Problem

If the end-of-game panel was removed from the page while it was still
fetching the game's XP, and then shown again later, its XP section
stayed hidden and never tried to fetch again.

Removing the panel cancels the fetch. The cancelled fetch came back
looking like "no XP to show", so the panel hid the section. It also kept
a "fetch in progress" flag set, so when the panel came back it thought a
fetch was still running and skipped starting a new one.

### Fix

In `WinModal.updateXp`, a cancelled fetch is no longer treated as an
answer. When the fetch was cancelled because the panel was removed, the
section is left as it was and the "fetch in progress" flag is cleared.
The panel also remembers that the fetch was cut short, and when it is
put back on the page (`connectedCallback`) it starts the fetch again.
That retry is needed because the end-of-game update that starts the
fetch only arrives once, so nothing else would start it. If the panel is
put back before the cancelled fetch has finished winding down (for
example, moved in the page), the fetch restarts as soon as it has.

### Testing

- Two new tests in `tests/client/graphics/layers/WinModalXp.test.ts`,
both going through the real remove/re-attach lifecycle without calling
`updateXp` directly:
- remove the modal while the XP fetch is pending, check the section
isn't switched to hidden and nothing more is fetched, then put it back
and check it fetches again and shows the result;
- remove and put it back straight away, before the cancelled fetch winds
down, and check it fetches again and shows the result.
  Both fail without the fix.
- `npm run typecheck` and `npm run lint` pass.
- Full `npx vitest --run`: 7954 passed, 1 skipped. The only failures are
the 2 in `tests/UpdateRegister.test.ts`, which also fail on Windows on
main.

**Combined with the provisional figure:** the aborted check sits right
after the poll, before the provisional fallback and the Legend preload.
If a provisional figure was showing, it stays up, still confirming, and
the confirmation runs again on re-attach. New test: "confirms afresh
when detached mid-confirm and re-attached" (fails without the re-attach
retry).

---

## Since combining

- **Old-API fallbacks dropped** (75cad09): the API always stamps the
day on today's allowances and always sends `scoresSingleplayer`, so the
"no day stamp" path and the older staging rule are gone. Allowances
whose day stamp doesn't read now give no provisional figure, and an
absent or malformed `scoresSingleplayer` still reads as no, so a bad
value can never overstate the figure.
- Typecheck and lint pass; full `npx vitest --run`: 8,313 passed, the
only failures being the Windows-only `UpdateRegister` tests.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory

## Please put your Discord username so you can be contacted if a bug or
regression is found:

Iamlewis

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Josh Harris <josh@openfront.io>
evanpelle pushed a commit that referenced this pull request Oct 12, 2026
…ing, and end-of-game rewards (#5774)

Resolves #5772
Resolves #5773
Part of #5785 and #5760.

This PR now combines three that were stacked on each other, so they can
be reviewed and merged as one: the Progression tab and reward track (was
this PR), profile links and sharing (was #5775), and the end-of-game
rewards and XP polish (was #5786, minus its prestige emblems; see
below). Every part has been reviewed in its own PR already; the history
is kept, with each part's commits as they were.

## What was dropped while combining

- **The per-rank prestige emblems** from #5786 (a colour and outline for
every rank, P1 bronze to P10 prismatic, winged P5/P10). The designer has
replaced that direction with a new unit-icon design, which will come as
its own PR. Badges here are main's current ones: `LevelBadge.ts` and its
tests are identical to `main`. The cached-template speed-up that only
existed to offset those emblems' drawing cost went with them.
- **Kept for the rollout:** the reward track still hides itself when the
config has no per-level amounts, and the owner still sees the visitor
view when level rewards don't say their level. This client can reach
players before the API that sends those fields is deployed (2419c2b
restores both after an earlier cleanup removed them).
- **Duplicates:** one shared `claimRewards()` (`RewardClaims.ts`) now
does the claiming for both the reward track's Claim all and the
end-of-game rewards row; one reward-amount parser; one set of section
styles.
- **Stale bits:** a comment saying search visibility wasn't supported
yet (it is, in #5771), and a reward-track test fixture still using the
old Plutonium cadence (now every 25 levels).

---

# Part 1: Progression tab and reward track


A **Progression** tab on the player profile: prestige history, and the
date each milestone level was first reached in every prestige run.

#### The data

`GET /public/player/:publicId/progress` gains two optional fields on the
backend:
- `prestigeHistory: [{ rank, at }]`, ordered by rank;
- `milestones: [{ prestige, level, at }]`, for levels 10/25/50/75/100,
the first time reached per prestige run.

Both are added to `PublicProgressSchema` as
`.optional().catch(undefined)`, so older responses still parse and a
malformed list reads as absent rather than failing the whole profile.

#### The tab (`<profile-progression>`, `PlayerProfileModal.ts`)

- **Where:** after Clans, **only when the player has progress**.
- If it's asked for before progress arrives (a link or the last tab
used), the profile stays on Stats and switches once progress loads.
  - With no progress it stays on Stats.
- **Prestige history:**
- one tile per rank from 1 to the current prestige: a 36px badge (level
1 at that rank), "Prestige N" and the date;
- tiles are built from the current prestige, so a missing or partial
history still lists every rank, just without dates;
  - at prestige 0: "Hasn't prestiged yet".
- **Milestones:** grouped by run, newest first.
- The current run ("Current run · Prestige N", or "Current run" at
prestige 0) shows all five levels. Unreached ones are faded with "Not
yet", and a level passed without a record shows as reached without a
date.
- Earlier runs show what they reached; a first run at prestige 0 is
"First run".
- Entries above the current prestige and duplicates are dropped. Any
level the API records is shown, even one the client doesn't list, so a
milestone added server-side appears without a client change; the
client's list only adds the current run's "Not yet" slots.
- Legend: "Legend · Prestige 10", all five reached, and level 100 drawn
as the Legend badge.
- **Dates:** the client's usual `toLocaleDateString(undefined, { year,
month: "short", day })`. An unreadable date isn't shown.
- The XP each past game earned already shows in `GameStatsModal` (from
#5726), so nothing was needed there.

#### Popup tabs on narrow screens (`baseComponents/Modal.ts`)

With a fourth tab, the shared tab row wrapped onto a second line on
phones. Now:
- it stays on one row that **scrolls sideways when it doesn't fit**;
- it's still centred when it does fit: auto margins on the first and
last tab, which, unlike `justify-center`, never push a tab off the left
edge out of reach;
- the scrollbar is hidden;
- the active tab is scrolled into view when it changes.

This is shared, so every popup with tabs gets it; wide screens look the
same as before.

### Screenshots

From this branch's stack (the profile page PR on top), opened from a
real `/player/<id>` link at 2x. The profile is sample data given to the
popup, because the dev API has no such player.

**Desktop, Progression tab:** prestige history, then milestones per run
(the current run shows "Not yet" for 50/75/100).

<img width="1000" alt="Progression tab on desktop"
src="https://github.com/user-attachments/assets/c90abe7a-f94a-40d7-8331-a8f821ed6cc8"
/>

**Phone:** the tab row stays on one line and scrolls sideways, with
Progression scrolled into view.

<img width="320" alt="Progression tab on a phone, tab row scrolled"
src="https://github.com/user-attachments/assets/ea5907fd-fa6e-4ab1-bce0-bda97b6fbcbe"
/>

### Testing

- **Schema:** parsing with, without and with malformed
`prestigeHistory`/`milestones`.
- **Tab:** shown only with progress; the deferred switch when progress
arrives late.
- **History:** tiles, partial history, the prestige-0 empty state.
- **Milestones:** grouping by run, newest first; the current run's "Not
yet" slots; reached-without-record; dropped entries; dates; Legend.
- **Tab row:** one scrollable row (`flex-nowrap overflow-x-auto`,
non-shrinking tabs), and the active tab scrolled into view on change.
- **Results:** `tsc`, `npm run lint` and the translation tests pass. In
the full suite the only failures were the known `InventoryModal` /
`MainInitialize` timeouts, which pass on their own.

#### Added (2026-10-04): the reward track

A "Reward track · Prestige N" section now heads the Progression tab,
above prestige history and milestones. It shows what each level of the
current run pays. Approved from mockups ("Layout A", with the next
rank's reward inside the end card).

- **Run strip:** levels 1–100.
- A tick every 10 levels, green dots for Plutonium levels and a purple
diamond for cosmetic levels.
- A gold "You · 47" knob, and "100 → P{n+1}" (or "100 → Legend") at the
end. Past level 85 the two labels merge.
- A highlighted window shows the stretch in view. Tapping or dragging
the strip jumps the track there.
- **Track:** about 15 levels in view on desktop and about 6 on phone.
  - Arrow buttons sit in their own gutters, never over a level.
- Gold fill runs up to your level. Your level is the large badge with
"YOU".
- Each level shows its Caps and a status: claimed, ready to claim, or
locked.
  - Plutonium levels carry a "+25" chip.
- **Cosmetic tags:** each cosmetic level gets a compact tag on its
level: preview, name, type, plus "+25" if it also pays Plutonium. The
data comes from the progression config's `flares[]`: this run's level
rewards, plus every-run ones.
- **End card:** "After level 100 · Prestige {n+1} · 2,500 Caps".
- If the next rank has an exclusive cosmetic, its tag sits inside the
card.
- On a P10 run the card is Legend, "The top of the track", with the
Legend reward tag.
  - There's no tag when the next rank has no exclusive.
- **Claim bar** (your own profile only): "{n} level rewards to claim ·
+{caps} Caps · +{plutonium} Plutonium [Claim all]". The Plutonium part
only shows when it's above 0.
- **What it claims:** only this run's level rewards, never subscription
dailies or win Caps.
- **Afterwards:** `/users/@me` is refreshed and `rewards-changed` is
sent.
- **Where the status comes from:** `/users/@me` rewards, which now say
which level and prestige run they paid for (openfrontio/infra#907). A
passed level with no reward row counts as claimed.
- **Visitors** see the track without statuses or the claim bar, and the
strip reads "Level 47".
- **Pop-in:** everything in the tab (track, history and milestones) pops
in, one item after another, about 55 ms apart. Nothing shows before its
turn, and the next milestone gets a breathing dashed ring.
- It plays once per opening; switching back to the tab shows it at rest.
  - It's off under reduced motion.
- **Code:**
  - New `RewardTrack.ts` (the model functions and `<reward-track>`).
  - The client `RewardSchema` gains optional `level` / `prestige`.
- `FlareCosmetic.ts` can show a flare's own name and image until the
cosmetics catalog answers.
- The profile modal's opening counter is shared by every profile modal,
so no two openings share a key.

**New `en.json` keys:** all under `reward_track`:
- `after_level_100`, `caps_amount`, `claim_caps`, `claim_count`,
`claim_plutonium`;
- `earlier`, `later`;
- `key_claimed`, `key_locked`, `key_ready`;
- `legend_reward`, `legend_sub`, `levels_label`, `plus`,
`prestige_reward`;
- `strip_end_legend`, `strip_end_prestige`, `strip_label`,
`strip_level`, `strip_start`, `strip_value`, `strip_you`;
- `title`, `title_first_run`, `you`.

**Screenshots and clip** (built UI at 2x, sample data Wonder /
wonder01):

Your own profile at level 47, then scrolled to the end for P4 → P5 with
the Solar Corona tag. The 34px Solar Corona preview is the catalog's own
animated effect, caught between bursts:

<img width="1000" alt="Reward track at level 47 with the claim bar"
src="https://github.com/user-attachments/assets/dbc28ec4-c341-42b5-ab0c-68b4aa749b32"
/>
<img width="1000" alt="End of the track with the Prestige 5 card and its
reward tag"
src="https://github.com/user-attachments/assets/8f82db84-ad69-4b0d-930d-65b4a22a1fcd"
/>

A P10 run ending in Legend, then P3 → P4 with no exclusive:

<img width="1000" alt="End of a P10 run with the Legend card"
src="https://github.com/user-attachments/assets/9d8fa4e9-4b89-496c-8cb2-91bd40e53447"
/>
<img width="1000" alt="End card with no reward tag"
src="https://github.com/user-attachments/assets/48d10af3-fa6e-4305-8dfb-b0d8966e5529"
/>

Phone, then a visitor's view. The visitor still predates a last fix that
removed an empty status row, so the live track is about 16px shorter:

<img width="300" alt="Reward track on a phone"
src="https://github.com/user-attachments/assets/5b0b7325-ed67-493f-9ac9-8399233175b7"
/> <img width="700" alt="Reward track as a visitor sees it"
src="https://github.com/user-attachments/assets/d7cd4c35-c433-4203-a192-323c74621d5a"
/>

The pop-in, then a switch to Stats and back, which shows the tab at
rest:


https://github.com/user-attachments/assets/ec336d4b-ff99-49c1-8bad-0356dfd992e3

**Testing for these additions:**
- `RewardTrack.test.ts` (39 tests):
  - the strip markers, knob and merged label;
  - level status from the reward fields;
  - tags from run-specific and every-run flares;
  - the end card with an exclusive, without one, and the Legend card;
- Claim all claims only this run's level rewards, plus the failure path;
  - Plutonium hidden at 0;
  - the visitor view;
  - the removed text absent;
- the pop-in order, fill-mode `both`, and no replay on the same opening.
- `PlayerProfileProgressionTab.test.ts`: the tab pops in once per
opening, not when switching back.

**Results:**
- `tests/client`, plus the translation and `ApiSchemas` tests: 277
files, 3,726 passed.
- `tsc` and lint are clean.

#### Performance: load the levels screens on demand (2026-10-05)

Benchmarks against main showed the levels UI added 9.4% (67 KB gzipped)
to the startup download. Screens most sessions never open now load when
they're needed:

- **Prestige flow** (and the shared `Ceremony` code): downloads as soon
as the Prestige button appears. If the download fails, pressing it shows
an alert (`prestige.load_failed`) and the next press retries.
- **Legend ceremony:** downloads at game end, only for players on their
last prestige run. If it isn't ready by the level-100 moment, the
in-card Legend pop plays, and the account isn't marked as having seen
it.
- **End-of-game XP section:** downloads when the game starts, so the
reveal never waits.
- **Past-game XP card:** downloads with its XP record.
- **Reward track** (#5774): downloads when the Progression tab first
opens, in parallel with that tab's data. If it fails, the tab shows
without the track.

Two build fixes go with this:
- `FlareCosmetic.ts` now imports `Cosmetics` / `CosmeticPresentation` /
`CosmeticPreview` statically. They were in the startup bundle anyway,
and this removes three `INEFFECTIVE_DYNAMIC_IMPORT` warnings.
- `vite.config.ts` keeps everything on the startup import chain in the
entry chunk, so the bundler no longer splits extra startup files. On
main it builds byte-identical chunks.

**Results:**

| Startup JS + CSS | main | before | after |
|---|---|---|---|
| gzip | 709 KB | 775 KB (+9.4%) | 741 KB (+4.6%) |
| Startup files | 4 | 5 | 4 |
| Play ready at 20 Mbps / 40 ms | 1,122 ms | 1,137 ms | 1,130 ms |

Unthrottled page load is within noise. The prestige, Legend, level-up,
Claim and reward-track scenarios pass on the lazy production build.

**Tests:**
- `WinModalXp`: the ceremony fallback, and downloading only for possible
Legends.
- `AccountModalPrestige`: the load on press, and the failure alert.
- `RewardTrack`: no track when it fails to load, and the parallel
download.
- `tsc`, lint and `tests/client` pass.

---

# Part 2: Profile links and sharing


`openfront.io/player/<publicId>` now opens that player's profile
directly, so a shared link lands on a real page. The backend's link
previews make the same URL unfurl in Discord, X and WhatsApp with the
player's level and card.

#### Opening from a link (`ModalRouter.ts`, `Main.ts`)

- `ModalRouter` gains `registerPath()` and `routeFromPath()`;
`Main.handleUrl` tries the path just before the hash.
- **Accepted:** `/player/<id>`, with or without a trailing slash, behind
`/w<n>/` and `/v/<commit>/`, with an optional
`?tab=stats|games|clans|progression`.
- Ids must match `[A-Za-z0-9_-]{1,22}`; anything else is ignored. The
hash form (`#modal=profile&publicID=…`) still works.
- **While open from a path,** the URL keeps the path and tab changes are
written to `?tab=`.
- **Closing** (Back or Escape) goes home and replaces the URL with `/`
(or `/v/<pin>/` on a version-pinned page), so a reload doesn't reopen
it. Opening another popup in-app hands the URL back to the hash form.

#### Links (`utilities/PlayerProfileUrl.ts`)

- `playerProfileUrl()` is now `shareOrigin()/player/<id>`: the page's
own origin on the web, and the site origin in the desktop shell, the
same as lobby invite links.
- Callers: the profile header chip, the account "Share Profile" button,
and the nav account menu's copy action.
- The friends box also accepts a pasted `/player/<id>` link.

#### Share row (`components/ProfileShare.ts`)

Under the profile card:
- **Copy link:** copies the canonical URL, shows the app's "Copied!"
toast, and announces it through an sr-only `aria-live` status, since the
toast isn't a live region.
- **Share on X:** opens `https://x.com/intent/post?url=…&text=…` with
`noopener,noreferrer`. The desktop shell already sends https links to
the system browser.
- **Copy for Discord:** "Check out {name}'s profile on OpenFront", then
the link on the next line, so it pastes as a ready message; Discord
unfurls the link.
- **On touch devices with `navigator.share`:** one **Share** button
opens the native share sheet instead of the three. It's gated on touch
because desktop Chrome on Windows also exposes `navigator.share`. A
dismissed sheet does nothing; any other failure falls back to copying
the link.

#### "Player not found"

Replaces the old 📊 line with the approved design:
- a person-with-question-mark icon in a circle;
- "Player not found" and "We couldn't find a player at this link. It may
be mistyped, or the account may have been deleted.";
- the opened link, shown faintly, only when opened from a path;
- **Play OpenFront** (home) and **View leaderboard** (closes the profile
and opens the leaderboard): side by side from `sm` up, stacked on a
phone.

The old `player_profile.not_found` key is removed; the translation test
fails on unused keys.

#### Server (`src/server/PlayerProfileRoute.ts`)

- The master's SPA fallback already serves the app shell at a bare
`/player/<id>`.
- `/w<n>/player/<id>` reaches a worker, which returned 404, so
`registerPlayerProfileRoute` serves the shell there, in the
`GamePreviewRoute` style.
- The dev proxy bypass covers `/w<n>/player/` as well as `/w<n>/game/`.

### Screenshots

From this branch at 2x, opened from real `/player/<id>` links. The
profile is sample data given to the popup, because the dev API has no
such player. The not-found shots are the real failure path.

**Desktop:** the profile card and the share row. Signed-in and
signed-out viewers see the same page (the "New to OpenFront?" banner was
removed in a18d74e).

<img width="1000" alt="Profile page Stats tab with share buttons"
src="https://github.com/user-attachments/assets/57331d4c-cabd-4520-84cd-947ea8fd772c"
/>

**Desktop, Progression** (from #5774), opened with `?tab=progression`:

<img width="1000" alt="Profile page Progression tab"
src="https://github.com/user-attachments/assets/c90abe7a-f94a-40d7-8331-a8f821ed6cc8"
/>

**Player not found** (the real failure path, from `/player/aB3dE5fX`):

<img width="1000" alt="Player not found screen on desktop"
src="https://github.com/user-attachments/assets/1a4137ac-efaf-430c-9c5b-6b434761a793"
/>

**Phone:** Stats (the three share buttons collapse to a native Share
button on touch devices with share support; headless Chrome has none, so
the three show here), Progression, and Player not found with the buttons
stacked.

<img width="300" alt="Profile page on a phone"
src="https://github.com/user-attachments/assets/a978817d-5b2c-43ee-9081-a8b9d812d224"
/> <img width="300" alt="Progression tab on a phone"
src="https://github.com/user-attachments/assets/ea5907fd-fa6e-4ab1-bce0-bda97b6fbcbe"
/> <img width="300" alt="Player not found on a phone"
src="https://github.com/user-attachments/assets/f6e3c7f5-288b-41bd-87ba-f0c34065348e"
/>

#### Added: sharing level-up moments

Players can now share a milestone, Legend or a new prestige rank, using
the same Copy link / Share on X / Copy for Discord options as the
profile. The link is the profile with the moment, e.g.
`/player/wonder01?moment=level50`. openfrontio/infra#910 unfurls it as
that moment's celebration card.

- **End-of-game popup:**
- A "Share Level 50" or "Share Legend" button sits in the footer,
between Exit Game and Keep playing.
- It opens the three options as a menu above it. On a touch device with
a share sheet, it opens the share sheet directly.
- On phones it takes its own full-width row above the other two buttons.
- **When it shows:**
- only for the server's confirmed result, never the early estimate shown
when a player is eliminated;
  - only once the reveal has finished or been skipped;
  - only for a signed-in player with a public ID;
- only when the game reached a milestone (10, 25, 50, 75 or 100) or made
the player a Legend.

With several milestones in one game it shares the highest, and Legend
wins over any level.
- **Prestige ceremony:** a share row with a dark backing sits between
the title and Continue. Clicks on it don't close the ceremony.
- **Code:**
- `<profile-share>` defines its actions once and gains a `menu` layout.
The profile's own row works as before.
  - `MomentShare.ts` works out what to share.
  - `GameXpPanel` fires `xp-reveal-settled` when the reveal ends.
  - `PrestigeFlow` takes a `publicId`.
- **New `en.json` keys:** `progression.share_moment_level`,
`share_moment_legend`, `share_text_level`, `share_text_legend`,
`prestige.share_text`.

Screenshots from this branch at 2x. The XP results are sample data given
to the popup. In dev the link starts with the local origin; in
production it's `https://openfront.io`.

**Level 50, closed and open, and Legend:**

<img width="700" alt="Share Level 50 button in the end-of-game footer"
src="https://github.com/user-attachments/assets/4571dc04-eb2a-4d61-bca7-2e04fb589265"
/>
<img width="700" alt="Share menu open above the button"
src="https://github.com/user-attachments/assets/227a8b31-dfa2-4430-9e60-92cca0275ca8"
/>
<img width="700" alt="Share Legend with the menu open"
src="https://github.com/user-attachments/assets/42f5b09e-187f-4653-9640-b998a9bb1b50"
/>

**Phone, closed and open:**

<img width="300" alt="Share button on its own row on a phone"
src="https://github.com/user-attachments/assets/482570aa-51cd-42ad-8ff3-2aa29780211e"
/> <img width="300" alt="Share menu open on a phone"
src="https://github.com/user-attachments/assets/7a891971-5042-411e-bef5-9b80805ac0a0"
/>

**Prestige ceremony, desktop and phone:**

<img width="700" alt="Prestige ceremony with share row"
src="https://github.com/user-attachments/assets/3c05a8f9-cb7f-4e74-90ad-16abfb40bb28"
/>
<img width="300" alt="Prestige ceremony share row on a phone"
src="https://github.com/user-attachments/assets/496faa57-48ba-4ab4-b5e6-28c5f2d2b9c1"
/>

**Testing for these additions:**
- `MomentShare.test.ts`, plus 7 menu tests in `ProfileShare`, 8 in
`WinModalXp` and 3 in `PrestigeFlow`. They cover:
  - result-only, never for the early estimate;
  - hidden mid-reveal, signed out, or with no public ID;
  - the highest milestone chosen, and Legend over a level;
  - the link and text built correctly;
  - clicks on the ceremony's share row not closing it.
- All 3,528 client tests pass. `tsc` and lint are clean.

#### Added (2026-10-04): share ring on hover, and a share row on the
Legend ceremony

- **Share ring:**
- **The flash:** a quick yellow ring flashes around a share button when
the mouse moves onto it, or on keyboard focus (`:focus-visible`). It's
at full strength instantly, held for 80 ms, then fades over 320 ms while
growing to 1.04×.
- **When it plays:** once per hover; a hover while it's still playing is
ignored. It's never shown on touch devices (`(hover: hover) and
(pointer: fine)` only) or under reduced motion.
- **How it's drawn:** `ProfileShare` renders its own ring element and
plays it with `ring.animate(...)`. It's not a pseudo-element on the
trigger, because the trigger's `.win-action::after` is already its hover
streak.
- **Where it appears:** the end-of-game footer button, the prestige
ceremony's row and the profile page's row. The share buttons no longer
rise in; they're simply there.
- **The Legend ceremony** (from #5734) now has the share row between
"The top of the track…" and Continue, linking `?moment=legend` the same
way the prestige ceremony does.
- It stops clicks from reaching the ceremony, and doesn't show without a
public ID.
- Tab now cycles through the share buttons and Continue inside the
ceremony, with Continue still focused first.

**Built UI, sample data:**

The share button in the end-of-game footer, hovered twice:


https://github.com/user-attachments/assets/ff34f99d-9e14-4b07-9929-0b23f29d7bf3

The Legend ceremony's final screen with the share row:

<img width="1000" alt="Legend ceremony final screen with Copy link,
Share on X and Copy for Discord"
src="https://github.com/user-attachments/assets/222e5512-937f-43d0-ae36-ce23aeea49a5"
/>

**Tests:**
- `ProfileShare.test.ts`: the ring element, one `animate` per hover with
the exact keyframes, a hover during playback ignored, focus-visible,
touch and pen ignored, nothing under reduced motion or a coarse pointer.
- `LegendCeremony.test.ts`: the row only at the end, between the line
and Continue; URL and text; clicks kept; no row without an ID; Tab
cycling.
- `WinModalXp.test.ts`: the `?moment=legend` URL.

**Results:** `tsc` and lint are clean, and `tests/client` passes
(3,654).

### Testing

- **New:**
- `PlayerProfilePage.test.ts`: path parsing in every form, invalid ids
ignored, opening on load, `?tab=` sync, closing resets the URL, the
not-found state;
  - `PlayerProfileUrl.test.ts`;
- `ProfileShare.test.ts`: copy calls the clipboard, X URL encoding, the
native share path and its fallback;
  - `tests/server/PlayerProfileRoute.test.ts`.
- **Updated:** `ShareBase`, `NavAccountMenu`,
`FriendsListPublicIdInput`, and the profile tests' mocks.
- **Results:** 63 tests pass in the 8 targeted files, plus the
translation tests. `tsc` and `npm run lint` are clean. In the full suite
the only failures were the known `InventoryModal` / `MainInitialize`
timeouts, which pass on their own.

---

# Part 3: End-of-game level rewards and XP polish

Approved from mockups and prototype clips.

### 1. The XP bar's leading edge glows while it fills

While the bar fills, its front edge glows and a sheen runs along the
fill. It uses `::after` / `::before`, moved by transform only. It shows
only while `barAnimate` is on, and it's off under reduced motion.

### 2. A game's level rewards, shown and claimed in the reveal

When the confirmed result (never the provisional one) levels you up, a
row shows what those levels paid. It reads `rewards` / `flares` from
`GET /users/@me/xp/:gameId` (openfrontio/infra#907, #911):
- **One level:** "Level 47 reward +100 Caps".
- **Several levels:** "Levels 45–47 +300 Caps (3 × 100)".
- **A milestone:** "Level 50 rewards +100 Caps +25 Plutonium".
- **A cosmetic unlocked:** "Unlocked · Firebird Trail · Boat trail · in
your Locker" wipes into the milestone card. On phones it wraps under it.

How the row behaves:
- **Arrival:** it rises in after the level-up lands, and its amounts
count up.
- **Claim:** it claims only this game's reward ids. It shows "Claiming",
then "✓ Claimed" with the balance counting up.
- On failure: an inline error, and a retry claims only what didn't go
through.
  - If the rewards were already claimed, it opens in the claimed state.
- **Account refresh:** after a game that unlocked a cosmetic,
`/users/@me` is refreshed so the Locker shows it.
- **Reduced motion:** the final state, without animation.
- **Code:** `GameRewards.ts` (sums, claiming, unlock lookup) and
`GameXpRewardSchema` / `GameXpFlareSchema`. The new fields are optional,
so an older API still parses.

### 3. The nav's XP bar catches up after a game

When a refreshed `/users/@me` shows more XP, the account menu's bar
fills to the end, the badge pops as the new level lands, the bar resets,
then it grows to the new progress.
- **When it animates:** only when XP went up, the menu is on screen, the
page is visible and reduced motion is off. First load, a prestige and
anything else snap straight to the new value.
- **Overlap fix:** the badge and avatar now sit in their own row, with
the bar only under them. The avatar is 28px while there's a bar, and the
email badge moved to the icon's top corner. The 32px avatar used to
reach 3px down into the bar. That overlap was already on #5726.

### 4. Reduced motion on the "calculating" spinner

The reveal's spinner now has `motion-reduce:animate-none`.

#### New `en.json` keys

All under `progression`:
- `rewards_balance`
- `rewards_balance_caps`
- `rewards_claimed`
- `rewards_claiming`
- `rewards_each`
- `rewards_level_many`
- `rewards_level_one`
- `rewards_levels`
- `unlock_in_locker`
- `unlocked`
- `unlocked_cosmetic`

#### Screenshots

From the branch at 2x with sample data (Wonder / wonder01). **These were
taken before the prestige emblems were dropped**, so any badge at
prestige 1 or above in them shows the old per-rank emblem; the shipped
badges are main's. Nothing else in them changed.

**The bar's leading edge while it fills:**


https://github.com/user-attachments/assets/88d2005b-98b5-481b-8aa0-b25d4132af0f

**Rewards: one level, several levels, a milestone with an unlock, and
claimed:**

<img width="700" alt="Level 47 reward row"
src="https://github.com/user-attachments/assets/4868b4f4-122c-48e3-91fb-f69db14137a9"
/>
<img width="700" alt="Levels 45 to 47 reward row"
src="https://github.com/user-attachments/assets/4adccdaa-c699-4a74-8de1-914237dfca3a"
/>
<img width="700" alt="Level 50 milestone with an unlocked cosmetic and
rewards"
src="https://github.com/user-attachments/assets/acecab8c-6cb2-4252-af4c-bf75a3a54cad"
/>
<img width="700" alt="Rewards claimed with the new balance"
src="https://github.com/user-attachments/assets/185c60e4-f23a-42ce-b63c-86b389d02b4b"
/>

**The full reveal to level 50, the unlock, the rewards row and a
Claim:**


https://github.com/user-attachments/assets/5fac69a5-4a22-4bee-bc2d-e8a5e29a7964

**Phone, milestone:**

<img width="300" alt="Milestone rewards on a phone"
src="https://github.com/user-attachments/assets/35481bec-8da1-415e-8ba7-6c62b20dc268"
/>

**Nav menu at rest, then catching up after a game:**

<img width="500" alt="Nav account menu with the XP bar under the badge
and avatar"
src="https://github.com/user-attachments/assets/9c4ed6f5-8495-46c7-902a-02e6c59ea0c4"
/>


https://github.com/user-attachments/assets/b5d63de2-43d6-4e3a-88d3-9efa565acfb3

#### Testing

**New:**
- `GameXpRewards.test.ts` (18 tests):
  - result only;
  - one, several and milestone;
  - the unlock;
  - Claim calls only this game's ids;
  - claimed, failure and retry, and already claimed;
  - mid-reveal clicks;
  - reduced motion.
- `NavAccountCatchUp.test.ts`: when it animates or snaps, the fill → pop
→ reset → grow sequence, the fallback, and the layout (bar inside the
badge-and-avatar row; the avatar node is kept).

**Updated:**
- `GameXpPanelReveal.test.ts`: the bar edge class and the spinner.
- `ProgressionSchemas.test.ts`.
- `WinModalXp.test.ts`.

**Results:** `npx tsc --noEmit` and `npm run lint` are clean. The
translation tests and all of `tests/client` (277 files, 3,574 tests)
pass.

---

## Checks after combining

- `npm run typecheck` and `npm run lint` pass.
- Full `npx vitest --run`: 8,200 passed; the only failures are the
Windows-only `UpdateRegister` tests, which fail on `main` too.
- New `tests/client/RewardClaims.test.ts` (6 tests) for the shared claim
helper, including a reward the API refuses as held (#5879).

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory

## Please put your Discord username so you can be contacted if a bug or
regression is found:

Iamlewis

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

1 active and 1 inactive deployments
backport — 812cc15b Deployed Oct 9, 2026 by Celant via Cherry-pick #233
staging — 812cc15b Deployed Oct 8, 2026 by openfrontio[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:client Best-guess area: client (rendering, UI, web components) Feature UI/UX UI/UX changes including assets, menus, QoL, etc.

Projects

Status: Complete

Development

Successfully merging this pull request may close these issues.

Show player levels and the XP each game earns

4 participants