Spaces:
Running on Zero
Running on Zero
Download engine.py from ageraustine/HAWK-ONE: direct link, hf CLI and curl.
- Browser
- Download file 44.9 kB
-
https://huggingface.co/spaces/ageraustine/HAWK-ONE/resolve/main/engine.py
- Command line
-
hf download hf://spaces/ageraustine/HAWK-ONE/engine.py
-
curl -L -o engine.py https://huggingface.co/spaces/ageraustine/HAWK-ONE/resolve/main/engine.py
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 | |
| def speed(s): | |
| return math.hypot(s.vx, s.vy) | |
| 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 | |
| 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)) | |
| 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 | |
| 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 "") |