{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "49e1414f",
   "metadata": {},
   "source": [
    "# 坦克競技場：課堂實作，一次跑完整個流程\n",
    "\n",
    "今天的目標是把整個流程**完整跑一次**，不求分數。分成兩段：\n",
    "\n",
    "| 段落 | 在哪裡 | 內容 |\n",
    "|---|---|---|\n",
    "| **第一段　離線** | notebook | 0 設定 → 1 練習：從 Action 到 Behavior（離線小場地） |\n",
    "| **第二段　網站** | 網站＋notebook | 3 登入、下載個人範本 → 4 上傳試跑、看重播 → 5 錄資料 → 6 神經網路：看資料、寫 agent.py、正式訓練 → 7 匯出 → 8 上傳，出現在天梯上 |\n",
    "\n",
    "每一格由上往下按 ▶ 執行（Colab：Shift+Enter）。跑完之後，回去看《規則策略函數說明書》和《神經網路訓練手冊》，自己改策略、改網路、多做實驗。\n",
    "\n",
    "**課前準備**：先到繳交網站「申請金鑰」：填學號、姓名和台科大信箱（…@ntust.edu.tw 或 …@mail.ntust.edu.tw），金鑰寄到這個信箱；第一次登入後學號就綁定這個信箱。上課時第二段在網站登入才會用到；notebook 本身不需要金鑰。"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0c5f12ec",
   "metadata": {},
   "source": [
    "# 第一段　離線\n",
    "\n",
    "## 0　設定\n",
    "繳交網站網址已填好（下載學生套件用，不用登入）。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "53b20248",
   "metadata": {},
   "outputs": [],
   "source": [
    "import os\n",
    "PORTAL_URL = os.environ.get(\"PORTAL_URL\") or \"https://arena.physictim.org\"   # 老師另外公布網址時改這裡\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "24e0fece",
   "metadata": {},
   "outputs": [],
   "source": [
    "# 下載學生套件（strategy_lib、train.py、export_model.py、範例、說明書），只要做一次\n",
    "import io, sys, zipfile, importlib, subprocess, requests\n",
    "if not os.path.exists(\"client/strategy_lib.py\"):\n",
    "    r = requests.get(PORTAL_URL + \"/kit.zip\", timeout=60); r.raise_for_status()\n",
    "    zipfile.ZipFile(io.BytesIO(r.content)).extractall(\".\")\n",
    "sys.path.insert(0, os.path.abspath(\"client\"))\n",
    "os.makedirs(\"my_tank\", exist_ok=True)\n",
    "for pkg in (\"numpy\", \"torch\", \"matplotlib\"):\n",
    "    if importlib.util.find_spec(pkg) is None:\n",
    "        subprocess.run([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", pkg], check=True)\n",
    "import torch\n",
    "print(\"套件就緒｜torch\", torch.__version__, \"｜GPU\" if torch.cuda.is_available() else \"｜CPU（夠用）\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cad45fea",
   "metadata": {},
   "source": [
    "## 1　練習：從 Action 到 Behavior（離線小場地，不用連伺服器）\n",
    "先在自己的電腦上擺一個小場地練習：一面牆、我、一台**假想坦克**（不會動，每 1.5 秒朝我射一發）。\n",
    "\n",
    "- `scene(...)` 可以改場面：`hp`（剩多少血）、`x`、`y`、`heading`（我的起點和車頭方向）、`zone_x`、`zone_y`、`zone_r`（安全區的圓心和半徑；圈外每秒扣 10 HP）\n",
    "- `scene().check(strategy)`：**單步**，印出策略這一步的 5 個數字和說明，畫出場面\n",
    "- `scene().run(strategy, 400)`：**多步**，照策略走 400 步（20 秒），**播放動畫**（可暫停、調速度、拖時間軸），統計開火、打中、被打中、剩多少血。想要靜態路線圖就加 `animate=False`。巡邏、繞圈的方向有隨機性：預設每次結果都一樣（`seed=1`），想看其他情況就改成 `run(strategy, 400, seed=4)` 之類的數字\n",
    "\n",
    "離線場地的移動、轉向、撞牆、開火規則和伺服器相同，但對手只是不會動的假想坦克；真正的成績最後還是要上傳試跑。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "a37489b1",
   "metadata": {},
   "outputs": [],
   "source": [
    "import importlib.util, random\n",
    "from sandbox import Scene\n",
    "\n",
    "def scene(hp=1.0, x=290, y=500, heading=-1.57, zone_x=400, zone_y=400, zone_r=500):\n",
    "    s = Scene()                                # 空地圖\n",
    "    s.add_wall(330, 280, 40, 140)              # 一面牆：左上角 (330, 280)、寬 40、高 140\n",
    "    s.set_me(x, y, heading=heading, hp=hp)     # 我：預設在牆的左下方、車頭朝上；hp＝剩多少血（1＝滿血）\n",
    "                                               # 換起點：scene(x=100, y=100, heading=0.785)；heading 是弧度，0 朝右、1.57 朝下\n",
    "    s.add_enemy(520, 380, fire_every=30)       # 假想坦克：牆的右下方，不會動，每 30 步（1.5 秒）朝我射一發\n",
    "    s.set_zone(zone_x, zone_y, zone_r)         # 安全區：圓心與半徑（預設在中央、半徑 500，整個場地幾乎都在圈內）\n",
    "                                               # 圈外每秒扣 10 HP（抗毒塗層 5）；小場地的安全區不會縮\n",
    "    return s\n",
    "\n",
    "def load_strategy(path):                                 # 讀一個 strategy 檔，回傳它的 Strategy()\n",
    "    spec = importlib.util.spec_from_file_location(\"s_\" + os.path.basename(path)[:-3], path)\n",
    "    m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)\n",
    "    return m.Strategy()\n",
    "\n",
    "scene().draw()                                   # 藍＝我（粗線車頭、細線砲口）、紅＝假想坦克、灰＝牆、虛線＝安全區"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "50d8065c",
   "metadata": {},
   "source": [
    "### ① 只用 Action\n",
    "移動用 `Action.drive_to` 開到一個固定點，砲口轉向敵人、對準了就開火。先看單步，再跑 20 秒。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9291cc41",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%writefile my_tank/step1.py\n",
    "from strategy_lib import Me, Memory, P, Action, combine\n",
    "\n",
    "class Strategy:\n",
    "    def reset(self):\n",
    "        self.memory = Memory()\n",
    "\n",
    "    def act(self, state):\n",
    "        me = Me(state, memory=self.memory)\n",
    "        e = me.nearest_visible_enemy()                 # 看得到的敵人（沒有就是 None）\n",
    "        move = Action.drive_to(me, P(620, 150))        # 移動：開到右上角這個固定點\n",
    "        aim = Action.aim_turret_at(me, e)              # 砲塔：轉向敵人\n",
    "        fire = Action.fire_when_aimed(me, e)           # 開火：對準了才開\n",
    "        return combine(move, aim, fire)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1ba21ab2",
   "metadata": {},
   "outputs": [],
   "source": [
    "scene().check(load_strategy(\"my_tank/step1.py\"))           # 單步：這一步的 5 個數字\n",
    "scene().run(load_strategy(\"my_tank/step1.py\"), 400)          # 多步：走 20 秒"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "47bba97b",
   "metadata": {},
   "source": [
    "### ② 換成 Behavior\n",
    "移動改用 Behavior：圈外回安全區、沒看到敵人就巡邏、看到了就保持 230 繞圈。比一比被打中幾發。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "4ba1588a",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%writefile my_tank/step2.py\n",
    "from strategy_lib import Me, Memory, Action, Behavior, combine\n",
    "\n",
    "class Strategy:\n",
    "    def reset(self):\n",
    "        self.memory = Memory()\n",
    "\n",
    "    def act(self, state):\n",
    "        me = Me(state, memory=self.memory)\n",
    "        e = me.nearest_visible_enemy()\n",
    "        if me.outside_safe_zone(50):\n",
    "            move = Behavior.go_to_safe_zone(me)        # 在圈外 → 回安全區\n",
    "        elif e is None:\n",
    "            move = Behavior.patrol(me)                 # 沒看到敵人 → 巡邏找人\n",
    "        else:\n",
    "            move = Behavior.keep_distance(me, e, 230)  # 看到敵人 → 保持 230 繞圈\n",
    "        return combine(move, Action.aim_turret_at(me, e), Action.fire_when_aimed(me, e))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "dbf76e62",
   "metadata": {},
   "outputs": [],
   "source": [
    "scene().run(load_strategy(\"my_tank/step2.py\"), 400)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d6b8060f",
   "metadata": {},
   "source": [
    "**試試看：把安全區縮小**。下面把安全區移到左下角、半徑只有 150，我一開始就在圈外（每秒扣 10 HP）。① 只會開到固定點，一直待在圈外；② 會先用 `outside_safe_zone(50)` 發現自己在圈外，再用 `go_to_safe_zone` 回到圈內（會自己繞過牆）。比一比「毒圈扣了多少 HP」和誰活下來。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ee140d6e",
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"① 安全區縮小：\"); scene(zone_x=150, zone_y=650, zone_r=150).run(load_strategy(\"my_tank/step1.py\"), 400, animate=False)\n",
    "print(\"② 安全區縮小：\"); scene(zone_x=150, zone_y=650, zone_r=150).run(load_strategy(\"my_tank/step2.py\"), 400)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8d992fd4",
   "metadata": {},
   "source": [
    "### ③ 自己寫 Behavior\n",
    "用 `@Behavior.custom` 寫一個「血少就躲到牆後並待著」的行為。讓 ② 和 ③ 都從**只剩 45% 血**開始，比一比誰活下來。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "323639d6",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%writefile my_tank/step3.py\n",
    "from strategy_lib import Me, Memory, Action, Behavior, combine\n",
    "\n",
    "@Behavior.custom                                       # 自己寫的行為：加進 Behavior\n",
    "def hide_and_stay(me, e, margin=40):\n",
    "    \"\"\"躲到牆後並待著：看得到敵人時找掩體位置記下來；看不到了也繼續開到記下的位置\"\"\"\n",
    "    if e is not None:\n",
    "        spot = me.cover_spot(e, margin=margin)         # 牆後、敵人看不到的位置（離牆 margin，預設 40）\n",
    "        if spot is not None:\n",
    "            me.mem.cover = spot                        # 記在 Memory，下一步還在\n",
    "    if me.mem.cover is None:\n",
    "        return Behavior.go_to_safe_zone(me)            # 找不到掩體就回安全區\n",
    "    return Action.drive_to(me, me.mem.cover)           # 開到掩體，到了就停\n",
    "\n",
    "class Strategy:\n",
    "    def reset(self):\n",
    "        self.memory = Memory()\n",
    "        self.memory.cover = None                       # 自己加的記憶：掩體位置\n",
    "\n",
    "    def act(self, state):\n",
    "        me = Me(state, memory=self.memory)\n",
    "        e = me.nearest_visible_enemy()\n",
    "        if me.hp < 0.5:\n",
    "            move = Behavior.hide_and_stay(me, e)       # 血少 → 用自己寫的行為躲起來\n",
    "        elif e is None:\n",
    "            move = Behavior.patrol(me)\n",
    "        else:\n",
    "            move = Behavior.keep_distance(me, e, 230)\n",
    "        return combine(move, Action.aim_turret_at(me, e), Action.fire_when_aimed(me, e))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f6cba5ed",
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"② 只剩 45% 血：\"); scene(hp=0.45).run(load_strategy(\"my_tank/step2.py\"), 400)\n",
    "print(\"③ 只剩 45% 血：\"); scene(hp=0.45).run(load_strategy(\"my_tank/step3.py\"), 400)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6cd68106",
   "metadata": {},
   "source": [
    "**（選用）想拿 ③ 去網站錄資料**：執行下面這格，把 `step3.py` 複製成 `my_tank/upload/strategy.py` 並下載，到繳交網站「② 策略與錄製」上傳、試跑、錄製（配裝以網站選的為準）。改了 ③ 之後要重新執行 ③ 那格和這一格。\n",
    "放在 `upload/` 資料夾是為了不和第 3 節的 `my_tank/strategy.py`（個人範本）互相蓋掉；兩個都可以錄，比較看看哪個訓練出來的模型比較好。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1688b2e4",
   "metadata": {},
   "outputs": [],
   "source": [
    "import os, shutil\n",
    "os.makedirs(\"my_tank/upload\", exist_ok=True)\n",
    "shutil.copy(\"my_tank/step3.py\", \"my_tank/upload/strategy.py\")\n",
    "print(\"✓ my_tank/upload/strategy.py（內容就是 step3.py）\")\n",
    "try:\n",
    "    from google.colab import files\n",
    "    files.download(\"my_tank/upload/strategy.py\")\n",
    "    print(\"已送出下載；沒看到檔案的話，點左邊資料夾找到 my_tank/upload/strategy.py，按右鍵下載\")\n",
    "except ImportError:\n",
    "    print(\"檔案在：\", os.path.abspath(\"my_tank/upload/strategy.py\"))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9aa821b9",
   "metadata": {},
   "source": [
    "# 第二段　到繳交網站操作\n",
    "\n",
    "上傳、試跑、看重播、錄製、繳交都在**繳交網站**（瀏覽器）上做，和你之後自己做作業、期末繳交的方式一樣。notebook 只負責改策略和訓練，檔案在兩邊之間搬：\n",
    "\n",
    "| 步驟 | 在哪裡 | 做什麼 |\n",
    "|---|---|---|\n",
    "| 3 | 網站＋notebook | 登入、選配裝、下載個人範本 → 放一份進 notebook（匯出時一起打包） |\n",
    "| 4 | 網站 | 上傳 `strategy.py`，試跑、看重播 |\n",
    "| 5 | 網站＋notebook | 錄資料、下載資料集 → 放進 notebook |\n",
    "| 6、7 | notebook | 訓練、匯出 `model.pt` → 下載到電腦 |\n",
    "| 8 | 網站 | 上傳 `model.pt`，出現在天梯上 |\n",
    "\n",
    "先執行下面這格，準備兩個搬檔案的小工具：\n",
    "- `upload_file(...)`：**Colab** 會跳出選檔案的按鈕；**Jupyter／Antigravity**（在自己電腦上）會自動到「下載」資料夾找最新的那個檔案。\n",
    "  - Colab 一直轉、沒出現「選擇檔案」按鈕（Safari 或瀏覽器擋第三方 Cookie 時常見）：按左邊 ■ 停止，**把檔案拖進 Colab 左邊的資料夾**（最外層），再執行一次那一格，就會直接用拖進來的檔案。\n",
    "- `download_file(...)`：**Colab** 會下載到電腦，沒看到檔案時會印出原因和備用方法（左邊資料夾按右鍵下載）；**Jupyter／Antigravity** 會印出檔案位置。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d1682320",
   "metadata": {},
   "outputs": [],
   "source": [
    "import glob, pathlib, shutil\n",
    "try:\n",
    "    from google.colab import files as colab_files       # 只有 Colab 有\n",
    "except ImportError:\n",
    "    colab_files = None\n",
    "\n",
    "def upload_file(dest, pattern, what):\n",
    "    \"\"\"把電腦上的檔案放進 notebook：dest＝放到哪裡，pattern＝在「下載」資料夾找哪種檔名，what＝提示文字\"\"\"\n",
    "    if colab_files:\n",
    "        dropped = sorted((f for f in glob.glob(pattern) if os.path.abspath(f) != os.path.abspath(dest)), key=os.path.getmtime)\n",
    "        if dropped:                                      # 已經從左邊的資料夾拖進來：直接用，不跳上傳視窗\n",
    "            shutil.move(dropped[-1], dest); print(\"從左邊資料夾拖進來的\", dropped[-1])\n",
    "        else:\n",
    "            print(f\"請選擇{what}（檔名像 {pattern}）。\")\n",
    "            print(\"一直轉、沒出現「選擇檔案」按鈕？按左邊 ■ 停止，把檔案拖進 Colab 左邊的資料夾（最外層），再執行一次這一格。\")\n",
    "            up = colab_files.upload()\n",
    "            if not up: raise RuntimeError(\"沒有選到檔案，再執行一次這格\")\n",
    "            name, data = next(iter(up.items()))\n",
    "            pathlib.Path(dest).write_bytes(data)\n",
    "            if os.path.exists(name) and os.path.abspath(name) != os.path.abspath(dest): os.remove(name)\n",
    "    else:\n",
    "        folders = [\"~/Downloads\", \"~/下載\", \"~/OneDrive/Downloads\", \"~/OneDrive/下載\", \"~/OneDrive*/Downloads\"]   # Windows 可能被 OneDrive 接管\n",
    "        found = sorted({f for d in folders for f in glob.glob(os.path.expanduser(f\"{d}/{pattern}\"))}, key=os.path.getmtime)\n",
    "        if not found:\n",
    "            raise FileNotFoundError(f\"在「下載」資料夾找不到 {pattern}：請把{what}手動複製到 {os.path.abspath(dest)}，再執行下一格\")\n",
    "        shutil.copy(found[-1], dest); print(\"從\", found[-1])\n",
    "    print(f\"✓ {dest}（{os.path.getsize(dest) / 1e6:.1f} MB）\")\n",
    "\n",
    "def download_file(path):\n",
    "    \"\"\"把 notebook 裡的檔案拿回電腦，準備上傳到網站\"\"\"\n",
    "    if not os.path.exists(path):\n",
    "        raise FileNotFoundError(f\"找不到 {path}：前面那一格可能沒成功，請先重新執行前一格\")\n",
    "    if colab_files:\n",
    "        try:\n",
    "            colab_files.download(path)\n",
    "            print(f\"已送出下載：{os.path.basename(path)}（看瀏覽器的下載清單）\")\n",
    "        except Exception as e:\n",
    "            print(f\"下載沒有成功（{type(e).__name__}）\")\n",
    "        print(\"沒看到檔案？① 網址列右邊若顯示「已封鎖下載」，按允許後再執行這一格 ② Safari 常常不能下載，請改用 Chrome／Edge\"\n",
    "              f\" ③ 點左邊的資料夾圖示，找到 {path}，按右鍵選「下載」\")\n",
    "    else:\n",
    "        print(\"檔案在：\", os.path.abspath(path))\n",
    "print(\"工具就緒｜\", \"Colab\" if colab_files else \"在自己的電腦上（Jupyter／Antigravity）\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ae339cfc",
   "metadata": {},
   "source": [
    "## 3　登入網站、下載個人範本\n",
    "1. 打開繳交網站。還沒有金鑰：在「1　申請金鑰」填學號、姓名和台科大信箱，金鑰寄到這個信箱（看一下垃圾郵件匣）；第一次登入後學號就綁定這個信箱。金鑰等同密碼，不要分享給別人。\n",
    "2. 用學號和金鑰登入，在「① 我的坦克」**選好配裝**（只能選一次）。\n",
    "3. 同一頁「規格書、範本與程式」的「程式」那一列按 **strategy.py 範本**：這是範例 C，已填好你的配裝。\n",
    "4. 這個範本可以直接拿到網站上傳試跑（第 4 節）。另外執行下面這格，放一份進 notebook：第 7 節匯出時會把它和 `agent.py` 一起打包（老師評分要看規則策略）。\n",
    "\n",
    "如果你改用 ③ 的策略（`my_tank/upload/strategy.py`）錄資料，這格就選那個檔案，打包的才是你真正錄資料用的策略。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5dcd7cdf",
   "metadata": {},
   "outputs": [],
   "source": [
    "upload_file(\"my_tank/strategy.py\", \"strategy*.py\", \"剛下載的個人範本 strategy.py\")\n",
    "print([l for l in open(\"my_tank/strategy.py\", encoding=\"utf-8\") if l.startswith(\"CHASSIS\")][0].strip(), \" ← 應該是你在網站選的配裝\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cae9cb8c",
   "metadata": {},
   "source": [
    "## 4　上傳試跑（網站）\n",
    "1. 網站切到「② 策略與錄製」，在「規則策略 strategy.py」區按「**點這裡選 strategy.py**」選檔（或把 `my_tank/strategy.py` 直接拖進去）。\n",
    "2. 網站會先檢查程式，再用你的配裝和 4 台範例 bot 試跑一局。按「**看重播**」看你的坦克怎麼打，按「**檢視樣本**」看動作比例：有沒有開火？是不是一直停著？\n",
    "3. 想比較第 1 節練習的三個版本，也可以把 `my_tank/step1.py`、`step2.py`、`step3.py` 上傳試跑（一局有運氣成分，只是參考）。\n",
    "\n",
    "離線小場地只有一台不會動的假想坦克；真的好不好，要看這裡的試跑和錄製成績。"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8b0c679f",
   "metadata": {},
   "source": [
    "## 5　錄資料（網站）\n",
    "讓你的策略打很多局，記下每一步「91 維觀測 → 策略的 5 個動作」，這就是神經網路的訓練資料。\n",
    "\n",
    "1. 在「錄製資料」錄**兩批**，每批 200 局：\n",
    "   - 第一批：用預設（隨機地圖、5 台、隨機對手），和比賽一樣。\n",
    "   - 第二批：換個情境，例如地圖選城區、對手選重裝＋護盾，資料比較多樣。\n",
    "2. 錄完在「暫存資料集」勾選兩批，按「**下載勾選的資料（zip）**」。\n",
    "3. 執行下面這格，把資料集放進 notebook。\n",
    "\n",
    "課堂上先各錄 200 局；回去可以錄到 2,000 局。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f8107313",
   "metadata": {},
   "outputs": [],
   "source": [
    "upload_file(\"my_tank/dataset.zip\", \"dataset_*.zip\", \"剛下載的資料集 dataset_….zip\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "21e4577d",
   "metadata": {},
   "source": [
    "## 6　神經網路\n",
    "模仿學習其實就是**分類問題**：給網路這一步的 91 個觀測數字，讓它猜規則策略在 5 個分類頭各選了哪一類。\n",
    "\n",
    "| 分類頭 | 類別 |\n",
    "|---|---|\n",
    "| 移動 | 前進、後退、停（3 類） |\n",
    "| 車身 | 左轉、不轉、右轉（3 類） |\n",
    "| 砲塔 | 左轉、不轉、右轉（3 類） |\n",
    "| 開火 | 否、開火（2 類） |\n",
    "| 技能 | 否、按技能（2 類） |\n",
    "\n",
    "| 小節 | 做什麼 |\n",
    "|---|---|\n",
    "| 6-1 | 看資料：網路的輸入和答案長什麼樣子 |\n",
    "| 6-3 | 選 MLP 還是 LSTM，寫成自己的 `agent.py`（網路設定＋訓練設定） |\n",
    "| 6-4 | 用 `train.py` 正式訓練 |"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "42273499",
   "metadata": {},
   "source": [
    "### 6-1　看資料\n",
    "資料集 zip 裡每一批是一個 npz 檔，裡面有 `obs`（每一步的 91 個觀測）、`action`（策略那一步的 5 個動作）、`episode_id`（第幾局）。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d6cba83d",
   "metadata": {},
   "outputs": [],
   "source": [
    "import zipfile, io\n",
    "import numpy as np\n",
    "\n",
    "zf = zipfile.ZipFile(\"my_tank/dataset.zip\")\n",
    "batches = [np.load(io.BytesIO(zf.read(name))) for name in sorted(zf.namelist()) if name.endswith(\".npz\")]\n",
    "\n",
    "obs = np.concatenate([b[\"obs\"] for b in batches]).astype(np.float32)     # 輸入：每一步 91 個數字\n",
    "act = np.concatenate([b[\"action\"] for b in batches]).astype(np.int64)    # 答案：策略那一步的 5 個動作\n",
    "ep = np.concatenate([b[\"episode_id\"] + 100000 * k for k, b in enumerate(batches)])   # 這一步屬於第幾局（每批分開編號）\n",
    "\n",
    "print(\"obs 的形狀：\", obs.shape, \"＝（筆數, 91）\")\n",
    "print(\"act 的形狀：\", act.shape, \"＝（筆數, 5）\")\n",
    "print(\"總共\", len(np.unique(ep)), \"局\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "16bf835c",
   "metadata": {},
   "outputs": [],
   "source": [
    "HEADS = [(\"移動\", [\"前進\", \"後退\", \"停\"]), (\"車身\", [\"左轉\", \"不轉\", \"右轉\"]), (\"砲塔\", [\"左轉\", \"不轉\", \"右轉\"]),\n",
    "         (\"開火\", [\"否\", \"開火\"]), (\"技能\", [\"否\", \"按技能\"])]\n",
    "\n",
    "def describe(a):                         # 把 5 個數字翻成文字，例如 [0 1 1 0 0] → 移動前進、車身不轉…\n",
    "    return \"、\".join(name + labels[a[h]] for h, (name, labels) in enumerate(HEADS))\n",
    "\n",
    "i = 500                                  # 隨便挑一筆來看，可以改\n",
    "print(\"第\", i, \"筆的輸入（91 個數字）：\")\n",
    "print(\"  地形射線 0–31（32 個方向離牆多遠，1＝很遠）：\", obs[i, 0:32].round(2))\n",
    "print(\"  敵人 32–55（最近 4 台 × 有無、距離、角度…）：\", obs[i, 32:56].round(2))\n",
    "print(\"  敵彈 56–75（最近 4 顆 × 5 個數字）：\", obs[i, 56:76].round(2))\n",
    "print(\"  自己、安全區、賽局、技能 76–90：\", obs[i, 76:91].round(2))\n",
    "print(\"答案（策略的動作）：\", act[i], \"＝\", describe(act[i]))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "edc23f37",
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"每個分類頭的類別比例：\")\n",
    "for h, (name, labels) in enumerate(HEADS):\n",
    "    counts = np.bincount(act[:, h], minlength=len(labels))\n",
    "    print(f\"  {name}：\" + \"　\".join(f\"{label} {c / len(act):.0%}\" for label, c in zip(labels, counts)))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c39d16d3",
   "metadata": {},
   "source": [
    "注意「開火」：大部分的步都是「否」。一個**永遠不開火**的網路，開火準確率也有九成以上，但上場一發都不會打。所以只看準確率不夠，還要看**召回率**：策略開火的那些步，網路有沒有也開火。"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d2187447",
   "metadata": {},
   "source": [
    "### 6-3　MLP 還是 LSTM？寫成自己的 agent.py\n",
    "最簡單的網路只看「這一步」。但坦克常常需要知道**過去**：敵人剛剛往哪走、子彈從哪來。有兩種做法：\n",
    "\n",
    "| | MLP＋疊加（預設） | LSTM |\n",
    "|---|---|---|\n",
    "| 怎麼看到過去 | 把最近 `STACK` 步的觀測接在一起當輸入（`STACK=4` 就是 4×91＝364 個數字） | 每一步把「記憶」傳到下一步，理論上能記得更久 |\n",
    "| 訓練時間（課堂 400 局、8 輪） | 約 4 秒 | 約 11 秒 |\n",
    "| 手冊實驗（每場得分，1,200 局資料） | `STACK=4`：0.71 | 0.39 |\n",
    "\n",
    "LSTM 的離線準確率和 MLP 差不多，上場卻差很多，這是值得研究的問題（手冊第 7 節）。**預設用 MLP、疊加 4 步**。\n",
    "\n",
    "`agent.py` 開頭有兩區設定，**所有旋鈕都在這裡**：\n",
    "- **網路設定**：網路長什麼樣子（`MODEL`、`STACK`、`HIDDEN`），比賽時怎麼選動作（`SAMPLE`）。\n",
    "- **訓練設定**：`train.py` 怎麼訓練它（`EPOCHS`、`LR`、`BATCH`、`SEED`、`FIRE_WEIGHT`）。`train.py` 本身不用改。\n",
    "\n",
    "想換成 LSTM：把 `MODEL = \"mlp\"` 改成 `MODEL = \"lstm\"`。**改了任何設定，都要依序重新執行 6-3、6-4、第 7 節**（寫檔、訓練、匯出）。匯出和網站上都會顯示 LSTM。\n",
    "\n",
    "`SEED` 建議保持 `None`：如果全班都填同一個數字，大家的初始權重會一模一樣，抄襲比對可能誤判。想重現某次結果時，再填那次訓練印出的種子。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "522326e9",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%writefile my_tank/agent.py\n",
    "# 我的網路。train.py 用這個檔案建網路、訓練；export_model.py 用它匯出 model.pt。\n",
    "import torch.nn as nn\n",
    "\n",
    "# ================= 網路設定（改這裡） =================\n",
    "MODEL = \"mlp\"      # \"mlp\"：前饋網路（可疊加最近幾步）；\"lstm\"：有記憶的遞迴網路\n",
    "STACK = 4          # 只有 mlp 用：一次看最近幾步（1＝只看當下）\n",
    "HIDDEN = 128       # 每層幾個神經元（寬度，不是層數）：越大越能學細節，也越慢\n",
    "LAYERS = 1         # 只有 lstm 用：LSTM 疊幾層（1～4），每層各有自己的記憶\n",
    "SAMPLE = False     # 比賽時怎麼選動作：False＝選分數最高的；True＝依機率抽\n",
    "\n",
    "# ================= 訓練設定（改這裡，train.py 會讀） =================\n",
    "EPOCHS = 8         # 訓練幾輪（整份資料看幾遍）\n",
    "LR = 0.001         # 學習率：太大會不穩，太小學很慢\n",
    "BATCH = 1024       # 每次拿幾筆算 loss、更新一次權重（MLP 才用；LSTM 固定每次 64 段）\n",
    "SEED = None        # 亂數種子：None＝每次隨機（建議）；要重現某次結果，填訓練時印出的數字\n",
    "FIRE_WEIGHT = None # 開火類加重幾倍（開火很少見，加重才學得會）；None＝依資料比例自動決定\n",
    "\n",
    "# ============ 以下是網路本體（看懂就好，也可以改結構） ============\n",
    "HEADS = (3, 3, 3, 2, 2)          # 5 個分類頭的類別數：移動、車身、砲塔、開火、技能（不能改）\n",
    "RECURRENT = MODEL == \"lstm\"      # train.py 依這個決定要不要整段序列一起訓練\n",
    "if RECURRENT:\n",
    "    STACK = 1                    # LSTM 自己有記憶，不用疊加\n",
    "\n",
    "\n",
    "class MLP(nn.Module):\n",
    "    \"\"\"前饋網路：輸入是最近 STACK 步接起來的 91×STACK 個數字\"\"\"\n",
    "    def __init__(self):\n",
    "        super().__init__()\n",
    "        self.shared = nn.Sequential(nn.Linear(91 * STACK, HIDDEN), nn.ReLU(),\n",
    "                                    nn.Linear(HIDDEN, HIDDEN), nn.ReLU())\n",
    "        self.heads = nn.ModuleList([nn.Linear(HIDDEN, n) for n in HEADS])   # 5 個分類頭\n",
    "\n",
    "    def forward(self, x, memory=None):      # x：(筆數, 91×STACK)\n",
    "        z = self.shared(x)\n",
    "        return [head(z) for head in self.heads], None       # MLP 沒有記憶，回傳 None\n",
    "\n",
    "\n",
    "class LSTMNet(nn.Module):\n",
    "    \"\"\"先把 91 個數字變成 HIDDEN 個特徵，再進 LSTM：每一步都帶著上一步留下的記憶\"\"\"\n",
    "    def __init__(self):\n",
    "        super().__init__()\n",
    "        self.encoder = nn.Sequential(nn.Linear(91, HIDDEN), nn.ReLU())\n",
    "        self.lstm = nn.LSTM(HIDDEN, HIDDEN, num_layers=LAYERS, batch_first=True)   # 疊 LAYERS 層：下一層吃上一層每一步的輸出\n",
    "        self.heads = nn.ModuleList([nn.Linear(HIDDEN, n) for n in HEADS])\n",
    "\n",
    "    def forward(self, x, memory=None):      # x：(筆數, 時間長度, 91)；memory：上一步留下的記憶\n",
    "        z, memory = self.lstm(self.encoder(x), memory)\n",
    "        return [head(z) for head in self.heads], memory\n",
    "\n",
    "\n",
    "def build_model():\n",
    "    return LSTMNet() if RECURRENT else MLP()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ce88651a",
   "metadata": {},
   "source": [
    "### 6-4　用 train.py 正式訓練\n",
    "`train.py` 會：按局切出驗證集、正規化、用 cross-entropy 和 Adam 訓練 5 個分類頭，並處理開火和技能的**類別權重**、MLP 的**疊加觀測**、LSTM 的**整段序列訓練**，最後把權重、正規化參數、資料來源一起存成 `my_tank/model.pt`。\n",
    "\n",
    "訓練設定都在 6-3 的 `agent.py` 裡，這格不用改。開頭會印出這次用的設定。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b07d07cb",
   "metadata": {},
   "outputs": [],
   "source": [
    "import subprocess\n",
    "\n",
    "def run(*cmd):                       # 執行 client/ 裡的程式，把輸出印出來\n",
    "    proc = subprocess.Popen([sys.executable, *cmd], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)\n",
    "    for line in proc.stdout:\n",
    "        if \"Warning\" not in line and \"warn(\" not in line:\n",
    "            print(line, end=\"\")\n",
    "    assert proc.wait() == 0, \"執行失敗，看上面的錯誤訊息\"\n",
    "\n",
    "run(\"client/train.py\", \"my_tank/dataset.zip\", \"my_tank/agent.py\")      # 設定都在 agent.py"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "04b1a458",
   "metadata": {},
   "outputs": [],
   "source": [
    "import json\n",
    "metrics = json.load(open(\"my_tank/metrics.json\"))\n",
    "print(\"驗證集準確率（模型和規則策略選一樣動作的比例）：\")\n",
    "for key, name in [(\"acc_move\", \"移動\"), (\"acc_body\", \"車身\"), (\"acc_turret\", \"砲塔\"), (\"acc_fire\", \"開火\"), (\"acc_skill\", \"技能\")]:\n",
    "    print(f\"  {name} {metrics[key]:.1%}\")\n",
    "print(f\"開火召回率 {metrics['recall_fire']:.1%}（策略開火的那些步，模型也開火的比例）\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a91ea0cb",
   "metadata": {},
   "source": [
    "### 這只是示範，要提高成績改這些地方\n",
    "課堂上只錄 400 局、訓練 8 輪，目的是讓你看到「資料、模型、上傳」整條路是通的，分數不會高。\n",
    "《神經網路訓練手冊》第 7 節的實驗（同一份 1,200 局資料、上場打 bot 200 場，每場得分）：\n",
    "\n",
    "| 做法 | 每場得分 |\n",
    "|---|---|\n",
    "| 只看當下（`STACK=1`） | 0.52 |\n",
    "| 疊加 4 步（`STACK=4`） | 0.71 |\n",
    "| 疊加 4 步＋`HIDDEN=256` | 0.80 |\n",
    "| LSTM | 0.39 |\n",
    "| 資料只有 200 局 | 0.49 |\n",
    "| 只訓練 3 輪 | 0.50 |\n",
    "\n",
    "回去可以試：\n",
    "1. **先把策略變好**：模型是模仿策略，策略打不好，模型也打不好。先用試跑、錄製的名次和擊殺確認策略。\n",
    "2. **多錄、錄得多樣**：每人最多 2,000 局，換地圖、換對手配裝、10 台混戰。\n",
    "3. **改網路與訓練**：6-3 `agent.py` 的網路設定（`MODEL`、`STACK`、`HIDDEN`）和訓練設定（`EPOCHS`、`LR`、`FIRE_WEIGHT`）；也可以改網路結構（加層、加 dropout）。\n",
    "4. **模型在牆邊停住、原地打轉？** 看天梯轉播或試跑重播。在 `strategy.py` 開頭把 `UNSTICK` 改成 `True`（或調 `UNSTICK_WAIT`、`UNSTICK_BACK`、`UNSTICK_TOWARD`），重新錄資料、訓練，比較兩個模型。內建脫困的參數比較、自己寫脫困規則，見另一本《脫困練習》notebook（手冊第 10 節）。\n",
    "5. **一次只改一件事**，記錄前後的變化（試跑、錄製的名次和擊殺，以及離線指標），不要只看準確率。"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7a6705a5",
   "metadata": {},
   "source": [
    "## 7　匯出\n",
    "把網路匯出成要上傳的 `submit/model.pt`。會自動比對匯出前後動作相同，並把 `strategy.py`、`agent.py` 一起打包（老師評分用）。最後把 `model.pt` 拿回電腦。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "821948a3",
   "metadata": {},
   "outputs": [],
   "source": [
    "run(\"client/export_model.py\", \"my_tank/agent.py\")\n",
    "download_file(\"my_tank/submit/model.pt\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "be1c1097",
   "metadata": {},
   "source": [
    "## 8　上傳，出現在天梯上（網站）\n",
    "1. 網站切到「③ 繳交模型與報告」，在「上傳 model.pt」區按「**點這裡選檔案**」選 `model.pt`（或直接拖進去）。\n",
    "2. 網站會檢查格式、試跑幾步（約 10–30 秒），通過後出現在「你的上傳紀錄」，每一份都有**版本編號**，頁首也會顯示目前的上場版本（報告封面要填）。第一次上傳的版本會自動上場；之後的版本要按「**設為上場**」。\n",
    "3. 天梯每 30 分鐘更新，到觀戰網站看你的坦克。"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6614c467",
   "metadata": {},
   "source": [
    "## 完成！接下來\n",
    "- 到觀戰網站看你的坦克（天梯每 30 分鐘更新）。\n",
    "- 進階：`client/portal_client.py` 可以在程式裡呼叫網站（上傳、錄製、下載），想自動化實驗時再用；一般用網站就好。\n",
    "- 讀《規則策略函數說明書》改策略、自己寫行為（`@Behavior.custom`）；讀《神經網路訓練手冊》改網路、做實驗。兩本都在網站「① 我的坦克」下載。\n",
    "- 期中報告：網站「① 我的坦克」下載「**期中報告範本（Word）**」（封面已填好你的姓名、學號、配裝），照 4 個表格填寫：我的策略、方法設計、訓練結果、心得；寫完存成 PDF，在「③ 繳交模型與報告」上傳。天梯名次由系統統整，不用寫。\n",
    "- 手冊第 7～11 節有每一步的詳細說明。"
   ]
  }
 ],
 "metadata": {
  "colab": {
   "provenance": []
  },
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
