# claudeSouls — 設計文件

> **命名對照(2026-09-08 之後)**:文件正文保留當時的用詞,因為改掉會讓當時的推理讀不通。
> 現在遊戲裡顯示的是:**魂 → 燼**、**篝火 → 爐**、**火防女 → 守夜人**。
> 識別碼(`T.BONFIRE`、`p.souls`、`key: 'firekeeper'`)沒有改,見 `docs/NAMING.md`。


> 狀態:**已實作,可以玩。**

## 怎麼讀這份文件

它有兩半,而且是兩種不同的東西:

| | |
| --- | --- |
| **[現況](#現況)** | 今天的遊戲長什麼樣:數值、敵人表、系統、介面、工具。**要動手改東西的話只需要讀這一節。** |
| [還沒做的](#還沒做的) | 已經設計好但沒實作的、擱置的,以及還懸著沒答案的問題。 |
| 其餘 | 怎麼走到這裡的。原始規格、被推翻的設計、試玩回饋,以及**[已知會再犯的錯](#已知會再犯的錯)**。 |

歷程留著不是為了懷舊。這個專案到目前為止最貴的幾個錯誤,都是**臭蟲假扮成平衡問題**——
每一次的第一反應都是去調數值,每一次都調錯了東西。那些章節記的是怎麼分辨這兩者,
而那個教訓沒有辦法從「現況」讀出來。

**被推翻的段落沒有刪掉,而是標成歷史快照。** 一份只留下正確結論的文件,讀起來會像是
每個決定都是第一次就對的,那是騙人的,而且會讓下一個人重犯同樣的錯。

原始規格寫於 2026-09-01。

## 一句話

**把魂系的「讀招、閃避、反擊」搬進回合制的爬地城遊戲。**

你不能硬扛傷害。敵人出手前會有預備動作,你要嘛翻滾閃開、要嘛打斷它,而這兩件事都消耗
同一條精力。血很薄,攻擊必中,所以死亡永遠是你自己的判斷失誤——這是刻意的。

## 這不是什麼

設計過程中被明確否決掉的方向,記下來是為了避免以後繞回去:

- **不是 Diablo。** 沒有掉寶洪流、沒有數值膨脹、沒有腦內啡循環。借用的只有「技能列 +
  點擊操作」的介面,和「以殲敵為目的」的流程。
- **不是 Into the Breach。** ItB 給你完整資訊,你是解題者,爽感來自「我找到唯一解」。
  這裡你是表演者,爽感來自「我剛剛那一手很漂亮」。差別在**權力落差的方向**:ItB 你永遠
  在勉強應付;這裡你會贏,問題只是順序。
- **不是 claudeHack。** 沒有飢餓、沒有詛咒、沒有物品鑑定——所有需要跨局累積知識的系統
  都拿掉了。專注在戰鬥與資源管理。

## 核心迴圈:精力

**精力是整個遊戲的引擎。** 它同時是攻擊的成本和防禦的成本,所以每一回合的問題都是同一句:

> 「我看到牠舉手了,我手上的精力夠不夠躲?還是我再貪一刀?」

這就是魂系最核心的情緒,而且它是被機制逼出來的,不是靠敘事。

> **上面這段是原始設計,已經被實測推翻一次。** 精力確實是引擎,但它推不動它原本
> 被指派的那份工作——見下面〈精力做得到什麼、做不到什麼〉。原文留著,因為它解釋了
> 為什麼後來的東西長這樣。

### 精力做得到什麼、做不到什麼(量出來的)

用 `STAM=1 node tools/botrun.mjs` 量 16 場,只算戰鬥中的回合:

| | 平均精力 | 打不起攻擊 | 滾不起 |
| --- | --- | --- | --- |
| 輕裝(改版前) | 17.6/20 | **0.0%** | **0.0%** |
| 重裝(改版前) | 13.0/20 | 3.6% | **16.6%** |

輕裝**八場裡沒有一個回合**買不起它想做的事。原因是結構性的,不是數值沒調好:
**你大概只有 15% 的回合在攻擊**,另外 85% 都在回精力。一個「每次攻擊扣多少」的
成本要能咬人,必須超過大約 6 倍回復速度——那個數字會讓單次攻擊直接無法使用。

推論,而且它推翻了上面那段:

> **在一個所有攻擊都有預告的回合制遊戲裡,等待是安全的。所以任何「等一下就還你」
> 的成本都不是成本,是延遲。**

輕裝從來不用付那個延遲,所以沒感覺;重裝要付,但因為血厚又減傷,付出去換來的是
**無聊**而不是**危險**。同一個壞掉的成本,兩種症狀。

### 現在的精力:它只回答一個問題——你穿多重

一個動作的價格**只由動作決定**,不由你穿什麼決定。以前有兩個違反這條的東西
(翻滾的重量附加費、盾牌的每次揮擊稅),它們讓同一顆按鈕因為另一隻手拿什麼而顯示
不同的數字,而且沒有教會任何人任何事。兩個都刪了。

重量只影響**回復速度**,每超出負重 5 點降一階:

| 配裝 | 重量 | 回復 |
| --- | --- | --- |
| 破布 + 匕首 | 1 | 6 |
| 皮甲 + 長劍 + 匕首 | 7 | 5 |
| ⋯換成小圓盾 | 9 | 4 |
| ⋯換成塔盾 | 16 | 3 |
| 鎖子甲 + 長劍 + 匕首 | 17 | 3 |
| 板甲 + 長劍 + 匕首 | 27 | 1 |

階距從 9 收成 5,是因為刪掉那兩個附加費之後,**這條曲線變成唯一知道你拿了什麼的
東西**——而 9 格寬的時候它分不出小圓盾和塔盾,七點鋼鐵完全沒有差別。

**過重**是唯一的例外,而且是一個你會知道自己踏進去的狀態(超出負重 26 點):
所有動作 +2。板甲配大武器會過重,板甲配普通武器不會。

上限 20。這組比例產生的節奏:

- 只攻擊:每回合淨 −1,可以耗很久
- 攻擊 + 翻滾:淨 −5,約四個循環見底
- 重甲攻擊 + 翻滾:淨 −8,約兩個半循環

**推論,而且是最重要的一條:敵人的出手頻率就是難度旋鈕。** 玩家每被逼閃一次就吃掉約兩
回合的精力預算,所以難度不必靠加傷害或加血,調「牠多久舉一次手」就好。

**精力歸零時:** 不能攻擊、不能翻滾,**但可以走路**。走路是喘氣動作,它花掉一個回合——
壓力來自這裡。不能讓玩家被鎖死在原地硬吃一發。

## 翻滾不推進回合

**這是最關鍵的一條結構決定。** 它把時鐘從「回合」換成了「精力」:

- 走路:推進回合、免費、慢
- 翻滾:不推進回合、快、吃掉你防禦要用的同一格資源

於是「被抓到位置不對又沒精力」會自然發生,不需要另外設計。

**唯一要小心的數字:一個回合內最多能翻幾次**(`精力上限 ÷ 翻滾消耗`)。如果是 2,走位是
戰術;如果是 5 而且每次兩格,一個回合能橫跨十格,所有距離威脅和包夾都失效。目標值
**上限 2、極限 3**,而且應該讓玩家幾乎總是差一點點。

## 敵人:預告 / 結算 / 收招

魂系的骨架本來就是離散的,只是被即時制包起來。對應關係:

| 魂系(即時) | 這裡(回合制) |
| --- | --- |
| 舉手預備動作 | 敵人花一回合蓄力,攻擊在下一回合結算 |
| 攻擊判定 | 結算的那一回合 |
| 收招硬直 = 反擊窗口 | 出手後 M 回合冷卻 |
| 精力條 | 每回合回復固定量的精力池 |
| 翻滾無敵幀 | 位移技能,消耗大量精力,對本回合結算免疫 |

回合制版本其實更好,因為它把「時機」從反射神經變成可讀的資訊。

### 打斷:讓戰鬥不只有一種節奏

> **玩家在敵人蓄力的那一回合打中牠,可以把牠的攻擊往後推一回合。**

如果面對預告的唯一答案是翻滾,戰鬥就只剩「等舉手 → 閃 → 反擊」一種循環。有了打斷之後,
每次看到舉手都有兩個選項:

- **翻滾** —— 安全,但吃精力
- **打斷** —— 有攻擊性,可能打不夠、可能你自己精力不夠收尾

一條規則、兩倍的決策空間、幾乎零內容成本。**進 MVP。**

## 確定性:攻擊必中,血量很薄

RNG 大幅減少是這個設計的必然結果:如果一個預告過的攻擊還會 miss,整個讀招迴圈就沒有
意義了。所以:

- **命中率固定 100%**
- **傷害固定或範圍很窄**
- **血量小到被打中 3~5 次就死**

最後一條是強制的:攻擊必中而血量厚,預備動作就不重要了,玩家會退化回硬扛。

**這也限制了成長方向。** 成長不能主要是加血,否則遊戲會慢慢自己退化。成長應該加的是
**選項**(多一個技能)、**精力**(多一次貪刀的空間)、或**資訊**(看得更遠、預告更早)。

隨機性搬去哪:**關卡佈局、敵人組合**。你決定要面對什麼,而不是骰子決定你有沒有打中。

## 投射物在場上飛

敵人的遠程攻擊是**盤面上的物件**,不是瞬間結算的傷害。它有飛行時間。

這一個機制買到四件事:

1. 敵人的攻擊從「擲骰」變成「盤面狀態」——可以躲、可以擋、可以讓它打到別人
2. 預告系統免費附贈:威脅本身就畫在地圖上,不需要額外的意圖 UI
3. **走位讓敵人打死敵人**——這是「玩弄」而不是「求生」最直接的表達,而且不用特別設計,
   它會自己發生
4. **距離變成一種資源**:遠 = 有回合可以反應,近 = 必須早就站對位置。敵人種類自然對應到
   距離帶,戰鬥變成「管理我現在處在誰的距離帶裡」

## 敵人可以多,但技巧是「不要一次打完」

很多敵人 + 不能硬扛 + 每隻都有預告 = 同時追蹤八個舉手動作,那是認知負擔不是樂趣。

魂系的答案很明確:**用走位把牠們拆開,一次只面對一隻。** 這是明確的設計立場,不是妥協。
場上可以有很多敵人,但遊戲的技巧是**安排交戰順序**。

走廊因此比在 claudeHack 裡更重要——漏斗化敵人是核心技巧,而走廊天生就是幹這個的。

**推論:不是每隻敵人都該追得上你。** 需要速度分層:慢的可以繞過、快的必須處理、遠程的
逼你換路。如果所有東西同速又完美尋路,「跑過去」就不存在,而死後走回篝火的路就只剩折磨。

## 篝火、死亡與持久化

**規則採用經典魂系公式:**

- 坐篝火:回滿 + **敵人全部重生**
- 死亡:回到最後坐過的篝火 + **敵人全部重生**

**走回去那段路不是重複戰鬥,是一場精力管理的迴避挑戰**——這剛好是核心機制的另一種用法。

**篝火密度是旋鈕,不是功能。** 把「上一層的下樓梯」直接當篝火,加上每層一兩個,走回去的
距離就被一層的範圍框住。先用旋鈕,不要急著做捷徑系統。

### 種子式的持久世界

每一層的地圖從 `run 種子 + 層數` 推導,不是存下來的——claudeHack 現在就是這樣做的,所以
這部分幾乎是免費的。第 13 層永遠是同一個第 13 層,你走回去它就長回來。一個種子四個位元組,
不是一張地圖。

**但種子只給你地形,不給你「玩家改變過什麼」:**

| 東西 | 從種子重建 | 需要另外存 |
| --- | --- | --- |
| 地形、房間、走廊 | ✓ | |
| 敵人初始配置 | ✓ | |
| 已點燃的篝火 | | ✓(一串 ID) |
| 已撿走的物品 | | ✓(一串 ID) |

這份差異表是幾十個數字,不是一張地圖。

**跨樓層移動:先做資料,不要做功能。** 每層的種子 + 已點燃篝火 ID 現在就存好(幾乎零成本,
以後加功能不用改存檔格式),但介面等到有真正的理由再做。

## 明確擱置的東西

寫下來是為了記住**為什麼**擱置,以及什麼症狀會讓它值得撿回來。

| 東西 | 為什麼不做 | 什麼時候重新考慮 |
| --- | --- | --- |
| **盾反** | 回合制把執行難度歸零,盾反會變成無條件最優解 | 除非敵人有多種預備動作、你得先讀對是哪一招——那需要一整套招式庫 |
| **堅韌值(poise)** | 它解決的問題(被打中會打斷動作)在回合制根本不存在 | 永不。但它的反面(打斷敵人)已經採用 |
| **體幹值(posture)** | 和精力功能重疊,兩條條子會讓交互關係混濁 | 如果實測發現戰鬥太被動(只想等、只想閃),那時的解法是**只給敵人體幹條**,不對稱比對稱乾淨 |
| **捷徑** | 篝火密度可以先解決同一個問題,而且不用新系統 | 如果走回去的路實測還是太痛 |
| **跨樓層回頭拿素材** | 這是內容系統堆到一定程度才有的需求,現在做只會弄亂 run 的流程 | 有真正的理由時(新能力開舊路、NPC 搬家、特定深度產出) |
| **三選一獎勵** | 進程系統,先把戰鬥做出來 | 樓層王死亡是天然的掛載點。**現在只留一行註解,不要寫事件系統**——沒有實作的鉤子就是死程式碼 |
| **魂經濟** | 和三選一是兩套獨立的進程系統,MVP 只需要一套(或零套) | 決定進程主軸之後 |

## 一條通用法則

從即時制搬任何機制過來之前,先套這一條:

> **回合制會把「執行難度」歸零,只留下「決策難度」。**

盾反在魂系難,難在那十幾格的判定窗口——那是**手感**的難度,回合制沒有,所以它變成免費。
翻滾之所以活得下來,是因為它的成本從來不是執行,是**精力**——而「我現在該花掉這格精力嗎」
在回合制裡一點都沒有變簡單。

## MVP

齊了這六條,遊戲就已經是「回合制類魂系爬地城」,剩下全部是內容和調整:

1. 精力:攻擊、翻滾、每回合回復
2. **翻滾不推進回合**,走路推進
3. 敵人:蓄力 → 結算 → 收招,蓄力期間可被打斷
4. **攻擊必中,血量小到三五下就死**
5. 篝火:休息 = 回滿 + 重生;死亡 = 回篝火 + 重生
6. 往下爬

## 從 claudeHack 繼承什麼

**可以直接用(而且是最貴的部分):**
`engine/` 底下的種子亂數、方向與距離工具、陰影投射視野、A\* 與流場尋路。加上**複製**一份
地圖生成、Level 容器、renderer 與選單機制。

**不能直接用:**

- **怪物的定義方式整個要換。** claudeHack 是「等級 / 速度 / 護甲 / 攻擊骰」;這裡需要
  「**預備動作長度 / 攻擊距離 / 收招硬直 / 精力**」。63 隻怪會變成 63 個外觀和名字,行為
  要重寫。**這是最大的一塊工。**
- **血量級距**:17 血、1d6 傷害 → 三五下就死
- **命中判定**:骰子 → 確定性
- 飢餓、鑑定、BUC 全部移除

**地圖大小不是問題。** 之前擔心「觸控要點得到、所以地圖要小到一屏」——探索用大地圖沒關係,
因為戰鬥天生就發生在房間和走廊裡,而那些本來就是一屏的大小。鏡頭跟著走就好。

## 操作(手機優先)

點技能 → 按住地圖 → 拖曳微調 → **放開即施放**。技術上用 Pointer Events 就行,同一套程式碼
同時吃觸控、滑鼠、觸控筆;關鍵是 `setPointerCapture()`(手指移出元素仍收得到事件)和
`touch-action: none`(不讓瀏覽器把拖曳當捲動搶走)。

拖曳比點兩下好的地方:**方向型技能**(直線、扇形、突進)天生適合——方向就是「施法者 → 手指」
這條向量。

**五個實作時一定會踩到的坑:**

1. **手指會遮住目標。** 解法要兩個一起用:實際目標點**偏移到手指上方**約一根手指的距離;
   傷害/誰會死等資訊顯示在**固定區域**,不要畫在手指底下。
2. **「放開即確認」等於沒有取消。** 解法:**拖回下方的技能列 = 取消**——那是手指出發的
   地方,面積大、在拇指區、不用瞄準。
3. **`pointercancel` 一定要處理。** 來電、系統手勢會把手指收走,不處理的話瞄準狀態會卡死。
   這是只在真機上出現、模擬器測不到的 bug。
4. **快速誤點會直接放技能。** 可考慮:手勢短於約 150ms 且完全沒移動時只鎖定不施放。但不
   一致的規則有時比嚴格的規則更煩,值得實測再決定。
5. **拖曳瞄準會和拖曳平移地圖打架。** 解法:瞄準模式中,拖曳永遠是瞄準,絕不平移。

**加分但非必要:** 目標鎖定到新格子時 `navigator.vibrate(10)`,讓「吸附到格子」可以被感覺到。
Android Chrome 支援,iOS Safari 不支援,所以它只能是加分不能是必要資訊。

## 待決定的數字

只能靠實測,不能靠推導:

- 精力上限 / 翻滾消耗 / 每回合回復(起始值見上表)
- 一次交戰的規模:幾隻敵人、幾回合結束
- 敵人蓄力回合數與收招回合數
- 玩家血量對應到「幾下死」

---

## 為什麼和 claudeHack 分開目錄

不是檔案整齊,是因為**共用的程式碼會產生抵抗分歧的壓力**。如果兩個遊戲都 import 同一個
`combat.js`,為了新遊戲改它就可能弄壞舊的,於是開始寫 `if (mode === 'souls')`。半年後兩
個遊戲都活在一堆分支裡,而且兩個都不好改。

共用的界線刻意畫得**窄**:只有真正零遊戲知識的 `engine/`(亂數、工具、視野、尋路)。其他
一律先複製,讓它自由漂移;等到兩份長期一模一樣,那時再提出去——那時才知道正確的介面長
什麼樣,因為有兩個真實的使用者了。

複製 450 行的 renderer 很便宜。錯的抽象很貴。


---

# 實作後改了什麼

規格寫得再仔細,還是有五件事只有在真的跑起來之後才知道。記在這裡,因為它們每一個都是
「設計看起來對、但機制不成立」的例子。

## 1. 快的敵人會跳過自己的預告(嚴重)

原本的迴圈是「每個敵人依速度可以行動 N 次,每次行動推進它的狀態機」。速度 18 的獵犬
一回合可以行動兩次,而它的蓄力只有一回合——於是它在**同一個遊戲回合裡**開始蓄力並且
打下來。玩家被一個從未顯示過的攻擊打中。

這是整個讀招契約從根部斷掉。修法是把**狀態推進**和**行動**分開:

- `tickEnemyState()` 每個遊戲回合跑**一次**,負責蓄力/收招倒數
- `enemyTurn()` 依速度跑 N 次,只負責移動和「決定要不要開始蓄力」

也就是說:**速度決定牠能移動幾次,不決定牠揮得多快。** 這件事在規格裡沒寫,因為
沒有人會想到要寫;測試抓到了它。

## 2. 敵人的精力從來沒有真的用完

原本敵人在「不是蓄力中」的時候回精力,也就是**收招期間也回**。但收招是最長的狀態
(通常 2~4 回合),所以每一次揮擊都用自己的收招期把成本賺回來了——精力條永遠是滿的,
「喘氣」狀態不可能發生,整個系統是死的。

改成**只有 READY 和 RESTING 才回精力**:收招不是休息。這一改之後行為變得很漂亮——
持續壓制一隻大蠻怪,牠會先付不起重擊(7 點),退化成只能用便宜的反手(3 點)。
**「一直打牠,牠就打不出大招」** 是這一行改出來的,不是設計出來的。

## 3. 重甲是嚴格更好的選項,所以它不是選項

機器人跑兩種誓約各半:**重甲整場零死亡,輕裝反覆死。** 原因很清楚——

- 減傷 −1 對上 2~5 點的傷害是巨大的
- 翻滾比較貴的代價幾乎沒有成本,因為**用走的**離開紅格子是免費的

所以重甲拿到全部好處、沒付代價。修法:**重甲翻滾只移動一格,輕裝兩格。** 現在重甲
真的失去機動性,必須更早讀、用血量換位置;輕裝用位置換血量。測試現在斷言
「重甲一條精力能覆蓋的格數 ≤ 輕裝的一半」。

## 4. 主角在畫面上找不到

生成的英雄貼圖是深色衣服的人物,而地板是深色石頭。在手機的 35px 格子上,
**主角是畫面上最難找到的東西**——在一個「你站的格子相對於紅格子在哪裡」就是全部技巧的
遊戲裡,這是缺陷不是氣氛。修法:主角腳下畫一圈暖色光暈,輕裝改用比較亮的遊俠貼圖。

## 5. 引擎裡還藏著一個 claudeHack 的詞彙

`engine/path.js` 呼叫 `level.monsterAt()`——但這個遊戲叫牠們 enemies。改成中性的
`level.occupantAt()`,兩邊各自提供。

**這正是「不要對著一個使用者設計介面」的實例。** 這個耦合在只有一個遊戲的時候完全看不
出來,第二個遊戲一接上就立刻炸出來。

---

# 現況

**這一節是「現在的遊戲長什麼樣」,後面所有章節是「怎麼走到這裡的」。**
兩者分開,是因為時序記錄回答不了「今天這個系統是什麼」——而那是動手改之前唯一需要知道的事。

10 層 · 11 種敵人 · 4 個英雄 · 36 個技能 · 29 件裝備 · 13 個消耗品 · 7 種詞條 · 2 條成長線。

## 玩家

精力上限 20,速度 12。**生命、減傷、翻滾消耗、回復速度全部來自你穿戴的東西**——
誓約只是一組起始裝備,不是焊死在角色選單上的決定。

## 契約:每一擊都會被宣告

**遊戲裡沒有任何一擊是沒有預告的。** 這條曾經被打破過,而修回來是這個專案最大的一次
更正——理由和數字見〈為什麼把無預告攻擊全部拿掉〉。

取代「隱瞞」的是**承諾**:攻擊可以在落地時往前衝,所以讀懂它不等於可以免費走開。
18 招裡有 7 招有第二段,其中 3 段會**重新瞄準**——轉向你剛才躲去的地方。那仍然誠實,
因為第二段是在第一段落地之後才預告的;它只是讓「躲開第一段的那一步」不再自動有效,
於是答案從**走一步(免費)**變成**翻滾(花精力)**。

## 裝備與技能

**武器就是一組動詞,不是一個傷害數字。** 攻擊必中、傷害固定,所以「+2 的劍」只會讓現有
戰鬥變鈍;一把帶著 `reach2` 和 `line6` 的武器會改變你該站在哪裡。

| 規則 | |
| --- | --- |
| 每把武器有 **主要** 和 **次要** 兩個技能 | |
| **副手只給主要技能** | 雙持有三個攻擊動詞但只有一個是武器的特色;雙手武器兩個都是自己的 |
| **換裝推進一回合** | 沒有這條,九顆按鈕等於「你擁有的所有武器的所有技能」 |
| 看背包免費 | 為了看一眼而懲罰玩家,只會教人背選單 |
| **走進敵人 = 用主手的主要技能** | 這裡曾經寫死 `strike`,見〈已知會再犯的錯〉 |

20 把武器 · 4 面盾 · 5 件防具。次要技能可以有**收招**——打完站在原地不能動、
也不回精力,連翻滾都不行。只有次要技能會有,而且不和 CD 疊。

## 地圖改成板塊拼接(geomorph)

玩家描述了桌遊的機制:方塊、接口、箭頭定方向、隨機堆之外另有一疊特殊房間。先用 ASCII
畫了 12 張板塊、用一支 scratchpad 腳本**照規則真的拼**(手排會騙人),三輪之後才動遊戲。

### 核心設計:板塊就是它的畫

```js
squeeze: { weight: 4, art: [
  '####++####',
  '###....###',
  '####.#####',   // 單人通道,三格,絕不超過 MAX_STRAIT
  ...
]},
```

接口是邊界上第 4–5 格的 `+`,任何旋轉都對得上;尺寸從畫的大小推(20×10 = 兩格);
情境的錨點是小寫字母(`l` = lane、`d` = ledge)。**加一張板塊 = 畫一段 ASCII**,
不用登記任何東西。`validateGeomorphs()` 會抓畫錯的接口、參差的行、沒命名的字母——
它第一次跑就抓到 `r`(碎石)被當成錨點字母,碎裂地板的石堆整個變成平地。地形符號
從此不准用小寫字母。

### 一個刻意的決定:整層預先拼好

桌遊是走到出口才抽下一塊,所以抽到什麼取決於你先走哪個出口。這個遊戲承諾「樓層是種子
的函數,死幾次你就記住它了」,所以**整層在你踏出第一步之前就拼完**,探索感靠照明——
房間本來就是進去才亮。

### 草稿三輪各修一個機制層的問題

| 輪 | 現象 | 規則(全是桌遊本來就有的) |
| --- | --- | --- |
| 1 | pale-well 只有 2 塊:第二塊抽到死巷 | 前 5 塊不准抽死巷 |
| 1 | ashen-oak 29% 地板:frontier 耗盡 | 「卡住就放一道門」,填到 70% 為止 |
| 1 | 死巷 8:迴路 1,是一棵樹 | 接口撞到已放板塊、對面是地板 → 40% 鑿門 |
| 2 | 吊橋東端撞地圖邊,通往牆壁的橋 | `allEnds`:四個接口都得接得上才放 |
| 2 | 兩格板塊被算成一塊,填滿率失真 | 填滿率算**格**不算**塊** |
| 2 | 六個大廳一字排開 | 拼完後掃描,直線連通 ≥4 塊就封中間那道門(連通性不變才封) |

### 移植時遇到的,每一個都是量出來的

**移植第一天的樓層會餓死在 3 塊。** 追蹤:骰到吊橋、吊橋放不下、**接口被當成死巷封掉**
——而不是改抽別的。同一層兩次。然後 `reopen` 又跳過所有「還登記為接口」的牆。
兩條修正:特殊板塊放不下就改抽一般堆;封掉的接口從接口清單移除。

**龍廳自己長了一棵樹。** 龍廳的接口也進 frontier,結果龍廳長出自己的板塊樹,和入口的樹
**在哪裡相遇靠運氣**——某個種子上兩棵樹相鄰的每一面牆都是 bend 的實心面,鑿不了門,
龍廳被當成不可達整個填成牆(641 格),龍退回入口房間跟篝火和火防女一起。修正:
龍廳**不推 frontier**,地板往它長過去然後鑿進去;龍廳四面都畫接口(沒接到的變牆);
擺放時取「最多接口朝內」而不是「全部朝內」——後者在三列的網格上永遠不成立。

**吊橋的岸是島。** 以「塊」為單位的連通性看不到「一塊裡面有兩個區域」:吊橋 = 橋面 +
兩岸,中間隔深淵。岸上的門通到的板塊,只能從岸過去;岸只能從那些板塊過去。整串是島,
上面還有篝火。修正是舊生成器 `ensureConnected` 的板塊版:**以格為單位**從樓梯 flood,
未達區域和已達區域之間、接口位置兩側都是地板 → 鑿門;沒得鑿就從已達的板塊往空格長
`cross`(四面都有地板);還是不行就把相鄰的單格板塊**換成 cross**;最後剩下的填牆,
錨點少了的情境降級成普通房間。量:王層 80 層龍廳零埋沒、一般樓層平均 6 格填牆。

### 測試裡露出來的三種假設

- **「最大的房間就是競技場」**——舊的房間是實心矩形,板塊的矩形裡可能大半是牆。
  `arena()` 改成找**最長的開放地面**,而且要上下各兩格淨空(最長的跑道在畫廊的邊排,
  上面就是牆,往北翻滾撞牆、側推撞牆)。
- **`walkable` 對關著的門是 false**——吊橋測試「繞遠路到對岸」要穿過門,改用 `passable`。
- **「一條 11 格的直線」**——搜到的是畫廊的邊排,牛頭人斜著走出跑道、三格外就衝鋒,
  「一段一段預告」沒東西可分段。機制測試現在**自己蓋一條有牆的走廊**,幾何不能漏進來。

### 門:一道門兩葉,只在進入側,有時沒有

第一版每個接口兩側都寫成門,一個接口就是 2×2 = 四葉擠在一起。玩家回報之後改成桌遊的
規則:**門屬於你走進去的那塊(箭頭那一側)**,另一側是地板;四分之一的接口乾脆沒有門,
是開口。畫的時候把原本的單葉門畫一次、鏡像再畫一次,鉸鏈在外、把手在中間,兩葉讀成
一片——另外生一張橫向的門圖被生成器壓進直幅裡,不如鏡像好看。開一葉另一葉一起開
(`Level.openDoor`),不然看起來像門被鋸成兩半。量 180 層:雙門 11.6 道/層、開口
5.2 個/層,兩格厚的門 **0**。slot 那張板塊的單葉門保留,它是斜向規則的對照組。

改門的時候骰子多擲了一次,整條亂數流位移,結果把兩個本來就會漏的地方逼出來:
慢/快敵人的保證和每層一隻精英,用嚴格條件找不到位置就**默默放棄**——現在條件放寬
再試,一層沒有慢的東西比慢的東西離樓梯近一點糟得多。還有一次是換板塊的修復在
**未達區域的內部**換了二十塊,每一塊都接到其他未達的塊、從來沒接到已達的那邊,然後把
640 格填成牆。換只能發生在**兩個區域相接的邊界**;而且一層填牆超過 40 格就視為生成
失敗,從同一條亂數流重拼(所以還是種子的函數)。400 層:填牆平均 **0**、重拼 3%。

### 箭頭:固定的入口

第一版的接口是對稱的,箭頭是拼的時候才決定——哪個接口朝回去,哪個就是入口。這對大多數
板塊是對的(cross 從哪邊進都一樣),對吊橋是錯的:從岸進去,同一張圖變成「走在弓箭手
背後」。所以接口可以畫成 `^^`,一張最多一個,**有箭頭的板塊只能從箭頭進**;其他 `+`
是出口;沒畫的維持對稱。`validateTile` 管三條:成對、最多一個、固定板塊不能有(它們是
手動擺的,沒有「進」這回事)。

加了箭頭之後吊橋幾乎絕種:**240 個夠深的樓層只出現 4 次**。原因是結構性的——吊橋的兩岸
要有南北鄰居,三列的網格上它只能放在**中間那列**;箭頭又讓每個接近方向只剩一種旋轉。
兩條修正都是桌遊本來的規則:抽到但放不下的特殊卡**留在牌堆頂**,下一個接口再試(4 → 17);
一半的樓層**一開始牌堆頂就有一張特殊卡**,在中間那列還是空的時候就試(17 → 33,
夠深的樓層 13.8%,大約一趟一座橋)。代價是兩個情境的樓層從 64% 升到 76%、每層敵人
從 10.4 升到 11.2——密度往上走了,要收的話 `pendingSpecial` 那個 1/2 是唯一的旋鈕。

測試第一版檢查「箭頭的接口沒被封成牆」,**把規則關掉它照樣過**——從岸進去的吊橋,箭頭
稍後會被當成出口接上,「沒封牆」分不出這兩種。現在組裝器把**實際進入的接口**記在
`room.entry` 上,測試對著它;規則關掉時 17 座橋有 16 座被抓到。順帶抓到直排斷路器會把
箭頭的門封掉(接著吊橋就從岸進了),它現在會跳過箭頭的門去封旁邊那道。

### 開口邊:房間的邊不一定是牆

「邊界一定是牆」本來不是美術規則,是拼接規則:牆是板塊擁有地面的方式,接口是板塊之間唯一
的連接。現在一條邊的**中間兩格畫成地板**,那條邊就是開口邊——一個沒有門也沒有牆的接口:

| 對面是 | 結果 |
| --- | --- |
| 另一個開口邊 | 兩個空間合併成一片,沒有門 |
| 對方的牆或接口 | 對方那邊決定 |
| 空格 | 地板貼著岩石(`STONE`) |

開口邊上**永遠不寫東西**:不放門、不放牆樁,不管是擺放時、迴路那一輪、直排斷路器,還是
可達性修復。認的是「畫在那裡的是什麼」而不是旗標,因為一半的呼叫端是當場算座標的。
半開的中間(`.#`)是半道門,validator 拒絕;情境和固定板塊要標 `openOk: true` 才准開口,
因為它們的房間本來就該是房間。箭頭只能畫在有牆的邊上。

接口的「已解決」從看字元改成看旗標——開口邊從一開始就是地板,看字元會以為它已經處理過。

兩張板塊用它:`cavern`(東邊開)、`court`(東、南開)。108 層量到 168 條開口邊,零牆樁零門,
78 條和鄰居合併。把守衛拿掉再量,168 條裡 90 條長出牆樁——測試抓得到。

### 第一張作者畫的板塊:causeway

```
####++####
..........     ← 三排平台,東西兩側的邊界是地板
..........
..........
~~~~==~~~~     ← 深淵一路延伸到板塊邊界:旁邊也是深淵就是一整條溝
~~~~==~~~~
~~~~==~~~~
~~~~==~~~~
~~~~==~~~~
~~~~^^~~~~     ← 從南邊踏上橋
```

validator 第一次擋了它:東西兩邊的**中間**兩格是 `~`,規則只准「接口/牆/地板」。規則太窄——
深淵在邊界正是「兩側不是牆是空間」。放寬成:中間兩格只要**一致**就行;深淵、坑、碎石是
「封閉但照畫」的邊,不會長出板塊,也永遠不會被寫上東西。

它在遊戲裡:540 層裡出現 211 次(39%,牌堆權重 1),**211 次全部從箭頭進**。可走 34 格
(平台 24、橋 10)。**但隨機補怪平均只放 0.4 隻,78% 的時候是空的**——一層約 11 隻怪
分給 15 個房間,大部分房間本來就是空的。「過了橋有東西在等」要靠**情境**(自己的選角
清單),不能靠隨機補。

### 板塊可以說自己要幾隻怪

`enemies: [lo, hi]`(或 `{ n: [lo, hi], aware: true }`)。這是情境的輕量版:沒有角色、
沒有錨點、沒有意圖,只有「這裡面要有東西」。生怪器擲數量、每一隻的種類照層數從同一張表抽
(和隨機補怪一樣,所以板塊不用知道第八層住著什麼),站在板塊自己的地板上。

三條規則,每條都有理由:

- **算進該層預算**,和情境的選角一樣。要三隻的板塊不是這層多三隻,是這層的三隻站在為它們
  畫的地方。量:causeway 加了 `[1, 3]` 之後每層敵人平均 11.1,和之前一樣。
- **房間登記為 `staged`**:隨機補怪、額外篝火、寶箱都不進去。不然「一到三隻」會變成
  「一到八隻」。守衛拿掉再量,57 個房間全部被抓到。
- **一隻試六次位置**:種類是照層數抽的,可能是 2×2,而 2×2 會拒絕身體放不下的格子。
  只試一次的時候,148 座 causeway 有一座是空的。

情境和固定板塊不能用這個欄位(情境有自己的選角表,入口和龍廳是手動放的)。

causeway 現在:148 座,平均 2.1 隻,**沒有一座是空的**(之前 78% 空的)。

### 指定「這一格一定要有怪」

`enemies` 的第三種寫法:`{ at: 'post' }` —— 畫一個錨點字母,**那個錨點的每一格都放一隻**。
加 `n: [lo, hi]` 就變成從那些格子裡挑幾格。種類仍然由層數決定,固定的只有地面。

現在三個層級,由弱到強:

| 寫法 | 誰 | 站哪 | 用在 |
| --- | --- | --- | --- |
| 什麼都不寫 | 照層數抽 | 任何空房間 | 大多數板塊 |
| `enemies: [1, 3]` | 照層數抽 | 這張板塊裡任何地板 | 「這裡要有東西」 |
| `enemies: { at: 'post' }` | 照層數抽 | **指定的每一格** | 「守在這裡」 |
| 情境的 `cast` | 照**角色**抽 | 指定的錨點群 | 「弓手在側道、擋路的在中間」 |

錨點本來只有情境在用,所以組裝器只把它存進 `lvl.chambers`。現在**每個房間都留著**
(`room.anchors`)——它本來就每張板塊都算了。validator 會擋「`at` 指到一個這張板塊沒畫的
錨點」,不然板塊會安靜地要不到任何怪。

新板塊 `gatepost` 用它:通道兩側各一個標記格,兩隻一開始就清醒。360 層放了 139 次,
106 個標記格**每一格都有東西站著**。把它改成只填第一格,測試抓到 106 格裡的 53 格。

### 標記格可以指定角色,而且板塊自己知道能出現在多深

`{ at: 'back', role: 'ranged' }` —— 用的是情境選角同一張角色表(`ranged` / `blocker` /
`charger` / `guard`),但不需要整套情境機制。`enemies` 可以是一串,所以一張板塊能說
「後排遠程、前排擋路」。

**危險在於角色有深度下限。** `ranged` 最淺是第 2 層(archer),第 1 層抽出來是 null。
所以 `minDepth` 從畫**推導**出來:用了哪些角色 → 那些角色最淺的物種 → 取最大。作者不用記,
也記不錯。causeway 因此自動變成 minDepth 2。把這條關掉,測試抓到 4 次「第 1 層的
causeway」;把角色關掉,120 個標記格有 93 個站錯人。

順帶把 `enemies` 的三種寫法收斂到一個 `enemyEntries()`——之前每個讀的人都要自己判斷
拿到的是範圍、單一物件還是清單。

### causeway 第二版(作者重畫)

```
####++####
..........
...b..b...     b = 遠程,在後
....aa....     a = 擋路,在前
~~~~==~~~~
   (橋)
~~~~^^~~~~     從南邊上橋
```

橋是決定:兩格寬、五格長,人在上面時沒有側移的餘地。過去之後是被擋住的正面加上一條
被射穿的縱線,而後排的遠程正是「不能待在近岸等」的原因。前排用 `blocker` 不用 `charger`
——衝鋒的會衝上橋,把渡橋變成走廊裡的近戰,那是另一張板塊,想要的話改一個字。

量 125 座:500 個標記格全滿,前排 brute/warden/husk,後排 archer/flamekeeper,
沒有一座出現在第 2 層以下。

### 「窄道沒被消滅」改成統計的

門檻 5% → 2% → 掉到 1.9%(它量的是牌堆大小);改成「每層至少 4 格」→ 八十層裡有一層
畫零格,因為**隨機牌堆本來就允許一層抽不到 squeeze/pinch/slot**。現在是「平均每層 ≥ 8 格,
而且沒有窄道的樓層 ≤ 5%」。量:每層 16 格,八十層裡一到兩層是零。

那層零格的樓層還有另一件事:18 塊裡有**六個 cross**——修復步驟永遠先拿 cross。往未達
區域長的那一步改成從 cross / hall / pillared / split 洗牌選(它們四面後方都是地板);換板塊
那一步維持 cross,它需要四面都有接口才能重新接上每個鄰居。

### 「窄道沒被消滅」改成每層的下限

這條守衛的門檻是 5%(挖一格寬的走廊)→ 2%(板塊,通道刻意兩格寬)→ 兩張開口板塊進牌堆
又掉到 1.9%。**它量的是牌堆的大小,不是有沒有窄道。** 真正要成立的是每層都有:量 80 層,
平均每層 16.8 格窄道,最少 6 格,沒有一層是零。守衛改成「每層至少 4 格」,`MAX_STRAIT`
照舊。

### 板塊編輯器

`tools/tile-editor.html`。畫布、筆刷、接口自動成對、四個朝向的預覽、錨點表、載入現有
板塊、從 ASCII 匯入、輸出可以直接貼進 `geomorphs.js` 的那一段。檢查用的是遊戲同一個
`validateTile`,所以編輯器拒絕的和遊戲拒絕的是同一套規則,由建構保證。手機可用:
單欄、44px 的筆刷、`touch-action: none` 加 pointer capture 讓手指拖曳不會捲頁面、
匯入用內嵌的文字框(手機的 `prompt()` 只有一行,貼十行會掉光換行)。

### 數字(400 層)

| | 舊生成器 | 板塊 |
| --- | --- | --- |
| 有情境的樓層 | 50.3% | **73.8%**(兩個情境:55.8%) |
| 每層敵人 | 10.17 | 10.59 |
| 每層篝火 | 2.49 | 2.00(入口一個 + 一個) |
| 有寶物間 | 29.8% | 32.3% |
| 單人窄道占比 | >5% | 2.2%——門檻改為 2%,因為通道**刻意**兩格寬 |
| 王層龍廳埋沒 | — | 0/80 |

寶物間曾掉到 17.5%:寶箱要一個**地板角落**,bend 的矩形四角都是牆。過濾掉就回來了。

`mapgen.js` 從 779 行變 297 行。留下的是本來就關於「完成的地板」的部分:額外的篝火、
寶物間、火防女。

## 生成:誰佔了這個房間,由登記決定,不由每個功能各自記得

玩家回報同一個房間出現兩個篝火,並且說「不然奇怪的房間和怪物生成只會繼續出現」。
量了 400 層,他是對的——那不是一個 bug,是一整類:

| | 400 層 |
| --- | --- |
| 下樓梯在篝火房裡 | 23.8% |
| 樓梯在情境房間裡 | 16.3% |
| 同一個房間兩個篝火 | 11.5% |
| 兩個篝火相距 ≤3 格 | 5.3% |
| 火防女塞在門口 | 0.8% |

**結構性的毛病是:每一個排除條件都是一個 opt-in 的布林值。**`avoidBonfires`、
`avoidChambers`,以及上一個人記得加的那些。那是 O(n²) 的耦合,而且失敗是無聲的:
加一個新功能,就必須去通知**其他每一個**功能,然後總有一個沒被通知到。

情境房間的排除清單就是活證據——它排除了篝火、上樓梯、寶物間,**漏掉下樓梯**,
所以六分之一的樓層有一道樓梯站在列柱大廳裡或吊橋上。

### 改成登記制,而且預設拒絕

佔用房間的人自己登記,要位置的人**預設避開所有被登記的房間**,共用要指名。

```
lvl.claimRoom('fire', roomId)
lvl.randomFreeSpot(rng, { share: ['store'] })   // 只願意跟寶物間共用
```

**失敗模式被反轉了:** 忘記宣告會讓擺放變得更保守,不會變成壞掉。上表五項全部歸零。

### 順帶挖出來的兩個

**一、火防女是對著一個還沒蓋完的地圖選位置的。** 她排在 `scatterCover` 和
`openAlcoves` **之前**,挑「最開闊的格子」——然後碎石落下來、別處的走廊被拓寬,
她挑的那格到別人走過去的時候已經是個門口了。這就是為什麼她在學會避開門口之後,
還是有 0.8% 站在門口。她現在排在**最後**,對著完成的地板選位置。

**二、情境房間是蓋在已經有碎石的地板上的。** `scatterCover` 先跑,而每個
`build()` 都會跳過不是純地板的格子——所以吊橋的橋面裡有碎石、深淵中間有個坑。
既不是通道也不是深淵,是一團亂。情境的組成**就是**那個房間,隨機掩體來補等於
隨機敵人來補,只是低一層;`spawnBoss` 從寫出來那天就會清空自己的競技場。

### 一個沒有預期到的代價,以及怎麼補回來

排除條件修正之後,**有情境的樓層從 54.3% 掉到 42.3%**——因為多排除一個房間,
可用的大房間就少了。

原因是挑選方式:它**抽一個**情境,然後如果沒有空房間符合那一個的尺寸就放棄。而三種
情境要的房間不一樣(列柱 9×5、吊橋 10×6、碎裂地板只要 8×5),所以一個明明有 8×5
可用的樓層,只要骰到吊橋就什麼都不會有。改成**洗牌整個池子依序試**,回到 50.3%,
而且上面那些歸零的項目全部保持歸零。

## 多段衝鋒要一段一段預告

牛頭人的衝鋒是「打 2×2、前進兩格、最多三次」,而它**整串在同一個回合結完**,而且一次
把三段的格子全部畫出來。原本的註解寫著:只預告第一段會變成「三次沒預告的攻擊共用一個
預告」。**那句話對危險的判斷是對的,對解法是錯的**——答案不是一次公告三下,是一次公告
一下。整串在一個回合結完,等於玩家對一個三段攻擊只有一次決策,中間完全沒得反應。

現在:揮、前進、**立刻預告下一段**,下一回合落下。撞牆就結束(撞牆那下的傷害它已經吃
過了)。前搖仍然可以被打斷,所以衝鋒現在是一個**可以被中途打斷的序列**。

## 預告的格子要跟著身體走

`attackTiles` 是在前搖開始時用**絕對座標**算一次存起來的,所以把一個正在前搖的敵人
推開,它的紅格會留在原地——然後長前搖結束時,一記攻擊從它已經不在的位置打出來。

這不違反 ai.js 開頭那條「前搖開始後不能改變要打哪裡」。**改的是「格子」不是「形狀」**:
形狀和它相對於攻擊者的幾何完全沒動,是攻擊者被移動了,格子跟著身體走,就像它自己的
身體一樣。而且這樣「把正在舉手的東西推離你的格子」才會做出直覺的事。

## 大型敵人的遠程攻擊是從錨點發射的

玩家回報:龍的遠程攻擊會打到自己。而且他的推論完全正確——**如果它打得到自己,就代表
這一發不是從離你最近的那一格打出去的。**

投射物是從 `e.x, e.y` 也就是**錨點**發射的。2×2 的龍往東開火,下一格就是它自己的肩膀,
所以它每次都射自己 4 點。近戰分支在四行之前就一直用 `origin()`/`nearestTileTo` 處理
這件事了,遠程那條沒有。

修了兩層:發射點改成離玩家最近的那一格,而且投射物現在記得**是誰射的**,識別碼相同就
穿過去。敵人的箭打到**別的敵人**是刻意的設計,打到自己不是。

## 雜訊層:藏世界,不藏預告

一種世界異狀的呈現。電視雪花蓋在地圖上,底下的地形和生物都看不見,而且**看不穿——
要走過去才會被抹掉**(半徑 1)。視線不夠,這是重點:地圖不是靠看回來的,是靠走回來的。

預定的用法是王的一個特殊行動:幾乎滿版蓋上雜訊,同一口氣宣告攻擊形狀,只留幾格沒蓋——
那幾格就是唯一的讀法。探索時它只是天氣,王戰時可能致命。能力做好了,還沒接到任何王身上。

### 預告也會被蓋住 —— 而契約靠「清空的環」保住

雜訊**會**蓋住攻擊預告。真正讓契約成立的不是「紅格照畫」,是另一條規則:

> **玩家所在的那格和周圍八格,永遠是清的。** 施放的當下就清,走到哪清到哪,
> 而且**清掉的不會因為玩家離開而長回來**,除非敵人再放一次。

於是「一記能打到你的攻擊」必然畫在你看得見的格子上——因為任何能打到你的格子,
都在那個 3×3 裡面。**你永遠知道自己在不在攻擊範圍內。**

雜訊拿走的是**它延伸多遠**。實測畫面:長矛的三格預告,你腳下和身旁那兩格是紅的,
第三格被雜訊吞掉。所以你知道要動,但不知道往哪動才安全——那才是異狀該有的壓力,
而不是「毫無徵兆地掉血」。

我第一版做錯了:讓紅格畫在雜訊之上。那保住了契約但拿掉了機制的重點,而且是我誤解了規格。
現在的版本兩者都有。

### 它在規則上是隱形的

沒有任何規則會問一格有沒有被雜訊蓋住。攻擊照樣結算、身體照樣擋路、尋路照樣走。
它是純粹的資訊層和演出層——這也是為什麼加它不需要碰任何既有的機制。

### 這個版本的契約是可以 headless 測的

第一版把契約放在渲染器裡(紅格畫在雜訊之上),而渲染器在測試環境沒有 canvas,所以那條
測試**守不到最重要的東西**——把 `afterMove` 的保證關掉它照樣通過,因為敵人就在視線內,
FOV 本來就會標記那些格子。

現在契約在資料層:**清空的 3×3**。那是一個可以直接斷言的性質,而且它守得住——
把「施放時清空 3×3」改成半徑 0,或把走路清除拿掉,測試都會失敗。

這是同一個教訓的第五次:**把規則放在測得到的地方,規則才守得住。**

## 王廳要不要獨立於迷宮(已決定:獨立。見 `docs/BOSSHALL.md`)

> **結論(2026-09-09)**:獨立,而且不走生成路徑——特定深度直接擺開一張畫好的樓層,
> 南邊前室(到達點 + 爐)、北邊王廳、進門後由腳本封起來。理由是**每個王的機制要靠地形**,
> 而生成的房間只給得出「一個空的大房間」,也就是現在的龍廳。
> 完整規格在 `docs/BOSSHALL.md`,下面是當時的量測,原文保留。

## 王廳要不要獨立於迷宮(討論紀錄,還沒做)

問題:王房要不要像魂系的霧門那樣獨立出來。動手之前先量了現況:

| 量到的 | 結果 |
| --- | --- |
| 龍能不能離開廳 | **不能**。80 回合每回合都在動,0/10 次出來 |
| 玩家站廳外會不會被打到 | **不會**,12/12 完全碰不到 |
| 玩家站廳外能不能打死龍 | **不能**,三個英雄各 10 次全滅 |
| 外面的怪會不會跟進來 | **會**,13/15,每次 1–2 隻 |
| 打到一半退出去的代價 | 10.8 步,平均只吃 **1.8** 傷害 |

**「必須走進去才打得到」已經由幾何自動成立**——龍是 2×2,而 2×2 只放得進 1.9% 的走廊和
2.6% 的門口,所以它出不來;玩家在外面也搆不到它。那是僵局,不是苟活。霧門的「逼你進場」
這個功能,現有的門其實已經給了。

真正缺的是三件:

1. **隔離**。13/15 的樓層會有護衛跟進王廳。
2. **專屬機制的掛勾**。每個王要有自己的地形、階段、自己召出來的東西,現在沒有地方掛。
3. **退出去幾乎免費**(1.8 傷害)。精力變成時鐘之後,「打幾下、退到門外、喘滿、再進來」
   是可行的,而龍不會回血。

方向(**不是規格**):不要做成像集會廳那樣的獨立 Level——設計已經花力氣在「接近的路不能
免費」(五隻護衛就是為此存在),獨立成關卡會把那段路丟掉。要做的是讓龍廳成為**一個有自己
規則的板塊**:它已經是固定板塊了,差的是封印(戰鬥開始後非王的敵人不能跨門)、
一個宣告自己的王和規則的欄位(`boss` / `arena`,順著 `enemies` / `signal` / `special`
既有的形狀),以及離開就重置。

要注意的取捨:封印之後「把護衛引進廳裡一起打」這個選項會消失,而它現在 13/15 會自己發生。
我認為值得——護衛的工作是讓你**到不了門口**,不是門口之後還跟著——但那是一個決定,
不是自然結論。

## 精力才是時鐘 —— 三條修正

前搖讀起來像表演,招式打起來沒有咬到人。量完之後這兩件事是同一件:**精力不稀缺**。

### 一、翻滾一回合只能一次

`roll` 的 `advancesTurn: false` 是設計的核心(時鐘是精力不是回合),但它讓精力條變成
**唯一**的限制。實測同一個回合裡:

| | 翻幾次 | 移動 | 回合推進 |
| --- | ---: | ---: | --- |
| 老騎士 | 3 | 6 格 | 沒有 |
| 執旗侍從 | 4 | 4 格 | 沒有 |
| 攝魂師 | **8** | **16 格** | 沒有 |

攝魂師可以在一個回合內橫越四分之一張地圖,而場上沒有任何東西動過。那不是閃避,是免費
重新佈署,而且和她自己的設計註解直接矛盾——「跳舞不會餵飽她,那正是讓她留在戰鬥裡而不是
繞著打的原因」。

一回合一次。翻滾之後仍然可以攻擊,那是核心,沒有動。

### 二、恢復依「這回合做了什麼」分級

```
EFFORT = { wait: 1, move: 0.5, attack: 0 }
```

用乘數不用固定值:三個英雄的恢復是 4 / 2 / 0.25,差十六倍,任何固定加成對其中一個是
捨去誤差,對另一個是整個經濟。

**攝魂師自動不受影響**,因為她的回復來自 `siphon` 的 `refund`,不是每回合的 tick。
她的設計本來就是為這個世界寫的,不用開特例。

老騎士的連續攻擊從 **12 回合掉到 2 次**(12 ÷ 5,而且不再回血)。

### 三、翻滾成本 > 恢復速度

| | 恢復 | 舊成本 | 新成本 | 連續翻滾幾回合 |
| --- | ---: | ---: | ---: | ---: |
| 老騎士 | 4 | 4 | **7** | ∞ → 4 |
| 執旗侍從 | 2 | 3 | **5** | 14 → 4.7 |
| 攝魂師 | 0.25 | 2 | 2(不動) | 11.4 |

老騎士的舊成本**正好等於**他的恢復,所以他可以每回合翻滾直到永遠——這才是前搖對他毫無
意義的真正原因,不是前搖太長。攝魂師不動:她的翻滾已經是真成本(機器人資料:半數回合窮到
翻不動),再貴就是把她的引擎拆掉。

### 測試寫錯兩次,兩次都是「因為錯的理由通過」

「一回合一次」那條測試:

1. 第一版在 arena 裡往北翻兩次 —— 第二次**撞牆**,規則關掉照樣過
2. 第二版改成自己蓋的走廊、往東翻 —— 但用老騎士,他 12 點精力付不起兩次 7,
   第二次是**被價格拒絕**,規則關掉照樣過

第三版用攝魂師(20 點、一次 2 點),而且**先斷言她付得起兩次**才開始測。規則關掉時
訊息是 `2 -> 4 -> 6`。

### 代價:等待變多了,而且那正是先前預期的風險

| | 改之前 | 改之後 |
| --- | ---: | ---: |
| 老騎士 戰鬥中等待 | 35.6% | **77.7%** |
| 執旗侍從 戰鬥中等待 | 0.4% | 41.2% |
| 攝魂師 窮到打不出招 | 50% | 64.6% |

老騎士 77.7% 的等待裡有 **94% 是機器人政策不是經濟**——它的規則是「沒存到夠再翻一次
就不出手」,而翻滾 7 + 普攻 5 = 12 **正好是他的上限**,所以它只在滿條時出手。人不會這樣打。

但方向是對的:**站著不動現在是最好的回復手段,而站著不動應該要有代價。** 那個代價得由
場上的東西提供,現在前期敵人打你要 6–8 下,站著幾乎不痛。這條和「狗做為波狀壓力」是同一
件事,兩個要一起才完整。

## 拔掉兩條「免費的回復」

兩條規則都在做同一件壞事:**在壓力最小的時刻付錢給你。**

**一、脫離戰鬥後精力恢復 ×4 —— 拿掉了。**
它的意思是精力只在「有東西看得到你」的那個窗口裡才是資源。每一條走廊都會把它重置,
所以你為了走到一場戰鬥而花掉的東西**不用付代價**,而且沒有任何理由留著精力離開戰鬥。
現在只有一種速率。

**二、擊殺退還一回合的所有 CD —— 變成詞條 `reaping`。**
同樣的毛病:**東西死掉的那一回合,正是你壓力最小的一回合。** 它獎勵的是你已經贏了的
那次交換。雙刃內建這條詞條,因為它本來就是為雙刃設計的。

## 攝魂師的恢復要小到「等待不是計畫」

她的設定是「不普通攻擊就沒有有意義的恢復」,但量出來不是這樣。機器人扮她跑十趟:

| 天然恢復 | 平均精力條 | 窮到打不出招的回合 |
| --- | --- | --- |
| 1(原本) | 15.3/20 | 0.1% |
| 0.5 | 7.8/20 | 11% |
| **0.25(現在)** | **4.7/20** | **50%** |

**恢復 1 根本不是限制。** 她兩回合就存得出一次翻滾,siphon 只是加速器。0.25 之後,
她的等待有 100% 是因為滾不動——也就是說,精力終於是那個會停下她的東西。

**不是 0,而且這條不能改。** siphon 是她唯一不接受用生命支付的技能,所以恢復如果是 0,
「精力 0 + 血不夠付 unmake」就是一個她永遠動不了的狀態。速率因此有 0.25 的下限,
而且精力改成用小數累積——低於每回合 1 的速率不累積的話會直接被無條件捨去成 0。

### 順帶:機器人本來沒在扮英雄

`arms()` 是從**武器**讀「你的主要攻擊」。技能改綁人之後,`HERO=knight` 會讓機器人
圍繞著 `strike` 做計畫——那是他沒有的招——然後 `hasSkill` 再把它提出的每個攻擊否決掉。
這和 `step()` 寫死 `strike`、機器人永遠只拿長劍是**同一個錯的第三次**:只要「你的普攻是
什麼」不是從當下的技能列回答,它遲早會答錯。

還有一條機器人規則對她是反的:「沒存到夠再翻滾一次就不要出手」。她的普攻花 2 還 5,
**出手正是她買到翻滾的方法**,所以那條規則讓她在戰鬥中站著不動 46%。

## 技能綁角色之後,武器剩下什麼

技能改綁人之後,武器有一段時間**什麼都不是**。英雄開局空手,而詞條是透過「哪件裝備給了
這一招」去定址的——英雄的招式不在任何武器上,所以那個查詢對每一招都回傳 null。

量出來的結果:侍從裝上一把帶 `keen` 的長矛,得到 **+5 重量和 0 的其他所有東西**。而掉寶
表裡有超過三分之一是武器(三個種子 × 八層:25 個掉落物,9 把武器)。也就是說**撿到更好的
劍會讓你變弱**。

修法是給武器一個它還配得上的職務:

| 武器現在提供 | |
| --- | --- |
| **族系** `family` | 誰能拿。老騎士是 `blade`、侍從是 `polearm`、攝魂師是 `focus` |
| **威力** `power` | 0–2 的小加值。傷害仍然住在招式上——三到五下就死的契約撐不起武器等級 |
| **恢復** `regen` | 重武器讓精力回得更慢。和盔甲用**同一個欄位**,因為那本來就是同一件事 |
| **消耗** `cost` | 每次出手多花的精力 |
| 詞條 | 沒變,但現在接得到英雄的招式了 |

**族系不是限制,是理由。** 撿到不屬於你的武器不是廢物,它是別人的——集會間的裝備櫃會
留著它給別的角色用,這正好接上「裝備可以帶回來分享」的設計,也給了換角色一個動機。
(裝備櫃還沒做;在那之前,背包裡的異族系武器**不佔重量**,只是躺著。)

### 為什麼要兩種貨幣

一開始只有 `regen`。量過之後有兩個地方會塌:

- **刃系根本沒有重武器**——匕首 0、雙刃 0、長劍 1、彎刀 1。四把武器,零個決定。
  補了一把 greatsword(威力 2、恢復 −1)。
- **攝魂師的重法器是免費的**。她的宣告恢復是 1,而 `regenRate` 的下限也是 1,
  所以 `regen: -1` 對她**剛好等於 0**。族系系統要防的那種塌陷,躲在一個 `Math.max` 裡面。

所以重武器可以用**恢復**或**每次消耗**其中一種收費,看哪一種對那個角色咬得動。這條由
測試守著:每個族系的最強武器,一定要在某一種貨幣上比開局武器貴。

## 技能改綁角色,不綁武器

這是對這個遊戲原本前提的反轉,而理由是量出來的:**12 把武器只產生 9 種不同的形狀
組合**,`front` 一個就佔了 24 個攻擊技能裡的 9 個,而長劍、釘錘、雙刃在機制上是同一把
武器。多樣性在數字裡,不在動詞裡。

武器現在是**詞條的載體**。你「能做什麼」來自你「是誰」。

### 真正區分角色的是「怎麼付錢」,不是形狀

| | 池子 | 回復 | 翻滾 | 沒力的時候 |
| --- | --- | --- | --- | --- |
| **老騎士** | 12(很小) | 4 | **7** / 2格 | 就是沒了 |
| **攝魂師** | 20 | **0.25** | 2 / 2格 | **燒血** |
| **執旗侍從** | 14 | 2 | **5** / **1格** | 法術補回 |

(翻滾的價格和攝魂師的回復後來調過,理由見上面〈翻滾〉那一節;這裡是現值。)

老騎士存不了東西,所以化解必須是選擇而不是靠山。攝魂師被動幾乎不回,所以**站著不打
就是餓死**——她的翻滾很便宜,但跳舞餵不飽她,那才是把她留在戰鬥裡的東西。侍從樣樣都慢,
帶一個買回一點的法術。

**第四個引擎:巡禮者付的不是耐力,是「拍」。** 她一回合兩拍,攻擊和翻滾各吃一拍,
走路吃掉整個回合。她的攻擊很便宜、回復很快,但**翻滾是全遊戲最貴的(7)**——
被迫閃避才是咬人的東西,而那正是規格要的。

她還有第五種付法:**唱完一句會退錢。** 引爆印記時,每一個多出來的印記退還 4 點精力,
乘的是 `mult - 1`,所以**沒有伴的引爆什麼都不給**。這條是量出來才加的:她的句子是
18 點、三個攻擊回合,而攻擊回合的回復是零,所以原本她連一次都唱不完。
見 `docs/FARWAYER.md` 的〈實作紀錄〉。

所以精力經濟從 `PLAYER` 那個全域常數搬到角色身上了。測試釘的是**三個引擎真的是三個**
——如果哪天它們的「一次翻滾等於幾回合」和「能連幾次」撞在一起,那就是換皮不是角色。

### 成長要加在人身上,不是覆蓋他

魂的「風」線本來直接把 `staminaMax` 指派成全域常數 + 加成。角色化之後那會在你第一次
花魂的瞬間**悄悄抹掉那個角色的身分**——攝魂師刻意的小池子變成所有人的池子。改成
獨立的加成欄位。

### 承諾的定價也要看是誰在付

規則本來是「大形狀必須有收招」。但那條規則的理由是**「可以靠等待支付的成本不是成本」**
——而等待貴不貴,取決於**誰在等**。

攝魂師一回合回 1 點,所以她 10 點的貫穿射線等於**十回合站著不動**,遠超過那個形狀
要求的 2 回合承諾。同一招給一個一回合回 5 點的角色,就只有 2 回合,那就需要額外的收招。

所以測試現在算的是「這一招對它的擁有者來說值幾回合」,而不是只看 windup+recovery。

## 灰燼之廳:起始區域是一個房間,不是一個選單

那是一個真的關卡,有真的人站在上面。整套東西本來就存在——關卡、渲染、站在格子上的
人、對話、篝火——所以**做一個選角畫面反而比做一個地方更費事**,而地方之後可以放東西
(見 docs/META.md:管種子的 NPC 住這裡,碎片回到這裡,而其他英雄就是你在收集其故事的人)。

**房間就是名單。** 廳裡的英雄是從 `HEROES` 生出來的,所以新增一個角色不需要有人記得
去把他擺進去——一個會跟名單不同步的選單,遲早會不同步。

三條規則:

- **走到誰旁邊就成為誰。** 沒有勾選框。
- **選之前就看得到自己會變成誰**:按鈕填上他的動詞、精力條縮成他的池子。
- **廳裡沒有時鐘。** `worldTurn` 在這裡直接 return——一個站著要付錢的地方,就不是拿來
  決定事情的地方了。而且沒選人不准下樓,不然會有人以「沒有人」的身分開始一場 run。

## 三個角色要的機制

| 機制 | 為什麼現有的不夠 |
| --- | --- |
| **真的取消** | `stagger` 只做 `timer++`——那一擊還是會來,只是晚一拍。「延後」和「消失」是不同的承諾 |
| **命中回充精力** | 攝魂師的引擎:被動一回合只回 1,所以**站著不打就是餓死** |
| **用血付帳** | 她唯一的另一個池子。也因此重甲對她是精力儲備,對別人不是 |
| **推開＋補位** | 化解一把刀和把持刀的人推離軌跡是同一個動作 |
| **強制連段** | 第三種不自由:不是「不能動」,是**「只能做這件事」** |

**化解這個動詞兩個角色共用,但文法不同。** 老騎士的版本必須推得動對方——所以對 2×2
完全無效,那是他的形狀;攝魂師直接把攻擊拆掉,所以她碰得到他碰不到的東西。

## 形狀在八個方向都必須一樣大

圖樣本來是「朝東寫一次,再用旋轉矩陣轉到面向」。那對**弧形**是對的,對任何**有深度**
的形狀是錯的:面向東南時單位向量是 (0.71, 0.71),所以 (1,0) 和 (2,0) 會四捨五入到
同一格,一條直線就這樣少一格。

加新形狀的時候順手量了整張表,結果是既有的:

| 形狀 | 朝東 | 斜向 | |
| --- | --- | --- | --- |
| `reach2` | 2 | **1** | 長矛斜著突刺只打一格 |
| `line3` | 3 | 2 | |
| `line6` | 6 | **4** | 長槍的招牌直線短了三分之一 |
| `sweepR` | 5 | 4 | 讓「兩段掃擊的聯集覆蓋整個前半圓」破洞 |

而且**畫面上完全看不出來**——預告是同一個函式畫的,所以它沒有說謊,它只是斜著的時候
比較弱,而且沒有任何提示。`around2` 早就因為完全相同的理由改成不旋轉了,但沒有人回頭
檢查其他的。

**有深度的形狀改成從面向「算出來」,而不是「轉過去」。** 測試釘的是性質不是做法:
不管一個形狀是怎麼來的,它在八個方向都必須一樣大。

## 為角色準備的四個新形狀

技能要綁角色而不是綁武器之後,八個角色分不到十種形狀——而且量過,12 把武器其實只
產生 **9 種**不同的形狀組合,`front` 一個就佔了 24 個攻擊技能裡的 9 個。長劍、釘錘、
雙刃在機制上是同一把武器。

每個新形狀都要問一個現有形狀不問的問題:

| 形狀 | 問題 |
| --- | --- |
| `cone` 錐形 | 越遠越廣,所以**後退是錯的**——跟其他所有形狀相反 |
| `behind` 背後 | 單獨用毫無價值,配翻滾才成立:穿過去、順手砍一刀 |
| `ring2` 環 | 打距離 2 的一圈但**不打貼身**,唯一逼你「靠近」的形狀 |
| `broad` 寬推 | 兩格寬兩格深,配擊退能一次推兩個身體——對著深淵就是處決 |

## 承諾:大招的價格是回合,不是精力

精力是可以用等待支付的,所以它不是成本。**收招不行**——它本身就是等待,而且是在
敵人挑的時機、不是你挑的。收招期間你不能行動、**不能翻滾**、不回精力。

以前收招只在 26 個技能中的 2 個上。現在由攻擊的形狀直接決定:

```
分數 = (格數 − 1) × 1.0 + (距離 − 1) × 1.2 + 威力 × 0.9
分數 ≥ 9.5 → 收招 2    ≥ 5.5 → 收招 1    否則 → 無
```

規則寫在測試裡而不是註解裡:新增一個技能而收招填錯,測試會直接告訴你應該是多少。

**取代冷卻,不疊加。** 這不只是為了避免三重稅——量過之後,舊的冷卻時間跟「攻擊有
多大」的相關性是 **r = 0.21**,等於沒有在定價。`hew` 掃四格零冷卻,`gut` 打一格
要等三回合。那不是平衡,是當初憑感覺填的。

**遠程不吃這條規則。** 收招只有在「有東西碰得到你」的時候才是成本,站在 9 格外收招
代價是零。遠程仍然用冷卻定價。

結果:**26 個技能裡有 9 個會定住你**(原本 2 個),而**巨斧、長戟、長槍三把武器
的兩招都會**——包括主要技能,也就是撞上去自動攻擊的那一招。這打破了原本
「只有次要技能能有收招」的規則,是刻意的:那條規則讓巨斧的普攻和匕首的普攻在機制上
一模一樣,而選武器的意義全靠傷害數字扛。

取而代之的保證是:**翻滾永遠不收招、永遠可用、而且不推進回合。** 全承諾武器的逃生門
屬於玩家,不屬於任何一把武器。

按鈕上會用紅點標出收招回合數——一個你要付了才知道的成本不是決策,是陷阱。

## 前搖:你也開始被讀了

收招是**已經打到了**,然後站在那裡。前搖是**還沒打到**——所以它可以被拿走。

同一筆承諾預算,不同的分法:

| 技能 | 前搖 | 收招 | 為什麼這樣切 |
| --- | --- | --- | --- |
| impale(六格穿刺) | 1 | 1 | 看得見的長矛突刺 |
| rend(五格橫掃) | 1 | 1 | 巨斧掄圓了才砍 |
| sunder(周圍八格) | 0 | 2 | **刻意不給前搖** |
| pierce(拉滿的弓) | 1 | 0 | 見下 |

**sunder 不給前搖是設計,不是遺漏。** 它是「我被包圍了」的按鈕,而前搖會被打中打斷
——被包圍的時候你一定會被打中。給它前搖等於讓這顆按鈕在唯一該用的場合失效。

**pierce 是前搖真正賺到的地方。** 收招是「有東西碰得到你」才要付的成本,站在 11 格外
收招等於免費——這就是遠程被排除在收招規則外的原因。前搖剛好相反:**它偏偏在遠距離
才貴**,因為在拉弓和放箭之間那一回合,目標會走到別的地方去,而你瞄準的那條線已經
沒有人了。所以 pierce 用一半的冷卻換了一個必須預判的射擊。

### 打斷

**任何一次造成傷害的命中都會打掉你的前搖,精力不退。** 沒有堅韌判定——玩家是一個
人,不是巨魔,而且一個扛得住命中的前搖會讓長技能無條件優於短技能,那就不是賭博了。

前搖期間也**不回精力**。前搖和收招是同一份承諾的兩半,如果其中一半會回精力,長攻擊
就等於在替自己的下一刀付錢。

按鈕上用**空心圈代表前搖、實心點代表收招**——同樣的預算,不同的賭注,而空心的那半
是可能被拿走的那半。畫面上你的攻擊範圍會用**藍色**畫出來,跟敵人的紅色分開。

## 演出:同時結算,依序表演

回合裡的每件事在規則上都是**同時**發生的。但眼睛需要的不是事實,是因果順序。

所以演出**按事件種類分相,不按角色分**——所有移動 → 所有攻擊 → 所有命中 → 所有死亡,
每一相之內全部同時播。

### 為什麼不是一個一個演

先量再做。5040 個回合的取樣:一個回合的可見事件**中位數是 10 個**。一個一個演、
每個 120ms,就是**每回合 1.2 秒**——那是中位數,不是最差情況。而且它隨敵人數成長,
等於越熱鬧的樓層越黏,剛好跟需要相反。

按種類分相之後,**1 隻和 30 隻的成本一模一樣(255ms)**,測試盯著這條。

### 空相零成本

這遊戲大部分時間在走路。沒有事件的相不佔時間,所以走一格是 130ms,打起來才 255ms,
什麼都沒發生是 0ms。

### 相是時間軸上的偏移,不是佇列

命中發生在前衝**到達最遠點那一瞬間**,不是等前衝播完。順序保證完全不變,牆鐘時間
少三分之一。實測分布:中位數 255ms、90 分位 320ms、最長 470ms(你揮完換他們走位、
出手、打中的完整一來一往)。

### 兩輪不能合併,但走路例外

「你做的」和「他們回的」是分開的兩輪相。合成一組的話,**你剛殺掉的東西會跟著揮刀**
——牠結算時已經死了、根本沒有出手,演出等於無中生有。

**但那是關於攻擊的**,因果掛在攻擊的順序上。走路沒有這個問題:你和牠們就是在同一個
回合裡動的,同時演出來反而更誠實。

而且把移動也分成兩輪的代價,剛好落在你整場遊戲看最多的東西上:一般走路的回合,
玩家滑 0→130ms,**敵人 105→235ms**——牠們的滑動是整段動畫的後半段,而任何輸入都會
`skip()` 把它從半路砍掉。以正常速度走路,幾乎每一回合都被砍,所以敵人是**抽動著**
橫過地板而不是走過去。

現在移動兩輪共用 t=0,走路回合從 235ms 降到 **130ms**。曲線也從 ease-out 換成
smoothstep:只緩和落點會讓 sprite 從起點猛地彈出去再飄過去,而那個猛彈剛好發生在
你的視線移過去的瞬間。

### 規則不知道有動畫這回事

`worldTurn()` 是全同步的,所以演出開始時,它要演的東西已經不見了:死者已離開敵人
清單、被擊退的已經在新格子上。動畫**不能讀結算後的狀態**,只能被告知——`js/game/fx.js`
就是那份告知。規則往清單裡推一行,UI 把清單變成時間軸。

移動是**用位置差算出來的**,不是由移動的程式碼回報。這遊戲改變格子的方式太多了
(走、翻滾、衝刺攻擊、擊退、衝鋒),逐一插樁的話一定會漏掉一個——這個專案有前科。
差分不會漏。

但分類很重要:**擊退是「命中造成的移動」**,放進移動相會變成目標在推它的那一擊
**之前**就飛出去。所以這一輪裡有被命中又有移動的,一律算擊退,跟命中同一相。

### 三個踩過的點

- **攝影機跟著「移動」走,但不跟著「被打」和「揮刀」走。** 這條線兩邊各有一個失敗:
  全部都跟,揮一刀整個世界就晃一下——那正是當初鎖攝影機要修掉的抖動;全部都不跟,
  走路更糟——鏡頭立刻到新格子,sprite 還在從舊格子滑過來,變成**世界先跳、小人再追著
  自己的鏡頭跑**。所以分界不是「玩家 vs 敵人」,是**「位移代表你去了別的地方」對上
  「位移代表有事發生在你身上」**。命中測試(點到哪一格)則永遠用靜態鏡頭,不然
  同一個點擊會因為動畫播到哪裡而落在不同格。
- **換樓層不是移動。** 位置差分分不出「走了一格」和「被搬到別的地方」,而樓梯會把你
  移動大約三十格(實測 seed 'stairs' 是 7,3 → 36,5),到一張舊座標毫無意義的地圖上。
  當成移動播,鏡頭就會滑完那整段距離——那就是下樓時的抽動。改成畫一道**幕**:切換是
  硬切(鏡頭瞬間就位),260ms 的淡出蓋住底下正在到位的地磚和迷霧,而且**不擋輸入**。
- **玩家自己的死亡不能畫粒子。** `die()` 會重建每一層、把你傳送到別層的篝火,粒子會
  畫在新地圖的錯座標上。所以那個事件帶 `final` 旗標,走全螢幕紅光而不是格子特效。
- **任何輸入立刻快轉到最終狀態。** 不做這條,每回合會吞掉 255ms 的輸入,而這遊戲的
  人是壓著方向鍵走的。實測 50ms 連打 20 次 → 推進 19 個回合。

## 走廊兩格寬,門是雙開的

生成器從 claudeHack 繼承了一格寬的通道,而那對這個遊戲**不只是窄,是敵意的**:每個
攻擊形狀塌成一格、只剩前後可走(那正是 `line3`／`line6` 設計來懲罰的)、格擋從
「無處可去」的選項變成義務。半個武器庫退化成 `front`。

以前的解法是 `openAlcoves`,限制一段窄路能**多長**。現在是直接把大部分拿掉。

### 中心線不變,加寬的那一格只吃岩石

路徑演算法一個字都沒改,包括它「撞到別的房間牆就打穿」的特權。**旁邊那一格只准挖
`STONE`,絕不碰 `WALL`**——一支能開牆的兩格寬刷子,會在它經過的每個房間上多打一個洞。

門也是成對的,而且**兩扇同一種**:一半開一半關的門是一道你走得過去的門,那另一半就
只是裝飾。實測 85% 的門扇屬於雙開門。

### 但只有大約四分之三加寬,這是測試逼出來的

第一版把**全部**加寬,窄地佔比從 22% 掉到 **4.1%**。那不是「隘口不再是預設」,那是
「隘口消失了」——而**一條走廊是面對一群獵犬的正當答案**,把它拿掉等於拿走一個解法。

守著這條的測試(`pc > 5`)擋下來了。現在四分之一的走廊維持一格寬,窄地回到 6.3%,
最長直段 3 格。

### 兩格寬的門不是門框

「門口不能斜向進出」這條規則,是為了阻止東西在走廊口互相溜過去——**把敵人漏斗進門口
是這個遊戲真正的防守技巧**。但它是一條關於「擠過一個框」的規則。

門變成兩格寬之後,大部分的門已經不是框了,而規則還在照一格的樣子執行。實測:
**6151 次斜向翻滾進門口,成功 0 次。一次都沒有。**

現在 `pinches()` 會問這扇門旁邊有沒有另一扇。有的話,那是一個可以兩人並肩走過去的
開口,沒有什麼東西需要被擋。

| | 之前 | 現在 |
| --- | --- | --- |
| 斜向滾進**雙開門** | 0% | **100%** |
| 斜向滾進**單開門** | 0% | **0%**(不變) |

隘口規則活在它還有意義的地方。

### 地圖的變化

| | 一開始 | 房間變大後 | 現在 |
| --- | --- | --- | --- |
| 實心岩石 | 83% | 76% | **74%** |
| 房間最高 | 4 | 8 | 8 |
| 2×2 放得進走廊 | 1.9% | 1.9% | **39.4%** |

### 大型敵人重新變成會追人的東西

2×2 現在跟得進寬走廊(1.9% → 39.4%),所以牛頭人不再被困在房間裡——**那本來就是把它
變大時的副作用,不是設計**。

要保住的性質不是「它出不了房間」,而是那個副作用裡真正有價值的部分:
**永遠有它跟不進去的地方**。實測 **100% 的樓層**都有。測試改成釘這一條。

## 情境池:幾何和選角一起決定

房間形狀的池子只能生出幾何,而幾何不是情境。規格格式在 docs/SITUATIONS.md,
實作在 js/data/chambers.js。

一個 chamber 帶三樣東西:`build()` 蓋幾何並留下**有名字的錨點**、`cast` 說誰站在
哪個錨點(**用角色不用種類**)、`intent` 是它該逼出的決定。`intent` 不是裝飾——它是
驗收標準,測試是從它寫出來的。

### 房間太矮,裝不下任何情境

第一個發現:地圖 64×25 切成 4×3 的格子,每格 15×7,**房間高度上限是 4**。列柱大廳
需要五排(走道、柱、中廊、柱、走道),所以**沒有任何一層放得下**——實測 0.0%。

改成 4×2 之後:

| | 之前 | 之後 |
| --- | --- | --- |
| 房間中位數 | 8×3 | **8×6** |
| 最高 | 4 | **8** |
| 實心岩石 | 83% | **76%** |
| 可走面積是房間 | 76% | **83%** |
| 有情境的樓層 | — | **55%** |

順帶把地圖推向該有的樣子:寬而淺的房間配細長走廊,正是要擺脫的形狀。

### 柱子:第一個擋視線的地形

`RUBBLE` 和 `PIT` 都是擋移動不擋視線的(一個決定誰碰得到誰、一個決定誰射得到誰),
所以在這之前**除了牆沒有東西會切斷視線**。

### 它和「每個房間都是亮的」正面衝突,兩條規則最後都保住了

原本的房間會**整間揭開**——那條程式寫在只有外牆是不透光的年代,那時候「揭開房間裡
全部」和「揭開你看得到的」是同一句話。房間**裡面**站著不透光的東西之後,兩句話就
不一樣了,而整間揭開等於免費把柱子的另一側送給你:**伏擊變成不可能**。

所以改成:邊界照舊整圈揭開(角落本來就是為此),**內部只揭開真的看得到的**。房間裡
沒有柱子的話每一格都通過,行為和以前完全相同——這也是 claudeHack 完全沒受影響的原因。

柱子於是做了兩件事:

| | |
| --- | --- |
| 被藏住的敵人 vs 露出來的 | **225 : 123** |
| 射手射得到的中廊格 | **34%** |

但「看不見的前搖不是預告」也沒有放棄:**任何正在前搖的東西一律強制可見**
(`Game.afterMove`)。所以你會被「有東西在那」嚇到,不會被「它出手了」嚇到。

加這條保證之前實測是 **2097 次前搖裡 0 次被藏住**——能碰到你的東西必然近到看得見
——但這條規則太核心了,不能交給幾何學運氣。

### 情境的組成就是情境本身

一般的隨機放置會往 chamber 裡再灑怪,把刻意的組合稀釋掉:兩個射手加一個擋路的變成
房間裡有六個東西,**那不是決定,是一群人**。隨機放置現在會跳過 chamber 房間,實測
剛好 3.00 隻。

而且 chamber 的選角**從樓層的總預算裡扣**——「敵人數量不是難度旋鈕,組成才是」這條
規則對情境一樣適用。

### 三個情境

| | 決定 | 幾何做的事 | 出現 |
| --- | --- | --- | --- |
| **列柱大廳** | 什麼時候穿過空隙 | 斷視線,不斷路 | 第 3 層+ |
| **崩落的中庭** | 要不要繞中央那堆碎石 | **斷路,不斷視線** | 第 2 層+ |
| **吊橋** | 低頭走完,還是繞一大圈解決守橋的 | 深淵:走不過去,但看得過去、射得過去 | 第 4 層+ |

前兩個是刻意的對照:列柱大廳把視線切斷、地面留著;崩落的中庭把地面切斷、視線留著。
同樣一堆石頭,`RUBBLE` 擋腳不擋眼、`PILLAR` 兩個都擋——這兩條在 tiles.js 裡本來就
寫著,情境只是把它們用出來。

### 吊橋:一條沒有牆的走廊。做錯三次

規格是「狹長的通道,但兩側不是牆是開放空間,對面有敵人卡住行動並施放遠程」。

1. **用坑當兩側。** 坑是「一道你看得過去的牆」——直接違背它自己那句話,而且畫面上
   那排等距的黑洞看起來像裝飾圖案。
2. **完全不放地形。** 那就失去了「通道」本身,變成一個有射手的房間。
3. **橋跨過深淵。** 兩者都有:路線是**真的、人造的、窄的**,而邊界是**空的**而不是石頭。

所以加了兩個地形。`CHASM` 不是大一號的坑——坑是**地板上的洞**(散在房間裡的障礙),
深淵是**地板不見了**(連續的一片,讀起來是一個地方)。而跨過它的東西是一個**結構**。

深淵**是全遊戲最好飛的東西**(`flyable`),那正是橋可怕的地方:**擋住你走路的那道缺口,
完全擋不住射向你的東西。**

橋是兩格寬。一格寬的橋就是一格寬的走廊,而那條規則的理由在這裡完全適用——第一版的
堤道就是這樣被 `MAX_STRAIT` 擋下來的。

**四周留一格實地是連通性規則,不是裝飾。** 門開在牆內側那一格,而開在虛空上的門
等於把樓層切成兩半——測試抓到了。

### 底層不放情境,而且競技場要清空

情境是在 mapgen 蓋幾何、在 populate 選角的。但 **populate 在底層會提早 return**
(「boss 樓層由手工放置」),所以蓋在底層的情境**地形被挖了、選角從來沒發生**——柱子
和深淵切進聖殿,什麼都不為。

而且 boss 拿最大的房間,情境也喜歡大房間,所以它們會撞在一起:實測 40 個底層有 25 個
被蓋了情境,其中 **6 個把龍放在列柱大廳裡、或放在一座橋上而深淵就在牠肩膀旁邊**。

順帶把競技場清空:瓦礫和坑會灑進每一個夠大的房間,而龍站的就是最大的那間。

現在:**0 個情境、0 個雜物**,最小的競技場 45 格可走地。

## 大型敵人:一個身體,好幾格

`size: 2` 的敵人佔 2×2。目前只有最底層的 First Flame 是。

### 地圖決定了它能是什麼

量過 300 層:2×2 的身體放得進 **39%** 的可走格,但**走廊只有 1.9%、門口 2.6%**。

所以大型敵人**天生是房間限定的**——它跟不出房間。對一隻遊蕩的怪來說那是缺陷,
對地牢最底下那個東西來說那只是事實,而且它讓聖殿變成**競技場**,而不是一條你可以
一格一格慢慢磨它的走廊。**你永遠可以退出去**,這比「逃不掉的大怪」有趣。

### 唯一一行真正的關鍵

```js
for (const e of this.enemies)
  for (const t of e.bodyTiles()) this._idx.set(this.idx(t.x, t.y), e);
```

把身體的**每一格**都註冊進索引,而不是只註冊錨點。全遊戲十七個 `enemyAt` 呼叫點
——攻擊、移動、尋路、對角規則、機器人——本來就都在問「這裡站著什麼」,現在從它蓋住的
任何一格都拿到**同一個物件**。

**自己不會 friendly fire 自己也是免費得到的**:既有的 `other !== e` 比的是物件
identity,所以不需要任何 size 判斷,而且以後新增的攻擊也不可能忘記。

### 會偽裝成平衡問題的那一個

遊戲裡每一次攻擊都是**一串格子**,而原本的迴圈是逐格問「這格站著誰」。一個蓋住三格
的身體會**被結算三次傷害、三次擊退**。

而它從外面看起來是「**大型敵人太弱**」——一個穿著平衡問題外衣的結算 bug,正好是這個
專案反覆上當的那一類。`bodiesIn(tiles)` 去重之後,一個身體一擊。

### 衝鋒:打、前進、重複

牛頭人現在是 2×2,牠的衝鋒分成三次「打前面那塊、往前兩格」,撞到牆就停。

**牠打的那塊地就是牠自己的身體往前平移**,不是形狀表裡的圖樣。這同時解決兩件事:
它是誠實的預告(牠威脅的正是牠即將佔據的空間),而且它**自動跟著體型長大**——這正是
上面那個「三格寬的身體用一格寬的拳頭」問題,至少在這一招上被修好了。

**整條路徑在前搖時就全部畫出來**(4 到 12 格)。只畫第一步的話,後面兩步就是兩次
沒有預告的攻擊。

### 三條規則

- **攻擊從離玩家最近的那一格發出。** 用錨點會讓它一半的攻擊從身體另一側的空氣裡冒出來。
- **不能被擊退。** 不然 3 點精力的 Shove 可以推著一條龍走。
- **對角封鎖很兇,而且是刻意的。** 一個 2×2 等於四個身體,站它旁邊幾乎所有斜向都被封。

### 大身體讓尋路整個垮掉,而且是兩層

把牛頭人變成 2×2 之後牠在房間裡**原地左右跳**,永遠走不到玩家面前。兩個原因疊在一起:

1. **A\* 用一格的寬度規劃路線。** `passable(x,y,mover)` 只問一格,所以它規劃出一條
   2×2 走不了的路,然後 `tryStep` 把每一步都拒絕掉。現在 `passable` 在 mover 比一格
   大的時候改問 `bodyFits`——注意 `bodyFits` 必須呼叫 `tilePassable` 而不是繞回
   `passable`,否則無限遞迴。
2. **尋路認為「自己站的格子有人」。** `occupied()` 沒有排除 mover 本身,而一個 2×2
   想踏進去的格子有**四分之三是自己的身體**。牠一步都走不了,就站在遇到的第一根柱子
   前面。這一條在 `engine/path.js`,是兩個遊戲共用的——對一格生物完全無害,因為
   沒有人會問它「你現在站的那格能不能走」。

一格的生物可以貼著柱子繞過去,2×2 不行。這是「平均只剩 3.4 個方向可走」那個數字的
實際後果。

### 踩到的兩個坑,都會讓 run 不能通關

1. **樓層可能沒有 boss。** 「最大房間的正中央」不再保證放得下 2×2,而舊的 fallback
   也沒檢查——20 個種子有 2 個的底層**完全沒有 boss**。
2. **有東西生在龍的身體裡。** `randomFreeSpot` 只把每隻敵人的**錨點**標記為已佔用,
   所以龍的另外三格看起來是空的。

兩個都是「多格佔位要被**每一個**問「這格有沒有人」的地方看見」的同一件事。測試現在
釘的是 60 個底層都有一隻完整的、四格都答得出來的 boss。

## 詞條:三個槽,一個來源一格

**約束本身就是設計,分類不是。** 每一條詞條都追得到來源,所以玩家看一把武器就知道
哪一部分是武器、哪一部分是自己加的、哪一部分快沒了——這是可讀性,也是**詞條不會變成
數值湯**的原因。

| 槽 | 來源 | |
| --- | --- | --- |
| **天生** | 武器被鍛出來的樣子 | 可以有**兩條**,而一旦有兩條就**永遠加工不了** |
| **後天** | 你用石頭打上去的 | 一條,永久 |
| **暫時** | 油、附魔 | 一條,用**命中次數**算,不是回合 |

「兩條天生 = 不能加工」讓**強力的撿到武器**和**可以客製化的武器**變成兩把不同的武器,
而不是一把嚴格更好的。戰錘天生帶淬火和霜,所以它永遠是它自己;彎刀天生一條銳利,
還留著一個後天槽。

**次數是誠實的單位**:它和消耗品用的是同一種貨幣,而且「還剩五刀」是可以拿來計畫的,
「還剩三十回合」不是。

詞條能改的東西**刻意很窄**:重量,和技能上的數字。**不能改形狀**——會改形狀的詞條會讓
按鈕上的圖示對它自己的技能說謊,而那些圖示是從 pattern 表生成的,正是為了不可能說謊。

賦予詞條的是**消耗品**(石頭給永久的、油給暫時的),不是 NPC。這樣之後真的出現鐵匠或
任何其他角色,只要「給你這個道具」就好——NPC 不需要自己的系統。

## 重量:防具的代價

| | 輕裝 | 重甲 |
| --- | --- | --- |
| 生命 / 減傷 | 12 / 0 | 16 / **25%** |
| 總重 | 7 | 17 |
| 翻滾消耗 | 4 | 7 |
| **翻滾距離** | **1 或 2 格,拖曳決定** | **同左** |
| 每回合回復 | 3 | 2 |

**減傷是比例而不是固定值。** 固定 −1 對 2 點傷害值 50%、對 6 點傷害只值 17%——
它精準地擋掉了輕裝最受不了的消耗傷害。實測:同一把劍、同一批敵人,皮甲付出 7.3 條血、
鎖甲 3.9 條。改成比例之後,鎖甲對小咬完全不減,對大招照樣少扛一點——那才是它說明文
寫的那個幻想。

**所有人翻滾都是兩格可選**,因為兩格是逃離任何一招的最小值——一格翻滾不是「閃得比較差」,
是根本不能閃。距離是懸崖,耐力是斜坡,所以重量放在斜坡上。

**移動永遠免費**(對移動收費等於對探索收費,而且會製造無法移動的死局),
**前幾點重量免費**,而且**沒有東西在追你的時候回復速度是四倍**。

## 位置:身體也是牆

**兩個並排的敵人,對走路來說就是一堵牆**——斜向溜出包夾不再免費,出路是翻滾。
這條只對玩家生效:玩家是唯一有「花精力穿過去」手段的角色,套用到敵人身上只會讓
密集的群體自己卡死。

走廊也受同一個想法管:**任何樓層都不存在連續超過 4 格無法側移的路段**(實測最長 3 格)。
窄路本身保留了(佔可行走格的 21.7%),被砍掉的是**長的**窄路——長走廊會讓弧形攻擊塌縮成
一格、讓唯一的移動變成前後,而那正是直線攻擊專門懲罰的事。

## 群體

敵人成組出現,而重點不是數量是**預告重疊**。因為每一擊都會宣告,好幾個東西同時舉手就是
一個讀得懂的物件:一條槍線、一個弧、一段延伸,中間有個縫。找到那個縫、並在它關上之前
到達,和讀單一招式是不同的問題。成員按**互補的形狀**選,而且**從樓層原本的預算裡扣**。

## 掉落、死亡與魂

- **儲藏庫**:約三分之一的樓層有,箱子放在角落,**守衛站在箱子和房間之間而且一開始就是醒的**。
- **菁英**:第 3 層起每層一隻,現有物種的強化版,帶著一件固定道具。
- 兩者的內容都來自 `seed#depth`,taken 集合是每個 run 一份——**所以不能刷**。
- **死亡會掉下「上次休息之後」拿到的一切,加上身上的魂**,留在原地。走回去拿,
  或者在路上再死一次就永遠沒了。**穿戴中的絕不會掉。**
- **有東西知道你在哪的時候不能休息**——否則篝火是一個隨處可用的重置鍵(回滿 + 敵人回到出生點)。
  「知道你在哪」是**看過你**或**被伏擊信號叫到你這裡**,不是「醒著」;細節見下面那一節。
- **魂只能變強,永遠不解鎖進度**,而且只在篝火花得掉。兩條線:精力上限、免費重量額度。
  **零升級必須能通關**,而且那是一條測試。

### 一個旗標背了兩個意思:剛下樓就不能坐火

玩家回報的現象:**下到新的一層,旁邊就有篝火,但坐不下去,說你被追殺中——即使下樓前是安全的。**

量出來比回報的更糟。40 個種子 × 第 2～5 層:

| | |
| --- | --- |
| 抵達點 5 格內有篝火的樓層 | **160 / 160** |
| 抵達時就有「追殺者」的樓層 | **159 / 160** |
| 平均追殺者數量 | **8～10 隻** |
| 最近的一隻(中位數) | 7 格 |
| 其中超過 12 格遠的 | 每層約 7 隻 |

也就是說**幾乎每一層的到達火都是壞的**,而且畫面上告訴你有十個東西在追你,而你什麼都看不到。

原因是 `aware` 這個旗標被兩種意思共用:

| 寫的人 | 意思 |
| --- | --- |
| `ai.js` 看到玩家 | 「它知道你在哪」 |
| 板塊 `aware: true`、情境的 `part.aware`、儲藏庫守衛、王的護衛 | 「它一開始就是醒的,不是睡的」 |

`hunters()` 數的是 `aware`,所以第二類——**從來沒看過你、也不知道你存在的東西**——照樣擋你的火。

規則本身是對的(休息 = 回滿 + 整層重置,不能當成戰鬥中的逃生鍵),錯的是它問錯了問題。
所以拆成兩個欄位:`aware` 還是「醒著」,新的 `hunting` 才是「知道你在哪」,而
`hunting` 只在**看到你**和**被伏擊信號叫到你這裡**時成立,在遺忘時和 `aware` 同一行清掉——
**同一行寫、同一行清,就漂移不了。**

修完:159/160 → **0/160**。

#### 舊測試為什麼沒抓到

因為它自己去設 `aware = true`,然後檢查坐不下去。**它測的是旗標,不是規則**,
而這次的 bug 正好就是「旗標有兩個意思」——測試用哪個意思寫,就驗不出另一個意思。

改成走真正的路徑:把敵人放到你旁邊、跑它的 AI 回合、讓它**自己看到你**。
斷開時也一樣,把它丟到 14 格外,跑到它自己放棄為止。

這裡也踩到一個小陷阱:剛看到你的敵人正在前搖,而 `enemyTurn` 在前搖期間直接 return,
所以只驅動 `enemyTurn` 的話**遺忘計數器永遠不會前進**。要連 `tickEnemyState` 一起跑。

新的那條測試釘的是後果,而且**先證明自己在測東西**:48 次抵達、其上 387 隻醒著的敵人、
0 隻在追你。如果哪天沒有 `aware` 的敵人了,它會因為「這條測試什麼都沒測到」而失敗。

兩條都驗過關掉規則會失敗:退回舊的 `aware` 版本,抵達測試掛;讓 `hunters()` 永遠回 0,
戰鬥中測試掛。

## 介面

- 地圖是**正方形**,鏡頭鎖定玩家、置中到次像素。
- **九顆按鈕固定 3×3**:主手主要/次要 · 副手主要 · 魔法 · 道具 · 格擋 · 翻滾 · 互動 · 背包。
  位置固定,沒有的留一個有名字的洞。
- 技能鈕上的形狀圖示**從攻擊實際結算用的同一張 pattern 表生成**。
- 血量精力在左上角,疊在地圖上,跟著畫布而不是視窗。
- **畫面外的預告會在邊緣畫箭頭**——看不見的預告不是預告。
- 互動鍵是情境的:瞄準時 Cancel、箱子上 Open、屍體上 Take back、篝火上 Bonfire、
  被追時 Hunted(並說出還有幾個)。規則是「任何兩個動作不得同時可用」。

## 面板紋理

面板和方形地圖兩側的留白鋪著一層**幾乎看不見的紋理**,六選一(無/顆粒/織紋/板岩/鑿痕/灰燼),
在 `?` 畫面裡即時切換。**地圖本身永遠沒有紋理**——board 底下的紋理會和預告搶注意力,
而那是這個介面唯一不能做的事。按鈕也沒有:控制項上的紋理會讓它看起來像是被停用了。

它們**不是用 asset generator 做的,而那是查證後的決定**。那個工具在三個地方說它不做 tile:
它認識的每一種資產都是「一個物件、置中、透明背景」,而它自己的註解寫著 tile
「填滿整格、四邊都要接得起來、而且**不能**被去背,那是另一條 pipeline」。
它的核心量測結論還是「平塗勝過紋理」——它是為了**移除**這種東西而造的。

改用 CSS 生成,對這個需求來說不是退而求其次:**接縫由構造保證**(那正是工具說它缺的那塊)、
**完全不佔下載**(這個 repo 就是網站,而玩家本來就不該注意到它)、
**顏色從調色盤衍生**(不可能和配色打架),而且**可以一邊看一邊換**——那才是「多做幾個備選」的用途。

## 工具

| | |
| --- | --- |
| `tools/systest.mjs` | 73 條系統測試,無頭執行 |
| `tools/botrun.mjs` | 平衡儀器。`--report` 死因與誓約對照、`--light`/`--heavy`、`--shield`、`WHY=1` 每次死亡當下的狀態、`WHYSTALL=1` 卡住時在重複什麼、`ACTMIX=1` 回合花在哪 |
| `tools/facing.html` | sprite 面向清點,八個方向即時預覽 |

# 還沒做的

**擱置中:** 盾反(明確排除)、體幹值、捷徑、跨樓層移動、樓層三選一獎勵。

**還沒有的:**

- ~~**大型敵人**(多格身體)。~~ **做了。** 牛頭人和第一火焰都是 2×2。
  當時擔心的那個風險——範圍攻擊對同一個身體結算好幾次——由
  〈an area attack hits a big body once, not once per square〉守著。
  剩下的是**數量**:11 種敵人裡只有兩隻是大的,而其中一隻是王。
- **即時回合模式**(每回合固定的實時視窗、以最後輸入為準)。聊過,沒定。
  兩個真正的問題:翻滾不推進回合會變成手速遊戲,以及拖曳瞄準在 0.25 秒內做不到。

**還懸著的問題:**

> **這兩個數字在 2026-09-09 之後全部失效。** 它們是在「死了回篝火、無限復活」的模型下量的,
> 而那個模型已經不存在——死亡結束一輪。**「每場 26 次死亡」在一個死一次就結束的遊戲裡
> 不是一個數字,是一句沒有意義的話。** 兩題都要重量,而重量之前不要拿它們做決定。

- **輕重甲差距**:~~輕裝每場約 26 次死亡走到第 8 層,重甲約 7 次走到第 8.9 層。~~
  舊模型下的數字。方向可能還對(死得多走得遠),但幅度完全不能用了。
- **箭矢曾佔死因 37.5%**。同樣是舊模型下的統計。原本的猜測(機器人不會處理飛行物)
  仍然沒有驗證過,而現在連分母都換了。

---

# 第一次試玩之後:六點回饋,和三個假扮成平衡問題的臭蟲

試玩回饋一共六點。有兩點是外觀,四點是戰鬥。有意思的是,**四點戰鬥回饋其實是同一件事**,
而修它的過程裡挖出三個臭蟲——每一個都長得像「數值沒調好」。

## 回饋一:主角身上那個歪掉的小圈圈

拿掉。光暈留著(光暈解決的是「找不到主角」,圈圈只是多餘的裝飾)。

## 回饋二、三、四是同一個問題

> 「感覺不用所有敵人都有攻擊預告,不然現在的行動經濟裡玩家永遠可以退一格讓敵人落空,
> 然後回頭砍一下。」
>
> 「我覺得有必要做堅韌值,不然 1v1 的情況下玩家永遠可以靠攻擊來延遲敵人攻擊然後獲勝。」
>
> 「篝火頻率有點高,有可能其實是生命在現在的戰鬥中永遠消耗不完的關係。」

這三點是同一個病的三個症狀:**戰鬥是免費的。** 所有攻擊都是「前方一格」,而後退一格
不花精力,所以最佳解永遠是「退一格、等它落空、走回去砍」。生命因此永遠不會扣光,篝火
於是顯得太多——**篝火頻率是症狀,不是原因**,在戰鬥修好之前調它只會蓋掉訊號。

三個修法:

**1. 攻擊形狀庫**(`js/game/patterns.js`)。每個形狀要求不同的應對:

| 形狀 | 正確應對 |
| --- | --- |
| `front` / `arc3` | 後退或側移 |
| `line3` / `line6` | **側移**——沿著槍線後退沒有用 |
| `arc5` + 前衝 | 五格牆,繞過去要兩格 → 翻滾 |
| `sweepL` → `sweepR` | 躲開第一段的直覺側移,正好走進第二段 |
| `around2` | 沒有任何一格單步能逃出 → 一定要翻滾 |

`around2` 原本是稀疏的星形,測試抓到它**東西向就漏兩格**,而且旋轉 45° 之後外圈會互相
四捨五入到同一格,再漏四格。改成實心 5×5,並且把放射狀形狀標記成「不旋轉」——
**圓環沒有面向,旋轉它只會製造破洞。**

**2. 堅韌值。** 我原本主張不需要,使用者是對的,而且需求是我自己造出來的:是我加了打斷
機制,才讓「用最便宜的招無限延後最貴的招」變成最佳解。現在普攻(衝擊 2)打不斷重招:
哨兵(堅韌 4)要兩下,巨魔(堅韌 8)在三回合的預告裡**玩家手上任何東西都打不斷**——
要嘛走開,要嘛把 lunge + sweep + strike 全押上去、花掉 20 點精力裡的 16 點。
打斷從反射動作變成一筆有價格的決定。

**3. 不預告的攻擊。** 快而弱的敵人直接打,所以「站在旁邊」永遠有代價。但**預不預告是每
一招固定的性質,絕不擲骰**——時而預告時而不預告的敵人學不起來,那是不講理,不是難。
無預告攻擊傷害上限 2,而且一樣有收招。

## 回饋六:手機上太小

格子從 13×8 放大到 9×6。375px 的手機上是每格約 41 CSS px(原本 29)——這是「看得出來
那是巨魔」和「只能讀 log 才知道被什麼打到」的差別。

**放大格子要付帳。** 視野半徑 11,畫面只有 9 格寬,而牛頭人預告六格衝鋒——所以完全可能
有東西在畫面外宣告一記會打到你的攻擊。**看不見的預告不是預告**,整個遊戲建立在「每一擊
都會先宣告」上,所以宣告必須能活著離開畫面。畫面外的預告會在邊緣畫一個箭頭,用同一組
紅色和同一條急迫度曲線,底下墊一個深色圓盤所以疊在任何東西上都看得見。

## 回饋五:技能鈕沒有視覺回饋

> 「他們對應什麼功能在視覺上完全沒有反饋。」

每個技能鈕上畫一張形狀小圖:紅色是會打到的格子,藍色是自己會移動到的格子,淺色是自己。
**圖是從攻擊實際結算用的同一張 pattern 表生出來的**,不是手畫的——手畫的圖遲早會偷偷
開始說謊。

手機上為此把底部控制列改成上下堆疊:並排時方向鍵吃掉 375px 裡的 134px,技能鈕只剩 70px
寬,塞不下圖示加名字,名字被裁成「Str」「Lung」。改成整列滿寬之後每顆約 119px,兩者都放
得下,而且地圖一格都沒變小(格子受寬度限制,不受高度限制)。

---

# 三個假扮成平衡問題的臭蟲

這一輪真正的教訓。三個都先以「數值沒調好」的樣子出現。

## 1. 前衝攻擊踩到目標身上

巨魔幾乎從不使用它的招牌重砸,整場只丟便宜的反手。看起來是機率權重沒調好。

實際上:前衝攻擊會先移動再從新位置結算,而移動沒有檢查目標站在那裡。巨魔踩到玩家的格子
上、然後從**玩家自己的格子**放出弧形攻擊,一格都打不到——所以那一招永遠不合法。
**是幾何錯誤,不是平衡問題。**

## 2. 敵人在收招時偷偷存行動力(最貴的一個)

修完之後,獵犬咬擊佔全部死亡的 25%,四種無預告攻擊合計 **64%**——全部是不可能反應的
傷害。看起來就是「無預告攻擊太強,得砍」。

實測敵人的移動速度才發現:

```
turn 5: hound moved 0 tiles, state=recover, energy=0
turn 7: hound moved 0 tiles, state=ready,   energy=36
turn 9: hound moved 0 tiles, state=recover, energy=48   ← 還在漲
```

`gainEnergy()` 被放在 `if (busy) continue;` **前面**,所以敵人在預告和收招期間照樣累積
行動力,而且沒有上限。一隻剛咬完的獵犬存夠了一回合跑三格——**所以從獵犬旁邊脫離在數學上
是不可能的**,牠的消耗傷害因此無法迴避。一行的位置,佔了四分之一的死亡。

修法是一行:只有在能用的時候才拿行動力。現在無預告攻擊的傷害率由自己的收招時間封頂
(獵犬 0.67/回合),而且 systest 有一條測試直接盯著這個上限——這是整個無預告攻擊設計
的承重保證。

## 3. 機器人從來不回篝火(最丟臉的一個)

輕裝每場死 61 次(上限)、平均只到第 5 層;重甲死 12 次、平均到第 9.6 層。4.5 倍差距,
看起來輕裝完全不成立。我先後試了縮小怪物群、把護甲改成只擋 3 點以上的傷害、輕裝加血、
重甲減血——**每一個都沒有讓輕裝變好**。

於是加了一行診斷,印出每次死亡當下的狀態:

```
died: husk's grasp   | st=20 adj=1 safeSteps=5 depth=2
died: crawler's lash | st=20 adj=1 safeSteps=1 depth=5
```

**精力滿的、只有一個敵人貼身、有五條逃生路線,然後死了。** 不是被圍、不是沒精力——是
一路帶著殘血在打,因為生命只有篝火能回,而機器人只有「剛好站在篝火上」才會休息,從來
不會走回去。

教會它半血就回篝火之後:輕裝 61 → 42.5 次死亡。差距從 4.5 倍縮到 1.4 倍。

**然後我把血量的改動全部退回去了。** 用修好的機器人重測,輕裝 12 血和 13 血的結果一樣
(41.4 vs 42.5,在雜訊裡),重甲從 16 降到 14 只是讓它少走兩層。那些改動當初是為了修一個
不存在的問題——留著就是把機器人的缺陷永久烙進遊戲數值裡。**留下來的只有怪物群縮小**,
因為那一項有獨立證據:死因分布從「無法迴避」搬到了「讀得出來」。

## 共同的教訓

機器人是這個遊戲唯一的平衡儀器,而**它死得多的時候,第一個問題永遠是「它是不是打得很爛」,
不是「數值是不是錯了」**。三次了。`tools/botrun.mjs` 的檔頭現在寫著這句話,以及
`WHY=1`、`--light`、`--heavy` 這幾個把問題挖出來的旗標。

---

## 當時的死因分布(歷史快照,不是現況)

這張表停在無預告攻擊還存在的時候。留著是因為下一節會推翻它。16 場,兩種誓約各半:

| 死因 | 佔比 | 有預告? |
| --- | --- | --- |
| the First Flame's pyre | 22.9% | 有(3 回合,實心 5×5) |
| hound's bite | 14.1% | 無(2 傷害) |
| swordsman's jab | 13.2% | 無(2 傷害) |
| crawler's lash | 11.4% | 無(2 傷害) |
| swordsman's sweep | 8.7% | 有(兩段) |
| 其餘 | 29.7% | 多數有 |

無預告的消耗傷害從 64% 降到約 28%,而**最大的單一死因是最終王最大的那一招**——
死亡集中在第 9、10 層,不再是第 1 層。這就是想要的形狀:你死在你本來讀得出來的招上。

## 當時的結論(也已經被推翻)

「輕裝仍然比重甲難,而我不繼續調,因為**在儀器本身還可疑的時候調數值,只是把儀器的偏差
寫進遊戲裡**。」——這句話後來被證明是對的,但理由比當時想的更嚴重:真正的問題不是數值,
是無預告攻擊本身。見下面。

---

# 第二次回饋:面向、方形視野、按鈕配置

## 1. sprite 的正面是錯的

回報是「輕裝的小圖臉朝後」。查下去發現的不只是一張圖畫反:

`facingAngle()` 把**每一張圖都當成朝北**來轉。但實際上這批素材**全部都是朝南**。

提示詞要求的是「完全俯視、看不到臉或任何正面細節」,而模型交回來的一律是**正面視角**
(看得到面甲、看得到牛的口鼻、看得到胸口)。我自己看靜態圖時把其中兩張判成背面視角
(遊俠的箭袋、龍的翅膀),兩張都判錯了——**看得出來的是在玩、看著牠們轉的人,不是盯著
靜態圖猜的我。** 這也是為什麼清點工具要存在,而不是我自己填一填就算了。

而角色靜止時的預設面向是 `{dx:0, dy:1}`,**朝南**。朝北的假設加上朝南的面向,
等於站著不動的主角被轉了整整 180 度——**是上下顛倒的**。這就是「臉朝後」。

修法是把「圖本身朝哪裡」變成資料(`js/data/sprites.js`),
旋轉量改成 `面向角度 − 圖本身的角度`。圖朝南、角色朝南,現在轉 0 度。

**清點工具:`claudeSouls/tools/facing.html`。** 每張圖旁邊列出遊戲真的會怎麼畫牠——
朝八個方向時的樣子,每格標著「應該」朝的方向。按一排 N/NE/E/… 就能改,右邊立刻重畫,
按「複製結果」吐出可以直接貼回程式的表。

表格即使現在每一列都是 `'S'`、和預設值一樣,也還是留著:**「檢查過,確實是南」和
「還沒有人看過這張」是兩種不同的狀態**,檔案應該講得出來是哪一種。之後新增的素材要自己
填一個值,而不是繼承一個沒人驗證過的預設。

測試釘住的是後果而不是機制:**一張朝著自己被畫的方向的圖,旋轉量必須是 0**,
而且八個面向必須產生八個不同的旋轉量。

## 2. 地圖改成正方形

視野是靠格子數算的,而畫布填滿一塊非方形的區域時,**橫向的視野就會比縱向大**——
左邊的敵人會比正上方同樣距離的敵人更早出現。在一個「讀懂什麼東西快打到你」就是全部技巧的
遊戲裡,不對稱的視野等於不對稱的預警。

畫布現在是「塞得進去的最大正方形」,置中,剩下的就是背景。手機上是 375×375、10×10 格、
每格 42 CSS px。`metrics()` 兩軸共用同一個 `MIN_TILES`,因為在方形表面上保證
「九欄六列」本身就是在承諾一個不對稱的視野。

實作上 `aspect-ratio` 單獨不夠:`height: 100%` 是確定尺寸,會贏過比例,所以畫布會變成
375×513。改用 `100cqmin` 直接把「塞得進去的最大正方形」講出來。

## 3. 按鈕配置退回原本的左移動、右技能

上一輪我為了讓技能鈕塞得下形狀圖示加名字,把控制列改成上下堆疊。這是錯的交易:
**配置是肌肉記憶,名字不是**——為了一個標籤把方向鍵從拇指底下移開,代價比省下的大。

退回並排之後按鈕變窄,所以圖示縮小、名字在 400px 以下直接不顯示。**圖示留下來**,
因為說明「這個技能在做什麼」的是圖示,不是那個英文單字,而數字仍然對應鍵盤。
確切的配置照回饋所說先不動,之後單獨談。

## 4. 拖曳施放時畫面會抖

回報是斜向施放時畫面顫抖,「不斷以為要往另一邊」。兩個獨立的原因疊在一起:

**其一,訊息列會改變地圖大小。** 瞄準時的讀數寫在 `#msgbar`,而那一列原本是
`min-height: 36px; max-height: 68px` 的**彈性高度**。量出來:

| 讀數 | 訊息列高度 |
| --- | --- |
| `Strike → east …` | 39px |
| `Strike → north` | 39px |
| `Strike → north-east …` | **52px** |
| `Strike → south-west …` | **52px** |

斜向的方向名比較長、多折一行,訊息列就高 13px。而地圖是「塞得進去的最大正方形」並且置中——
訊息列一變高,畫布就跟著位移(在高度受限的螢幕上還會整個縮小,連格子大小都變)。
所以手指在斜向邊界上抖一下,方向在 E 和 NE 之間跳,地圖就跟著跳。

**訊息列永遠不該有能力改變地圖的大小**,所以改成固定高度。

**其二,攝影機本來就不是鎖在玩家身上。** 舊的 `viewport()` 會把鏡頭夾在樓層邊界內,
所以玩家在畫面上的位置取決於他離牆多近——鏡頭會因為「和玩家移動無關的理由」而移動。
現在無條件以玩家為中心,超出地圖的部分就是黑的,不花任何成本;
**而手指按著的時候鏡頭在動,代價很大。**

另外補了一個次格位移:格子是整數像素、畫布不是整數個格子,所以把格線對齊到像素 0 會讓玩家
最多差將近一格。現在畫布上每一個東西都加上這個位移,玩家的格子中心就是畫布中心,分毫不差。
格數各多取兩格,免得邊緣因為這個位移而缺一排。

---

# 第三次回饋:情境按鍵、方向鍵放大、血條移到左上

## 1. 下樓和 Rest 合併成一個情境按鍵

> 「把下樓和 rest 合併成一個互動按鍵改放到右邊(因為兩者不會同時成立)」

這個觀察是對的,而且它剛好是**一顆按鍵能誠實存在的條件**:你站在樓梯上或站在篝火上,
不會同時站在兩者上。所以這不是一個「模式切換」,而是一個永遠只有一種解讀的按鍵。

`contextAction()` 是一張優先序表,新的動作就是加一列,唯一的規則就是那條讓按鍵成立的規則:
**任何兩列不得同時可用。** 目前:

| 狀態 | 按鍵 |
| --- | --- |
| 正在瞄準技能 | Cancel |
| 站在篝火上 | Rest |
| 站在下樓梯上 | Descend(顯示要去第幾層) |
| 其他 | 停用 |

Esc 一併收進來了。它原本佔一顆鍵,實際上只用來取消鍵盤瞄準——而**瞄準的時候你哪裡也不會去**,
所以它天生就滿足那條規則。要注意的是「瞄準」有兩份狀態(觸控拖曳的和鍵盤的),
Cancel 兩份都要放下。

按鍵放在技能格的第六格:五個技能剛好空出一格,而它是那一區唯一一個「用點的」而不是
「用拖的」東西——所以樣式刻意和技能不同,一眼就該分得出來。

**實作上踩到一個小坑:** 這顆按鍵為了吃到格線尺寸而掛著 `.skill` class,
結果 `renderSkillBar()` 的 `querySelectorAll('.skill')` 把它也選進去,
去查一個叫 `undefined` 的技能。所有「真的是技能」的選擇器現在都寫成 `.skill[data-skill]`。

## 2. 方向鍵放大

原本是 4 列(8 方向 + 等待,再加 `>`/Rest/Esc 一列)。合併掉那一列之後,
**同樣的高度可以給每顆鍵 50px,而不是 37px。** 手機和桌面都是 3×3。

## 3. 血量和精力移到左上角

照 Souls 的做法疊在畫面左上,而不是自己佔一條橫幅——地圖因此拿回那條橫幅的高度。

兩個細節:

**HUD 跟著畫布走,不是跟著視窗走。** 地圖是置中的正方形,如果把血條釘在視窗角落,
在寬螢幕上它會飄在信箱黑邊裡、離地圖很遠。`#hudframe` 是和畫布一樣的置中正方形,
所以它給的角落就是地圖自己的角落。

**血量永遠一排,不換行。** 12 顆點還好,重甲 16 顆就會擠到第二行——而**血條會在你正在讀它的
時候改變形狀,那正是最不該發生的時刻。** 改成不換行、點小一點。

文字標籤拿掉了:紅色的點是血、綠色的條是精力,這兩個不會有人搞混,
而拿掉標籤才夠窄到能疊在地圖上不擋東西。

---

# 為什麼把無預告攻擊全部拿掉

這是這個專案最大的一次設計更正,而且它的形狀值得記住:**一個理由過期了,但它的副作用留著。**

當初引入無預告攻擊的理由是真的:

> 「不然現在的行動經濟裡玩家永遠可以退一格讓敵人落空,然後回頭砍一下」

但那是在**每一招都是「前方一格」**的時候。後來攻擊形狀庫上線,直線懲罰沿線後退、
兩段掃懲罰側移、寬弧要兩格才走得掉——**形狀已經獨立解決了那個問題**。理由花掉了,
副作用沒有。

副作用有多大,是量出來的:

| | |
| --- | --- |
| 每層遇到的敵人裡帶著無預告攻擊的比例 | **54~62%** |
| 用長劍打贏巨魔,必然挨的傷害 | 輕裝血量的 **33%** |
| 用長劍打贏最終王,必然挨的傷害 | **133%**——比你整條血還多 |

**在一個沒有骰子的遊戲裡,那不是難度,是算術。**

取代它的是**承諾**:獵犬會預告撲擊然後跟著你衝,所以後退沒用;而牠堅韌值 2、普攻衝擊值 2,
**一刀就打得斷**——對付撲來的獵犬的正確答案,變成近戰角色本來就想做的事。

改完之後死亡數掉了大約三倍,死因排序變成 boss 的兩段尾掃、boss 的 pyre、哨兵的槍線、
守衛者的回擊——**每一個都是本來讀得出來的招**。

# 太簡單之後,量到的是幾何而不是冷卻

第一次零死亡通關之後的直覺反應是「敵人的 CD 該縮短」。量完之後不是:

> **免費走一格,可以躲掉 18 招裡的 14 招。**

躲不掉的只有兩段掃和 boss 的 pyre(八個方向裡只有一個安全)。所以縮短收招只會讓你有
更多次免費閃避的機會。

而且其中一個問題是前一輪我自己造的:**`step`(前衝)只堵住後退,堵不住側移**——
而且當敵人已經貼身時它完全沒作用,偏偏那正是牠出手的時候。

真正的修法是**寬度**(獵犬的撲擊改成三格弧)和**會重新瞄準的第二段**。

# 已知會再犯的錯

## 在生成流程裡抽一個亂數,會把整座地城換掉

加火防女的時候,她的位置是用 `rng.pick` 選的。看起來無害。

但地圖生成的每一步都在讀**同一條亂數流**,所以多抽一個數字會讓**後面每一個決定
都位移**——房間、走廊、樓梯、寶箱、怪物,全部變成別的東西。

於是「有她 vs 沒有她」的 A/B 測試,量到的根本不是她擋不擋路,而是**兩座不同的
地城**。那個對照跑了三次,數字在兩個方向各飄超過一層樓,我還在那邊解釋為什麼她讓
輕裝多死 36%。

修法是讓她的位置**完全不碰 rng**(同分時用座標排序決定)。這樣同一個種子,有她和
沒有她是同一張地圖,對照才成立。

**通則:任何加進生成流程的新步驟,如果會抽亂數,就不能用「開/關」來做對照實驗。**
要嘛讓它不抽,要嘛接受你在比較兩個不同的世界。

## 猜錯的啟發法要用後果來擋,不是用直覺

她是不能被殺、不能被推開的,所以對尋路來說她就是一面牆——一面**每層自動放置、
而且就在所有人降落的路線旁邊**的牆。

第一版把她塞進「鄰居最少的角落」,理由是「不要佔用逃跑空間」。聽起來對,實測**完全
相反**:鄰居最少的格子**就是隘口**,她變成塞在門口的塞子,機器人少走了兩層半。

正確的方向是反過來——**擋開闊地是便宜的,因為開闊地有替代路線;擋窄地會把地圖切成
兩半**。

所以測試釘的不是「她該站哪」(那是啟發法,而啟發法正是需要被防的東西),而是
**從入口做 flood fill,除了她自己那一格以外不能有任何格子變成不可達**。

## 被拒絕但不推進回合的動作,是一個迴圈不是一個提示

同一個形狀第三次出現了(火防女之後是第四次:走進她是對話、不推進回合,而機器人
不知道有這回事,於是一直撞她,把 step 預算耗在篝火旁,少走了一層半)。前兩次是篝火拒絕休息、以及機器人卡住四輪找不到原因;
這次是**收招中按技能鍵**。

`command()`(方向鍵)那條路徑本來就會把收招的回合燒掉,但 `useSkill()` 那條
不會——它顯示「你還在收招」然後回傳 false,**沒有回合經過**。對機器人是無限迴圈;
對玩家更糟:**整排技能按鈕沒有反應**,而且沒有任何東西告訴你只有方向鍵還能推進時間。

修法是讓兩條路徑一致:收招的回合不管你按什麼都會被燒掉。

**測試要釘後果不是回傳值。** 原本的測試寫 `assert(!g.useSkill('roll', ...))`,
釘的是「這個呼叫回傳 false」——那是機制。改成檢查**你有沒有真的移動、有沒有真的
打到東西**,那才是這條規則在講的事。

## 機器人只會用長劍,所以量到的都是長劍

`botrun.mjs` 裡有九個地方直接寫死 `strike` 和 `sweep`——長劍的兩招。也就是說
**這個專案至今所有的平衡數據都是單一武器的數據**。

這正是 DESIGN.md 裡已經記過一次的錯(`step()` 寫死 `strike`,導致鎚+盾沒辦法用
方向鍵攻擊),換一個檔案又發生一次。

加上 `WEAPON=pike` 之後,第一次執行就抓到兩個真實的 bug:

1. 匕首和手斧的**主要技能有冷卻**,而走進敵人 = 用主要技能。冷卻中撞上去會被拒絕
   且不推進回合 → 每一場都在第二層卡死。十二把武器裡只有這兩把會觸發。
2. 上面那條收招按鈕失效。

十二把武器現在都能跑完。順帶量到的第一份跨武器數據,而且它反直覺:

| 武器 | 重裝死亡/場 | 輕裝死亡/場 |
| --- | --- | --- |
| 長戟(雙招都承諾) | **3.3** | 7.3 |
| 巨斧(雙招都承諾) | **5.5** | 9.8 |
| 長劍 | 11.8 | 18.3 |
| 雙刃(最快最便宜) | **19.0** | 28.3 |

**全承諾的武器是最安全的,不是最危險的。** 因為它們一次砍好幾格、在對方行動前就
清掉了。承諾的代價比多打到的範圍便宜。這個結論需要人類實測驗證——機器人的決策
邏輯是繞著長劍長出來的,它不會為了承諾而調整打法。

## 規則寫在 A 檔案,違規發生在 B 檔案

玩家的外觀是**在 `newGame` 裡設定一次**的欄位,依據開局配裝二選一。而整套 loadout
系統存在的理由就是讓你中途換裝——所以從破布換到全身板甲,畫面上你長得一模一樣,
五件防具共用兩張圖。

真正該記下來的不是這個疏漏,是它**早就被禁止過了**。`save.js` 裡本來就寫著:

> hpMax 和減傷不存檔:它們是從防具上讀出來的,而存一個推導值就是存檔和規則
> 悄悄開始各說各話的方式。

同一條規則,同一個類別,隔壁檔案。`heavyArmour`、`armourReduce`、`weight` 全都是
getter,只有 `sprite` 是欄位——而唯一過期的就是它。**一條規則寫在某個檔案的註解裡,
不會阻止另一個檔案違反它。** 能阻止的只有讓違規狀態無法表達:`sprite` 現在是 getter,
`this.sprite = ...` 會直接丟例外,存檔那行也一併刪掉了。

測試釘的是後果不是機制——「五件防具要有五個剪影」加上「每個要求得到的圖檔都要存在」。
釘 getter 本身的測試,對一個放在正確位置的欄位也會通過。

## 階梯函數要避開標準配置的邊界

精力回復每 6 點重量降一階、從 5 開始算,而重甲起始配裝**剛好 17 重——正好過了一階**。
所以副手那把**重量 1 的匕首讓回復速度砍半**,機器人有 25% 的回合站著不動等精力。

那不是「你得看得更遠」,那是等待。**階梯要放寬,而且要錯開標準配裝落點。**

## 所有自動檢查都拿同一套起始裝備

走進敵人攻擊時程式寫死呼叫 `strike`——長劍的主要技能。所以換成釘錘之後,
遊戲裡最古老的互動就壞了。

**73 條測試和機器人全都沒抓到,因為它們全都拿著起始長劍。** 這不是漏測一個案例,
是漏測**一整類**:任何和配裝有關的東西都結構性地看不見。真人玩了一分鐘就撞到。

### 這個錯目前犯了四次

1. `step()` 寫死 `strike` —— 換釘錘就不能用方向鍵攻擊
2. `botrun.mjs` 九個地方寫死 `strike`/`sweep` —— 所有平衡數字都是長劍的數字
3. 機器人的 `arms()` 從**武器**讀普攻 —— `HERO=knight` 圍繞著他沒有的 `strike` 做計畫
4. `meleeSkill()` 優先用主手的 `primary` —— **英雄拿到武器的那一刻**,老騎士和侍從
   就再也沒辦法走進敵人攻擊了(攝魂師純粹是運氣好,她的法器剛好指向她真的有的招)

第四次特別值得記,因為**它是被一個功能帶進來的**。英雄空手的時候那行是對的;給了他們
武器之後才變錯。而且我在上一個 commit 的訊息裡剛寫完「只要這個問題不是從當下的技能列
回答,它遲早會答錯」——然後修了機器人,沒修遊戲本身。

> **「你的普攻是什麼」只有一個合法的答案來源:當下的技能列。** 不是武器、不是英雄
> 定義、不是常數。任何從裝備推導它的寫法,都會在下一次裝備規則改變時失效。

而且這是**同一個 bug 第二次由真人回報**——1011 行那條測試的註解本來就寫著「玩的時候
回報的,沒有任何測試抓到」。所以現在兩邊都釘住了:武器那條蓋非英雄路徑,英雄那條蓋
每一個角色。

## 卡住的時候,插一行診斷比猜四輪快

加上「有東西在追你就不能休息」之後,機器人的失敗數暴增。我花了四輪去加固它的走位,
因為那一輪同時改了斜向移動規則——**真正的原因是篝火的拒絕不推進回合,所以它永遠地問
「我可以休息嗎」**。

印出卡住當下重複的動作,一次就找到了。那個診斷留在 `WHYSTALL` 後面。

## 探針本身要先被驗證

我用來檢查「敵人有沒有被斜向規則卡死」的探針,是設 `aware = true` 然後等 40 回合。
但**警覺 12 回合就會消退**,所以它其實什麼都沒量到——改前改後都是 0,
我是對照兩次結果一模一樣才發現的。

## 存檔的兩側會在註解裡達成共識,在程式碼裡各走各的

兩天內同一個錯犯了兩次,而且兩次都在同一個檔案裡:

- `saveGame` **故意不存** `hpMax`(它是從盔甲算出來的),而且**寫了註解說明**。
  四十行以下的 `saveSummary` 照樣去讀 `d.player.hpMax`——所以每個回鍋的玩家
  看到的都是 `16/undefined HP`。
- `saveGame` **有存** `hero`。`loadGame` 的欄位清單沒有讀回來——所以每次「繼續」
  都給你一個 hero 為 null 的角色。技能現在綁人,所以那不是小事:三招剩 `roll`
  一招(那就是空的技能列,看起來像裝備不見了)、耐力掉回舊的全域常數、
  小人變回盔甲的 sprite,也就是**改版前的角色直接透出來**。

共同點是:**一份格式有兩側,而兩側是分開手寫的欄位清單。** 註解寫得再清楚都沒用,
因為註解不會在另一側被執行。

所以這類測試**不要比對欄位清單**——那正是已經錯掉的東西。要比對**玩家會察覺到的
東西**:技能、耐力、翻滾、sprite、盔甲、血量。存檔前後如果這些全部一樣,欄位有沒有
漏掉就不重要了;如果不一樣,漏了哪個欄位測試會直接講出來。

## 同步的測試框架會把 async 測試報成 PASS

`check()` 呼叫 `fn()` 之後直接 `pass++`。所以一個 `async` 的測試,裡面的
assertion 全部變成 unhandled rejection,而它印出來的是 `PASS -- [object Promise]`
——**一個不可能失敗的測試,比沒有測試更糟**。

規則是同步的(`command()` 是 async 只是為了動畫,見 `await0`),所以沒有理由需要它。
`check()` 現在直接拒絕。加上這個守衛之後,它在一分鐘內抓出我自己探針裡的三個錯。
