Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions formulas.py
Original file line number Diff line number Diff line change
Expand Up @@ -637,3 +637,109 @@ def get_triple_threshold(
return float("nan")
opw_pow = pitcher_opw**1.5
return 0.042 + 0.2 * batter_gf - 0.056 * opw_pow - 0.05 * fielder_chase + 0.1 * ballpark_sum

def get_double_play_threshold(
batter: PlayerData,
pitcher: PlayerData,
fielder: PlayerData,
batting_team: TeamData,
pitching_team: TeamData,
stadium: StadiumData,
meta: StatRelevantData,
):
pitcher_vibes = pitcher.vibes(meta.day)
fielder_vibes = fielder.vibes(meta.day)

hype = stadium.hype * (1 if meta.top_of_inning else -1)

# no vibes, yes multipliers
batter_trag = batter.multiplied(
"tragicness", 1 / get_multiplier(batter, batting_team, "batter", "tragicness", meta, stadium)
)
inv_batter_trag = 1 - batter_trag

pitcher_shakes = pitcher.multiplied(
"shakespearianism", get_multiplier(pitcher, pitching_team, "pitcher", "shakespearianism", meta, stadium)
)
pitcher_shakes = (pitcher_shakes + 0.2 * hype) * (1 + 0.2 * pitcher_vibes)

fielder_tenac = fielder.multiplied(
"tenaciousness", get_multiplier(fielder, pitching_team, "fielder", "tenaciousness", meta, stadium)
)
fielder_tenac = (fielder_tenac + 0.2 * hype) * (1 + 0.2 * fielder_vibes)

elong = stadium.elongation - 0.5

return -0.05 + 0.4 * pitcher_shakes - 0.18 * inv_batter_trag + 0.1 * fielder_tenac - 0.16 * elong


def get_advance_on_hit_threshold(
runner: PlayerData,
fielder: PlayerData,
pitching_team: TeamData,
stadium: StadiumData,
meta: StatRelevantData,
):
# no vibes
fielder_tenac = fielder.multiplied(
"tenaciousness", get_multiplier(fielder, pitching_team, "fielder", "tenaciousness", meta, stadium)
)

# No mods or vibes here -- not sure if I need to use .multiplied at all
runner_cont = runner.multiplied("continuation", 1.0)

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.

I think this does need multipliers? See hit_advance.ipynb:
df["threshold_rounded"] = 0.70 - 1.0*df["fielder_tenaciousness_scaled"] + 0.60*df['runner_continuation_scaled']


threshold = 0.7 - fielder_tenac + 0.6 * runner_cont
return max(min(threshold, 0.95), 0.01)

def get_advance_on_ground_out_threshold(
runner: PlayerData,
fielder: PlayerData,
pitching_team: TeamData,
stadium: StadiumData,
meta: StatRelevantData,
):
runner_vibes = runner.vibes(meta.day)
fielder_vibes = fielder.vibes(meta.day)

# no mods
runner_ind = runner.multiplied("indulgence", 1.0) * (1 + 0.2 * runner_vibes)

@alefeld alefeld Aug 23, 2026

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.

according to groundout_advance.ipynb it should include mods:

df["threshold_rounded"] = 0.50 \
                        + 0.35 * df["runner_indulgence_with_vibe"] \
                        - 0.10 * df["fielder_tenaciousness_with_vibe"] \
                        - 0.10 * (df["ballpark_inconvenience"] - 0.50) \
                        - 0.10 * (df["ballpark_elongation"] - 0.50)
    df[attr + "_scaled"] = df[attr] * df["batter_multiplier"]
    df[attr + "_with_vibe"] = df[attr + "_scaled"] * (1 + 0.2 * df["batter_vibes"])

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.

Also, I never got to seasons with hype... but I would guess anywhere we have vibes we should also have hype?


fielder_tenac = fielder.multiplied(
"tenaciousness", get_multiplier(fielder, pitching_team, "fielder", "tenaciousness", meta, stadium)
) * (1 + 0.2 * fielder_vibes)

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.

hype?


elong = stadium.elongation - 0.5
incon = stadium.inconvenience - 0.5

return 0.5 + 0.35 * runner_ind - 0.1 * fielder_tenac - 0.1 * elong - 0.1 * incon


def get_advance_on_flyout_threshold(
advancing_from: int,
runner: PlayerData,
batting_team: TeamData,
stadium: StadiumData,
meta: StatRelevantData,
):
runner_vibes = runner.vibes(meta.day)

runner_ind = runner.multiplied(
"indulgence", get_multiplier(runner, batting_team, "batter", "indulgence", meta, stadium)
)
runner_ind = runner_ind * (1 + 0.2 * runner_vibes)

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.

hype?

runner_ind_sq = runner_ind ** 2
runner_ind_p4 = runner_ind ** 4

elong = stadium.elongation - 0.5
inconv = stadium.inconvenience - 0.5
park_factor = 0.10 * elong + 0.10 * inconv

if advancing_from == 0:
return -0.085 + 0.36 * runner_ind - 0.38 * runner_ind_sq + 0.24 * runner_ind_p4 - park_factor
elif advancing_from == 1:
return 0.045 + 0.065 * runner_ind + 0.30 * runner_ind_sq - park_factor
elif advancing_from == 2:
return 0.45 + 0.35 * runner_ind - park_factor
else:

@alefeld alefeld Aug 24, 2026

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.

This error gets thrown 94% of the way through running resim.py because the 5th base leads to advancing_from == 3. We don't have a formula afaik, so maybe add a special case for the 5th base that just prints a warning for now?

raise RuntimeError(f"Unexpected advancing-from base {advancing_from} (expected 0, 1, or 2).")

9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
[project]
name = "resim"
version = "0.0.1"
dependencies = [
"dataclasses-json>=0.6.7",
"requests>=2.34.2",
"tqdm>=4.70.0",
]

[tool.black]
line-length = 120
extend-exclude = '\.ipynb$'
81 changes: 58 additions & 23 deletions resim.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
get_out_threshold,
get_double_threshold,
get_triple_threshold,
get_advance_on_hit_threshold,
get_advance_on_ground_out_threshold,
get_double_play_threshold,
get_advance_on_flyout_threshold,
)
from item_gen import ItemRollType, roll_item

Expand Down Expand Up @@ -1436,7 +1440,9 @@ def handle_batter_reverb(self):
self.roll("psychic bug reverb")

def handle_mild(self):
mild_roll = self.roll("mild")
threshold = 0.0005 + 0.004 * self.stadium.mysticism

mild_roll = self.roll("mild", threshold=threshold, passed=self.ty == EventType.MILD_PITCH)
if self.ty == EventType.MILD_PITCH:
# skipping mild proc

Expand Down Expand Up @@ -2036,9 +2042,16 @@ def did_advance(base, runner_id):
is_next_free = True

roll_outcome = did_advance(base, runner_id)
advance_threshold = get_advance_on_flyout_threshold(
base,
runner,
self.batting_team,
self.stadium,
self.get_stat_meta(),
)

if is_next_free:
adv_roll = self.roll(f"adv? {base}/{runner.name} ({roll_outcome})")
adv_roll = self.roll(f"adv? {base}/{runner.name} ({roll_outcome})", threshold=advance_threshold, passed=roll_outcome)
self.log_roll(
Csv.FLYOUT, f"advance_{base}", adv_roll, roll_outcome, fielder=fielder, relevant_runner=runner
)
Expand All @@ -2059,7 +2072,13 @@ def did_advance(base, runner_id):
elif self.ty == EventType.GROUND_OUT:
if len(self.update["basesOccupied"]) > 0:
# roll needs batter tragicness, fielder tenaciousness, pitcher shakespearianism
dp_roll = self.roll("dp?")
dp_threshold = get_double_play_threshold(
self.batter, self.pitcher, fielder, self.batting_team, self.pitching_team, self.stadium, self.get_stat_meta()
)

is_dp = "into a double play!" in self.desc

dp_roll = self.roll("dp?", threshold=dp_threshold, passed=is_dp)
if self.batter.undefined():
# tragicness?
# the control flow is really weird here
Expand All @@ -2071,8 +2090,6 @@ def did_advance(base, runner_id):
if fielder.undefined():
self.roll("undefined (dp fielder)")
pass

is_dp = "into a double play!" in self.desc
is_fc = "on fielder's choice" in self.desc
self.log_roll(Csv.GROUNDOUT_FORMULAS, "DP", dp_roll, is_dp, fielder=fielder)

Expand Down Expand Up @@ -2166,7 +2183,11 @@ def did_advance(base, runner_id):
roll_outcome = did_advance(base, runner_id) if not was_forced else None

# needs... fielder tenaciousness and runner indulgence?
adv_roll = self.roll(f"adv? {base}/{runner.name} ({roll_outcome})")
adv_roll = self.roll(
f"adv? {base}/{runner.name} ({roll_outcome})",
threshold=get_advance_on_ground_out_threshold(runner, fielder, self.pitching_team, self.stadium, self.get_stat_meta()),
passed=roll_outcome,
)
if self.batter.undefined() and base == base_before_home: # sac?
# self.roll("undefined (advance batter)")
pass
Expand Down Expand Up @@ -2212,17 +2233,25 @@ def handle_hit_advances(self, bases_hit, defender_roll):
for runner_id, base, roll_outcome in calculate_advances(
bases_before, bases_after, bases_hit, base_before_home + 1
):
runner = self.data.get_player(runner_id)
fielder = self.get_fielder_for_roll(defender_roll)

advance_threshold = get_advance_on_hit_threshold(
runner,
fielder,
self.pitching_team,
self.stadium,
self.get_stat_meta(),
)
# work around missing data in next_update
if self.event["created"] == "2021-04-14T15:11:04.159Z":
roll_outcome = False
roll = self.roll(f"adv ({base}, {roll_outcome}")
runner = self.data.get_player(runner_id)
roll = self.roll(f"adv ({base}, {roll_outcome}", passed=roll_outcome, threshold=advance_threshold)

if runner.undefined():
self.roll("undefined (runner adv?)")
pass

fielder = self.get_fielder_for_roll(defender_roll)
if fielder.undefined():
self.roll("undefined (runner adv? from fielder)")

Expand Down Expand Up @@ -3758,16 +3787,22 @@ def handle_consumers(self):
self.log_roll(Csv.CONSUMERS, "Miss", attack_roll, False, attacked_team=team)

def handle_party(self):
party_threshold = 0.0055 if self.season < 20 else 0.00525

if self.season == 23 and "SIM_PARTY_TIME" not in self.data.sim["attr"]:
return
if self.season != 16 or self.day >= 85:
# lol. turns out it just rolls party all the time and throws out the roll if the team isn't partying
party_roll = self.roll("party time")

# If this event is a Party we know this roll must have passed, but if it's not a Party we don't know
# anything, since there's a possibility that the roll still passed but the team wasn't in party time
passed = None
if self.ty == EventType.PARTY:
passed = True
party_roll = self.roll("party time", threshold=party_threshold, passed=passed)
else:
party_roll = 1

party_threshold = 0.0055 if self.season < 20 else 0.00525

if self.ty == EventType.PARTY:
self.log_roll(Csv.PARTY, "Party", party_roll, True)
team_roll = self.roll("target team") # <0.5 for home, >0.5 for away
Expand Down Expand Up @@ -4072,7 +4107,7 @@ def handle_grind_rail(self):

self.roll("trick 1 name")

m = re.search("They do a .*? \(([0-9]+)\)", self.desc)
m = re.search(r"They do a .*? \(([0-9]+)\)", self.desc)
expected_score_1 = int(m.group(1))
pro_factor = 2 if "Pro Skater" in self.desc else 1
self.print(f"(press: {runner.pressurization}, cinn: {runner.cinnamon})")
Expand Down Expand Up @@ -4110,7 +4145,7 @@ def handle_grind_rail(self):

if "lose their balance and bail!" not in self.desc:
self.roll("trick 2 name")
m = re.search("They(?: land|'re tagged out doing) a .*? \(([0-9]+)\)", self.desc)
m = re.search(r"They(?: land|'re tagged out doing) a .*? \(([0-9]+)\)", self.desc)
expected_score_2 = int(m.group(1))
lo2 = runner.pressurization * 500
hi2 = runner.cinnamon * 3000 + 1000
Expand Down Expand Up @@ -4175,18 +4210,18 @@ def handle_steal(self):
):
runner = self.data.get_player(self.update["baseRunners"][i])

steal_roll = self.roll(f"steal ({base})")
was_success = self.ty == EventType.STOLEN_BASE and (
base + 1 == base_stolen
or base_stolen == Base.FIFTH
and bool(filter(lambda i: i.name == "The Fifth Base", runner.items))
)

steal_roll = self.roll(f"steal ({base})", passed=was_success)
if steal_fielder.undefined():
self.roll("undefined (fielder steal)")
if runner.undefined():
self.roll(f"undefined (runner steal {base})")
self.roll(f"undefined (runner steal {base})")

was_success = self.ty == EventType.STOLEN_BASE and (
base + 1 == base_stolen
or base_stolen == Base.FIFTH
and filter(lambda i: i.name == "The Fifth Base", runner.items)
)
self.log_roll(
Csv.STEAL_ATTEMPT,
f"StealAttempt{base}",
Expand All @@ -4204,9 +4239,9 @@ def handle_steal(self):
if runner.undefined():
self.roll("undefined (steal success runner)")

success_roll = self.roll("steal success")
was_caught = "caught stealing" in self.desc

success_roll = self.roll("steal success", passed=not was_caught)
self.log_roll(
Csv.STEAL_SUCCESS,
f"StealSuccess{base}",
Expand Down Expand Up @@ -4237,7 +4272,7 @@ def handle_steal(self):
break

def create_item(self, event, roll_type: ItemRollType, prev_event):
match = re.search("(?:gained|The Winner gets) (.+?)( and ditched| and dropped|\.?$)", self.desc)
match = re.search(r"(?:gained|The Winner gets) (.+?)( and ditched| and dropped|\.?$)", self.desc)

expected_item_name = match.group(1) if match else ""
if roll_type == ItemRollType.PRIZE:
Expand Down