{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "7f136af8",
   "metadata": {},
   "source": [
    "# Analyzing the Geography of CTF \ud83d\uddfa\ufe0f\n",
    "\n",
    "**Coworld CTF is played on one board. This notebook asks what that board is,\n",
    "then asks 995 real games whether they agree.**\n",
    "\n",
    "Two teams of eight fight in a symmetric arena of staggered cover, with a heart\n",
    "on each pedestal. Vision is a fog-of-war cone tied to where you *aim* rather\n",
    "than where you walk. Bullets are hitscan, meaning they cross the whole map the\n",
    "instant they are fired. Stone stops sight and shots alike; six glass windows\n",
    "stop shots but not sight.\n",
    "\n",
    "Every question below is a question about *place*: where can you be seen from,\n",
    "where does the killing actually happen, which way do the flag runs go, and is\n",
    "the map as fair in play as it is in geometry. Each scenario ends in a\n",
    "**Verdict** and a prompt you can hand to a coding agent.\n",
    "\n",
    "The one fact everything rests on: **a `.replay` file holds only the players'\n",
    "inputs and a per-tick state hash.** None of the derived state is in there: no\n",
    "positions, no events, no scores. All of it exists only after the game engine\n",
    "*re-simulates* the inputs, on the engine build that recorded them. The hash is\n",
    "there to prove the re-sim is bit-exact. Everything you are about to see has\n",
    "already been through that machine; \u00a79 shows how to run it on your own games.\n",
    "\n",
    "> **The corpus.** 995 league replays, 34.4 hours of game time, 32 million\n",
    "> alive player-ticks, 218,333 shots, 39,510 kills, 2,419 heart steals,\n",
    "> 330 captures.\n",
    "> Split GV22 (557) / GV23 (438): two engine versions, one identical arena."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "daf2003e",
   "metadata": {},
   "source": [
    "## 0 \u00b7 Setup\n",
    "\n",
    "Install the wheel and fetch the bundle, both from `https://sw.gy/files`, so this\n",
    "notebook runs anywhere with a network connection. Everything below runs from\n",
    "pre-baked data on a laptop CPU, with no game engine and no replay corpus needed."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "925359d0",
   "metadata": {},
   "outputs": [],
   "source": [
    "%pip install --quiet https://sw.gy/files/ctf_analysis-0.1.0-py3-none-any.whl"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "96323c88",
   "metadata": {},
   "outputs": [],
   "source": [
    "%matplotlib inline\n",
    "import tarfile\n",
    "import urllib.request\n",
    "from pathlib import Path\n",
    "\n",
    "FILES  = \"https://sw.gy/files\"\n",
    "BUNDLE = Path(\"ctf-geography.tgz\")     # ~20 MB; cached next to the notebook\n",
    "\n",
    "if not Path(\"data/layers.npz\").exists():\n",
    "    if not BUNDLE.exists():\n",
    "        print(f\"downloading {FILES}/{BUNDLE.name} (~20 MB)...\")\n",
    "        urllib.request.urlretrieve(f\"{FILES}/{BUNDLE.name}\", BUNDLE)\n",
    "    with tarfile.open(BUNDLE) as t:    # unpacks into data/ and assets/\n",
    "        try:\n",
    "            t.extractall(\".\", filter=\"data\")\n",
    "        except TypeError:              # Python < 3.12\n",
    "            t.extractall(\".\")\n",
    "print(sorted(p.name for p in Path(\"data\").glob(\"*\")))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "51aa1b88",
   "metadata": {},
   "outputs": [],
   "source": [
    "import collections\n",
    "import numpy as np\n",
    "import polars as pl\n",
    "import matplotlib.pyplot as plt\n",
    "\n",
    "from ctf_analysis.arena import load_arena, LANE_NAMES\n",
    "from ctf_analysis.bake import load_geometry\n",
    "from ctf_analysis.aggregate import load_layers\n",
    "from ctf_analysis.render import (\n",
    "    PAGE, INK, RED, BLUE, arena_axes, basemap, density_layer,\n",
    "    field_layer, ratio_layer, share_layer, show, to_grid,\n",
    ")\n",
    "\n",
    "plt.rcParams.update({\n",
    "    \"figure.facecolor\": PAGE, \"savefig.facecolor\": PAGE,\n",
    "    \"text.color\": INK, \"axes.labelcolor\": INK,\n",
    "    \"xtick.color\": INK, \"ytick.color\": INK, \"font.size\": 10,\n",
    "})\n",
    "\n",
    "arena = load_arena(\"assets\")\n",
    "geo   = load_geometry(\"data/geometry.npz\", pairs=False)\n",
    "L, M  = load_layers(\"data/layers.npz\")\n",
    "free  = geo[\"free_cells\"]\n",
    "px, py = geo[\"px\"], geo[\"py\"]\n",
    "\n",
    "episodes = pl.read_parquet(\"data/episodes.parquet\")\n",
    "events   = pl.read_parquet(\"data/events.parquet\")\n",
    "\n",
    "TICKS_PER_SECOND = 24\n",
    "print(f\"arena      {arena.width} x {arena.height} px, \"\n",
    "      f\"lattice {M['gw']} x {M['gh']} = {M['n_cells']} cells of {M['cell']} px \"\n",
    "      f\"({int(free.sum())} standable)\")\n",
    "print(f\"episodes   {episodes.height}  \"\n",
    "      f\"({dict(episodes.group_by('game_version').len().sort('game_version').iter_rows())})\")\n",
    "print(f\"game time  {episodes['playing_ticks'].sum()/TICKS_PER_SECOND/3600:.1f} h\")\n",
    "print(f\"events     {dict(events.group_by('kind').len().sort('len', descending=True).iter_rows())}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3d3ec689",
   "metadata": {},
   "source": [
    "### Data cheat sheet (for you and your coding agent)\n",
    "\n",
    "Units first: the engine runs at **24 ticks/second**; coordinates are **map\n",
    "pixels**, 1:1 with the 1235\u00d7659 arena raster, **y pointing down**. Aim is in\n",
    "**brads**: 256 per turn, 0 = east, counter-clockwise *on screen*. Policies are\n",
    "opaque uploader ids.\n",
    "\n",
    "Two grids run through everything below. The **pixel** raster above is the fine\n",
    "one, and the per-cell fields live on a coarser **lattice** of 8 \u00d7 8 px cells,\n",
    "155 \u00d7 83 of them, which is what \"one row per lattice cell\" means in the table.\n",
    "\n",
    "| file | one row / cell per | key contents |\n",
    "|---|---|---|\n",
    "| `assets/arena.mask.bin` | map pixel | `0` floor, `1` stone, `2` glass, straight from the engine's own `wallMask` |\n",
    "| `assets/arena.geom.json` | whole file | pedestals, spawns, capture zones, pickup points, tuning constants |\n",
    "| `data/geometry.npz` | lattice cell | `exposure`, `threat`, `glass_delta`, `sightline_*`, `travel_*`, `betweenness_*` |\n",
    "| `data/layers.npz` | lattice cell | `occupancy_*`, `attention_*`, `bullets`, `death`, `lethal_shot`, `shot`, `hit`, `carrier_*`, `policy::*` |\n",
    "| `data/episodes.parquet` | episode | `winner, is_draw, ticks, kills, captures, steals, shots, policies` |\n",
    "| `data/events.parquet` | event | `tick, kind, source, target, weapon, x, y, source_policy, target_policy` |\n",
    "| `data/runs.parquet` | heart carry | `outcome, ticks, path_px, start/end x y, centre_lane, efficiency` |\n",
    "| `data/mined/*.frames.bin` | tick \u00d7 seat | positions, aim, hp, lives, carry/shield/grenade flags, pending shot |\n",
    "\n",
    "Two words that get used precisely throughout:\n",
    "\n",
    "- **exposure**: how many cells can *see* you here. Glass is transparent, so this\n",
    "  counts through windows.\n",
    "- **threat**: how many cells can *shoot* you here. Glass stops bullets, so this\n",
    "  is the strictly smaller set. Their difference is `glass_delta`."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "789ec77a",
   "metadata": {},
   "source": [
    "## 1 \u00b7 Cold open: the board, and what 995 games did to it\n",
    "\n",
    "Before any method, four pictures. Top left is the arena's own geometry: how much\n",
    "of the map can see each patch of ground, computed from the walls alone, with no\n",
    "game played on it. The other three are the corpus: where players stood, where\n",
    "the bullets flew, and where the bodies fell.\n",
    "\n",
    "Read them together and the notebook's argument is already visible. The bright,\n",
    "wide-open ground on the *exposure* map is the two endzones, the ground the most\n",
    "of the map can see, and on the *death* map almost all of it is cold. The killing\n",
    "is in the cluttered middle, in the dark.\n",
    "\n",
    "There is one exception, and the rest of the notebook keeps running into it: the\n",
    "two pedestals themselves burn hot. The most-seen ground on the board is safe\n",
    "everywhere except at the one thing worth coming for."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1a80f6b9",
   "metadata": {},
   "outputs": [],
   "source": [
    "occ   = L[\"occupancy_red\"] + L[\"occupancy_blue\"]\n",
    "grid  = lambda v: to_grid(v, M)\n",
    "freeg = grid(free).astype(bool)\n",
    "\n",
    "fig, axes = plt.subplots(2, 2, figsize=(19, 11))\n",
    "r, _ = field_layer(grid(geo[\"exposure\"]), freeg, cmap=\"viridis\")\n",
    "show(axes[0, 0], arena, r, title=\"the board: exposure (how much of the map can SEE you)\")\n",
    "r, _ = density_layer(grid(occ))\n",
    "show(axes[0, 1], arena, r, title=f\"the play: {occ.sum()/1e6:.0f}M alive player-ticks\")\n",
    "r, _ = density_layer(grid(L[\"bullets\"]), cmap=\"inferno\")\n",
    "show(axes[1, 0], arena, r, title=f\"the fire: {len(events.filter(pl.col('kind')=='shot')):,} reconstructed bullet lines\")\n",
    "r, _ = density_layer(grid(L[\"death\"]), cmap=\"magma\", sigma=1.4)\n",
    "show(axes[1, 1], arena, r, title=f\"the cost: {int(L['death'].sum()):,} deaths\")\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7d34863b",
   "metadata": {},
   "source": [
    "Diamonds are the two heart pedestals; dashed lines are the capture-zone\n",
    "thresholds; small markers are the fixed pickups (med kits on the centre line,\n",
    "shields and spray cans in the back columns, grenades in the corners)."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0cc06e92",
   "metadata": {},
   "source": [
    "## 2 \u00b7 Scenario: \u201cWhat does this map actually offer?\u201d\n",
    "\n",
    "Start with the board alone. Everything in this section is computed from the\n",
    "terrain raster, with no replays involved, which makes it an honest prediction\n",
    "fixed before any game is looked at. The interesting results later are all places\n",
    "where the games disagree with it.\n",
    "\n",
    "The arena is 14.5% cover by area, laid out as five staggered columns per half\n",
    "(border stubs, diamonds, discs, 45\u00b0 chevrons, and diamonds flanking the heart\n",
    "ring) plus a square-bracket wall framing the centre. Every shape is placed in\n",
    "the left half and **mirrored**, and the raster is pixel-exact under that mirror.\n",
    "That symmetry is what lets any red/blue difference later be read as behaviour\n",
    "rather than as terrain."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "694d81dc",
   "metadata": {},
   "outputs": [],
   "source": [
    "print(f\"terrain: {(arena.mask==0).sum():,} floor  {(arena.mask==1).sum():,} stone  \"\n",
    "      f\"{(arena.mask==2).sum():,} glass   ({100*(arena.mask!=0).mean():.1f}% cover)\")\n",
    "print(f\"pixel-exact under the x-mirror: {arena.mirror_exact()}\")\n",
    "\n",
    "# Every derived field is folded on the mirror during the bake, so this is a\n",
    "# check that the bake did its job rather than a discovery in its own right.\n",
    "mirror = np.arange(M[\"n_cells\"]).reshape(M[\"gh\"], M[\"gw\"])[:, ::-1].reshape(-1)\n",
    "for k in (\"exposure\", \"threat\", \"glass_delta\", \"betweenness_routes\"):\n",
    "    err = np.abs(geo[k].astype(float) - geo[k].astype(float)[mirror]).max()\n",
    "    print(f\"  {k:20s} max |field - mirror(field)| = {err:g}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d2e31a08",
   "metadata": {},
   "source": [
    "### An aside: eight of these obstacles are spinning\n",
    "\n",
    "Watch a match and the **eight diamonds flanking the centre** (column 5 and its\n",
    "mirror) rotate slowly on the spot, a quarter turn every ~2.7 seconds. A diamond\n",
    "is four-fold symmetric, so that is a full visual cycle.\n",
    "\n",
    "They are **decoration**. The engine draws them as live sprites instead of baked\n",
    "wall art, but collision, line of sight and the fog masks all keep the exact\n",
    "static diamond, and the spin never enters the state hash. Structurally it could\n",
    "not be otherwise: the wall mask is built once when the server starts and never\n",
    "reassigned, and the obstacle test takes no tick argument. There is also\n",
    "independent proof sitting in this corpus, in that all 995 replays re-simulated\n",
    "bit-exact against their recorded per-tick hashes, which a time-varying wall\n",
    "would have desynced immediately.\n",
    "\n",
    "This matters because it is a trap for anyone deriving terrain from what the game\n",
    "*looks* like. The renderer keeps a second mask that **deletes those eight\n",
    "diamonds** so the spinning sprites can be drawn over floor. Read the board off a\n",
    "screenshot or the broadcast art and you get an arena with eight holes punched\n",
    "through the middle, precisely where the heart-ring fights happen. Every\n",
    "sightline, exposure value and shortest path through the centre would be wrong.\n",
    "\n",
    "The raster here is taken from the sim's own collision mask rather than from\n",
    "anything drawn, so the diamonds are solid. That is worth checking rather than\n",
    "asserting:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "18bb734c",
   "metadata": {},
   "outputs": [],
   "source": [
    "# The eight animated diamonds: column 5 at x = 565 and its mirror at x = 669\n",
    "# (the pixel mirror is width-1-x), radius 30, four heights each.\n",
    "centres = [(x, y) for x in (565, arena.width - 1 - 565)\n",
    "                  for y in (156, 252, 406, 502)]\n",
    "for cx, cy in centres:\n",
    "    ys, xs = np.mgrid[cy-30:cy+31, cx-30:cx+31]\n",
    "    inside = (np.abs(xs - cx) + np.abs(ys - cy)) <= 30      # the diamond itself\n",
    "    solid = arena.mask[ys[inside], xs[inside]] != 0\n",
    "    print(f\"  spinning diamond ({cx:4d},{cy:3d}): \"\n",
    "          f\"{100*solid.mean():5.1f}% of its footprint is solid in the raster\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ec2473f2",
   "metadata": {},
   "outputs": [],
   "source": [
    "fig, axes = plt.subplots(2, 2, figsize=(19, 11))\n",
    "for ax, (key, title, cmap) in zip(axes.ravel(), [\n",
    "    (\"exposure\",  \"exposure: cells that can SEE you (glass is transparent)\",  \"viridis\"),\n",
    "    (\"threat\",    \"threat: cells that can SHOOT you (glass stops bullets)\",   \"magma\"),\n",
    "    (\"glass_delta\", \"glass delta: seen but NOT shootable\",                    \"cividis\"),\n",
    "    (\"betweenness_routes\", \"designed routes: spawn \u2192 pedestal shortest paths crossing here\", \"inferno\"),\n",
    "]):\n",
    "    r, (lo, hi) = field_layer(grid(geo[key]), freeg, cmap=cmap)\n",
    "    show(ax, arena, r, title=f\"{title}   [{lo:.0f} \u2026 {hi:.0f}]\")\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "263c86da",
   "metadata": {},
   "outputs": [],
   "source": [
    "n = int(free.sum())\n",
    "e, t, d = geo[\"exposure\"][free], geo[\"threat\"][free], geo[\"glass_delta\"][free]\n",
    "print(f\"exposure   median {np.median(e):.0f} cells \"\n",
    "      f\"({100*np.median(e)/n:.0f}% of the standable map can see a typical spot)\")\n",
    "print(f\"threat     median {np.median(t):.0f} cells ({100*np.median(t)/n:.0f}%)\")\n",
    "print(f\"glass gives a mean {d.mean():.0f} extra watchers who cannot shoot; \"\n",
    "      f\"nonzero on {100*(d>0).mean():.0f}% of the ground, up to {d.max()}\")\n",
    "print(f\"sightlines mean {geo['sightline_sight_mean'][free].mean():.0f} px, \"\n",
    "      f\"longest {geo['sightline_sight_max'][free].max():.0f} px \"\n",
    "      f\"(the map is {arena.width} px wide; no shot crosses it)\")\n",
    "\n",
    "band = arena.band_index(px)\n",
    "endzone = (band == 0) | (band == len(arena.band_names) - 1)\n",
    "print(f\"\\nmean exposure: endzones {geo['exposure'][free & endzone].mean():.0f}\"\n",
    "      f\"  vs midfield {geo['exposure'][free & ~endzone].mean():.0f}\"\n",
    "      f\"  \u2192 home ground is {geo['exposure'][free & endzone].mean()/geo['exposure'][free & ~endzone].mean():.2f}x more exposed\")\n",
    "\n",
    "t_ped = geo[\"travel_pedestal_red\"]\n",
    "bx, by = arena.pedestal(\"blue\")\n",
    "walk = t_ped[by, bx]\n",
    "speed = arena.geom[\"consts\"][\"maxSpeed\"] / arena.geom[\"consts\"][\"motionScale\"]\n",
    "print(f\"\\npedestal to pedestal: {walk:.0f} px by the shortest walkable path = \"\n",
    "      f\"{walk/speed/TICKS_PER_SECOND:.1f}s at full speed, \"\n",
    "      f\"{walk/(speed*arena.geom['consts']['carrierSpeedPct']/100)/TICKS_PER_SECOND:.1f}s carrying a heart\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "64470fb4",
   "metadata": {},
   "source": [
    "### Verdict: the map's most exposed ground is your own home\n",
    "\n",
    "Two things fall out of the geometry that are worth carrying into every scenario\n",
    "below.\n",
    "\n",
    "**The endzones are the open ground.** The capture columns are deliberately\n",
    "clear, with nothing to hide behind all the way to the back wall, so home ground\n",
    "is **1.7\u00d7 more exposed** than the cluttered middle. The place you respawn is the\n",
    "place the most of the map can see.\n",
    "\n",
    "**The mid-field slalom works exactly as designed.** No sightline anywhere on the\n",
    "board reaches even 1,140 px on a 1,235 px map, and the typical one is 159 px,\n",
    "about five body-lengths. Guns are effectively map-wide, so the walls are the\n",
    "entire reason a rush is survivable.\n",
    "\n",
    "And the glass does real work: on 91% of the ground somebody can watch you who\n",
    "cannot shoot you, up to 2,039 cells' worth. That asymmetry has no analogue\n",
    "anywhere else in the engine.\n",
    "\n",
    "**Hand your agent:** `data/geometry.npz` (`exposure`, `threat`, `glass_delta`,\n",
    "`travel_*`) plus `assets/arena.geom.json`.\n",
    "\n",
    "> *\"Given per-cell exposure and threat fields for this arena, write a\n",
    "> positioning term that prefers ground where `threat` is low but `exposure` is\n",
    "> high, meaning places I can watch from without being shot from, and show me\n",
    "> where on the map that term is maximised.\"*"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "264271d8",
   "metadata": {},
   "source": [
    "## 3 \u00b7 Scenario: \u201cWhere does the game actually happen?\u201d\n",
    "\n",
    "Now the corpus. Two questions: where do bodies spend their time, and where is\n",
    "the border between the two halves *in practice*?\n",
    "\n",
    "The second is the more interesting one. The map's midline is at x = 617 by\n",
    "construction. The **front line**, meaning the contour where red presence and\n",
    "blue presence are equal, is an empirical object, and it does not have to sit\n",
    "there."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7c06db2e",
   "metadata": {},
   "outputs": [],
   "source": [
    "red, blue = L[\"occupancy_red\"], L[\"occupancy_blue\"]\n",
    "\n",
    "fig, axes = plt.subplots(1, 2, figsize=(19, 5.6))\n",
    "r, _ = density_layer(grid(red + blue))\n",
    "show(axes[0], arena, r, title=\"occupancy: all alive player-ticks\")\n",
    "r, norm, base = share_layer(grid(red), grid(blue))\n",
    "show(axes[1], arena, r, title=f\"territory: red's share of presence \"\n",
    "                              f\"(white = the corpus base rate, {base:.2f})\")\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9b102a06",
   "metadata": {},
   "outputs": [],
   "source": [
    "# The front line: per row of the lattice, the x where red and blue presence cross.\n",
    "gw, gh = M[\"gw\"], M[\"gh\"]\n",
    "R, B = grid(red), grid(blue)\n",
    "xs = np.array([px[:gw][j] for j in range(gw)], dtype=float)\n",
    "front = []\n",
    "for row in range(gh):\n",
    "    share = np.divide(R[row], R[row] + B[row],\n",
    "                      out=np.full(gw, np.nan), where=(R[row] + B[row]) > 500)\n",
    "    ok = np.isfinite(share)\n",
    "    if ok.sum() < 4:\n",
    "        front.append(np.nan); continue\n",
    "    idx = np.flatnonzero(ok)\n",
    "    cross = np.flatnonzero((share[idx][:-1] - 0.5) * (share[idx][1:] - 0.5) <= 0)\n",
    "    front.append(xs[idx[cross[len(cross)//2]]] if len(cross) else np.nan)\n",
    "front = np.array(front)\n",
    "print(f\"front line: median x = {np.nanmedian(front):.0f} px \"\n",
    "      f\"(the geometric midline is {arena.center[0]})\")\n",
    "print(f\"            spread {np.nanpercentile(front, 10):.0f} \u2026 \"\n",
    "      f\"{np.nanpercentile(front, 90):.0f} px across the map's height\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "349f8c33",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Do bots prefer cover? Weight the map's own exposure field by where they stand.\n",
    "w_occ   = (red + blue)[free] / (red + blue)[free].sum()\n",
    "w_death = L[\"death\"][free] / max(L[\"death\"][free].sum(), 1)\n",
    "expo    = geo[\"exposure\"][free].astype(float)\n",
    "print(f\"exposure of the average standable cell : {expo.mean():.0f}\")\n",
    "print(f\"exposure where players actually stand  : {(expo*w_occ).sum():.0f}\"\n",
    "      f\"   ({100*((expo*w_occ).sum()/expo.mean()-1):+.0f}%)\")\n",
    "print(f\"exposure where players actually die    : {(expo*w_death).sum():.0f}\"\n",
    "      f\"   ({100*((expo*w_death).sum()/expo.mean()-1):+.0f}%)\")\n",
    "\n",
    "corr = lambda a, b, m: float(np.corrcoef(np.asarray(a,float)[m], np.asarray(b,float)[m])[0,1])\n",
    "print(f\"\\nhow well the designed routes predict where players actually go: \"\n",
    "      f\"correlation {corr(geo['betweenness_routes'], red+blue, free):+.3f}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7705c011",
   "metadata": {},
   "source": [
    "### Verdict: they play in the cover, and the front line sits where the map put it\n",
    "\n",
    "Presence-weighted exposure comes in about **10% below** the map average: bots do\n",
    "prefer ground fewer cells can see, but only mildly, at the level of a positional\n",
    "tendency rather than a discipline.\n",
    "\n",
    "The front line lands essentially on the geometric midline, and the map's\n",
    "*designed* routes (spawn \u2192 enemy pedestal shortest paths) correlate with\n",
    "observed traffic at only **+0.24**, on a scale where 0 is no relationship at all\n",
    "and +1 is perfect prediction. The walls set the boundary; they do not\n",
    "choose the roads. Most of the traffic runs through the two mid-field columns\n",
    "either side of the bracket, well off those shortest paths.\n",
    "\n",
    "**Hand your agent:** `data/layers.npz` (`occupancy_red`, `occupancy_blue`) and\n",
    "`geometry.npz` (`betweenness_routes`).\n",
    "\n",
    "> *\"Compare where my policy stands against the corpus occupancy map, cell by\n",
    "> cell. Report the ten cells where my policy is most over-represented and most\n",
    "> under-represented, and tell me what those cells have in common.\"*"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7b52649f",
   "metadata": {},
   "source": [
    "## 4 \u00b7 Scenario: \u201cWhich sightlines actually get used?\u201d\n",
    "\n",
    "A shot is a hitscan along the aim angle locked at the trigger pull, released\n",
    "five ticks later from wherever the shooter has moved to. That makes a shot a\n",
    "**line** rather than a point, so a map of shot *origins* tells you almost\n",
    "nothing about which lanes are live.\n",
    "\n",
    "Every one of the 218,333 bullets below was reconstructed from the frame stream\n",
    "alone: a shot releases on the tick a seat's windup counter runs out from 1, and\n",
    "the ray is (position at release, angle locked at the pull). That reconstruction\n",
    "was checked against the engine's own shot events, where tick, seat and position\n",
    "must match exactly on every shot in every episode. The agreement is also the\n",
    "strongest evidence that the mined frames are faithful."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "34e4c8b6",
   "metadata": {},
   "outputs": [],
   "source": [
    "fig, axes = plt.subplots(1, 2, figsize=(19, 5.6))\n",
    "r, _ = density_layer(grid(L[\"shot\"]), cmap=\"inferno\", sigma=1.4)\n",
    "show(axes[0], arena, r, title=\"shot ORIGINS: where triggers were pulled\")\n",
    "r, _ = density_layer(grid(L[\"bullets\"]), cmap=\"inferno\")\n",
    "show(axes[1], arena, r, title=\"bullet TRAFFIC: the same shots, drawn as lines\")\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5331dd22",
   "metadata": {},
   "outputs": [],
   "source": [
    "# How concentrated is fire, compared with how concentrated bodies are?\n",
    "def top_share(values, mask, frac):\n",
    "    v = np.sort(np.asarray(values, float)[mask])[::-1]\n",
    "    return float(np.cumsum(v)[max(int(frac*len(v)) - 1, 0)] / v.sum())\n",
    "\n",
    "bull = L[\"bullets\"]\n",
    "print(f\"cells a bullet ever crossed: {int((free & (bull>0)).sum())} \"\n",
    "      f\"of {int(free.sum())} standable ({100*(free & (bull>0)).sum()/free.sum():.0f}%)\")\n",
    "for f in (0.05, 0.10, 0.25):\n",
    "    print(f\"  busiest {100*f:>2.0f}% of cells carry {100*top_share(bull, free, f):.0f}% \"\n",
    "          f\"of bullet crossings   (of presence: {100*top_share(occ, free, f):.0f}%)\")\n",
    "print(f\"\\nhow well threat (the fire the geometry allows) predicts real bullet traffic: \"\n",
    "      f\"correlation {corr(geo['threat'], bull, free):+.3f}\")\n",
    "\n",
    "shots = len(events.filter(pl.col(\"kind\") == \"shot\"))\n",
    "hits  = len(events.filter(pl.col(\"kind\") == \"hit\"))\n",
    "print(f\"\\naccuracy: {hits:,} hits / {shots:,} shots = {100*hits/shots:.0f}%\")\n",
    "print(dict(events.filter(pl.col('kind')=='kill')\n",
    "                 .group_by('weapon').len().sort('len', descending=True).iter_rows()))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "639218e6",
   "metadata": {},
   "source": [
    "### Verdict: there is no ground that fire does not reach\n",
    "\n",
    "**A bullet crossed every single standable cell on this map** at some point in\n",
    "995 games. There is no safe corner, only corners that are crossed rarely.\n",
    "\n",
    "And fire is *less* concentrated than presence: the busiest tenth of cells hold\n",
    "**49% of all presence** but only **32% of all bullet crossings**. That is the\n",
    "mechanical difference between a body, which occupies one cell, and a hitscan\n",
    "shot, which sweeps a whole lane. It is also why cover in this arena buys angles\n",
    "rather than safety.\n",
    "\n",
    "And where the fire goes is the **opposite** of where the geometry allows most of\n",
    "it: bullet traffic runs *against* the `threat` field, at a correlation of\n",
    "**\u22120.30**. The most\n",
    "shootable-through ground on the board is the open endzone, and almost nothing is\n",
    "shot there. Fire follows the fight, and the fight is in the cover. \u00a75 finds the\n",
    "same inversion for deaths, and it arrives here first. The bright lanes are the\n",
    "diagonals the chevron columns open across the midfield, crossing in an X that no\n",
    "shortest path uses.\n",
    "\n",
    "58% of shots connect, which is very high for a game with map-wide guns and a\n",
    "0.2s windup. The corollary for policy design: taking a shot is cheap and usually\n",
    "rewarded, so the interesting decision is which lane you are standing in when you\n",
    "fire, rather than whether to fire at all.\n",
    "\n",
    "**Hand your agent:** `data/layers.npz` (`bullets`, `shot`), and\n",
    "`events.parquet` filtered to `kind == \"shot\"`.\n",
    "\n",
    "> *\"Overlay my policy's bullet-traffic map on the corpus one. Am I shooting down\n",
    "> the same lanes as everyone else? If I am shooting down lanes that carry little\n",
    "> corpus traffic, tell me whether that is an edge or a waste by checking my hit\n",
    "> rate on those lanes specifically.\"*"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a66f7251",
   "metadata": {},
   "source": [
    "## 5 \u00b7 Scenario: \u201cWhere do I die, per unit of time spent there?\u201d\n",
    "\n",
    "A raw death map mostly redraws the occupancy map: things happen where people\n",
    "are. The question that matters is a **rate**: of the ticks spent standing here,\n",
    "what fraction ended in a death?\n",
    "\n",
    "And then the real question behind it: what property of a place predicts that\n",
    "rate? The map offers two candidates from \u00a72, how many cells can see you\n",
    "(`exposure`) and how many can shoot you (`threat`), plus one that needs the\n",
    "replays: how many cells were *actually looking* at you."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ce015efe",
   "metadata": {},
   "outputs": [],
   "source": [
    "occ_all = red + blue\n",
    "enough  = free & (occ_all > 2000)          # traffic floor, so a rate means something\n",
    "rate = np.zeros_like(occ_all); rate[enough] = L[\"death\"][enough] / occ_all[enough]\n",
    "\n",
    "fig, axes = plt.subplots(1, 2, figsize=(19, 5.6))\n",
    "r, _ = density_layer(grid(L[\"death\"]), cmap=\"magma\", sigma=1.4)\n",
    "show(axes[0], arena, r, title=\"deaths: where the bodies fell\")\n",
    "r, hi = ratio_layer(grid(L[\"death\"]), grid(occ_all), min_den=2000, sigma=1.3)\n",
    "show(axes[1], arena, r, title=f\"lethality: deaths per player-tick (clipped at {1000*hi:.1f}/1k)\")\n",
    "plt.tight_layout(); plt.show()\n",
    "print(f\"death rate per 1000 player-ticks: median {1000*np.median(rate[enough]):.2f}, \"\n",
    "      f\"95th pct {1000*np.percentile(rate[enough],95):.2f}, \"\n",
    "      f\"max {1000*rate[enough].max():.2f}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "39e9c8b2",
   "metadata": {},
   "source": [
    "Now the attention model, the one layer here that could not be computed any other\n",
    "way. Vision is a \u00b145\u00b0 cone around a player's **aim**, unlimited in range,\n",
    "stopped by stone but not by glass, plus a 90 px omnidirectional bubble. We know\n",
    "every player's exact position *and* aim on every tick, so we know exactly which\n",
    "ground each of them was looking at.\n",
    "\n",
    "Rasterising tens of millions of vision cones one at a time is not affordable.\n",
    "Instead the whole corpus is reduced to a histogram over (cell, aim-bucket)\n",
    "states, a few hundred thousand of them, and the map falls out as one product\n",
    "against the pre-baked cell-to-cell visibility relation. That reduction is what\n",
    "makes this layer exist; it is baked into `layers.npz` as `attention_red` and\n",
    "`attention_blue`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0129f8ae",
   "metadata": {},
   "outputs": [],
   "source": [
    "att = L[\"attention_red\"] + L[\"attention_blue\"]\n",
    "watch = np.zeros_like(att); watch[enough] = att[enough] / occ_all[enough]\n",
    "\n",
    "# Attention spans under one decade (9x from the least-watched ground to the\n",
    "# most watched), so it wants a linear field with percentile clipping. On the log\n",
    "# scale the occupancy layers use, the whole map saturates.\n",
    "lo, hi = np.percentile(att[free], [2, 98])\n",
    "fig, axes = plt.subplots(1, 2, figsize=(19, 5.6))\n",
    "r, _ = field_layer(grid(att), freeg, cmap=\"cividis\", vmin=lo, vmax=hi)\n",
    "show(axes[0], arena, r, dim=0.30,\n",
    "     title=f\"attention: where the arena was actually WATCHED  [{lo/1e6:.1f}M \u2026 {hi/1e6:.1f}M]\")\n",
    "r, top = ratio_layer(grid(att), grid(occ_all), min_den=2000, sigma=1.2, cmap=\"cividis\")\n",
    "show(axes[1], arena, r, dim=0.30,\n",
    "     title=f\"watchedness: enemy eyes per unit of traffic  (clipped at {top:.0f})\")\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "a7d1d872",
   "metadata": {},
   "outputs": [],
   "source": [
    "mid = free & (band >= 2) & (band <= len(arena.band_names) - 3)\n",
    "print(\"correlation with the death rate  (0 = no relationship, +1 = moves together):\")\n",
    "for name, field in ((\"exposure  (could see you)\", geo[\"exposure\"]),\n",
    "                    (\"threat    (could shoot you)\", geo[\"threat\"]),\n",
    "                    (\"attention (was looking at you)\", att)):\n",
    "    print(f\"  {name:32s} whole map {corr(field, rate, enough):+.3f}\"\n",
    "          f\"   midfield only {corr(field, rate, enough & mid):+.3f}\")\n",
    "\n",
    "sel = np.flatnonzero(enough & mid)\n",
    "e_mid = geo[\"exposure\"][sel].astype(float)\n",
    "edges = np.quantile(e_mid, np.linspace(0, 1, 6))\n",
    "print(\"\\nmidfield death rate per 1000 player-ticks, by exposure quintile:\")\n",
    "for i in range(5):\n",
    "    s = sel[(e_mid >= edges[i]) & (e_mid <= edges[i+1])]\n",
    "    print(f\"  Q{i+1}  exposure {edges[i]:4.0f}-{edges[i+1]:4.0f}   n={len(s):4d}   \"\n",
    "          f\"{1000*L['death'][s].sum()/occ_all[s].sum():.2f}\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0fa7172d",
   "metadata": {},
   "outputs": [],
   "source": [
    "busy = enough & (occ_all > np.quantile(occ_all[enough], 0.75))\n",
    "order = np.argsort(watch[busy]); idx = np.flatnonzero(busy)\n",
    "print(\"busiest quartile of the map, LEAST watched per unit of traffic (the blind spots):\")\n",
    "for c in idx[order][:5]:\n",
    "    print(f\"  ({px[c]:4d},{py[c]:4d})  {arena.zone_name(int(px[c]), int(py[c])):28s}\"\n",
    "          f\"  traffic {occ_all[c]:8.0f}  watchedness {watch[c]:6.0f}\"\n",
    "          f\"  deaths/1k {1000*rate[c]:.2f}\")\n",
    "print(\"\\n...and the most watched:\")\n",
    "for c in idx[order][-3:]:\n",
    "    print(f\"  ({px[c]:4d},{py[c]:4d})  {arena.zone_name(int(px[c]), int(py[c])):28s}\"\n",
    "          f\"  traffic {occ_all[c]:8.0f}  watchedness {watch[c]:6.0f}\"\n",
    "          f\"  deaths/1k {1000*rate[c]:.2f}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cb246cef",
   "metadata": {},
   "source": [
    "### Verdict: cover is where you die, and attention is the tell\n",
    "\n",
    "This is the notebook's sharpest result, and it runs against intuition.\n",
    "\n",
    "**Exposure is *negatively* correlated with the death rate** (\u22120.17 map-wide,\n",
    "\u22120.16 inside the midfield alone, so the safe endzones are not doing the talking\n",
    "on their own). Split into five equal groups by exposure, the most hidden\n",
    "midfield ground kills at **1.67 per thousand player-ticks** and the fourth group\n",
    "at **0.79**, less than half. Ground that everyone can see is ground nobody contests; the fights\n",
    "are in the cover, because the cover is worth having.\n",
    "\n",
    "The trend runs one way through four of those groups and then turns back up in\n",
    "the fifth (1.16), and that tail is worth naming rather than smoothing over: the\n",
    "most\n",
    "exposed midfield ground is the ground closest to the endzones, which is where\n",
    "the pedestal fights are. Very exposed is safe *because it is empty*, right up\n",
    "until it is the approach to a heart.\n",
    "\n",
    "**Attention flips the sign.** How many cells *were looking at you* correlates\n",
    "**+0.14** with dying, where \"could see you\" correlates \u22120.17. Being visible in\n",
    "principle is nearly harmless; being looked at is the risk. In a game where\n",
    "vision rides on aim, those are very different quantities, and only one of them\n",
    "is knowable from the map.\n",
    "\n",
    "The gap between them is exploitable. Across the busiest quarter of the arena,\n",
    "watchedness spans about **5\u00d7 between the least- and most-watched decile**, and\n",
    "the blind spots are consistent: the ground immediately behind the diamond and\n",
    "chevron columns on the centre row, which carries enormous traffic on a fiftieth\n",
    "of the attention, at a death rate 10\u201325\u00d7 below the most-watched ground near the\n",
    "enemy stub column.\n",
    "\n",
    "**Hand your agent:** `layers.npz` (`attention_red`, `attention_blue`,\n",
    "`occupancy_*`, `death`) and the blind-spot table above.\n",
    "\n",
    "> *\"Build a per-cell 'watchedness' field from attention \u00f7 occupancy. Add a term\n",
    "> to my movement policy that, when two candidate positions are otherwise equal,\n",
    "> prefers the one with lower watchedness. Re-measure my death rate per\n",
    "> player-tick and tell me whether it moved.\"*"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "fe50327b",
   "metadata": {},
   "source": [
    "## 6 \u00b7 Scenario: \u201cWhere do heart runs die?\u201d\n",
    "\n",
    "A run is the only thing on this board that has a fixed start and a fixed goal\n",
    "under a deadline, which makes it the cleanest object to measure. Touch the enemy\n",
    "heart and carry it home at 70% speed without dying, because the heart returns to\n",
    "its pedestal the instant the carrier drops.\n",
    "\n",
    "The bundle ships the full per-tick frames for a sample of episodes, so this\n",
    "section is recomputed live rather than read from a pre-baked grid."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "fdd8f1d3",
   "metadata": {},
   "outputs": [],
   "source": [
    "import json, os\n",
    "from ctf_analysis.frames import load_frames, load_meta, load_events\n",
    "from ctf_analysis.routes import extract_runs, route_efficiency, lane_of\n",
    "from ctf_analysis.aggregate import _team_of\n",
    "\n",
    "index = json.loads(Path(\"data/index.json\").read_text())\n",
    "runs = []\n",
    "for ep in index[\"episodes\"]:\n",
    "    fb = Path(f\"data/mined/{ep['episode_id']}.frames.bin\")\n",
    "    if not fb.exists():\n",
    "        continue\n",
    "    fr = load_frames(fb)\n",
    "    meta = load_meta(f\"data/mined/{ep['episode_id']}.meta.json\")\n",
    "    evs = load_events(f\"data/mined/{ep['episode_id']}.events.jsonl\")\n",
    "    runs += extract_runs(fr, ep[\"episode_id\"], _team_of(meta), evs)\n",
    "\n",
    "caps = [r for r in runs if r.outcome == \"capture\"]\n",
    "lost = [r for r in runs if r.outcome == \"dropped\"]\n",
    "print(f\"{len(runs)} heart carries in the bundled sample \"\n",
    "      f\"({len(caps)} captures, {len(lost)} lost, \"\n",
    "      f\"{len(runs)-len(caps)-len(lost)} still live at the final tick)\")\n",
    "print(f\"capture rate {100*len(caps)/len(runs):.1f}% of steals\")\n",
    "print(f\"carry length: captured median {np.median([r.ticks for r in caps])/TICKS_PER_SECOND:.1f}s\"\n",
    "      f\"  |  lost median {np.median([r.ticks for r in lost])/TICKS_PER_SECOND:.1f}s\")\n",
    "print(f\"{100*np.mean([r.ticks <= TICKS_PER_SECOND for r in lost]):.0f}% of lost runs \"\n",
    "      f\"ended within ONE SECOND of the steal\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7f37cb05",
   "metadata": {},
   "outputs": [],
   "source": [
    "fig, ax = plt.subplots(figsize=(15, 8))\n",
    "ax.imshow(basemap(arena, dim=0.26), extent=[0, arena.width, arena.height, 0])\n",
    "for r in lost:\n",
    "    ax.plot(r.path[:, 0], r.path[:, 1], color=\"#c9a37c\", lw=0.9, alpha=0.55, zorder=4)\n",
    "for r in caps:\n",
    "    ax.plot(r.path[:, 0], r.path[:, 1],\n",
    "            color=RED if r.team == \"red\" else BLUE, lw=1.4, alpha=0.9, zorder=6)\n",
    "arena_axes(ax, arena, title=\"every heart carry: brown = lost, team colour = made it home\")\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6310eb24",
   "metadata": {},
   "source": [
    "The sample is 60 games; the same extraction over all 995 is shipped as\n",
    "`runs.parquet`, so every number below is the corpus one. (Re-derive it yourself\n",
    "by pointing the loop above at a full mined directory; the code is identical.)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e6cf618a",
   "metadata": {},
   "outputs": [],
   "source": [
    "all_runs = pl.read_parquet(\"data/runs.parquet\")\n",
    "print(f\"{all_runs.height:,} heart carries across the full 995-game corpus\")\n",
    "print(dict(all_runs.group_by(\"outcome\").len().sort(\"len\", descending=True).iter_rows()))\n",
    "print(f\"capture rate {100*(all_runs['outcome']=='capture').mean():.1f}% of steals\")\n",
    "\n",
    "made = all_runs.filter(pl.col(\"outcome\") == \"capture\")\n",
    "died = all_runs.filter(pl.col(\"outcome\") == \"dropped\")\n",
    "print(f\"\\ncarry length: captured median \"\n",
    "      f\"{made['ticks'].median()/TICKS_PER_SECOND:.1f}s  |  lost median \"\n",
    "      f\"{died['ticks'].median()/TICKS_PER_SECOND:.1f}s\")\n",
    "print(f\"{100*died.select((pl.col('ticks') <= TICKS_PER_SECOND).mean()).item():.0f}% of \"\n",
    "      f\"lost carries ended within ONE SECOND of the steal\")\n",
    "print(f\"successful runs walk {made['efficiency'].median():.2f}x the shortest walkable distance \"\n",
    "      f\"home (p90 {made['efficiency'].quantile(0.9):.2f}); pathing is not the bottleneck\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5548387f",
   "metadata": {},
   "outputs": [],
   "source": [
    "crossed = all_runs.filter(pl.col(\"centre_lane\") != \"\")\n",
    "print(f\"{crossed.height} of {all_runs.height} carries \"\n",
    "      f\"({100*crossed.height/all_runs.height:.0f}%) ever reached the centre column\")\n",
    "print(\"\\nof the runs that got that far, by lane at the crossing:\")\n",
    "by_lane = (crossed.group_by(\"centre_lane\")\n",
    "                  .agg(pl.len().alias(\"n\"),\n",
    "                       (pl.col(\"outcome\") == \"capture\").mean().alias(\"rate\")))\n",
    "rates = dict(by_lane.select(\"centre_lane\", \"n\").iter_rows())\n",
    "pct = dict(by_lane.select(\"centre_lane\", \"rate\").iter_rows())\n",
    "for lane in LANE_NAMES:\n",
    "    if rates.get(lane):\n",
    "        print(f\"  {lane:7s}  n={rates[lane]:4d}   capture rate {100*pct[lane]:5.1f}%\")\n",
    "\n",
    "bands = [arena.band_names[int(arena.band_index(np.array([x]))[0])]\n",
    "         for x in died[\"end_x\"].to_list()]\n",
    "print(\"\\nwhere lost carries ended:\")\n",
    "for k, v in collections.Counter(bands).most_common(5):\n",
    "    print(f\"  {k:18s} {v:5d}  ({100*v/len(bands):.0f}%)\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b982416f",
   "metadata": {},
   "source": [
    "### Verdict: the run is decided in the first second\n",
    "\n",
    "Only **13.6%** of the corpus's 2,419 heart steals reach home. But the shape of\n",
    "the failure is the finding: **34% of lost carries end within one second of the\n",
    "steal**, and only **24% of all carries ever reach the centre column**. The\n",
    "pedestal sits deep inside the enemy pocket, in the most-watched ground on the\n",
    "board (\u00a75), which makes the steal itself the dangerous act and the journey home\n",
    "the comparatively survivable part.\n",
    "\n",
    "For the quarter that do get to midfield, crossing is close to a coin flip:\n",
    "**42\u201361% depending on lane**, top best, lower worst. That is a real but modest\n",
    "effect, and it only becomes visible once you exclude the runs that never got\n",
    "there. *(That exclusion carries the result: the pedestal sits dead on the centre\n",
    "row, so counting instant deaths as \"middle-lane crossings\" would dump every one\n",
    "of them into the middle lane and manufacture a ten-to-one lane effect that does\n",
    "not exist. The uncorrected version of this table is one of the most convincing\n",
    "wrong answers in the whole dataset.)*\n",
    "\n",
    "And pathing is fine: successful runs cover **1.22\u00d7** the shortest walkable\n",
    "distance home, which is close to optimal for a body with acceleration and\n",
    "friction that has to round corners.\n",
    "\n",
    "**Hand your agent:** the run table above, plus `travel_pedestal_red` and\n",
    "`travel_pedestal_blue` from `geometry.npz`.\n",
    "\n",
    "> *\"Runs die at the steal rather than on the way home. Write me an approach\n",
    "> policy for the enemy pedestal that treats the last 200 px as the dangerous\n",
    "> part: check the watchedness field before committing, and prefer to arrive from\n",
    "> the top or bottom lane rather than straight down the centre row.\"*"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "42d6894a",
   "metadata": {},
   "source": [
    "## 7 \u00b7 Scenario: \u201cIs the arena fair, and does everyone play it the same?\u201d\n",
    "\n",
    "The terrain is pixel-exact under the mirror and every derived field was folded\n",
    "so that ray-marching noise cannot masquerade as an asymmetry. So any red/blue\n",
    "difference that survives is behaviour, or luck."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "13284d55",
   "metadata": {},
   "outputs": [],
   "source": [
    "dec = episodes.filter(~pl.col(\"is_draw\"))\n",
    "rw = int((dec[\"winner\"] == \"red\").sum()); bw = int((dec[\"winner\"] == \"blue\").sum())\n",
    "draws = int(episodes[\"is_draw\"].sum())\n",
    "se = np.sqrt(0.25 / (rw + bw))\n",
    "print(f\"decisive games {rw+bw}: red {rw}, blue {bw}  \"\n",
    "      f\"\u2192 red win rate {rw/(rw+bw):.3f} \u00b1 {se:.3f} (one standard error)\")\n",
    "print(f\"draws {draws} ({100*draws/episodes.height:.0f}%)\")\n",
    "print(f\"deaths      red {int(L['death_red'].sum()):>7,}  blue {int(L['death_blue'].sum()):>7,}\")\n",
    "print(f\"heart steals {dict(all_runs.group_by('team').len().sort('team').iter_rows())}\")\n",
    "print(f\"captures     \"\n",
    "      f\"{dict(all_runs.filter(pl.col('outcome')=='capture').group_by('team').len().sort('team').iter_rows())}\")\n",
    "\n",
    "# Presence does NOT fold cleanly onto its mirror, and that is expected rather\n",
    "# than damning: in every game red and blue are two DIFFERENT policies, so\n",
    "# red-side habits are not blue-side habits reflected. The map's fairness is\n",
    "# established by the exact terrain symmetry (\u00a72) and tested by the win rate\n",
    "# above; this number measures how differently the two sides played rather\n",
    "# than how differently the map treated them.\n",
    "fold = np.abs(grid(red) - grid(blue)[:, ::-1]).sum() / (red.sum() + blue.sum())\n",
    "print(f\"\\nred presence vs mirrored blue presence: {fold:.3f} of the time is spent \"\n",
    "      f\"in places the other side's mirrored habits do not go\")\n",
    "print(f\"correlation of the same pair: \"\n",
    "      f\"{np.corrcoef(grid(red).ravel(), grid(blue)[:, ::-1].ravel())[0,1]:+.3f}\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "aea8db82",
   "metadata": {},
   "outputs": [],
   "source": [
    "pol_keys = sorted(k for k in L if k.startswith(\"policy::\"))\n",
    "fig, axes = plt.subplots(3, 4, figsize=(19, 9))\n",
    "for ax, key in zip(axes.ravel(), pol_keys):\n",
    "    r, _ = density_layer(grid(L[key]), sigma=1.2)\n",
    "    show(ax, arena, r, title=key.split(\"::\", 1)[1], landmarks=False)\n",
    "for ax in axes.ravel()[len(pol_keys):]:\n",
    "    ax.axis(\"off\")\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7473400c",
   "metadata": {},
   "outputs": [],
   "source": [
    "# One number per policy: how exposed is the ground it chooses to stand on?\n",
    "print(f\"{'policy':22s} {'player-ticks':>13s} {'mean exposure':>14s}  vs map\")\n",
    "rows = []\n",
    "for key in pol_keys:\n",
    "    g_ = L[key]\n",
    "    if g_.sum() < 1e5:\n",
    "        continue\n",
    "    w = g_[free] / g_[free].sum()\n",
    "    rows.append((key.split(\"::\", 1)[1], g_.sum(), float((expo * w).sum())))\n",
    "for name, tot, ex in sorted(rows, key=lambda r: r[2]):\n",
    "    print(f\"{name:22s} {tot:13,.0f} {ex:14.0f}  {100*(ex/expo.mean()-1):+5.0f}%\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3f20b5b6",
   "metadata": {},
   "outputs": [],
   "source": [
    "print(f\"{'':6s} {'n':>5s} {'captures/game':>14s} {'kills/game':>11s} \"\n",
    "      f\"{'median length':>14s} {'draws':>7s}\")\n",
    "for (v,), sub in episodes.group_by(\"game_version\", maintain_order=True):\n",
    "    print(f\"GV{v:4s} {sub.height:5d} {sub['captures'].mean():14.2f} \"\n",
    "          f\"{sub['kills'].mean():11.1f} \"\n",
    "          f\"{sub['playing_ticks'].median()/TICKS_PER_SECOND:12.0f}s \"\n",
    "          f\"{100*sub['is_draw'].mean():6.0f}%\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "53d55ce1",
   "metadata": {},
   "source": [
    "### Verdict: the board is fair; the policies are not interchangeable\n",
    "\n",
    "Red wins **50.8% \u00b1 1.6%** of 923 decisive games, a coin flip. Deaths split\n",
    "19,703 / 19,805. Whatever separates two policies in this league, it is not which\n",
    "edge they spawned on.\n",
    "\n",
    "Presence does *not* fold cleanly onto its mirror (about **a quarter** of one\n",
    "side's time is spent where the other side's mirrored habits do not go), and that\n",
    "is the expected answer rather than a red flag: in every game red\n",
    "and blue are two **different policies**, so red's habits are not blue's habits\n",
    "reflected. The map's fairness is established by the exact terrain symmetry in \u00a72\n",
    "and tested by the win rate; the fold measures how differently the two sides\n",
    "*played*.\n",
    "\n",
    "The per-policy panels are the opposite story. These are eleven genuinely\n",
    "different geographies: some policies live in their own half, some push to the\n",
    "midfield columns and stay there, and the presence-weighted exposure they choose\n",
    "spans a real range around the map average. That is a positional signature you\n",
    "can measure your own policy against directly.\n",
    "\n",
    "The GV22 \u2192 GV23 change (a shield that breaks when depleted, plus a 500-tick\n",
    "action clock floor) moved the numbers only slightly: captures per game\n",
    "0.32 \u2192 0.35, draws 9% \u2192 5%. The action floor did what it was meant to, and fewer\n",
    "games run out the clock. The geography barely moved.\n",
    "\n",
    "**Hand your agent:** the per-policy exposure table and `layers.npz`'s\n",
    "`policy::*` grids.\n",
    "\n",
    "> *\"Here is my policy's occupancy grid and the ten other policies' grids from\n",
    "> this corpus. Cluster them. Which policy is my nearest neighbour\n",
    "> geographically, and how does its win rate compare to mine?\"*"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9b9cd6c8",
   "metadata": {},
   "source": [
    "## 8 \u00b7 What to take away\n",
    "\n",
    "Four findings, in the order they should change what you build:\n",
    "\n",
    "1. **Attention beats exposure.** How many cells *could* see you barely predicts\n",
    "   dying (\u22120.17, wrong sign); how many *were looking at you* does (+0.14).\n",
    "   Watchedness varies about 5\u00d7 across the busy parts of the map, and the low end\n",
    "   is consistent, reachable ground behind the diamond and chevron columns. This\n",
    "   is the largest untapped lever in the notebook.\n",
    "2. **Cover is where the killing happens.** The safest ground on this map is the\n",
    "   wide-open endzone that 1.7\u00d7 more of the map can see. Contested ground is\n",
    "   cover; that is what makes it worth contesting.\n",
    "3. **Heart runs are lost at the steal.** 13.6% of steals get home, 34% of the\n",
    "   failures die inside one second, and pathing is already near-optimal\n",
    "   (1.22\u00d7 the shortest path). Effort spent on carry routes is effort misspent; spend it\n",
    "   on *how you arrive at the pedestal* instead.\n",
    "4. **The map is fair and the map is not the constraint.** Red and blue are a\n",
    "   coin flip. The map's designed routes predict traffic at only +0.24. The walls\n",
    "   set the boundary; the policies choose the roads, and they choose eleven\n",
    "   visibly different ones.\n",
    "\n",
    "### The compound prompt\n",
    "\n",
    "> *\"Using `layers.npz` and `geometry.npz` from this notebook: build a per-cell\n",
    "> watchedness field (attention \u00f7 occupancy) and a per-cell lethality field\n",
    "> (deaths \u00f7 occupancy). Add a positioning term to my policy that prefers low\n",
    "> watchedness at equal tactical value, and a separate approach policy for the\n",
    "> enemy pedestal that treats the final 200 px as the risky segment. Re-run and\n",
    "> report deaths per player-tick, steal-to-capture conversion, and win rate\n",
    "> against the same field.\"*"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "27cf2b69",
   "metadata": {},
   "source": [
    "## 9 \u00b7 Where this comes from\n",
    "\n",
    "Everything above ran on baked data. To rebuild it, or to run it on your own\n",
    "games, you need the game engine, because a `.replay` only *becomes* state when\n",
    "the engine re-simulates it.\n",
    "\n",
    "```sh\n",
    "# 1. two engine builds, pinned at the last commit of each GameVersion\n",
    "cd coworld-ctf\n",
    "git worktree add ../ctf-gv22 d0f1827        # GameVersion \"22\"\n",
    "git worktree add ../ctf-gv23 feb2ef1        # GameVersion \"23\"\n",
    "nimby --global sync nimby.lock\n",
    "# then, in each worktree:\n",
    "nim c -d:release -o:bin/mine_geo      tools/mine_geo.nim\n",
    "nim c -d:release -o:bin/dump_geometry tools/dump_geometry.nim\n",
    "\n",
    "# 2. mine, bake, aggregate\n",
    "ctf-mine ~/ctf-replays data --engines ~/Dev   # ~16 s for 995 replays on 20 cores\n",
    "ctf-geometry --assets assets --out data/geometry.npz\n",
    "ctf-aggregate --out data/layers.npz\n",
    "```\n",
    "\n",
    "The pipeline lives in **`ctf-analysis`**; the Nim tools it adds to the game repo\n",
    "are `mine_geo.nim` (per-tick state + the tier-2 event stream),\n",
    "`dump_geometry.nim` (the terrain raster, straight from the sim's own `wallMask`)\n",
    "and `los_probe.nim` (the engine's line of sight, for checking ours against it).\n",
    "\n",
    "### What is checked, and why you should care\n",
    "\n",
    "Analysis on re-simulated data is worth exactly as much as the re-simulation.\n",
    "Five things are verified before any of the above is believed:\n",
    "\n",
    "- **Every episode's per-tick hash validates.** 995 of 995, both versions. A\n",
    "  mismatch means the build is wrong and the positions are fiction; the driver\n",
    "  drops the episode and shouts rather than working around it.\n",
    "- **Shot reconstruction matches the engine exactly.** Rays rebuilt from the\n",
    "  frame stream agree with the engine's own shot events on tick, seat and\n",
    "  position, on every shot in every episode.\n",
    "- **Our line of sight is the engine's line of sight.** 100% agreement with\n",
    "  `sim.lineOfSightClear` on random walkable pairs, glass included. The 2.4% of\n",
    "  pairs where sight and fire disagree are exactly the windows.\n",
    "- **The map's symmetry is exact, and so is every derived field.** The raster is\n",
    "  pixel-identical under the x-mirror, and each baked field is folded on that\n",
    "  mirror, because uncorrected ray-marching noise runs about 1% of a cell's\n",
    "  visible set, which is the same size as the red/blue effects \u00a77 goes looking\n",
    "  for.\n",
    "- **The terrain comes from the sim's collision mask.** `dump_geometry.nim` reads\n",
    "  `game.wallMask` and splits it with the engine's own window predicate, so the\n",
    "  raster cannot drift from what the game actually collides against. Deriving it\n",
    "  from rendered art instead would silently drop the eight spinning centre\n",
    "  diamonds (\u00a72).\n",
    "\n",
    "One thing the corpus cannot tell you: it is a *league* corpus, eleven policies\n",
    "playing each other. Where those policies share a blind spot, this notebook will\n",
    "report the blind spot as a property of the map. The static geometry in \u00a72 is the\n",
    "antidote, and it is the only part here that no policy's habits can bias."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "pygments_lexer": "ipython3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
