"""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 "")