Commit Graph
27 Commits
Author SHA1 Message Date
b379502516 fix(store): specific rate-limit message on tier change + use shared dialogs for subscription flows (#4622)
## Summary

- The change-tier API allows one tier change per minute per player
(`playerRateLimit` on `/subscriptions/@me/change-tier`). The client
previously lumped the 429 in with every other failure and showed
"Couldn't update your subscription. Please try again." — retrying
immediately just fails again. A 429 now gets its own message: *"You just
changed tiers. Please wait a minute before changing again."*
- Replaced all native `alert()` / `window.confirm()` calls in the
subscription flows with the shared `showInGameAlert` /
`showInGameConfirm` helpers (styled `<confirm-dialog>`, consistent with
the rest of the store, and safe on CrazyGames):
- Tier change: already-subscribed notice, upgrade/downgrade confirmation
(warning variant with "Change Tier" heading),
rate-limited/failure/success notices
  - Dollar checkout failures (first-time subscribe path)
- Subscription panel: cancel confirmation (danger variant), cancel
failure/success, portal-open failure

The currency-purchase alerts in Cosmetics.ts (`login_required`,
`purchase_failed`, `purchase_success`) are untouched — subscriptions
can't reach that path.

## Test plan

- [x] Typecheck, ESLint, Prettier
- [ ] Manual: change tier twice within a minute against the real API and
confirm the rate-limit dialog appears

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 12:35:33 -07:00
e05dce3437 Add crown cosmetic type (#4618)
## What

Adds **crowns** as a new cosmetic category, wired end-to-end like
flags/skins, plus a Crowns tab in the lobby cosmetics modal (#4617).

Catalog shape in cosmetics.json:

```json
"crowns": {
  "gold_crown": {
    "name": "gold_crown",
    "url": "...",
    "affiliateCode": null,
    "product": null,
    "priceHard": 5,
    "artist": "...",
    "rarity": "common"
  }
}
```

## Changes

- **Core**: `CrownSchema` + optional `crowns` record in
`CosmeticsSchema`; `crownName` in `PlayerCosmeticRefs`; resolved `crown:
{ name, url }` in `PlayerCosmetics`
- **Server**: `isCrownAllowed` in the privilege checker — requires a
`crown:*` or `crown:<name>` flare, resolves the ref to the catalog URL
- **Client**: crown selection persisted under the `crown` localStorage
key (stale/unowned selections self-clear), `crownRelationship` +
resolve/refs wiring, `"crown"` purchase type, Crowns tab in the store,
and a Crowns tab in the cosmetics modal (owned crowns + Default tile;
selecting persists and keeps the modal open)
- **i18n**: `store.crowns`, `store.no_crowns`
- Tests for schema parsing, privilege checks, and cosmetic resolution

## Not in this PR

- In-game rendering of `player.cosmetics.crown` (name pass /
leaderboards / player panel)
- API-side support (`/shop/purchase` accepting `cosmeticType: "crown"`,
granting `crown:<name>` flares, serving `crowns` in cosmetics.json)

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 12:49:45 -07:00
d76691372c Feature/reuse private lobby (#4536)
**Add approved & assigned issue number here:**

Resolves #4476 

## Description:

Lets a private-lobby host reuse the same group for **back-to-back games
without
re-sharing the invite link**.

**Flow:** in a private game, the host clicks a **"New lobby"** button in
the
top-right bar (next to pause). The game's **server** creates a fresh
private
lobby (same creator, default settings) and broadcasts its id to everyone
still
connected. Non-hosts get a one-click **"Join"** banner at the top of the
screen;
the host is taken straight back to the host view for the new lobby. The
chain
can repeat indefinitely.

### Key design decision: the server creates and broadcasts the successor
lobby

The successor lobby is minted by the **finished game's server**, not the
host's
browser. The old game's server is the only thing still connected to
every player, so it has to be what announces the new
lobby; because it also *creates* that lobby, the id everyone is
redirected to is
**authoritative**. A real lobby the server just made for the
authenticated
creator, not an id a client handed it to trust and fan out. The request
is
**creator-only** and **idempotent** per game, and every successor is
wired the
same way, so the group can keep playing game after game.

### How it works

1. Host clicks "New lobby" (host + private only) → confirm dialog → the
client
   sends a `create_next_lobby` message.
2. The game server verifies the sender is the lobby creator, mints a
successor
private lobby on the same worker, stores it (idempotent), and broadcasts
a
   `new_lobby` message with the new id to all connected clients.
3. Each client reacts: the host is navigated to the new lobby's host
view
(`/…/game/<id>?host`); everyone else sees a dismissible "Host started a
new
   lobby — Join" banner that navigates to the join URL in one click.

Two new Zod wire messages in `src/core/Schemas.ts` (`create_next_lobby`,
`new_lobby`) carry the request and the broadcast.

### Screenshots

<table>
  <tr>
    <td width="50%" align="center" valign="top">
<img width="220" alt="In-game New lobby button"
src="https://github.com/user-attachments/assets/9a4d4425-a7f6-4b3a-9c4f-9205300b1e5b"
/><br />
<sub><b>1.</b> In-game <b>New lobby</b> button (top-right, next to
pause) — shown only to the host of a private lobby</sub>
    </td>
    <td width="50%" align="center" valign="top">
<img width="320" alt="Confirmation dialog"
src="https://github.com/user-attachments/assets/fa023f5a-58e8-439b-8899-5150235a1e8c"
/><br />
<sub><b>2.</b> Confirmation so a stray click doesn't pull everyone into
a new lobby</sub>
    </td>
  </tr>
  <tr>
    <td colspan="2" align="center">
<img width="100%" alt="Join banner for non-hosts"
src="https://github.com/user-attachments/assets/7e5ef490-aa7e-4449-add9-e857fe273bde"
/><br />
<sub><b>3.</b> Everyone else gets a one-click <b>Join</b> banner at the
top of the screen</sub>
    </td>
  </tr>
  <tr>
    <td colspan="2" align="center">
<img width="330" alt="Host view for the new lobby"
src="https://github.com/user-attachments/assets/9920b070-4ed3-41f8-9345-78778b4648a7"
/><br />
<sub><b>4.</b> The host lands back in the host view for the brand-new
lobby</sub>
    </td>
  </tr>
</table>


### Design Decisions

**A. The server creates & broadcasts the successor, not the client.**
The finished game's server mints the successor and broadcasts its id.
Why this
and not "host's browser calls `POST /api/create_game`, then asks the
server to
relay the id"?
- The broadcast id is **authoritative/verified**: it's a real lobby the
server
just created for the **authenticated** lobby creator (creator identity
comes
from the JWT the game already holds), not an arbitrary id a client hands
the
  server to fan out to everyone.
- The **old game server is the only thing still connected to all the
players**,
so it must be the one to broadcast. Having it also create the lobby
keeps it
to one authoritative round-trip instead of "client creates, then client
asks
  server to trust an id it didn't make."
- The server can **authorise** (only the creator) and stay
**idempotent**.

**B. The successor starts with default settings (not a copy of the old
game).**
A deliberate scope choice. The host lands in the normal host view and
reconfigures. Copying the exact config would mean reverse-mapping every
`GameConfig` field back into the host-modal controls, which I thought
would be out of scope for this PR. Same **creator**
is preserved; same **settings** intentionally is not.

**C. It's a brand-new lobby, not the same game resurrected.**
This directly follows @evanpelle's guidance on the issue: *"A 'lobby' is
really
just a game that hasn't started yet. So making a persistent lobby isn't
really
possible. I think instead having a simple way to transfer players to a
new lobby
is probably the way to go."* A `GameServer` runs exactly one game
(start → end → archive), so rather than reworking that lifecycle to
resurrect the
old game, the server spins up a fresh successor lobby and transfers the
group
into it, which is also why the feature is framed as "reuse the group,"
not
"reuse the game object."

**D. The host returns via a `?host` URL flag + full reload ("attach
mode").**
Navigating to a normal join URL (`/game/<id>`) always lands you in the
**join**
view, which has no Start button. So the host can't just use the join
URL. The
`?host` flag routes the creator to the **host view** instead
(`Main.handleUrl` → `HostLobbyModal` in "attach" mode, which binds to
the
existing lobby id and skips creating a new one). A full reload is used
because
it cleanly tears down the finished game and mirrors the existing
win-screen
"Requeue" button's `window.location.href` pattern.

**E. Each successor can spawn its own successor (recursive factory).**
The first version only chained **one** generation. A spawned lobby had
no
factory of its own, so the *second* "New lobby" click did nothing
(button just
greyed out). Fixed by `wireSuccessorLobby` (a small dependency-injected
helper)
that wires every successor the same way. This is the whole point of the
issue
("back-to-back games"), so it has its own regression test.

**F. Private-only.**
The successor factory is installed **only** on the private `POST
/api/create_game` path in `Worker.ts`. Public games (scheduled by the
master)
and singleplayer never get a factory, so `handleCreateNextLobby` is a
no-op for
them. The client button is also gated on `isLobbyCreator &&
isPrivateLobby`.

**G. In-game button + confirm; the win-modal button was removed.**
An earlier version put the "New lobby" button on the win screen. I moved
it to
the in-game bar so the host can reuse the lobby **at any time** (without
dying
or waiting for the game to end), and added a **confirm** (matching the
adjacent
Exit button) so a stray click next to pause/exit doesn't yank everyone
into a
new lobby. The win-screen button became redundant and was removed.


### Testing

- **Unit tests:** wire-message schema round-trips
(`tests/NewLobbyMessages.test.ts`); the server handler — authorisation,
  broadcast, idempotency — against a real `GameServer`
(`tests/server/CreateNextLobby.test.ts`); and successor **chaining**
across
  multiple generations (`tests/server/SuccessorLobby.test.ts`).
- **Full suite:** `npm test` passes — **1782 tests across 154 files**.
- **Manual:** created a private lobby with multiple clients and played
consecutive games via the button; verified non-hosts get the Join
banner, the
host lands back in the host view, and the chain works for 3+ games in a
row.

## 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:

MushroomLamp

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:51:49 -07:00
bf61f5847b feat(rewards): claimable subscription rewards UI (#4566)
## Summary

Subscription perks (signup bonus + daily soft/hard currency) now land as
**unclaimed rewards** the player must explicitly claim, instead of
crediting balances directly (API side: openfrontio/infra#409). This PR
adds the client UI:

- **`RewardsPanel`** — shared Lit element listing pending rewards
(currency icon, `+amount`, server `note` with translated fallbacks for
known reasons), with per-reward **Claim** and **Claim All**. Both claim
endpoints return post-claim balances, so the wallet updates without
re-fetching `/users/@me`.
- **Account modal** — renders the panel above the subscription panel
(rewards survive subscription lapse, so it doesn't depend on an active
sub).
- **`RewardsModal`** — popup at login when rewards are pending. Reuses
`RewardsPanel`, auto-closes when the last reward is claimed. Only opens
on a clean homepage load (`pathname === "/"`, empty hash) so it never
pops over join links, `#modal=...` deep links, or the Stripe
`#purchase-completed` return — that flow reloads clean, so the signup
bonus popup appears right after purchase.
- **Schemas/API** — `RewardSchema` + `rewards[]` on `/users/@me`
(optional for older API versions), `claimReward` / `claimAllRewards` in
`Api.ts`. Reward `id`/`amount` stay opaque bigint strings; a 404 on
single claim means "already claimed elsewhere" (double-click / second
device) and re-syncs instead of erroring.

## Screenshots

Login popup (also embedded in the account modal):

- Panel: currency icon + amount + note per row, Claim / Claim All
buttons
- Amounts formatted via `BigInt` (can exceed `Number.MAX_SAFE_INTEGER`)

## Test plan

- [x] `npm test` — 161 tests pass, including new coverage for
`RewardSchema`, `/users/@me` with/without `rewards`, and both claim
response shapes
- [x] `tsc --noEmit`, ESLint, Prettier clean
- [x] Verified in the running app (Playwright): panel renders in the
account-modal style; single claim against a stubbed endpoint credits
balances and removes the row; Claim All empties the list and auto-closes
the popup; body scroll restored

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:21:52 -07:00
8e5f27949f feat: marketing email consent UI (post-login prompt + account settings) (#4554)
Resolves #4029

## Description:

Client-side marketing-email consent capture — the client half of #4029.
It consumes the already-merged API (`GET /users/@me` marketing-consent
state + `POST /marketing/consent`); **no backend changes**.

**Three surfaces:**

- **Post-login prompt** — a small, non-blocking toast docked top-right,
shown once after login when the player's consent is undecided
(`no_response`) and a verified email is on file. Yes / No thanks /
dismiss all record a decision, and it never re-nags.
- **Account → "Account Settings" tab** — a persistent on/off toggle to
change or withdraw consent at any time (the GDPR change/withdraw path).
- **No-email state** — when the account has no verified email, the tab
offers to bind one (magic link to a plain email — the backend's
`new-association` path — or link Google) so the player can subscribe.

Styling matches the game's existing panels (`bg-surface`,
`border-white/10`, `shadow-[var(--shadow-malibu-blue)]`, malibu-blue
accent), reusing `o-button` and the account modal's existing email
field/handlers.

**Changes:**

- New `<marketing-consent-toast>`
(`src/client/MarketingConsentToast.ts`), mounted in `index.html`,
registered in `Main.ts`.
- `AccountModal.ts`: new "Account Settings" tab (toggle + bind-email
state), optimistic with revert-on-failure.
- `Api.ts`: `setMarketingConsent()` → `POST /marketing/consent`,
invalidates cached `/users/@me`.
- `ApiSchemas.ts`: optional `player.marketingConsent { consented,
hasEmail }` on `UserMeResponse`.
- `en.json`: `marketing_consent.*` + `account_modal.marketing_*` /
`tab_settings`.

## Please complete the following:

- [x] I have added screenshots for all UI updates <!-- attaching in a
follow-up comment -->
- [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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 10:13:37 +00:00
0c83444dc9 feat(client): custom plutonium amount purchase in store (#4515)
## Summary

Adds a **custom plutonium amount** purchase option to the Store, wiring
the client to the new `POST /stripe/create-custom-currency-checkout`
endpoint (infra [PR
#400](https://github.com/openfrontio/infra/pull/400)). Players can buy
any integer **20–2000** plutonium, priced inline server-side at **20
plutonium = \$1.00**.

A new `<custom-currency-card>` renders as the **last tile in the Store
"Packs" tab** — a slider + number input with a live price and a "Buy for
\$X.XX" button that redirects to Stripe Checkout.

## Changes

| File | Change |
|---|---|
| `src/client/Api.ts` | New `createCustomCurrencyCheckout(hardAmount)` —
POSTs `{ hardAmount, hostname }`, returns the Stripe `url` (mirrors
`createCheckoutSession`). |
| `src/client/components/CustomCurrencyCard.ts` | New
`<custom-currency-card>` component: plutonium slider + number input,
live price, clamps to 20–2000 integer client-side, redirects to Stripe
on buy. |
| `src/client/Store.ts` | Renders the card after the fixed packs in the
"Packs" tab; drops the now-unreachable "no packs" empty state. |
| `src/client/Main.ts` | Handles `type=custom_currency` in the
`#purchase-completed` redirect (success alert + strip hash; balance
refetches from `/users/@me` on load). |
| `src/client/LangSelector.ts` | Registers `custom-currency-card` for
translation-reload re-render. |
| `resources/lang/en.json` | Adds `store.buy_for`,
`store.custom_amount`, `store.custom_currency_purchase_success`; removes
the orphaned `store.no_packs`. |

## Notes

- **No catalog product** — unlike currency packs, the custom amount is
priced inline server-side, so the card is standalone (not a
`resolveCosmetics` item).
- **Async credit** — no optimistic balance bump; the balance reflects
the Stripe webhook via `/users/@me` on return, matching the
currency-pack flow.
- Server is authoritative on bounds/rate; client-side clamping is
UX-only.

## Testing

- Full suite green: **1842 tests / 153 files**; `tsc --noEmit` and
eslint clean.
- Manually verified in the running app (headless Chromium): card renders
in the Packs tab; amount/price stay in sync across slider + number
field; clamping confirmed (5000→2000/\$100.00, 5→20/\$1.00, 750→\$37.50
— matches the pricing table).

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 17:23:58 -07:00
5d21be826d feat: subscriber-hosted public lobby listing (#4480)
Part of #4040 (v1 scope: listing + browser + per-subscriber limit;
custom lobby name/description left for a follow-up).

## What

Subscribers can toggle their **private lobby** to be **publicly
listed**; a browsable **"Open Lobbies"** list appears in the Join Lobby
modal. Hard limit of **one listed lobby per subscriber**, enforced
cluster-wide.

## How

**Semantics** — a listed lobby stays `GameType.Private`: the host keeps
full control and starts the game manually; the toggle only controls
visibility. The `listed` flag lives on `GameServer` (not `GameConfig`),
so it cannot be smuggled in through `update_game_config` and never
touches core/sim/records.

**Distribution** — reuses the existing public-lobby pipeline end to end:
a new `"hosted"` `PublicGameType` bucket flows worker → master IPC →
`/lobbies` websocket → `PublicLobbySocket`. Master scheduling now
iterates only `SCHEDULED_PUBLIC_GAME_TYPES` (`ffa`/`team`/`special`), so
it never sets countdowns on or schedules replacements for hosted
lobbies. Lobbies delist automatically when the game starts/fills/dies
(phase change). The broadcast fingerprint now includes browser-visible
config, so host edits (map/mode) refresh the list even though the gameID
doesn't change.

**Gating** — new authenticated endpoint `POST /api/game/:id/listing`:
- creator-only (403), private + not-started only (409)
- fresh subscription check via server-side `getUserMe` using the shared
`hasActiveSubscription()` helper (`active`/`trialing`); skipped in
`GameEnv.Dev` (same precedent as Turnstile) so it's testable locally
- one-lobby-per-creator (409): a SHA-256 hash of the creator's
persistentID rides worker↔master IPC (`PublicGameInfo.creatorID`); the
master dedupes as a race backstop. The hash — and host-only config
(whitelist, name reveals) — are **stripped from every client payload**
(broadcast + primed snapshot).

**Client** — subscriber-gated "List lobby publicly" toggle in the host
modal (server rejection reverts the toggle and shows a translated
message); "Open Lobbies" rows (map, mode, player count) in the Join
Lobby modal that reuse the existing private-join flow.

**Compat** — `PublicGames.games` is now a `partialRecord`, so newer
clients tolerate servers that don't send every bucket. Note:
already-open old clients will fail to parse broadcasts containing the
new `hosted` key until refreshed (closed Zod enum) — same class of break
as previous wire-schema changes.

## Testing

- `tests/server/HostedLobbyListing.test.ts` (15 tests): listed-lobby
filtering, flag not settable via config intent, master aggregation +
creator dedupe + no scheduling of hosted, creatorID stripping (broadcast
+ primed snapshot), `creatorHasListedLobby` (broadcast + local),
fingerprint refresh on config change
- `hasActiveSubscription` cases in `ApiSchemas.test.ts`; hosted
counts-delta patch in `LobbySocket.test.ts`
- Full suite green (1723 + 141 tests), tsc/eslint/prettier clean
- **E2E in the real app** (headless Chromium, two browser contexts):
host lists lobby → appears in second browser's Join Lobby list
(creatorID absent from payload) → join succeeds (2 players in lobby) →
same creator's second lobby rejected 409 with toggle revert → unlist
removes it from a fresh browser's list. Curl negatives: missing auth
400, bad token 401, non-creator 403, missing game 404, bad body 400.

## Known follow-ups

- Custom lobby name/description in the browser (needs the censor
pipeline) — rest of #4040
- A listed lobby whose host closes the tab stays advertised indefinitely
(an empty private lobby never leaves the Lobby phase) — pre-existing
lifecycle, now more visible; consider delisting on creator disconnect

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:25:56 -07:00
1d5a6ae246 Account Modal - Games Tab (#4473)
## Description:

Continuation of https://github.com/openfrontio/infra/pull/386, adds play
games sessions

<img width="971" height="771" alt="image"
src="https://github.com/user-attachments/assets/42c6bcbb-d690-4cd1-b859-3299a03f4350"
/>

## 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:

w.o.n

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:12:37 -07:00
bd9ef9a317 feat: effects cosmetic category (transport-ship trail) + UI (#4418)
## What

Adds a new **`effects`** cosmetic category alongside `skins`/`flags`.
Each effect is discriminated by **`effectType`** (only
`transportShipTrail` today), whose visual config lives in
**`attributes`** (`solid` / `rainbow` / `pulse` / `gradient`). Schema
matches the production cosmetics.json shape exactly (incl. the `url`
field).

**This PR is UI + taxonomy only — the in-game WebGL trail rendering is
intentionally deferred.**

## UI

- **Store** gains an **"Effects"** tab.
- **Home page** gains an **"Effects"** button opening a picker modal.
- Both render effects **grouped by `effectType` with a sub-header per
type**, via a shared `<effects-grid>` Lit element (`mode="select"` for
the picker, `mode="purchase"` for the store). The picker shows owned
effects + a Default tile and persists per-type; the store shows
purchasable effects.

## Data flow

- Ownership via `effect:*` / `effect:<name>` flares (reuses
`cosmeticRelationship`).
- Selection is a per-`effectType` map persisted in UserSettings
(`settings.effects`).
- Server validates in `isEffectAllowed`, wired into `isAllowed`.
- `getPlayerCosmeticsRefs` / `getPlayerCosmetics` resolve effects the
same way as skins/flags (kept-on-fetch-failure, server is authority).

## Tests

- `tsc --noEmit`, ESLint, Prettier clean; full suite green.
- New: `CosmeticSchemas` parse tests (incl. parsing the **real**
`read_transport_trail` entry), `UserSettings` per-type selection, and
`Privilege` effect validation.

## Notes / follow-ups

- The effect's display label shows **"Boat Trail"** for the
`transportShipTrail` type (friendlier than the id).
- Closed-source API gap: `/shop/purchase` (`purchaseWithCurrency`) needs
to learn `"effect"` for **currency** purchase of effects; the
**dollar/product** purchase path already works. Client types were
widened accordingly.
- In-game wake rendering can be ported from #4416.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:13:48 -07:00
Josh HarrisandGitHub ff5eb78689 Login with Google — client UI (#4028) (#4279)
Resolves #4028 (client half — backend is openfrontio/infra#368, which
must be deployed first).

## Description:

Adds "Login with Google" to the client, alongside the existing Discord
login. Companion to the backend PR (openfrontio/infra#368).

- `Auth.ts` — `googleLogin()` (full-page redirect to
`/auth/login/google?redirect_uri=…`, mirrors `discordLogin()`).
- `ApiSchemas.ts` — `GoogleUserSchema` + optional `user.google` on
`UserMeResponseSchema`.
- `AccountModal.ts` — a "Login with Google" button (Google brand
guidelines: white surface, dark text, the multicolor "G" mark) in the
login options, and the logged-in view now renders a Google-authenticated
user's email (also added `google` to `isLinkedAccount()`).
- `en.json` — `main.login_google`.
- `resources/images/GoogleLogo.svg` — the Google "G" mark.

> **Draft.** Depends on infra#368 being deployed (the button hits the
live `/auth/login/google`).

## Please complete the following:

- [x] I have added screenshots for all UI updates <!-- TODO: add
screenshot of the Google button -->
- [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 <!-- no client
tests exist for AccountModal/Auth; verified via tsc --noEmit + eslint.
Backend behaviour is covered in infra#368 -->

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

jish
2026-06-19 11:47:40 +01:00
EvanandGitHub ca565eaa1a Subscription upgrade/downgrade + tier management (#3927)
## Summary

- Tier upgrade/downgrade in the Store. The Subscriptions tab now shows
all tiers including the user's current one. Other tiers swap "Subscribe"
→ "Switch" when the user already has a sub, and clicking them calls the
new `POST /subscriptions/@me/change-tier` endpoint with a
direction-aware confirm (upgrade charges prorated diff now, downgrade
gives account credit).
- Owned-tier card renders a **Current Plan** badge in place of the
purchase button. Resolution logic in `resolveCosmetics` now reads
`userMeResponse.player.subscription.tier` (with flare fallback) and
marks that tier as `owned`.
- AccountModal's `<subscription-panel>` reworked into a proper
two-column layout:
- **Left**: tier name, `$X.XX/mo` price, description, daily Pu/Caps
amounts.
- **Right**: status badge (Active / Renews date / Cancels date),
`[Manage] [Change Tier]` button row, `[Cancel]` centered underneath.
When `cancelAtPeriodEnd === true`, the row collapses to a single
`[Reactivate]` button (opens the Stripe portal).
- New `<o-button size="xs">` variant (`py-2 px-3 text-xs`) for the
compact panel buttons.
- Store dollar-purchase price label now supports an optional suffix
(`/mo` for subs only) via a `priceSuffix` prop plumbed through
`CosmeticContainer` → `PurchaseButton`.
- `Api.ts` gains `changeSubscriptionTier(tierName)` with the same
401-handling pattern as the existing subscription helpers.


<img width="1114" height="728" alt="Screenshot 2026-05-14 at 7 09 20 PM"
src="https://github.com/user-attachments/assets/688f83d5-4010-4580-9214-6885af8ec98e"
/>

<img width="1038" height="276" alt="Screenshot 2026-05-14 at 7 09 33 PM"
src="https://github.com/user-attachments/assets/458197f5-a0d4-4c32-bc55-31e5679629b5"
/>

<img width="887" height="286" alt="Screenshot 2026-05-14 at 7 09 55 PM"
src="https://github.com/user-attachments/assets/8149ed82-89cc-4bbe-83de-3614f886b331"
/>

## Discord

evan
2026-05-15 12:01:31 -07:00
EvanandGitHub e0f73598d6 Add subscriptions: store tab, account panel, manage/cancel (#3918)
## Summary

- Add a **Subscriptions** tab to the Store. Each tier renders as a
`<cosmetic-button>` with description, daily Pu/Caps amounts, and a
Stripe checkout button driven by the existing `createCheckoutSession`
flow.
- Show the player's active subscription in the **Account modal** via a
new `<subscription-panel>` Lit component (status badge, period-end /
cancel-at-period-end, daily currency breakdown).
- **Manage** button opens the Stripe billing portal in a new tab (`POST
/subscriptions/@me/portal`).
- **Cancel** button (hidden once `cancelAtPeriodEnd === true`) calls
`POST /subscriptions/@me/cancel` after a `confirm()` prompt, then
invalidates the userMe cache and refetches.
- Block re-purchase: clicking Subscribe when the user already has a
`subscription:*` flare alerts "Already subscribed" before opening
checkout (upgrade/downgrade flows are out of scope for now).
- Schema additions:
- `CosmeticsSchema.subscriptions: Record<string, SubscriptionSchema>`
(optional) in `src/core/CosmeticSchemas.ts`.
- `UserMeResponse.player.subscription: { tier, status, currentPeriodEnd,
cancelAtPeriodEnd } | null` in `src/core/ApiSchemas.ts`.
- Translations: new `store.*` and `account_modal.sub_*` keys in
`resources/lang/en.json` (English only — Crowdin handles the rest).
- 
<img width="942" height="313" alt="Screenshot 2026-05-14 at 1 13 05 PM"
src="https://github.com/user-attachments/assets/3d28df13-9e03-49f0-bee8-a25f9ad0c420"
/>
<img width="545" height="439" alt="Screenshot 2026-05-14 at 1 13 32 PM"
src="https://github.com/user-attachments/assets/b413b275-d6f2-40dc-9230-d68cd11fb07a"
/>

## Discord

evanpelle
2026-05-14 13:47:16 -07:00
df05d21fc2 Clan System Part 2 - UI (#3625)
## Description:

Continuation from #3276 

Adds the complete client-side clan UI as a Lit web component
(`<clan-modal>`), a typed API client with Zod-validated responses,
shared response schemas, and a reusable `<confirm-dialog>` component.


### New: `ClanModal.ts`

| View | What it does |
|------|-------------|
| **My Clans** | Lists joined clans + pending join requests (built from
`/users/@me`, no extra fetches) |
| **Browse** | Search by tag (min 3 chars), paginated results,
configurable per-page (10/25/50) |
| **Clan Detail** | Stats, paginated + searchable member list, role
badges, join/leave/request actions |
| **Manage** | Edit name (max 35 chars) + description, toggle
open/invite-only, disband |
| **Transfer** | Leadership transfer with member selector + confirmation
|
| **Requests** | Approve/deny join requests (leader/officer) |
| **Bans** | View and unban (leader/officer) |
| **My Requests** | View and withdraw outgoing requests |

### New: `ConfirmDialog.ts`

Reusable `<confirm-dialog>` Lit component — replaces native
`confirm()`/`prompt()` which are blocked or broken on mobile and
CrazyGames iframes. Supports danger/warning variants and an optional
textarea (used for ban reasons). Fires `confirm`/`cancel` events.

### New: `ClanApi.ts`

Typed API client covering all clan endpoints. Every response is
Zod-validated. Auth header is always last in the spread (can't be
overridden by callers). Unknown server error messages always fall back
to a generic client-side string — never displayed verbatim.

### New: `ClanApiSchemas.ts` (in `src/core/`)

Shared Zod schemas for clan API responses with max-length constraints on
`name` (35) and `description` (200). Lives in `core/` so it can be
consumed by both client code and the leaderboard table.

### Modified: `ApiSchemas.ts`

- Added `clans` and `clanRequests` arrays to `UserMeResponseSchema`
- Moved clan leaderboard schemas out to `ClanApiSchemas.ts`
- Renamed `LeaderboardClanTagSchema` → `RequiredClanTagSchema`

### Modified: `Api.ts`

- Added `invalidateUserMe()` to bust the cached `/users/me` response
after mutations
- Removed `fetchClanLeaderboard` (moved to `ClanApi.ts`)

### Tests

- `ClanModal.test.ts` — rendering, view navigation, user actions
- `ClanApiQueries.test.ts` — fetch functions, error handling, pagination
- `ClanApiMutations.test.ts` — join, leave, kick, ban, promote,
transfer, etc.
- `ClanApiBans.test.ts` — ban/unban calls and error paths
- `ClanApiSchemas.test.ts` — Zod schema validation edge cases
- `LeaderboardModal.test.ts` — updated imports

## Notable design decisions

- **Not-logged-in state** — shows "Sign in to join clans" instead of
false "no clans" empty state
- **Rate limit feedback** — reads `Retry-After` header and surfaces wait
time to the user

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

w.o.n

---------

Co-authored-by: evanpelle <evanpelle@gmail.com>
2026-04-30 21:27:35 -06:00
EvanandGitHub 616ba1c794 Add support to purchase cosmetics with in-game currency (#3648)
## Description:

Caps & Plutonium can be used to purchase different cosmetics. 

* The cosmetic button can display pluto/caps/dollars
* Create a "purchaseCosmetic" helper function that handles purchase
logic

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

evan
2026-04-13 10:19:43 -07:00
55e8a4edb7 feat: add NewsBox component and integrate news items into PlayPage (#3545)
Resolves #2998 

## Description:

Adds a news box to the lobby homepage that advertises upcoming clan
tournaments, weekly tournaments, and new player tutorials. The component
sits above the username input and cycles through items automatically.

<img width="1138" height="591" alt="screenshot-2026-03-31_00-48-33"
src="https://github.com/user-attachments/assets/4b79287d-6aca-4c81-9bfe-36aad043f381"
/>

<img width="1107" height="595" alt="screenshot-2026-03-31_00-48-24"
src="https://github.com/user-attachments/assets/598e6b8b-e0f2-4864-a5fb-a00c0cc98f37"
/>

<img width="1367" height="599" alt="screenshot-2026-03-31_00-48-04"
src="https://github.com/user-attachments/assets/14f74e70-9dc0-4d67-af6e-c4708e539490"
/>


## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

deathllotus

---------

Co-authored-by: Evan <evanpelle@gmail.com>
2026-04-08 10:53:02 -07:00
EvanandGitHub 39ad547c04 support for unlockable flags (#3479)
## Description:

Add support for purchasable/gated flags.

* Create a new "Store" modal that renders both skins & flags
* move all store related logic out of TerritoryPatternsModal
* use nation:code for existing nation flags & flag:key for gated flags
* check if user has the appropriate flags before purchasing

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

evan
2026-03-23 17:09:18 -07:00
evanpelle f09177f8fe Merge branch 'v29' 2026-02-26 17:40:57 -06:00
EvanandGitHub 7f03072e9b revert skin trials (#3293)
## Description:

Skin trials has been a failure, very low fill rate and cause a major
drop in sales.

reverts 


https://github.com/openfrontio/OpenFrontIO/commit/97d0a05d58e926e3de4ba46d8dd14a04d60d6698

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

evan
2026-02-24 15:47:21 -06:00
evanpelle 8e889fe857 Merge branch 'v29' 2026-02-14 12:14:19 -08:00
EvanandGitHub 97d0a05d58 Rewarded videos ads to test a skin (#3120)
## Description:

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

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

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

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

evan
2026-02-11 20:52:48 -08:00
59e808b63b fix(leaderboards): update & move reached_limit check (#3128)
If this PR fixes an issue, link it below. If not, delete these two
lines.
Resolves #3126 

## Description:

update & move reached_limit check

## Please complete the following:

- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory
- [X] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

wraith4081

---------

Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com>
2026-02-05 19:35:49 +00:00
106938c395 Add Ranked 1v1 Leaderboard (#3008)
If this PR fixes an issue, link it below. If not, delete these two
lines.
Resolves #(issue number)

@wraith4081 's pr

updates the stats modal to show both 1v1 and clan stats

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
regression is found:

w.o.n

---------

Co-authored-by: Wraith <54374743+wraith4081@users.noreply.github.com>
Co-authored-by: iamlewis <lewismmmm@gmail.com>
2026-02-01 15:02:43 -08:00
e4280c28e1 Add Ranked 1v1 Leaderboard (#3008)
If this PR fixes an issue, link it below. If not, delete these two
lines.
Resolves #(issue number)

## Description:

@wraith4081 's pr

updates the stats modal to show both 1v1 and clan stats

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

w.o.n

---------

Co-authored-by: Wraith <54374743+wraith4081@users.noreply.github.com>
Co-authored-by: iamlewis <lewismmmm@gmail.com>
2026-02-01 14:58:54 -08:00
EvanandGitHub 6c2e0d1528 Fix matchmaking double join bug (#3065)
## Description:

There were several issues with the matchmaking modal:

1. It was defined twice (once before login, and once after login), so
players would sometimes join the matchmaking queue twice.
2. When clicking away from the modal (not clicking the back button), the
"onClose" callback was not triggered. So if a person closed & reopened
the modal, they would join twice'
3. Cache the userMe response so it can be called multiple times

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

evan
2026-01-30 15:27:34 -08:00
DevelopingTomandGitHub f532dab704 Add end of game report window (#2598)
Resolves #1664

## Description:

Add a game ranking window, accessible through the player game history:
<img width="508" height="140" alt="image"
src="https://github.com/user-attachments/assets/51a628d9-628d-44c3-9776-d9b359b94e65"
/>

There is a lot of data players could be ranked with.
Three main ranking categories  with their own sub-categories:

<img width="371" height="264" alt="image"
src="https://github.com/user-attachments/assets/8b3b7c53-c52f-4b96-8039-23180c9181cf"
/>

### Duration:
Rank players according to their survival time

<img width="284" height="281" alt="image"
src="https://github.com/user-attachments/assets/6dfa0d11-7f5b-4f4f-81f8-f31e24ade6bf"
/>


### War:
#### Conquests:
Number of conquered players and bots

#### Bombs:
Show all bomb launched by each players. Can be sorted with each
category.
<img width="289" height="193" alt="image"
src="https://github.com/user-attachments/assets/fc0f9663-9a50-4098-b5c6-f434354accff"
/>

### Economy:
Show all gold earned by each players with trade, conquests, pirate or
total:

<img width="276" height="195" alt="image"
src="https://github.com/user-attachments/assets/a925249d-b2d2-4c61-92a5-4dbf5922b32b"
/>


### Responsiveness:


![ranking_showcase_resize](https://github.com/user-attachments/assets/5316d7f4-803f-4223-b834-783040226b7d)


## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

IngloriousTom
2025-12-18 19:41:29 -08:00
evanpelle 01e5aaa832 If user has no linked account, ensure that their skin is set to default 2025-12-11 12:18:06 -08:00
EvanandGitHub a09f0c67f1 Update auth & login to follow best practices (#2559)
## Description:

The previous login system used long lived jwts which could be stolen by
XSS. The current system uses long lived refresh tokens that are stored
as http-only cookies. Then the client calls /refresh to get a short
lived jwt using the refresh token. The jwt is stored in memory only so
it's discarded on page close. This way a XSS can only steal the
short-lived jwt.

It also updates how accounts work: players get an account automatically
when they join the webpage. They can see their stats even if not logged
in. If a player wants to keep their account, they can tie it to their
Discord or email, allowing them to log in if cookies are lost.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

evan
2025-12-11 11:25:27 -08:00