Pity System

Pity is the bad-luck-protection layer on top of banner pulls. It guarantees that long dry streaks always end in a rare or epic hull, and that every 10-pull contains at least one uncommon+. Pity state is per-banner and server-authoritative.

Soft pity

Starting at pull 40 on a banner (SOFT_PITY_START), the epic roll rate increases by +2% per pull (SOFT_PITY_RATE_PER_PULL = 0.02). Pulls 1–39 use the flat SHIP_RARITY_RATES table; pull 40 adds +2%, pull 41 adds +4%, and so on, ramping until hard pity ends the streak. The boost only applies to the epic slice — common/uncommon/rare weights are still drawn from the base table.

Hard pity

ThresholdConstantSourceEffect
Pull 10 without a rare+PITY_RARE_THRESHOLDdata/pull-rates.tsGuaranteed rare or higher
Pull 50 without an epic+PITY_EPIC_THRESHOLD / HARD_PITY_THRESHOLDdata/pull-rates.ts / data/pull-config.tsGuaranteed epic

The rare counter and the epic counter are independent. Hitting a rare on pull 9 resets the rare counter to 0 but leaves the epic counter ticking, so the slow march toward the pull-50 epic guarantee continues across rare hits.

10-pull minimum

A 10-pull is guaranteed at least one uncommon+ result (TEN_PULL_GUARANTEE_RARITY / TEN_PULL_GUARANTEED_MIN_RARITY = 'uncommon'). If all ten rolls would otherwise come back common, the final slot is upgraded.

Per-banner storage

Pity counters are stored per banner ID in the player_pity table and mirrored into playerStore.pity[bannerId] on the client. The Sun banner and Moon banner each have their own counter — pulling on Moon does not advance Sun pity, and vice versa. services/pullService.ts::getBannerPity(bannerId) reads the current value; setPity(...) replaces it from the canonical server snapshot returned by the perform_pull RPC.

How the service consumes the constants

services/pullService.ts is intentionally thin on pity logic — the server is the source of truth:

  1. The client reads pityCount from playerStore.pity[bannerId] and increments it locally by +1 per roll inside executePull(...) purely so the reveal animation can show the right next-counter value.
  2. Rolls call weightedRoll(SHIP_RARITY_RATES) against the flat table. The soft-pity epic boost is not currently applied client-side — the client rolls a candidate and ships it to invokeRpc('perform_pull', ...) for validation.
  3. The server re-rolls or rewrites the result against the canonical pity state, enforces both hard-pity guarantees and the 10-pull uncommon floor, persists the new counters to player_pity, and returns the authoritative pity map.
  4. The client replaces its local pity state with the server response via playerStore.setPity(rpcResponse.pity). Any drift between the client preview and the server outcome is overwritten silently.

The wasPity flag on PullV4Result exists for UI highlight (the reveal animation flashes pity hits) but is set to false by executePull and is currently never patched from the RPC response — pity-triggered pulls reveal indistinguishably from natural ones.

Constant divergence between pull-config.ts and pull-rates.ts

Two files declare overlapping pity constants and they do not fully agree:

Conceptdata/pull-config.tsdata/pull-rates.ts
Soft-pity startSOFT_PITY_START = 40SOFT_PITY_START = 40
Soft-pity ramp(absent)SOFT_PITY_RATE_PER_PULL = 0.02
Epic hard pityHARD_PITY_THRESHOLD = 50PITY_EPIC_THRESHOLD = 50
Rare hard pity(absent)PITY_RARE_THRESHOLD = 10
10-pull floorTEN_PULL_GUARANTEE_RARITY = 'uncommon'TEN_PULL_GUARANTEED_MIN_RARITY = 'uncommon'
Base rarity ratescommon 0.55 / uncommon 0.27 / rare 0.12 / epic 0.06common 0.60 / uncommon 0.25 / rare 0.10 / epic 0.05

The two files also declare separate SHIP_RARITY_RATES tables and separate BANNERS records. services/pullService.ts imports only from pull-config.ts — it pulls BANNERS, SHIP_RARITY_RATES, and PullPullResult from that file and never touches pull-rates.ts. That means:

  • The rates the client actually rolls against are pull-config.ts’s values (55/27/12/6 split).
  • The rare-pity-at-10 and soft-pity-ramp-of-2% values exist only in pull-rates.ts and are therefore only enforced server-side (or not at all in the client preview).
  • The numeric values that do appear in both files (SOFT_PITY_START = 40, the 50 for epic pity, 'uncommon' for the 10-pull floor) agree, so the divergence does not produce contradictory pity windows — only contradictory base rates.

The service “reconciles” this implicitly by deferring to the server’s perform_pull RPC, which holds the authoritative pity rules. The two data files are effectively the client preview (pull-config.ts) and a parallel spec (pull-rates.ts); the latter exists but is unimported by the runtime path. A future cleanup should collapse them into one file so the soft-pity ramp and rare-pity threshold live next to the rates the client actually uses.

Source files

  • data/pull-config.tsHARD_PITY_THRESHOLD, SOFT_PITY_START, TEN_PULL_GUARANTEE_RARITY, base SHIP_RARITY_RATES, BANNERS
  • data/pull-rates.tsPITY_RARE_THRESHOLD, PITY_EPIC_THRESHOLD, SOFT_PITY_START, SOFT_PITY_RATE_PER_PULL, TEN_PULL_GUARANTEED_MIN_RARITY
  • services/pullService.tsexecutePull, executePullPull, getBannerPity, server RPC perform_pull