HAWK-ONE / engine.py
ageraustine's picture
refactor draws
e1aa47c verified
Raw History Blame Contribute Delete
44.9 kB
"""HAWK ONE play engine: one play, simulated at 20 Hz with real motion limits and collisions.
Pure rules + hand-written football logic (no learning). Level-specific behaviour comes ONLY from the
RuleProfile passed in (catch feet, down-by-contact, DPI spot, ineligible-downfield limit).
"""
from __future__ import annotations
import math
import random
from formations import DEFENSE, OFFENSE
from playbook import DEFENSE_CALLS, OFFENSE_PLAYS
from rules import (FIELD_WIDTH_YD as W, RuleProfile, catch_is_complete, dpi_enforcement,
ineligible_downfield_ok, runner_is_down)
DT = 0.05
REC = 2 # record every 2nd tick -> 10 fps
MAXT = 9.0
RUN_ADV = 16.0 # extra leverage drift the offensive line gets on run plays
LB_READ = 0.25 # extra read delay for non-linemen on run plays
LOOK = 0.35 # seconds ahead on a scripted path that the player steers toward
OL = {"LT", "LG", "C", "RG", "RT"}
DL = {"DE", "DT", "NT"}
ELIG = {"WR", "TE", "RB", "FB"}
DB = {"CB", "FS", "SS", "NB"}
def dist(a, b):
return math.hypot(a[0] - b[0], a[1] - b[1])
def unit(dx, dy):
d = math.hypot(dx, dy)
return (dx / d, dy / d) if d > 1e-9 else (0.0, 0.0)
class P:
def __init__(s, spec, side, attr, bx, by):
s.id, s.pos, s.num, s.side, s.a = spec["id"], spec["pos"], spec["num"], side, attr
anc = spec["anchor"]
s.x = bx + spec["ox"]
s.y = by + spec["oy"] if anc == "B" else (spec["oy"] if anc == "T" else W - spec["oy"])
s.vx = s.vy = 0.0
s.r = attr.radius
s.m = attr.weight_lb / 200.0
s.task, s.tx, s.ty, s.frac = "hold", s.x, s.y, 0.0
s.cap, s.slow_until, s.slow = 1.0, -1.0, 1.0
s.lev, s.free_until = 0.0, -1.0
s.blockers, s.btarget, s.targeters, s.retarget = [], None, set(), 0.0
s.route, s.rpt, s.rdelay = [], 0, 0.0
s.mark, s.zone, s.zone_reached = None, None, False
s.tcd = 0.0
s.react = 0.15 + 0.30 * (1 - attr.awareness / 100)
s.script, s.script_end = None, -1.0 # coach-drawn timed path [(t,x,y)...] overriding movement
@property
def speed(s):
return math.hypot(s.vx, s.vy)
@property
def xy(s):
return (s.x, s.y)
class Sim:
def __init__(self, profile: RuleProfile, off_team, def_team, off_play: str, def_call: str,
bx: float, by: float, seed=None, scripts=None, react=None):
self.pf, self.rng = profile, random.Random(seed)
self.bx, self.by = bx, by
self.play, self.call = OFFENSE_PLAYS[off_play], DEFENSE_CALLS[def_call]
self.off_play, self.def_call, self.kind = off_play, def_call, self.play["kind"]
self.O = [P(s, "O", off_team.attrs[s["id"]], bx, by) for s in OFFENSE[self.play["formation"]]]
self.D = [P(s, "D", def_team.attrs[s["id"]], bx, by) for s in DEFENSE[self.call["formation"]]]
self.all = self.O + self.D
self.byid = {p.id: p for p in self.all}
self.pidx = {p.id: i for i, p in enumerate(self.all)}
self.qb = self.byid["QB"]
self.t, self.n = 0.0, 0
self.ball_state, self.carrier, self.thrown = "held", self.qb, None
self.ball = [bx, by, 0.0]
self.frames, self.events, self.result = [], [], None
self.handed, self.decided = False, False
self.flags = dict(hold=False, inel=None, pressure=False, dpi=None)
self.pressure_t = None
self.aim = (bx + self.play.get("aim", (3, 0))[0], by + self.play.get("aim", (3, 0))[1])
self.mesh = None
self.setup_offense()
self.setup_defense()
self.info = {}
self.gap = {}
# reaction-time edits (seconds, negative = reads it sooner): defenders break earlier/later; the QB's first read moves
self.t_first = self.play.get("t_first", 0.0) + max(-0.6, min(0.6, (react or {}).get("QB", 0.0)))
self.t_first = max(0.3, self.t_first)
for pid, dt in (react or {}).items():
p = self.byid.get(pid)
if p is not None and p.side == "D":
p.react = max(0.02, p.react + dt)
for pid, pts in (scripts or {}).items():
p = self.byid.get(pid)
if p is not None and pts and len(pts) >= 2:
p.script = sorted((float(a), float(b), float(c)) for a, b, c in pts)
p.script_end = p.script[-1][0]
self.gap[pid] = [0.0, 0.0, 0]
# ------------------------------------------------------------------ setup
def event(self, text, kind="info"):
self.events.append(dict(t=round(self.t, 2), text=text, kind=kind))
def setup_offense(self):
pl, k = self.play, self.kind
for p in self.O:
if p.pos in OL:
p.task = "pblock" if (k == "pass" or pl.get("fake_pass")) else "rblock"
elif p.id == "QB":
p.task = "drop" if k == "pass" else "mesh"
else:
p.task = "hold"
for pid, rt in pl.get("routes", {}).items():
p = self.byid.get(pid)
if p:
p.route = [(p.x + dx, p.y + dy) for dx, dy in rt["pts"]]
p.rdelay, p.task = rt["delay"], "route"
if k in ("run", "rpo"):
self.byid[pl["runner"]].task = "runner"
self.mesh = (self.bx + pl["mesh"][0], self.by + pl["mesh"][1])
for p in self.O:
if p.task == "hold":
p.task = "rblock" if p.pos in ("TE", "FB") else "stalk"
else:
for pid in pl["blockers"]:
self.byid[pid].task = "pblock"
if pl.get("play_action"):
self.byid["RB"].task = "fake_run"
for p in self.O:
if p.task == "hold":
p.task = "pblock"
def setup_defense(self):
call, D = self.call, self.D
blitz = set(call["blitz"])
rushers = [p for p in D if p.pos in DL or p.id in blitz]
for p in rushers:
p.task = "rush"
cover = [p for p in D if p not in rushers]
elig = [p for p in self.O if p.pos in ELIG]
cov, bx = call["coverage"], self.bx
if cov in ("cover1", "cover0"):
if cov == "cover1":
fs = next((p for p in cover if p.id == "FS"), None)
if fs:
fs.task, fs.zone = "zone", (bx + 14, W * 0.5, True)
cover.remove(fs)
pairs = sorted((dist(d.xy, e.xy), i, j) for i, d in enumerate(cover) for j, e in enumerate(elig))
used_d, used_e = set(), set()
for _, i, j in pairs:
if i in used_d or j in used_e:
continue
cover[i].task, cover[i].mark = "man", elig[j]
used_d.add(i), used_e.add(j)
for i, d in enumerate(cover):
if i not in used_d:
d.task = "spy"
else:
deep_ids = ["CB1", "CB2", "FS"] if cov == "cover3" else ["FS", "SS"]
deep = [p for p in cover if p.id in deep_ids]
if cov == "cover3" and len(deep) < 3:
deep += [p for p in cover if p.id == "SS"][:3 - len(deep)]
under = [p for p in cover if p not in deep]
depth = 13.0 if cov == "cover3" else 14.5
for i, p in enumerate(sorted(deep, key=lambda q: q.y)):
p.task, p.zone = "zone", (bx + depth, W * (i + 0.5) / len(deep), True)
n = len(under)
for i, p in enumerate(sorted(under, key=lambda q: q.y)):
fy = 0.5 if n == 1 else 0.14 + 0.72 * i / (n - 1)
edge = n >= 4 and i in (0, n - 1)
p.task, p.zone = "zone", (bx + (3.5 if edge else 6.0), W * fy, False)
pa = self.play.get("play_action")
for p in D:
if self.kind in ("run", "rpo") or pa:
p.react += (0.6 if self.play.get("fake_pass") else 0.0) + (0.0 if p.pos in DL else LB_READ)
p.bite_end = 0.45 + 0.5 * (1 - p.a.awareness / 100)
p.lane = self.rng.uniform(-0.6, 0.6)
def assign_blocks(self):
"""Snap-time blocking scheme: match blockers to the front by alignment; extras climb or double."""
blockers = [b for b in self.O if b.task in ("pblock", "rblock") and (b.pos in OL or b.pos in ("TE", "FB", "RB"))]
if self.kind == "pass":
front = [d for d in self.D if d.task == "rush"]
else:
front = [d for d in self.D if d.pos in DL]
pairs = sorted((abs(b.y - d.y) + 0.2 * abs(b.x - d.x), i, j)
for i, b in enumerate(blockers) for j, d in enumerate(front))
ub, ud = set(), set()
for _, i, j in pairs:
if i in ub or j in ud:
continue
ub.add(i), ud.add(j)
blockers[i].btarget = front[j]
front[j].targeters.add(blockers[i].id)
rest = [d for d in self.D if d not in front and d.pos not in DB] if self.kind != "pass" else front
for i, b in enumerate(blockers):
if i in ub:
continue
pool = rest if rest else front
if not pool:
continue
d = min(pool, key=lambda q: abs(b.y - q.y) + 0.15 * abs(b.x - q.x) + 1.5 * len(q.targeters))
b.btarget = d
d.targeters.add(b.id)
for b in blockers:
b.retarget = 0.4
# ------------------------------------------------------------------ main loop
def run(self):
self.assign_blocks()
self.record()
while self.result is None and self.t < MAXT:
self.tick()
if self.result is None:
self.end("tackle", "Play ends (time)")
for _ in range(6):
self.record(force=True)
return self.result
def tick(self):
self.t += DT
self.n += 1
self.update_engagements()
for p in self.O:
self.off_logic(p)
for p in self.D:
self.def_logic(p)
self.check_handoff()
self.apply_scripts()
for p in self.all:
self.integrate(p)
self.collide()
self.update_ball()
self.resolve()
if self.n % REC == 0:
self.record()
# ------------------------------------------------------------------ coach-drawn paths
@staticmethod
def script_pos(s, t):
if t <= s[0][0]:
return s[0][1], s[0][2]
for i in range(1, len(s)):
if t <= s[i][0]:
a, b = s[i - 1], s[i]
f = (t - a[0]) / max(1e-9, b[0] - a[0])
return a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f
return s[-1][1], s[-1][2]
def apply_scripts(self):
"""A drawn path replaces the player's *movement* only. His reads, blocks and tackles still run; the
physics decides whether he can actually be where the path says (the shortfall is recorded)."""
for p in self.all:
s = p.script
if not s or self.t > p.script_end:
continue
if self.ball_state == "flight" and (p.side == "D" or self.thrown["target"] is p):
continue # everyone attacks the ball once it is thrown
p.tx, p.ty = self.script_pos(s, self.t + LOOK)
p.frac = 1.0
gx, gy = self.script_pos(s, self.t)
g = dist(p.xy, (gx, gy))
rec = self.gap[p.id]
rec[0], rec[1], rec[2] = max(rec[0], g), rec[1] + g, rec[2] + 1
def record(self, force=False):
self.frames.append(dict(t=round(self.t, 2),
p=[round(v, 2) for q in self.all for v in (q.x, q.y)],
b=[round(self.ball[0], 2), round(self.ball[1], 2), round(self.ball[2], 2)],
e=[[self.pidx[b.id], self.pidx[d.id], round(d.lev, 2)] for d in self.D for b in d.blockers]))
# ------------------------------------------------------------------ physics
def integrate(self, p):
cap = p.a.vmax * p.frac * p.cap
if self.t < p.slow_until:
cap *= p.slow
dx, dy = p.tx - p.x, p.ty - p.y
d = math.hypot(dx, dy)
if d < 1e-6 or cap <= 1e-6:
wx = wy = 0.0
else:
sp = min(cap, 3.2 * d)
wx, wy = dx / d * sp, dy / d * sp
dvx, dvy = wx - p.vx, wy - p.vy
sp0 = p.speed
f = 0.7 + 0.6 * p.a.agility / 100
if p.pos in ELIG and p.task == "route":
f *= 0.85 + 0.3 * p.a.route / 100
if sp0 > 0.3: # forward effort tapers with speed; cutting does not
ux, uy = p.vx / sp0, p.vy / sp0
par, perp = dvx * ux + dvy * uy, -dvx * uy + dvy * ux
lp = p.a.accel * f * max(0.12, 1 - 0.8 * min(1.0, sp0 / p.a.vmax)) * DT * (1.6 if par < 0 else 1.0)
lq = p.a.accel * f * 0.6 * DT
par = max(-lp, min(lp, par))
perp = max(-lq, min(lq, perp))
dvx, dvy = par * ux - perp * uy, par * uy + perp * ux
else:
dv = math.hypot(dvx, dvy)
lim = p.a.accel * f * DT
if dv > lim:
dvx, dvy = dvx * lim / dv, dvy * lim / dv
p.vx += dvx
p.vy += dvy
p.x = min(119.5, max(0.5, p.x + p.vx * DT))
p.y = min(W + 4, max(-4, p.y + p.vy * DT))
@staticmethod
def acc_at(p, v):
"""Acceleration available at speed v: strong off the line, tapering toward top speed."""
f = 0.7 + 0.6 * p.a.agility / 100
if p.pos in ELIG and p.task == "route":
f *= 0.85 + 0.3 * p.a.route / 100
return p.a.accel * f * max(0.12, 1 - 0.8 * min(1.0, v / p.a.vmax))
def collide(self):
A, n = self.all, len(self.all)
for _ in range(2):
for i in range(n):
a = A[i]
for j in range(i + 1, n):
b = A[j]
if a.side == "O" and b.side == "O" and not (a.pos in OL and b.pos in OL):
continue # teammates avoid each other; only the line is a physical wall
dx, dy, rr = b.x - a.x, b.y - a.y, a.r + b.r
if abs(dx) > rr or abs(dy) > rr:
continue
d2 = dx * dx + dy * dy
if d2 >= rr * rr:
continue
d = math.sqrt(d2) or 1e-6
nx, ny, ov = dx / d, dy / d, rr - d
s = a.m + b.m
a.x -= nx * ov * b.m / s
a.y -= ny * ov * b.m / s
b.x += nx * ov * a.m / s
b.y += ny * ov * a.m / s
vrel = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny
if vrel > 0:
j_ = 1.1 * vrel / (1 / a.m + 1 / b.m)
a.vx -= j_ * nx / a.m
a.vy -= j_ * ny / a.m
b.vx += j_ * nx / b.m
b.vy += j_ * ny / b.m
# ------------------------------------------------------------------ blocking
@staticmethod
def _power(p, blocking):
a = p.a
return (a.block if blocking else a.shed) * 0.5 + a.strength * 0.3 + (a.weight_lb - 250) * 0.12
def update_engagements(self):
t = self.t
for p in self.D:
p.blockers = []
for b in self.O:
d = b.btarget
if (b.task in ("pblock", "rblock", "stalk") and d is not None and t >= d.free_until
and dist(b.xy, d.xy) <= b.r + d.r + 0.55):
d.blockers.append(b)
for d in self.D:
if d.blockers:
tot = 0.0
for i, b in enumerate(sorted(d.blockers, key=lambda q: -self._power(q, True))):
tot += self._power(b, True) * (1.0 if i == 0 else 0.6)
adv = tot - self._power(d, False) + (RUN_ADV if self.kind != 'pass' else 0.0)
d.lev += adv * 0.02 * DT + self.rng.gauss(0, 0.45) * math.sqrt(DT)
d.lev = max(-1.2, min(1.2, d.lev))
if d.lev <= -1.0:
d.free_until, d.lev = t + 1.0, -0.2
if d.task == "rush" and self.kind == "pass":
self.event(f"{d.pos} #{d.num} beats his block", "pressure")
if self.rng.random() < 0.05:
self.flags["hold"] = True
self.event("Offensive holding on the blocker", "penalty")
d.cap = max(0.05, 0.15 + 0.6 * -d.lev) if d.lev < 0 else max(0.04, 0.15 - 0.2 * d.lev)
if d.lev > 0:
b0 = d.blockers[0]
ux, uy = unit(d.x - b0.x, d.y - b0.y)
d.x += ux * 0.9 * d.lev * DT
d.y += uy * 0.9 * d.lev * DT
else:
d.lev *= max(0.0, 1 - 0.8 * DT)
d.cap = 1.0
def pick_block_target(self, b, exclude=None):
t = self.t
if b.task == "pblock":
cands = [d for d in self.D if d.task == "rush" and t >= d.free_until - 0.2]
if not cands:
cands = [d for d in self.D if dist(b.xy, d.xy) < 4.0]
def cost(d):
return dist(b.xy, d.xy) + 3.0 * (len(d.targeters) - (1 if b.id in d.targeters else 0))
else:
def cost(d):
c = dist(b.xy, d.xy) + 0.35 * dist(d.xy, self.aim)
if b.task == "stalk":
c += 0 if d.pos in DB or d.pos == "OLB" else 8
elif d.pos in DB:
c += 6
elif d.pos not in DL and b.pos in OL:
c += 2.0 if t < 0.5 else -1.0
if b.id == self.play.get("lead") and d.pos not in DL:
c -= 2.0
return c + 4.0 * (len(d.targeters) - (1 if b.id in d.targeters else 0))
cands = [d for d in self.D if d.x > b.x - 1.0 and t >= d.free_until - 0.2]
if exclude is not None:
cands = [d for d in cands if d is not exclude]
if not cands:
return None
return min(cands, key=cost)
def block_logic(self, b):
d0 = b.btarget
beaten = d0 is not None and self.t < d0.free_until
climb_now = (d0 is not None and b.task == "rblock" and b in d0.blockers and len(d0.blockers) >= 2
and d0.lev > 0.35 and b is not max(d0.blockers, key=lambda q: self._power(q, True)))
keep = d0 is not None and not beaten and not climb_now
if (self.t >= b.retarget and not keep) or b.btarget is None:
climb = d0 if (d0 is not None and b in d0.blockers and not keep) else None
new = self.pick_block_target(b, exclude=climb)
if b.btarget is not None:
b.btarget.targeters.discard(b.id)
if new is not None:
new.targeters.add(b.id)
b.btarget = new
b.retarget = self.t + 0.3 + self.rng.random() * 0.2
d = b.btarget
if d is None:
b.tx, b.ty, b.frac = b.x, b.y, 0.0
return
if b.task == "pblock":
ux, uy = unit(self.qb.x - d.x, self.qb.y - d.y)
b.tx, b.ty = d.x + ux * (b.r + d.r) * 0.9, d.y + uy * (b.r + d.r) * 0.9
if b.pos in OL:
b.tx = min(b.tx, self.bx - 0.1)
else:
side = 1.0 if self.aim[1] >= d.y else -1.0 # take the hole-side shoulder, wash him away from the hole
k = min(1.0, self.t / 0.7) # square up first, then wash
b.tx, b.ty = d.x + 0.25 * k, d.y + side * 0.9 * k
b.frac = 0.9
# ------------------------------------------------------------------ offense AI
def off_logic(self, p):
tk = p.task
if tk == "carry":
self.carry_logic(p)
elif tk in ("pblock", "rblock", "stalk"):
self.block_logic(p)
elif tk == "route":
self.route_logic(p)
elif tk == "drop":
self.drop_logic(p)
elif tk == "mesh":
self.mesh_logic(p)
elif tk == "runner":
ux, uy = unit(self.aim[0] - self.mesh[0], self.aim[1] - self.mesh[1])
if dist(p.xy, self.mesh) > 1.6 and not self.handed:
p.tx, p.ty, p.frac = self.mesh[0], self.mesh[1], 0.9
else:
p.tx, p.ty, p.frac = self.mesh[0] + ux * 5, self.mesh[1] + uy * 5, 0.9
elif tk == "fake_run":
p.tx, p.ty, p.frac = self.aim[0], self.aim[1], 0.8
else:
p.tx, p.ty, p.frac = p.x, p.y, 0.0
def route_logic(self, p):
if self.ball_state == "flight" and self.thrown["target"] is p: # adjust to the ball in the air
p.tx, p.ty, p.frac = self.thrown["ex"], self.thrown["ey"], 1.0
return
if self.t < p.rdelay:
p.tx, p.ty, p.frac = p.x, p.y, 0.0
return
if p.rpt >= len(p.route):
p.tx, p.ty, p.frac = p.x, p.y, 0.0
return
wx, wy = p.route[p.rpt]
if math.hypot(wx - p.x, wy - p.y) < 1.0 and p.rpt < len(p.route) - 1:
p.rpt += 1
wx, wy = p.route[p.rpt]
p.tx, p.ty, p.frac = wx, wy, 0.97
def mesh_logic(self, qb):
if self.carrier is qb and not self.handed and self.mesh:
qb.tx, qb.ty, qb.frac = self.mesh[0], self.mesh[1], 0.55
if self.kind == "rpo" and not self.decided and self.t >= 0.7:
self.decided = True
box = sum(1 for d in self.D if d.x < self.bx + 5 and abs(d.y - self.by) < 9)
if box >= 7 or self.rng.random() < 0.12:
self.event(f"RPO read: {box} in the box β€” pull and throw", "decision")
self.throw(self.byid[self.play["tgt"]])
else:
self.event(f"RPO read: {box} in the box β€” hand it off", "decision")
if self.t > 2.2 and not self.handed:
self.event("Handoff missed β€” QB keeps", "decision")
qb.task = "carry"
else:
qb.tx, qb.ty, qb.frac = qb.x, qb.y, 0.0
def check_handoff(self):
if (self.ball_state != "held" or self.carrier is not self.qb or self.handed
or self.kind not in ("run", "rpo") or self.thrown or self.qb.task == "carry"
or (self.kind == "rpo" and not self.decided)):
return
rb = self.byid[self.play["runner"]]
if self.t >= self.play["min_handoff"] and dist(rb.xy, self.qb.xy) < 1.3:
self.handed = True
self.carrier, rb.task = rb, "carry"
self.qb.task = "hold"
if self.play.get("fake_pass"):
for q in self.O:
if q.task == "pblock":
q.task = "rblock"
self.event(f"Handoff to {rb.pos} #{rb.num}", "play")
# ---- QB
def drop_logic(self, qb):
if self.carrier is not qb or self.ball_state != "held":
qb.tx, qb.ty, qb.frac = qb.x, qb.y, 0.0
return
dx = self.bx - self.play["drop"]
if qb.x > dx + 0.25:
qb.tx, qb.ty, qb.frac = dx, qb.y, 0.65
else:
qb.tx, qb.ty, qb.frac = qb.x, qb.y, 0.0
if self.t < self.t_first:
return
free = [d for d in self.D if d.task == "rush" and not d.blockers and dist(d.xy, qb.xy) < 2.4]
pressured = bool(free)
if pressured and self.pressure_t is None:
self.pressure_t = self.t
self.flags["pressure"] = True
self.event(f"QB pressured by {free[0].pos} #{free[0].num}", "pressure")
reads = self.play["prog"]
k = int((self.t - self.t_first) / 0.4)
cand = reads if pressured else reads[:k + 1]
best, bo = None, -9.0
for rid in cand:
r = self.byid[rid]
if self.rng.random() > 0.55 + 0.45 * qb.a.awareness / 100:
continue
op = self.openness(qb, r)
if op > bo:
best, bo = r, op
thr = 1.9 - (0.9 if pressured else 0.0)
if best is not None:
thr += 0.07 * max(0.0, dist(qb.xy, best.xy) - 8) # deep throws need a bigger window
if best is not None and bo >= thr:
self.throw(best)
return
exhausted = self.t > self.t_first + 0.4 * len(reads) + 0.3
if (pressured and exhausted) or self.t > self.t_first + 3.2:
if best is not None and bo >= 0.7 and not self.play.get("screen"):
self.throw(best)
elif self.rng.random() < 0.55 and qb.a.vmax > 8.2:
self.event("QB scrambles", "decision")
qb.task = "carry"
else:
self.throw(None)
def travel(self, r, T):
"""Distance receiver r covers in T seconds from his current speed (same accel curve as the integrator)."""
vm = 0.96 * r.a.vmax
v, d, t = min(r.speed, vm), 0.0, 0.0
while t < T:
h = min(0.1, T - t)
v = min(vm, v + self.acc_at(r, v) * h)
d += v * h
t += h
return d
def predict(self, r, T):
"""Where receiver r will be T seconds from now, following his remaining route."""
if r.script and self.t <= r.script_end:
return self.script_pos(r.script, min(self.t + T, r.script_end))
if r.task != "route":
return (r.x + r.vx * T, r.y + r.vy * T)
if self.t < r.rdelay:
T = max(0.0, T - (r.rdelay - self.t))
pts = [r.xy] + r.route[r.rpt:]
left = self.travel(r, T)
for i in range(len(pts) - 1):
seg = dist(pts[i], pts[i + 1])
if left <= seg:
f = left / seg if seg else 0
return (pts[i][0] + (pts[i + 1][0] - pts[i][0]) * f, pts[i][1] + (pts[i + 1][1] - pts[i][1]) * f)
left -= seg
if i + 2 < len(pts): # speed lost rounding a hard cut
a1 = math.atan2(pts[i + 1][1] - pts[i][1], pts[i + 1][0] - pts[i][0])
a2 = math.atan2(pts[i + 2][1] - pts[i + 1][1], pts[i + 2][0] - pts[i + 1][0])
turn = abs((a2 - a1 + math.pi) % (2 * math.pi) - math.pi)
if turn > math.radians(12):
left -= 5.5 * min(1.0, turn / (math.pi / 2)) * (1.15 - r.a.agility / 150)
return pts[-1]
def flight_target(self, qb, r):
spd = 14 + 0.16 * qb.a.throw_power
T = dist(qb.xy, r.xy) / spd
for _ in range(3):
pt = self.predict(r, T)
T = dist(qb.xy, pt) / spd
return pt, T
def openness(self, qb, r):
pt, T = self.flight_target(qb, r)
if pt[1] < 0.2 or pt[1] > W - 0.2 or dist(qb.xy, pt) > 50:
return -9.0
cov = [d for d in self.D if d.task != "rush"]
return min(dist((d.x + d.vx * T * 0.5, d.y + d.vy * T * 0.5), pt) for d in cov)
def throw(self, r):
qb = self.qb
spd = 14 + 0.16 * qb.a.throw_power
if r is None:
ex, ey = qb.x + 3, (-1.5 if qb.y < W / 2 else W + 1.5)
T = dist(qb.xy, (ex, ey)) / spd
self.event("QB throws it away", "decision")
else:
pt, T = self.flight_target(qb, r)
sig = (100 - qb.a.throw_acc) / 100 * (0.5 + 0.24 * dist(qb.xy, pt)) + (0.35 if self.flags["pressure"] else 0)
ex, ey = pt[0] + self.rng.gauss(0, sig * 1.1), pt[1] + self.rng.gauss(0, sig * 0.8)
self.event(f"QB throws to {r.pos} #{r.num} ({dist(qb.xy, (ex, ey)):.0f} yds)", "play")
if ex > self.bx:
for q in self.O:
if q.pos in OL:
ok, _ = ineligible_downfield_ok(self.pf, q.x + q.r - self.bx)
if not ok and self.flags["inel"] is None:
self.flags["inel"] = q
self.thrown = dict(t0=self.t, t1=self.t + T, sx=qb.x, sy=qb.y, ex=ex, ey=ey, target=r)
cov = [d for d in self.D if d.task != "rush"]
self.info.update(target=r.id if r else None, t_throw=round(self.t, 2), t_land=round(self.t + T, 2),
air=round(dist(qb.xy, (ex, ey)), 1),
sep_throw=round(min(dist(d.xy, (ex, ey)) for d in cov), 2) if cov else None)
self.ball_state, self.carrier = "flight", None
qb.task = "hold"
# ---- ball carrier
def carry_logic(self, c):
if self.t < getattr(c, "next_think", 0.0):
return
c.next_think = self.t + 0.15
best, bs = (1.0, 0.0), -1e9
for deg in range(-80, 81, 10):
a = math.radians(deg)
ux, uy = math.cos(a), math.sin(a)
risk = 0.0
for d in self.D:
w = 0.35 if d.blockers else 1.0
dxp, dyp = d.x + d.vx * 0.3 - c.x, d.y + d.vy * 0.3 - c.y
along = dxp * ux + dyp * uy
proj = max(0.0, min(6.0, along))
dd = math.hypot(dxp - ux * proj, dyp - uy * proj)
if along < 0:
w *= 0.3
risk += w * math.exp(-dd / 1.25)
score = ux * 5.0 - 2.4 * risk
py_end = c.y + uy * 6.0
if py_end < 1.5 or py_end > W - 1.5:
score -= 3.0
if c.x < self.bx + 1.0 and self.handed:
ax, ay = unit(self.aim[0] - c.x, self.aim[1] - c.y)
score += 1.5 * (ux * ax + uy * ay)
score += self.rng.gauss(0, (100 - c.a.vision) / 100 * 1.2)
if score > bs:
best, bs = (ux, uy), score
c.tx, c.ty, c.frac = c.x + best[0] * 6, c.y + best[1] * 6, 0.93
# ------------------------------------------------------------------ defense AI
def def_logic(self, p):
t, c = self.t, self.carrier
if self.ball_state == "flight":
th = self.thrown
if t >= th["t0"] + p.react:
if dist(p.xy, (th["ex"], th["ey"])) < 18 or p.task in ("man", "zone"):
p.tx, p.ty, p.frac = th["ex"], th["ey"], 1.0
return
if p.task == "rush":
p.tx, p.ty, p.frac = self.qb.x, self.qb.y, 1.0
return
if c is None:
return
live = c.side == "O" and c.task == "carry"
if live:
deep = p.task == "zone" and p.zone[2]
man_db = p.task == "man" and p.pos in DB
if (deep or man_db) and c.x < self.bx + 2.5 and self.kind == "pass" and c is self.qb:
pass
elif (deep or man_db) and c.x < self.bx + 2.5 and c is not self.qb:
self.cover_logic(p)
return
else:
self.pursue(p, c)
return
run_play = self.kind in ("run", "rpo") and not self.thrown
pa_bite = self.play.get("play_action") and t < p.bite_end and p.task in ("zone",) and p.pos not in DB
if (run_play and t >= p.react) or pa_bite:
if p.task == "zone" and p.zone[2] and t < p.react + 0.9 and not live:
self.cover_logic(p)
elif p.task in ("man",) and p.pos in DB and not live:
self.cover_logic(p)
else:
if pa_bite:
tx, ty = self.bx + 1.0, p.y
elif p.pos in DL and self.ball[0] < self.bx + 0.3 and p.blockers:
tx, ty = self.bx + 0.4, p.y + max(-2.0, min(2.0, self.ball[1] - p.y)) # flow with the blocker
elif p.pos not in DL and self.ball[0] < self.bx + 0.3:
tx, ty = self.bx + 0.7, min(W - 1, max(1, 0.5 * self.ball[1] + 0.5 * p.y))
else:
tx, ty = self.ball[0], self.ball[1]
p.tx, p.ty, p.frac = tx, ty, 0.95
return
if p.task == "rush":
q = self.qb
p.tx, p.ty, p.frac = q.x, q.y + p.lane, 1.0
else:
self.cover_logic(p)
def pursue(self, p, c):
d = dist(p.xy, c.xy)
T = min(1.2, d / (p.a.vmax * 0.9 + 1e-6))
p.tx, p.ty, p.frac = c.x + c.vx * T, c.y + c.vy * T, 1.0
def cover_logic(self, p):
tk = p.task
if tk == "man":
m = p.mark
cushion = 1.2 + (100 - p.a.coverage) / 100 * 1.6
p.tx, p.ty, p.frac = m.x + m.vx * 0.25 + cushion, m.y + m.vy * 0.25, 1.0
elif tk == "zone":
zx, zy, deep = p.zone
if not p.zone_reached and dist(p.xy, (zx, zy)) < 1.4:
p.zone_reached = True
if not p.zone_reached:
p.tx, p.ty, p.frac = zx, zy, 0.85
return
rho = 6.0 if deep else 4.8
rs = [e for e in self.O if e.pos in ELIG and dist(e.xy, (zx, zy)) < rho]
if rs:
r = min(rs, key=lambda e: dist(e.xy, p.xy))
p.tx, p.ty, p.frac = r.x + r.vx * 0.2 + (2.6 if deep else 0.2), r.y + r.vy * 0.2, 0.95
else:
p.tx, p.ty, p.frac = zx, zy, 0.5
elif tk == "spy":
p.tx, p.ty, p.frac = self.bx + 5.5, self.qb.y, 0.6
else:
p.tx, p.ty, p.frac = self.qb.x, self.qb.y, 1.0
# ------------------------------------------------------------------ ball + resolution
def update_ball(self):
if self.ball_state == "held" and self.carrier:
self.ball[:] = [self.carrier.x, self.carrier.y, 0.0]
elif self.ball_state == "flight":
th = self.thrown
u = min(1.0, (self.t - th["t0"]) / max(1e-6, th["t1"] - th["t0"]))
D = math.hypot(th["ex"] - th["sx"], th["ey"] - th["sy"])
self.ball[:] = [th["sx"] + (th["ex"] - th["sx"]) * u, th["sy"] + (th["ey"] - th["sy"]) * u,
math.sin(math.pi * u) * min(1.0, 0.25 + D / 30)]
def resolve(self):
if self.result is not None:
return
if self.ball_state == "flight" and self.t >= self.thrown["t1"]:
self.resolve_pass()
elif self.ball_state == "held" and self.carrier is not None:
self.resolve_carrier()
def resolve_carrier(self):
c, t = self.carrier, self.t
if c.side != "O":
return
if c.x >= 110:
self.end("touchdown", f"TOUCHDOWN β€” {c.pos} #{c.num}", td=True)
return
if c.y < 0 or c.y > W:
self.end("out_of_bounds", f"{c.pos} #{c.num} runs out of bounds")
return
pocket = c is self.qb and c.task != "carry"
near = [d for d in self.D if dist(c.xy, d.xy) < c.r + d.r + 1.0]
for d in near:
if t < d.tcd or (pocket and d.blockers):
continue
d.tcd = t + 0.35
p = (0.55 + (d.a.tackling - c.a.elusive) / 130 + (d.a.weight_lb - c.a.weight_lb) / 900
- (c.a.strength - 50) / 400 + 0.12 * (len(near) - 1))
if d.blockers:
p *= 0.28
gap = max(0.0, dist(c.xy, d.xy) - (c.r + d.r + 0.38)) # arm-tackle at the edge of reach is harder
p *= max(0.35, 1 - 0.6 * gap / 0.62)
p = min(0.93, max(0.10, p))
if self.rng.random() < p:
ok, why = runner_is_down(self.pf, True, True)
self.tackle(c, d)
return
d.slow_until, d.slow = t + 0.6, 0.5
c.slow_until, c.slow = t + 0.25, 0.8
self.event(f"{c.pos} #{c.num} breaks a tackle from {d.pos} #{d.num}", "play")
if not pocket and self.rng.random() < 0.012:
ok, why = runner_is_down(self.pf, True, False)
if ok:
self.end("tackle", f"{c.pos} #{c.num} loses his footing untouched β€” down ({self.pf.key} rule: {why})",
rule="down_no_contact")
return
self.event(f"{c.pos} #{c.num} slips untouched β€” play continues ({self.pf.key} rule)", "rule")
c.slow_until, c.slow = t + 0.8, 0.4
def tackle(self, c, d):
kind = "sack" if (c is self.qb and c.x < self.bx and c.task != "carry") else "tackle"
fumble_p = 0.010 + 0.025 * (1 - c.a.ballsec / 100) + 0.01 * (d.a.strength - 50) / 50
txt = f"{c.pos} #{c.num} tackled by {d.pos} #{d.num}"
if kind == "sack":
txt = f"SACK β€” {d.pos} #{d.num} drops the QB"
if self.rng.random() < fumble_p:
lost = self.rng.random() < 0.55
self.end("fumble", txt + (" β€” FUMBLE, recovered by the defense" if lost else " β€” fumble, offense recovers"),
turnover=lost)
return
self.end(kind, txt, tackler=f"{d.pos} #{d.num}")
def resolve_pass(self):
th, tgt = self.thrown, self.thrown["target"]
ex, ey = th["ex"], th["ey"]
self.ball_state, self.ball[:] = "dead", [ex, ey, 0.0]
if tgt is None:
self.end("incomplete", "Incomplete β€” thrown away")
return
# defensive pass interference
for d in self.D:
if dist(d.xy, tgt.xy) < d.r + tgt.r + 0.55 and d.x <= tgt.x + 0.4 and d.task in ("man", "zone"):
pr = 0.05 + 0.14 * (100 - d.a.coverage) / 100
if self.rng.random() < pr:
r = dpi_enforcement(self.pf, max(0.0, ex - self.bx), 110 - self.bx)
self.flags["dpi"] = r
self.end("dpi", f"FLAG β€” defensive pass interference on {d.pos} #{d.num}: {r['yards']} yds, "
f"automatic first down ({self.pf.key}: {r['note']})", penalty=dict(
kind="dpi", yards=r["yards"], first=True, replay=False), rule="dpi")
return
cands = []
for q in self.all:
if (q.side == "O" and (q.pos in OL or q.pos == "QB")):
continue
d = dist(q.xy, (ex, ey))
reach = q.a.reach * 0.95 + (0.25 if q is tgt else 0.0) # the target adjusts to the ball
if d <= reach:
cands.append((q, d, reach))
off_c = [x for x in cands if x[0].side == "O"]
def_c = [x for x in cands if x[0].side == "D"]
best, bscore = None, -1e9
for q, d, reach in cands:
if q.side == "O":
sc = q.a.catching * 0.8 + (reach - d) * 14 + self.rng.gauss(0, 9) - 8 * len(def_c)
else:
sc = (q.a.catching * 0.5 + q.a.coverage * 0.5) * 0.8 + (reach - d) * 14 + self.rng.gauss(0, 9) - 4 * len(off_c)
if sc > bscore:
best, bscore = q, sc
if best is None:
self.end("incomplete", f"Incomplete β€” pass off target ({dist(tgt.xy, (ex, ey)):.1f} yds from {tgt.pos} #{tgt.num})")
return
if best.side == "D":
if self.rng.random() < 0.10 + 0.22 * best.a.catching / 100:
self.end("interception", f"INTERCEPTED by {best.pos} #{best.num}", turnover=True)
else:
self.end("incomplete", f"Incomplete β€” broken up by {best.pos} #{best.num}")
return
air = math.hypot(th["ex"] - th["sx"], th["ey"] - th["sy"])
if air > 18 and self.rng.random() > max(0.45, 1 - 0.024 * (air - 18)):
self.end("incomplete", f"Incomplete β€” deep ball just out of reach of {best.pos} #{best.num}")
return
near = min(self.D, key=lambda d: dist(d.xy, best.xy))
dd = dist(near.xy, best.xy)
if dd < 1.8 and self.rng.random() > 0.45 + 0.3 * (best.a.catching - 60) / 40 + 0.4 * dd / 1.8:
if self.rng.random() < 0.06 + 0.15 * near.a.catching / 100:
self.end("interception", f"INTERCEPTED by {near.pos} #{near.num}", turnover=True)
else:
self.end("incomplete", f"Incomplete β€” contested, broken up by {near.pos} #{near.num}")
return
side = min(best.y, W - best.y)
feet = 2 if side >= 0.8 else 1 if side >= 0.35 else 0
ok, why = catch_is_complete(self.pf, True, feet, False)
if not ok:
self.end("incomplete", f"No catch β€” {best.pos} #{best.num}: {why} ({self.pf.key} rule)", rule="catch")
return
if feet < 2:
self.event(f"{best.pos} #{best.num} drags ONE foot β€” legal catch under {self.pf.key} rules", "rule")
self.info.update(completed=True, catcher=best.id, sep_catch=round(dd, 2), catch_x=round(best.x - self.bx, 1))
self.ball_state, self.carrier, self.thrown = "held", best, None
best.task = "carry"
for q in self.O:
if q is not best and q.pos != "QB":
q.task = "rblock" if q.pos in OL else "stalk"
q.btarget, q.retarget = None, 0.0
self.event(f"Catch β€” {best.pos} #{best.num} at {best.x - self.bx:+.0f} yds", "play")
def end(self, outcome, text, td=False, turnover=False, penalty=None, rule=None, tackler=None):
c = self.carrier
ex = c.x if c is not None else self.ball[0]
ey = c.y if c is not None else self.ball[1]
pen = penalty
if pen is None and outcome not in ("dpi",):
# penalties that nullify the play
if self.flags["hold"]:
pen = dict(kind="holding", yards=-10, first=False, replay=True)
text = "FLAG β€” offensive holding (10 yds), play nullified"
elif self.flags["inel"] is not None and self.kind in ("pass", "rpo") and self.thrown is None and outcome != "interception":
q = self.flags["inel"]
pen = dict(kind="ineligible", yards=-5, first=False, replay=True)
text = (f"FLAG β€” ineligible receiver downfield ({q.pos} #{q.num} beyond the "
f"{self.pf.ineligible_downfield_yd}-yd {self.pf.key} limit), 5 yds")
rule = "ineligible"
self.ball_state = "dead"
if c is not None:
self.ball[:] = [c.x, c.y, 0.0]
self.event(text, "result")
yards = 0.0 if outcome in ("incomplete", "interception") else round(ex - self.bx, 1)
self.result = dict(outcome=outcome, text=text, yards=yards, end_x=ex, end_y=ey, td=td, turnover=turnover,
penalty=pen, rule=rule, t=round(self.t, 2), pressure=self.flags["pressure"],
tackler=tackler, info=dict(self.info),
gap={k: (round(v[0], 2), round(v[1] / max(1, v[2]), 2)) for k, v in self.gap.items()})
if pen and pen["kind"] in ("holding", "ineligible"):
self.result["yards"] = 0.0
self.result["td"] = False
self.result["turnover"] = False
# ------------------------------------------------------------------ export for replay
def scheme(self):
"""What each player was asked to do, for the coach's board (coverage shells, man matchups, progressions)."""
cov = self.call["coverage"]
d = []
for p in self.D:
z = p.zone
d.append(dict(id=p.id, task=p.task, mark=p.mark.id if p.mark is not None else None,
zone=[round(z[0], 1), round(z[1], 1), bool(z[2])] if z else None))
return dict(coverage=cov, call=self.def_call, play=self.off_play, kind=self.kind, d=d,
prog=list(self.play.get("prog") or []), runner=self.play.get("runner"),
blitz=list(self.call.get("blitz") or []))
def replay(self):
return dict(ids=[p.id for p in self.all],
meta=[dict(id=p.id, side=p.side, pos=p.pos, num=p.num, vmax=round(p.a.vmax, 2),
react=round(self.t_first if p.id == "QB" else p.react, 2),
mph=round(p.a.mph, 1), ht=round(p.a.height_in), wt=round(p.a.weight_lb),
forty=p.a.forty) for p in self.all],
marks=dict(throw=self.info.get("t_throw"), land=self.info.get("t_land"),
end=self.result["t"] if self.result else None, target=self.info.get("target")),
scheme=self.scheme(),
frames=self.frames, events=self.events, bx=self.bx, by=self.by,
result=dict(outcome=self.result["outcome"], yards=self.result["yards"], td=self.result["td"],
turnover=self.result["turnover"],
penalty=(self.result["penalty"] or {}).get("kind")) if self.result else None,
summary=self.result["text"] if self.result else "")