- compute_variant_hash: use refractor_tier key, json.dumps with sort_keys instead of str(), add variant=0 remapping guard - Section 5.3.1 step 3: scaling denominator is total_requested_addition, not total_requested_reduction Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
12 KiB
5. Rating Boost Mechanics
< Back to Index | Next: Database Schema >
5.1 Rating Model Overview
The card rating system is built on the battingcardratings and pitchingcardratings models.
Each model defines outcome columns whose values represent chances out of a 108-chance total
(derived from the D20 probability system: 2d6 × 3 columns × 6 rows = 108 total chances).
Batter ratings have 22 outcome columns summing to 108:
| Category | Columns |
|---|---|
| Hits | homerun, bp_homerun, triple, double_three, double_two, double_pull, single_two, single_one, single_center, bp_single |
| On-base | hbp, walk |
| Outs | strikeout, lineout, popout, flyout_a, flyout_bq, flyout_lf_b, flyout_rf_b, groundout_a, groundout_b, groundout_c |
Pitcher ratings have 18 outcome columns + 9 x-check fields summing to 108:
| Category | Columns |
|---|---|
| Hits allowed | homerun, bp_homerun, triple, double_three, double_two, double_cf, single_two, single_one, single_center, bp_single |
| On-base | hbp, walk |
| Outs | strikeout, flyout_lf_b, flyout_cf_b, flyout_rf_b, groundout_a, groundout_b |
| X-checks | xcheck_p (1), xcheck_c (3), xcheck_1b (2), xcheck_2b (6), xcheck_3b (3), xcheck_ss (7), xcheck_lf (2), xcheck_cf (3), xcheck_rf (2) — always sum to 29 |
Key differences: Batters have double_pull, pitchers have double_cf. Batters have
lineout, popout, flyout_a, flyout_bq, groundout_c — pitchers do not. Pitchers have
flyout_cf_b and x-check fields — batters do not.
Evolution boosts apply flat deltas to individual result columns within these models. The 108-sum constraint must be maintained: any increase to a positive outcome column requires an equal decrease to a negative outcome column.
Rating Cap Enforcement
All boosts are subject to the existing hard caps on individual stat columns. If applying a delta would push a value past its cap, the delta is truncated to the cap value.
Key caps (from existing card creation system):
| Stat | Cap | Direction | Example |
|---|---|---|---|
| Hold rating (pitcher) | -5 | Lower is better | A pitcher at -4 hold can only receive -1 more |
| Result columns | 0 floor | Cannot go negative | A 0.1 strikeout column can only lose 0.1 |
Truncated points are lost, not redistributed. If a boost would push a stat past its cap, the delta is truncated and the excess is simply discarded. This is an intentional soft penalty for cards that are already near their ceiling — they're being penalized because they're already that good. Lower-rated cards have more headroom and benefit more from the same flat delta.
5.2 Boost Budgets Per Tier
Rating boosts are defined as flat deltas to specific result columns within the 108-sum model. The budget per tier is the total number of chances that can be shifted from negative outcomes (outs) to positive outcomes (hits, on-base).
| Tier | Batter Budget | Pitcher TB Budget | Approx Impact |
|---|---|---|---|
| T1 | 2.0 chances net (+2.0 pos, -2.0 neg) | 1.5 TB units | Fixed deltas / priority drain |
| T2 | 2.0 chances net | 1.5 TB units | Same — consistent per-tier reward |
| T3 | 2.0 chances net | 1.5 TB units | Same — consistent per-tier reward |
| T4 | 2.0 chances net | 1.5 TB units | Same — plus rarity upgrade |
| Total | 8.0 chances net | 6.0 TB units | ~7.4% of chances shifted (batter) |
Every tier provides the same fixed boost. T4 is distinguished not by a larger delta but by the rarity upgrade, which is the real capstone reward.
Flat delta design rationale: All cards receive the same absolute boost regardless of rarity.
A Replacement card (where homerun might be 0.3) gains much more relative value from a fixed
+0.50 HR boost than a Hall of Fame card (where homerun might be 5.0). This intentionally
incentivizes using lower-rated cards and prevents elite cards from becoming god-tier. Cards
already near column caps receive even less due to truncation.
Example — T1 batter boost:
homerun: +0.50 (from 2.0 → 2.50)
double_pull: +0.50 (from 3.5 → 4.00)
single_one: +0.50 (from 4.0 → 4.50)
walk: +0.50 (from 3.0 → 3.50)
strikeout: -1.50 (from 15.0 → 13.50)
groundout_a: -0.50 (from 8.0 → 7.50)
Net: +2.0 / -2.0 = 0, sum stays at 108
5.3 Shipped Boost Distribution
Updated 2026-04-08 to reflect shipped implementation. The original spec described profile-based boost distribution (power hitter, contact hitter, patient hitter profiles). The implementation uses a simpler, more predictable approach: fixed deltas for batters and a TB-budget priority algorithm for pitchers. Profile detection was not implemented.
5.3.1 Batter Boost — Fixed Column Deltas
Every batter receives identical fixed deltas per tier regardless of their profile. There is no
player-style detection. The implementation is in apply_batter_boost() in
database/app/services/refractor_boost.py.
Positive deltas (applied each tier):
| Column | Delta |
|---|---|
homerun |
+0.50 |
double_pull |
+0.50 |
single_one |
+0.50 |
walk |
+0.50 |
Negative deltas (funding source):
| Column | Delta |
|---|---|
strikeout |
-1.50 |
groundout_a |
-0.50 |
0-floor truncation behavior: If strikeout or groundout_a cannot supply their full
requested reduction (because the column is already near zero), the positive deltas are scaled
proportionally so the 108-sum invariant is always preserved. Specifically:
- Negative deltas are applied first, each capped at the column's current value (0 floor).
- The total amount actually reduced is computed.
- Positive deltas are scaled by
actually_reduced / total_requested_additionso that additions always equal reductions. - A warning is logged when truncation occurs.
This differs from the original spec's statement that "truncated points are lost, not redistributed." In the shipped implementation, positive deltas are scaled down to match what was actually taken — the 108-sum is always exactly preserved.
5.3.2 Pitcher Boost — TB-Budget Priority Algorithm
Pitchers use a total-bases budget approach instead of fixed column deltas. Each tier awards a 1.5 TB-unit budget. The algorithm converts hit-allowed chances into strikeouts, iterating through outcome types in priority order (most damaging hits first) until the budget is exhausted.
The implementation is in apply_pitcher_boost() in database/app/services/refractor_boost.py.
Priority order and TB cost per chance:
| Priority | Column | TB Cost |
|---|---|---|
| 1 | double_cf |
2 |
| 2 | double_three |
2 |
| 3 | double_two |
2 |
| 4 | single_center |
1 |
| 5 | single_two |
1 |
| 6 | single_one |
1 |
| 7 | bp_single |
1 |
| 8 | walk |
1 |
| 9 | homerun |
4 |
| 10 | bp_homerun |
4 |
| 11 | triple |
3 |
| 12 | hbp |
1 |
Algorithm per tier:
- Start with
remaining = 1.5TB budget. - Iterate priority list in order. Skip columns already at 0.
- For each column: compute
chances_to_take = min(column_value, remaining / tb_cost). - Reduce the column by
chances_to_take; addchances_to_taketostrikeout. - Reduce
remainingbychances_to_take * tb_cost. - Stop when
remaining <= 0or the priority list is exhausted.
X-check columns (xcheck_p through xcheck_rf, always summing to 29) are never touched by
the boost algorithm.
Budget not fully spent: If all priority columns are already at zero before the budget is exhausted (extremely rare), the remaining budget is discarded and a warning is logged.
No separate SP vs. RP logic: The same algorithm applies to both starting pitchers and
relief pitchers. Card type (sp vs. rp) determines how the card is used in the game engine
but does not change the boost formula.
5.3.3 Function Signatures (Shipped)
The boost logic lives in the database repo (database/app/services/refractor_boost.py),
not in card-creation. The functions called per tier-up are:
# Batter
apply_batter_boost(ratings_dict: dict) -> dict
# Pitcher (sp or rp)
apply_pitcher_boost(ratings_dict: dict, tb_budget: float = 1.5) -> dict
Both functions accept a dict of outcome column values and return a new dict with updated values (all other keys passed through unchanged). They are pure functions — no DB access.
The orchestration function that applies the correct boost, creates the variant card row, updates
RefractorCardState, and writes the audit record is:
apply_tier_boost(
player_id: int,
team_id: int,
new_tier: int,
card_type: str, # 'batter', 'sp', or 'rp'
...injectable test stubs...
) -> dict # {'variant_created': int, 'boost_deltas': dict}
The card-creation repo does not contain boost application code. The pd_cards/evo/ package
referenced in the original spec was not created; the boost logic was implemented directly in the
database API service layer.
5.4 Rarity Upgrade at T4
When a card completes T4, the card's rarity is upgraded by one tier (if below HoF):
- The
player.rarity_idfield is incremented by one step (e.g., Sta -> All) - The card's base rating recalculation is skipped; only the T4 boost deltas are applied on top of the accumulated evolved ratings
- The card cost field is NOT automatically recalculated (rarity upgrade is a gameplay reward, not a market event; admin can manually adjust if needed)
- The rarity change is recorded in
evolution_card_state.final_rarity_idfor audit purposes - HoF cards cannot upgrade further — they receive the T4 boost deltas but no rarity change
Live series interaction: If a card's rarity changes due to a live series update (e.g., Reserve → All-Star after a hot streak), the evolution rarity upgrade stacks on top of the current rarity at the time T4 completes. The evolution system does not track or care about historical rarity — it simply increments whatever the current rarity is by one step.
5.5 Variant System Usage (Hash-Based)
The existing battingcard.variant and pitchingcard.variant fields (integer, UNIQUE with player)
are currently always 0. The evolution system uses variant to store evolved versions, with the
variant number derived from a deterministic hash of all inputs that affect the card:
import hashlib, json
def compute_variant_hash(player_id: int, refractor_tier: int,
cosmetics: list[str] | None) -> int:
"""Compute a stable variant number from refractor + cosmetic state."""
inputs = {
"player_id": player_id,
"refractor_tier": refractor_tier,
"cosmetics": sorted(cosmetics or []),
}
raw = hashlib.sha256(json.dumps(inputs, sort_keys=True).encode()).hexdigest()
result = int(raw[:8], 16) # 32-bit unsigned integer from first 8 hex chars
return result if result != 0 else 1 # variant=0 is reserved for base cards
variant = 0: Base card (standard, shared across all teams)variant = <hash>: Evolution/cosmetic-specific card with boosted ratings and custom image
Key property: two teams with the same player_id, same evolution tier, and same cosmetics produce the same variant hash. This means they share the same ratings rows and the same rendered S3 image — no duplication. If either team changes any input (buys a cosmetic), the hash changes, creating a new variant.
Each tier completion or cosmetic change computes the new variant hash, checks if a battingcard
row with that variant exists (reuse if so), and creates one if not. The card table instance
points to its current variant via card.variant.
Evolved rating rows coexist with the base card in the same battingcardratings/pitchingcardratings
tables, keyed by (battingcard_id, vs_hand) where battingcard_id points to the variant row.
No new columns needed on the ratings table itself.
Image storage: Each variant's rendered card image URL is stored on battingcard.image_url
and pitchingcard.image_url (new nullable columns). The bot's display logic checks card.variant:
if set, look up the variant's battingcard.image_url; if null, fall back to player.image.
Images are rendered once via the existing Playwright pipeline (with cosmetic CSS applied) and
uploaded to S3 at a predictable path: cards/cardset-{id}/player-{player_id}/v{variant}/battingcard.png.
The 5-6 second render cost is paid once per variant creation, not on every display.