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
| Threshold | Constant | Source | Effect |
|---|---|---|---|
| Pull 10 without a rare+ | PITY_RARE_THRESHOLD | data/pull-rates.ts | Guaranteed rare or higher |
| Pull 50 without an epic+ | PITY_EPIC_THRESHOLD / HARD_PITY_THRESHOLD | data/pull-rates.ts / data/pull-config.ts | Guaranteed 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:
- The client reads
pityCountfromplayerStore.pity[bannerId]and increments it locally by+1per roll insideexecutePull(...)purely so the reveal animation can show the right next-counter value. - 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 toinvokeRpc('perform_pull', ...)for validation. - 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 authoritativepitymap. - 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:
| Concept | data/pull-config.ts | data/pull-rates.ts |
|---|---|---|
| Soft-pity start | SOFT_PITY_START = 40 | SOFT_PITY_START = 40 |
| Soft-pity ramp | (absent) | SOFT_PITY_RATE_PER_PULL = 0.02 |
| Epic hard pity | HARD_PITY_THRESHOLD = 50 | PITY_EPIC_THRESHOLD = 50 |
| Rare hard pity | (absent) | PITY_RARE_THRESHOLD = 10 |
| 10-pull floor | TEN_PULL_GUARANTEE_RARITY = 'uncommon' | TEN_PULL_GUARANTEED_MIN_RARITY = 'uncommon' |
| Base rarity rates | common 0.55 / uncommon 0.27 / rare 0.12 / epic 0.06 | common 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.tsand 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, the50for 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.ts—HARD_PITY_THRESHOLD,SOFT_PITY_START,TEN_PULL_GUARANTEE_RARITY, baseSHIP_RARITY_RATES,BANNERSdata/pull-rates.ts—PITY_RARE_THRESHOLD,PITY_EPIC_THRESHOLD,SOFT_PITY_START,SOFT_PITY_RATE_PER_PULL,TEN_PULL_GUARANTEED_MIN_RARITYservices/pullService.ts—executePull,executePullPull,getBannerPity, server RPCperform_pull