# 08 · Official waves, enemy counts, routes and preview (卫戍协议:盟约 下半) Written 2026-09-28 in response to the user's test report. The user asked for enemy kinds, counts, routes and numbers to follow the official game (items 1, 2, 3, 4 and 9). **This file supersedes** 05-enemies-levels.md §3.2 steps 1–7 (the generation algorithm and the `k` copies) and 00-INDEX §8 #12. It also supersedes every `[ASSUMED]` pathing statement in 05 §1 and §2. ## Tags - **[DATA]**: official game data or official client code. - Game data: Kengxxiao zh_CN `activity_table`, `levels/*`, `enemy_database`. - Client code means the user's local Windows client. The method names come from `global-metadata.dat` (IL2CPP v29, not encrypted). The method bodies come from disassembling `GameAssembly.dll`. The IL2CPP method RVAs are listed in Appendix B. - **[WIKI]**: PRTS or BWIKI text. - **[VIDEO]**: in-game screenshots. These are the PRTS `战场一览` map images, taken at round-1 prep. - **[ASSUMED]**: not proven. --- ## 0. TL;DR 1. **Our enemy counts are 2–4× too high, and our faction mixing is wrong.** - Official rule [DATA, client `RandomEnemyGenerater`]: each round uses exactly **one** special-enemy entry. - Every placeholder spawn **action** (not every enemy) is replaced by `clamp(roundHalfEven(n·BE_template / BE_new), 1, 5)` enemies in total. - The placeholders of the other movement class (fly placeholders in a ground round, walk placeholders in a FLY round) are **not spawned at all**. - Official multi 险境 R3 has **6–10 enemies** (average 7.7). Ours averages 28 (max 68). R13 is ≈ 37 officially vs ours ≈ 81 (max 115). 2. **Factions per match.** 3 random types. Each is given **exactly 3 of the 15 round slots**; SPECIAL gets the other 6. The slot order is shuffled, so there is one type per round [DATA]. 3. **Lanes.** Official pathing is a **4-directional** SPFA flow field (unit cost, crate tiles 1000), followed by line-of-sight smoothing [DATA]. We use 8-directional A* with √2 diagonals. - That is why upper-gate enemies drop into the lower lane on 战场#02 (act1 m02) and 战场#04 (act1 m04). - Our data also turns on four act1 m02 crates that are hidden in the 下半 level and absent in the 下半 screenshot. Those crates block the top road. 4. **Preview.** Each spawn action is previewed at its route's start gate + 6 rows [DATA]: - Upper gate (12,10) → zone rows 17–18. - Lower gate (9,10) → zone rows 14–15. - Up to 50 models are shown. Elites and bosses are always shown. 5. **Custom balance to remove:** - All of `data/tuning.json` `enemyHpMul` / `enemyAtkMul` / `bossHpMul`. - The `k`-copies logic and the kept fly placeholders. - The extra ×0.7 on solo bounties. - The custom 联防 slot grouping. - The details are in §6 and §7. --- ## 1. Sources and method - **Game data [DATA]:** - `autoChessData.constData`: `min/maxReplacedEnemyCount` 1/5, `templateEnemy*`, `maxLevelCnt` 15, `specialEnemyNum` 3, `enemyTypeIdentifierToFillRandom` 1, and the BE factors 1/5/3/3. - `act2autochess.specialEnemyInfoDict` (67 entries) and `specialEnemyRandomTypeDict` (count 3 / weight 3 per random type; SPECIAL −1). - `modeDataDict[*].inactiveEnemyKey`, the round templates `level_act1autochess_01…h06` and `h07_*`/`h08_*`/`escaped_*`, the stage files `level_act{1,2}autochess_m0X`, and `enemy_database`. - **Client code [DATA].** Class and field names come from `global-metadata.dat`. The x64 bodies were disassembled with capstone and annotated with IL2CPP method names. - `RandomEnemyGenerater` (sic): `_GenerateSpecialEnemyTypesLocal`, `_GenerateRandomEnemyData`, `_DoReplaceLevelDataClient`, `_DoReplaceActionDataClient`, `_CalculateActionBattleEffectiveness`, `_CalculateEnemyCountByBattleEffectiveness`, `_GetEnemyAttrPower`, `_InitData`, `ShouldActionUpToServer`, `_GenerateRoundEnemyData`. - `Torappu.Battle.SPFA`: `_GenerateNextMapImpl`, `_PostprocessAndMakeNextMapSmoothly`, `_RaycastSegmentLine`, `_RaycastBresenhamLine`. - Also `Torappu.Battle.Tile.get_moveCost`, the `GridPosition` static constructor, `AutoChessEnemyPreviewManager`, and `AutoChessLevelEnemyManager`. - **Wiki [WIKI]:** PRTS `卫戍协议:盟约 下半` (§开始模拟, the enemy multiplier table), `卫戍协议/帮助`, `卫戍协议:盟约` (上半 update log), `…下半/战场一览` and `…下半/PRTS盟约记录`, and BWIKI `盟约`. - **Screenshots [VIDEO]:** PRTS `卫戍协议盟约 战场01…08 (ver2) 地图.png`. These are round-1 prep screenshots, and they show the round-1 enemies in the preview pen (§2.6). - **No community per-round enemy tables exist.** PRTS and BWIKI list only the faction pools (`战术特训敌人`). - **Scratch scripts** (not project files) are in `…/scratchpad/research2/waves/`: - `comp.py` / `official_counts.py`: the count algorithm. - `simofficial.py`: the distribution. - `akpath.py` / `akpath2.py`: the official pathing. - `pathtest.mjs` / `ourcounts.mjs`: our current behaviour. - `akdis.py`: the disassembler. --- ## 2. Official per-round enemy composition ### 2.1 Match setup: the type schedule [DATA, `_GenerateSpecialEnemyTypesLocal`] ``` types = [t for t in autoChessData.enemyTypeDatas if t.involveRandom] // FLY TIMES ELEMENT DOT INVISIBLE REFLECTION shuffle(types, battleRng); types = first specialEnemyNum (3) of them // uniform, distinct slots = [] for t in types: repeat specialEnemyRandomTypeDict[t].count (3) times: slots.push(t.typeIdentifier) while slots.length < maxLevelCnt (15): slots.push(enemyTypeIdentifierToFillRandom = 1 = SPECIAL) shuffle(slots, battleRng) // slots[r-1] = type of round r ``` - Every match therefore has **9 rounds** of its 3 chosen types (3 each) and **6 rounds of SPECIAL**. The positions are random across R1–R15. - R14 (leader) and R15 (hidden core) consume slots 14 and 15. - The solo 标准 leader round R9 consumes slot 9. - The briefing shows "三种特训敌人的类型" [WIKI 帮助: "您将事先得知本次战斗即将迎战的领袖单位,三种特训敌人的类型"]. SPECIAL is the filler and is not one of the three. - In co-op the server sends the per-round types (`AutoChessBattleRoundEnemyInfo{enemyType, enemyKey, actionIndex, count, round}`). The local generator is used when none are sent. The whole team shares one composition per round, because the round data has no player index [DATA]. ### 2.2 Per-round pick [DATA, `_GenerateRandomEnemyData`] ``` for round r in 1..15: typeId = slots[r-1]; if typeId == 0: skip half = r <= maxLevelCnt/2 (=7) // R1–R7 first half, R8–R15 second half cands = [e in specialEnemyInfoDict if typeOf(e.type).typeIdentifier == typeId and e.isInFirstHalf == half and e.specialEnemyKey ∉ mode.inactiveEnemyKey] e = weightedPick(cands, e.randomWeight) // weights 8/10/15 as in data special[r] = e.specialEnemyKey normal[r] = e.attachedNormalEnemyKeys[randInt(len)] // always 1 element in 下半 data elite[r] = e.attachedEliteEnemyKeys[randInt(len)] // always 1 element ``` - `inactiveEnemyKey` filters **only the special key**. Attached normal and elite keys are not filtered. For example, 疯狂的逐腐兽 can still appear as the elite of 假想敌:蚀裂 in 险境. This matches the 上半 note [WIKI]: "以上调整仅针对战术特训敌人,不影响悬赏决策出场". - In every entry the special, normal and elite share the same fly flag [DATA check]: FLY entries are all flyers, every other entry is all walkers. ### 2.3 Template action → actual spawns [DATA, `_DoReplaceLevelDataClient` / `_DoReplaceActionDataClient`] The placeholder map is built from constData in `_InitData`: | template key | class | |---|---| | `enemy_1422_lrsldr` | NormalWalk | | `enemy_1005_yokai` | NormalFly | | `enemy_1427_lrnazg` | EliteWalk | | `enemy_1042_frostd` | EliteFly | | `enemy_1425_lrcmra` | SpecialWalk | | `enemy_1040_bombd` | SpecialFly | Token placeholders (`1000_gopro_2`, `1041_lazerd`) are **not** in the map. They are only used by the 联防 templates (§5). For every SPAWN action of `waves[0].fragments[0].actions` (processed last to first): ``` if action.key not in placeholderMap: // literal (e.g. R1 源石虫 ×2, boss escorts): keep as is keep else: cls, isFlySlot = placeholderMap[action.key] newKey = {Normal: normal[r], Elite: elite[r], Special: special[r]}[cls] if isFly(newKey) != isFlySlot: // walk slot but FLY round, or fly slot but ground round action.isValid = false // NOT spawned, NOT sent to server, NOT previewed else: originBE = action.count * P(action.key) / f(action.key) // float32 newBE = P(newKey) / f(newKey) n' = clamp(RoundHalfToEven(originBE / newBE), minReplacedEnemyCount 1, maxReplacedEnemyCount 5) action.interval = action.count * action.interval / n' // same time window action.key = newKey; action.count = n' P(k) = maxHp*1 + atk*5 + def*3 + magicResistance*3 // enemy_database, level = randomEnemyAttributeDict[k].level (all 0) f(k) = randomEnemyAttributeDict[k].enemyBattleEffectivenessFactor ``` Notes on the count rule: - The **cap of 5 applies per action.** One template action (for example R3 "N ×10") becomes at most 5 enemies. It never becomes 10·k. - **Rounding** is C# `Math.Round(double)`, which is banker's rounding. The only exact .5 case in the data is 2 × 灵幛 → 隐形弩手组长: ratio 4.5 gives **4**. - P uses `enemy_database` values. The `level_autochess_enemy_data` overrides (灼藤 and 元核孽生者 ATK 400) are **not** used for the count. They still apply to the spawned stats. - Spawn timing [DATA, `AutoChessLevelEnemyManager._CalculateActionPredelay`]: the i-th unit of an action spawns at `preDelay + i·step`. - `step = max(window/n', 0.05·window)`, where `window = originalCount·originalInterval`. `MIN_ACTION_INTERVAL_RATIO` = 0.05. - For n' ≤ 20 this equals the rescaled interval. - `ShouldActionUpToServer` [DATA] excludes: - invalid actions, - boss enemies (`AutoChessBattleUtil.IsBossEnemy`), - non-SPAWN actions, - actions with a `randomSpawnGroupKey`. **Kinds per round:** at most 3 faction kinds (normal, elite, special), plus the literal 源石虫 in R1. They are all flyers (FLY round) or all walkers (every other type). ### 2.4 Leader and hidden rounds [DATA] - The `h07_*` / `h08_*` templates contain only **E and EF** placeholders (escorts), for example h07_01: `lrnazg` 1+1 walk and `frostd` 1+2 fly. They are replaced with slot 14 / 15's elite by the same rule. - Ground round: E becomes the elite and **EF is dropped**. - FLY round: the reverse. - Literal escorts (重装防御组长 ×14 and the like) and the boss itself are kept. - The leader's HP is not scaled [WIKI: "领袖单位于服务器的生命值加成不受上述加成影响"]. ### 2.5 Enemy stat scaling - The per-mode, per-round `atk × 1.1^kA`, `hp × 1.2^kH` table with base 0.7 / 0.8 / 1 is the **only** source [WIKI, PRTS 下半 §开始模拟, flagged "敌方属性加成情况由用户提供"]. - `data/config.json → modes[*].enemyScale` reproduces it. I spot-checked 险境 multi R1/R2/R4/R8/R9; docs/BALANCE.md §2 reports every mode and round as matching. - The same table is in 01-core-data.json `_criticAddendum.enemyStatMultipliers`. - Base values [WIKI, mode descriptions]: - Solo: "所有敌人的生命值、攻击力降低至70%". 绝境 80 %; 终极 not reduced. - Co-op: 80 % in 标准 and 险境; 绝境 and 终极 not reduced. - **DEF:** the 上半 11/18 note said "敌人生命值、攻击力和防御力降至70%/80%". The 下半 text names only HP and ATK. We apply HP/ATK only, which matches 下半. Adding DEF would be optional [ASSUMED]. - 终极: every enemy moves at ×1.15 from R3 [WIKI]. - The scaling applies to wave, faction and bounty enemies. It does **not** apply to the leader's server HP pool [WIKI]. - The BE count rule (§2.3) uses **unscaled** DB stats [DATA]. Scaling happens at spawn. ### 2.6 Evidence from official screenshots [VIDEO] The PRTS map images are R1 prep screenshots, 1 280 × 720, with the preview pen at the top right (rows 14–15). They match §2.3 exactly and **contradict our k-copies rule**. | image | visible R1 enemies | official prediction (entry → R1) | ours would give | |---|---|---|---| | 战场02 ver2 (act1 m02) | 源石虫 ×2, 猎狗pro ×4, **no drones** | SPECIAL 高级军用猎狗 → 源石虫 ×2 + 猎狗pro ×4 (N 2 × 6620/3060 = 4.33 → 4) | 源石虫 ×2 + 猎狗pro ×4 + 妖怪 ×2 | | 战场07 ver2 (act2 m03) | 源石虫 ×2, 猎狗pro ×4 | same | same as above | | 战场04 (act1 m04) | 源石虫 ×2, 灼热源石虫 ×5 | ELEMENT 炽焰源石虫 → 源石虫 ×2 + 灼热源石虫 ×5 (2 × 6620/1450 = 9.1 → cap 5) | 源石虫 ×2 + 灼热源石虫 ×10 + 妖怪 ×2 | | 战场02 (上半) / 06 ver2 | 源石虫 ×2 + 3 humanoid walkers (上半: white Sarkaz swordsmen) | e.g. SPECIAL 萨卡兹大剑组长 → 萨卡兹刀兵 ×3 (2 × 6620/4660 = 2.84 → 3); 粉碎攻坚手 / 山海众头目 → 宿主士兵 ×3 also fit | +妖怪 ×2 | | 战场01 | 源石虫 ×2 + drone(s), **no ground N** | a FLY round (walk N dropped, NF replaced) | ground N ×2 + flyers | | 战场03 / 05 ver2 / 08 | 源石虫 ×2 + 2 walkers, no drones | e.g. TIMES/DOT/INVISIBLE picks with N ×2 | +妖怪 ×2 | Every screenshot shows only one movement class besides the 源石虫. This confirms that the placeholders of the other class are dropped. ### 2.7 Resulting counts **Distribution: official vs ours**, 同盟 险境 (`mode_multi_normal`), enemies per board per round. - Official: 4 000 simulated matches of §2.1–2.3. - Ours: `server/match/waves.js` over 200 seeds. | R | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | |---|---|---|---|---|---|---|---|---|---|---|---|---|---| | official avg | 4.7 | 7.7 | 7.7 | 10.3 | 20.0 | 23.2 | 26.2 | 18.2 | 20.3 | 22.2 | 23.6 | 35.0 | 37.5 | | official min–max | 3–7 | 4–10 | 6–10 | 7–20 | 13–30 | 14–30 | 17–35 | 8–29 | 8–33 | 8–35 | 10–35 | 14–53 | 12–57 | | ours avg | 7.1 | 17.6 | 28.3 | 24.5 | 42.8 | 50.5 | 58.5 | 39.8 | 42.1 | 47.1 | 51.4 | 80.1 | 81.0 | | ours max | 14 | 39 | **68** | 48 | 93 | 113 | 134 | 54 | 60 | 65 | 69 | 107 | 115 | - 绝境 and 终极 differ only by the unbanned keys: R12 ≈ 36.8, R13 ≈ 38.7 on average. - A FLY round happens in ≈ 10 % of rounds (3 of 15 slots in the half of the matches that draw FLY). **Totals per possible round pick** [DATA, computed with §2.3 on the real templates] follow. Per-action breakdowns for all 67 entries are regenerable with `comp.py`, and §7 step 1 describes the test fixtures. **First half (R1–R7).** Total enemies spawned per board when this entry is the round pick. ⛔ = special key banned in 标准/险境. | type | special (key) | w | attached N / E | R1 | R2 | R3 | R4 | R5 | R6 | R7 | |---|---|---|---|---|---|---|---|---|---|---| | SPECIAL | 萨卡兹大剑组长 (`1010_demon_2`) | 10 | 萨卡兹刀兵 / 萨卡兹大剑手 | 5 | 8 | 7 | 9 | 19 | 23 | 27 | | SPECIAL | 粉碎攻坚手 (`1045_hammer`) | 10 | 宿主士兵 / 步兵 | 5 | 10 | 10 | 12 | 24 | 27 | 28 | | SPECIAL | 拳师囚犯 (`1118_lidbox_2`) | 10 | 普通囚犯 / 老练囚犯 | 4 | 6 | 7 | 8 | 17 | 21 | 25 | | SPECIAL | 高级军用猎狗 (`1325_cbgpro_2`) | 10 | 猎狗pro / 军用猎狗 | 6 | 10 | 8 | 14 | 27 | 30 | 33 | | SPECIAL | 重犯 (`1121_lifbos`) | 10 | 普通囚犯 / 老练囚犯 | 4 | 6 | 7 | 7 | 16 | 19 | 22 | | SPECIAL | 弧光锋卫 (`1328_cbjedi`) | 10 | Dor-1号失败品 / 莱茵生命防卫科高级成员 | 5 | 8 | 7 | 9 | 21 | 24 | 26 | | FLY | 法术大师A1 (`1041_lazerd`) | 10 | “萨科塔之翼” / 妖怪MKII | 3 | 7 | 9 | 14 | 18 | 22 | 23 | | FLY | 护障 (`1355_mrfly`) | 10 | 妖怪 / 妖怪MKII | 4 | 9 | 9 | 14 | 19 | 23 | 23 | | FLY | 御4 (`1017_defdrn`) | 10 | 妖怪 / 妖怪MKII | 4 | 9 | 9 | 16 | 21 | 26 | 25 | | FLY | “萨科塔之眼” (`10084_hlegle`) | 10 | “萨科塔之翼” / 妖怪MKII | 3 | 7 | 9 | 11 | 15 | 19 | 21 | | FLY | 远眺 (`1407_hummbd`) | 10 | 妖怪 / 妖怪MKII | 4 | 9 | 9 | 15 | 20 | 24 | 23 | | FLY | 护障·P ⛔ (`1355_mrfly_2`) | 8 | 妖怪 / 御4 | 4 | 9 | 9 | 14 | 19 | 22 | 23 | | TIMES | 明鉴 (`1195_sfyin_2`) | 10 | 磨砻 / 俗心 | 4 | 6 | 6 | 7 | 16 | 20 | 24 | | TIMES | 深池逐火精锐战士 (`1288_duskls_2`) | 10 | 深池逐火战士 / 深池逐火战士 | 4 | 4 | 7 | 9 | 16 | 19 | 23 | | TIMES | 俗心 (`1197_sfshu`) | 10 | 磨砻 / 俗心 | 4 | 6 | 6 | 7 | 16 | 19 | 23 | | TIMES | 雅气 ⛔ (`1197_sfshu_2`) | 8 | 磨砻 / 雅气 | 4 | 6 | 6 | 6 | 14 | 17 | 21 | | ELEMENT | 控潮术师 (`1161_tidmag`) | 10 | 潜水员 / 码头水手 | 5 | 8 | 6 | 8 | 18 | 21 | 25 | | ELEMENT | 炽焰源石虫 (`1305_mhslim_2`) | 10 | 灼热源石虫 / 炽焰源石虫 | 7 | 10 | 10 | 20 | 30 | 30 | 35 | | DOT | 深溟巢涌者 (`1234_dsubrl`) | 10 | 单核掠食者 / 异光体掠食者 | 4 | 4 | 7 | 7 | 13 | 14 | 17 | | DOT | 萨卡兹枯朽战车 (`1272_nhtank`) | 10 | 萨卡兹枯朽战士 / 萨卡兹枯朽战士组长 | 4 | 6 | 8 | 8 | 17 | 19 | 21 | | DOT | 逐腐兽 (`1270_nhstlk`) | 10 | 萨卡兹枯朽战士 / 萨卡兹枯朽战士组长 | 4 | 6 | 8 | 9 | 18 | 21 | 24 | | INVISIBLE | 山海众头目 (`1299_ymkilr`) | 10 | 宿主士兵 / 隐形弩手 | 5 | 10 | 8 | 10 | 24 | 27 | 28 | | INVISIBLE | 节日爵士乐手 (`10034_cnvsax`) | 10 | 宿主士兵 / 隐形弩手 | 5 | 10 | 8 | 11 | 25 | 28 | 30 | | INVISIBLE | 扶桥老手 (`10042_prtrop_2`) | 10 | 寻险水手 / 架桥船工 | 4 | 6 | 7 | 8 | 17 | 21 | 25 | | REFLECTION | 深池伙友卫队 (`1174_duholy`) | 10 | 深池侦察兵 / 深池重甲卫士 | 5 | 8 | 6 | 8 | 19 | 22 | 26 | | REFLECTION | 深池暗影术师 (`1168_dumage`) | 10 | 深池侦察犬 / 深池方阵步兵 | 5 | 8 | 8 | 13 | 24 | 27 | 32 | | REFLECTION | 深池重甲卫士 (`1170_dushld`) | 10 | 深池侦察兵 / 深池方阵步兵 | 5 | 8 | 8 | 11 | 22 | 26 | 29 | **Second half (R8–R13).** Same columns. | type | special (key) | w | attached N / E | R8 | R9 | R10 | R11 | R12 | R13 | |---|---|---|---|---|---|---|---|---|---| | SPECIAL | 重装五十夫长 (`1006_shield_3`) | 10 | 宿主士兵 / 重装侦察兵 | 24 | 30 | 30 | 28 | 39 | 41 | | SPECIAL | 弧光镜卫 (`1329_cbshld`) | 10 | Dor-1号失败品 / 莱茵生命防卫科高级成员 | 26 | 29 | 31 | 33 | 51 | 50 | | SPECIAL | 粉碎攻坚组长 (`1045_hammer_2`) | 10 | 宿主重装士兵 / 宿主重装士兵 | 9 | 10 | 13 | 15 | 20 | 26 | | SPECIAL | 访问团持盾者 (`1387_winshd`) | 10 | 访问团新兵 / 访问团老兵 | 15 | 16 | 18 | 19 | 32 | 38 | | SPECIAL | 传奇重犯 (`1121_lifbos_2`) | 10 | 老练囚犯 / 强壮囚犯 | 14 | 16 | 18 | 18 | 30 | 35 | | SPECIAL | 萨卡兹悖谬暴虐兵长 (`1320_wdrrl_2`) | 10 | 萨卡兹枯朽辟路前锋 / 萨卡兹大剑手 | 18 | 22 | 23 | 24 | 33 | 37 | | SPECIAL | 集团军中坚盾卫 (`10124_uashld_2`) | 10 | 宿主士兵 / 宿主重装士兵 | 23 | 30 | 30 | 29 | 38 | 43 | | SPECIAL | 测试用动力装甲 ⛔ (`1254_lypa_2`) | 10 | Dor-1号失败品 / 莱茵生命防卫科高级成员 | 29 | 31 | 34 | 36 | 57 | 55 | | SPECIAL | 渎罪奇美拉 ⛔ (`1425_lrcmra_2`) | 10 | 萨卡兹枯朽辟路前锋 / 萨卡兹大剑手 | 19 | 22 | 24 | 26 | 37 | 41 | | SPECIAL | 弧光镜卫长 ⛔ (`1329_cbshld_2`) | 10 | Dor-α号失败品 / Dor-β号复制体 | 23 | 27 | 30 | 28 | 42 | 48 | | SPECIAL | 萨卡兹穿刺手 ⛔ (`1072_dlancer`) | 10 | 萨卡兹刀兵 / 萨卡兹大剑组长 | 26 | 29 | 32 | 36 | 50 | 49 | | FLY | 威龙 (`1005_yokai_3`) | 10 | 妖怪 / 妖怪MKII | 19 | 19 | 20 | 25 | 39 | 31 | | FLY | 帝国炮火先兆者 ⛔ (`1112_emppnt`) | 10 | 妖怪 / 妖怪MKII | 19 | 19 | 20 | 25 | 39 | 31 | | FLY | 法术大师A2 (`1041_lazerd_2`) | 10 | 妖怪MKII / 寒霜 | 12 | 12 | 12 | 15 | 24 | 22 | | FLY | 假想敌:黑云 (`9009_acfort`) | 10 | 妖怪MKII / 威龙 | 8 | 8 | 8 | 10 | 14 | 12 | | TIMES | 深池逐火护卫 (`1292_duskld`) | 10 | 深池逐火战士 / 深池逐火精锐战士 | 19 | 18 | 23 | 28 | 43 | 45 | | TIMES | 沉沙 ⛔ (`1207_sfji`) | 10 | 磨砻 / 俗心 | 17 | 21 | 21 | 21 | 32 | 33 | | TIMES | 身观 (`1199_sfjin`) | 10 | 磨砻 / 俗心 | 17 | 21 | 21 | 22 | 32 | 35 | | TIMES | 假想敌:再生 (`9010_acpupp`) | 10 | 俗心 / 雅气 | 11 | 10 | 14 | 17 | 25 | 31 | | ELEMENT | 掠海漂移体 (`2025_syufo`) | 10 | 骨海漂流体 / 骨海漂流体 | 24 | 26 | 29 | 30 | 45 | 48 | | ELEMENT | 领潮员 (`1161_tidmag_2`) | 10 | 潜水员 / 码头水手长 | 20 | 24 | 26 | 28 | 34 | 35 | | ELEMENT | 元核孽生者 (`1439_dslntf`) | 10 | 底海滑动者 / 富营养的滑动者 | 26 | 31 | 31 | 31 | 45 | 41 | | ELEMENT | 萨卡兹王庭军精锐术师 ⛔ (`1275_dwlock_2`) | 10 | 萨卡兹王庭军战士 / 萨卡兹王庭军精锐战士 | 16 | 18 | 20 | 18 | 32 | 35 | | ELEMENT | 假想敌:淤困 (`9007_acelem`) | 10 | 萨卡兹王庭军战士 / 萨卡兹王庭军精锐战士 | 15 | 18 | 19 | 17 | 30 | 32 | | ELEMENT | 灼藤 ⛔ (`10067_ftsjc`) | 8 | 卷心籽 / 灼藤 | 20 | 24 | 25 | 25 | 31 | 29 | | DOT | 富营养的巢涌者 ⛔ (`1234_dsubrl_2`) | 10 | 异光体掠食者 / 异光体掠食者 | 15 | 18 | 18 | 17 | 30 | 32 | | DOT | 集团军重型火炮 ⛔ (`10122_uacann_2`) | 10 | 萨卡兹枯朽战士组长 / 萨卡兹枯朽战士组长 | 20 | 20 | 23 | 27 | 42 | 42 | | DOT | 尖端萨卡兹枯朽战车 ⛔ (`1272_nhtank_2`) | 10 | 萨卡兹枯朽战士组长 / 疯狂的逐腐兽 | 13 | 15 | 17 | 20 | 25 | 27 | | DOT | 假想敌:蚀裂 (`9006_actoxi`) | 15 | 萨卡兹枯朽战士组长 / 疯狂的逐腐兽 | 15 | 15 | 19 | 23 | 31 | 34 | | DOT | 疯狂的逐腐兽 ⛔ (`1270_nhstlk_2`) | 8 | 萨卡兹枯朽战士 / 萨卡兹枯朽战士组长 | 24 | 25 | 28 | 29 | 47 | 49 | | INVISIBLE | 访问团强攻冠军 (`1389_winbab_2`) | 10 | 访问团新兵 / 隐形弩手组长 | 18 | 18 | 21 | 22 | 38 | 44 | | INVISIBLE | 山海众秘使 (`1299_ymkilr_2`) | 10 | 萨卡兹枯朽辟路前锋 / 隐形弩手组长 | 20 | 23 | 24 | 25 | 38 | 41 | | INVISIBLE | 重弩突袭者 (`1404_msnip`) | 10 | 潜伏者 / 隐形弩手组长 | 29 | 33 | 35 | 35 | 53 | 57 | | INVISIBLE | 假想敌:骨刺 ⛔ (`9008_acbunn`) | 10 | 业余竞演者 / 隐形弩手组长 | 20 | 23 | 25 | 24 | 38 | 39 | | INVISIBLE | 家族灭迹人 ⛔ (`1283_sgkill`) | 8 | 业余竞演者 / 隐形弩手组长 | 20 | 23 | 25 | 24 | 38 | 39 | | REFLECTION | 深池伙友卫队精英 ⛔ (`1174_duholy_2`) | 10 | 深池侦察队长 / 深池重甲卫士队长 | 18 | 20 | 22 | 24 | 34 | 39 | | REFLECTION | 深池重甲卫士队长 (`1170_dushld_2`) | 10 | 深池侦察队长 / 深池方阵指挥官 | 22 | 23 | 26 | 29 | 46 | 49 | | REFLECTION | 守墓石像 (`1172_dugago`) | 10 | 深池侦察队长 / 深池方阵指挥官 | 20 | 23 | 24 | 24 | 38 | 39 | | REFLECTION | 假想敌:镜膜 (`9011_acrefr`) | 10 | 深池侦察队长 / 深池重甲卫士队长 | 18 | 20 | 22 | 24 | 34 | 39 | | REFLECTION | 深池伙友影刃精英 ⛔ (`1175_dushdo_2`) | 8 | 深池侦察犬 / 深池伙友卫队精英 | 20 | 24 | 26 | 28 | 34 | 35 | **Two concrete example matches** (同盟 险境). Per round: slot type, the special entry, the total, and the kinds and counts **per gate** (what the preview shows). Example A. Types INVISIBLE / FLY / REFLECTION; slots `SPEC INVI SPEC FLY REFL REFL SPEC FLY INVI SPEC FLY SPEC REFL SPEC INVI`. | R | type | entry | total | lower gate (9,10) | upper gate (12,10) | |---|---|---|---|---|---| | 1 | SPECIAL | 萨卡兹大剑组长 | 5 | 萨卡兹刀兵 ×3, 源石虫 ×2 | — | | 2 | INVISIBLE | 节日爵士乐手 | 10 | 宿主士兵 ×10 (2 actions × 5) | — | | 3 | SPECIAL | 重犯 | 7 | 普通囚犯 ×5, 老练囚犯 ×2 | — | | 4 | FLY | 法术大师A1 | 14 | 妖怪MKII ×5, 法术大师A1 ×4, “萨科塔之翼” ×3 | “萨科塔之翼” ×2 | | 5 | REFLECTION | 深池伙友卫队 | 19 | 深池侦察兵 ×8, 深池重甲卫士 ×3, 深池伙友卫队 ×1 | 深池侦察兵 ×7 | | 8 | FLY | 法术大师A2 | 12 | 妖怪MKII ×2, 寒霜 ×2, 法术大师A2 ×1 | 寒霜 ×3, 妖怪MKII ×2, 法术大师A2 ×2 | | 12 | SPECIAL | 集团军中坚盾卫 | 38 | 宿主士兵 ×21, 宿主重装士兵 ×6, 集团军中坚盾卫 ×3 | 宿主重装士兵 ×4, 宿主士兵 ×3, 集团军中坚盾卫 ×1 | | 13 | REFLECTION | 深池重甲卫士队长 | 49 | 深池方阵指挥官 ×15, 深池重甲卫士队长 ×14, 深池侦察队长 ×12 | 深池方阵指挥官 ×4, 深池重甲卫士队长 ×3, 深池侦察队长 ×1 | Example B. Types DOT / TIMES / ELEMENT. | R | type | entry | total | lower gate | upper gate | |---|---|---|---|---|---| | 1 | SPECIAL | 高级军用猎狗 | 6 | 猎狗pro ×4, 源石虫 ×2 | — | | 3 | TIMES | 俗心 | 6 | 磨砻 ×5, 俗心 ×1 | — | | 4 | SPECIAL | 拳师囚犯 | 8 | 普通囚犯 ×2, 老练囚犯 ×2, 拳师囚犯 ×2 | 普通囚犯 ×2 | | 7 | DOT | 逐腐兽 | 24 | 萨卡兹枯朽战士 ×8, 萨卡兹枯朽战士组长 ×5, 逐腐兽 ×5 | 萨卡兹枯朽战士 ×6 | | 8 | ELEMENT | 假想敌:淤困 | 15 | 萨卡兹王庭军战士 ×5, 萨卡兹王庭军精锐战士 ×2, 假想敌:淤困 ×1 | 萨卡兹王庭军精锐战士 ×4, 萨卡兹王庭军战士 ×2, 假想敌:淤困 ×1 | | 11 | DOT | 假想敌:蚀裂 | 23 | 萨卡兹枯朽战士组长 ×8, 假想敌:蚀裂 ×6, 疯狂的逐腐兽 ×4 | 萨卡兹枯朽战士组长 ×4, 假想敌:蚀裂 ×1 | | 12 | SPECIAL | 弧光镜卫 | 51 | Dor-1号失败品 ×18, 莱茵生命防卫科高级成员 ×15, 弧光镜卫 ×8 | 莱茵生命防卫科高级成员 ×5, Dor-1号失败品 ×3, 弧光镜卫 ×2 | --- ## 3. Routes and lanes ### 3.1 Official ground pathfinding [DATA, client `Torappu.Battle.SPFA` + `Tile` + `GridPosition`] 1. **Flow field per destination.** The engine computes one flow field per destination (a route's end, or a MOVE checkpoint) over the **whole map grid**. - `PathRequest` is `{targetPos, motionMode, allowDiagonalMove}`. - It uses a FIFO queue (SPFA), seeded with the destination at `dist` 0. 2. **Neighbours.** The neighbour order is `GridPosition.GRID_FOUR_WAYS` = **UP (row+1), RIGHT (col+1), DOWN (row−1), LEFT (col−1)**. There are **no diagonal edges**. 3. **Edge cost.** The cost is `Tile.moveCost` of the neighbour: **1**, or **1000** if the tile is "obstacle-like" (a tile holding an active 阻隔工事). FLY always costs 1. 4. **Passability.** A neighbour is used only if it is passable for the motion. For WALK that is `passableMask` ALL (floor, road, start, end, telin, telout, mire, smog, deepsea, infection). `fence_bound`, wall and forbidden tiles are **not** walkable. 5. **Relaxation.** Relax only on strict improvement: `if unvisited or dist[cur]+cost < dist[nb]: dist[nb] = dist[cur]+cost; parent[nb] = cur; enqueue if not queued`. With equal costs, a tile keeps the first parent that reached it. That tie-break comes from the BFS order plus the UP/RIGHT/DOWN/LEFT neighbour order. 6. **Smoothing** (`_PostprocessAndMakeNextMapSmoothly`): - Visit tiles row 0→H−1, col 0→W−1, updating in place. - For each tile n: `b = parent[n]; while parent[b] exists and LOS(n, parent[b]): b = parent[b]; parent[n] = b`. - Every autochess route has `allowDiagonalMove: true`, so LOS is the **Bresenham** variant (`_RaycastBresenhamLine`). Every tile on the line must be valid and not obstacle-like (`Node.CheckObstacleLikeOrInvalid`). A diagonal step also needs both orthogonal neighbours clear. - With `allowDiagonalMove: false`, LOS is true only for the same row or column (`_RaycastSegmentLine`). 7. **Movement.** An enemy moves from its current tile straight toward `parent[tile]`. The field is rebuilt when tiles change, for example when a crate dies (`ClearCache` / `GenerateNextMap`). 8. **Crates** (`trap_1105_accrate` 阻隔工事: "改变敌人的行进路线 / 如果阻隔工事阻挡了敌人,敌人会破坏此工事" [DATA]). - They are passable at cost 1000, so enemies route around them when any detour is < 1000 tiles longer. - If a crate is the only way, the enemy walks into it, is blocked, and destroys it. - Moving up and back down is not special-cased. Lane choice is purely this BFS. ### 3.2 Expected lanes per stage (official algorithm, crates as in each level file's non-hidden predefines) | stage | upper gate (12,10) → goal (9,2) | lower gate (9,10) → goal | |---|---|---| | act1 m01 #01 | **row 12** → (12,4) → col 4 → (9,4) → E. Waypoints (12,10)→(12,4)→(9,4)→(9,2) | (9,9) → col 9 up to row 12 → row 12 → col 4 → E. Merges into the upper road. | | act1 m02 #02 (no crates in 下半) | **row 12 all the way** → (12,3) → col 3 → (9,3) → E | (9,9) → (11,9) → **row 11** → (11,6) → col 6 → row 9 → E | | act1 m03 #03 | row 12 → col 6 → **row 10** → (10,3) → (9,3) → E. If 射击台 blocks (10,3)/(10,4) [ASSUMED], it goes (10,5) → (9,5) → row 9. | **row 9** straight | | act1 m04 #04 | (12,9) → (11,9) → **row 11** → (11,4) → (10,4) → (9,4) → E. Smoothed waypoints: (12,10) → (12,9) → (11,9) → (11,4) → (9,4) → E, so it stays on row 11 to col 4. | (9,9) → (10,9) → **row 10** → (10,4) → (9,4) → E | | act2 m01 #05 | one snake for both gates: col 9 down → row 9 → col 7 up → row 12 → col 5 down → row 9 → E | same snake (enters at (9,9)) | | act2 m02 #06 | row 12 → **col 7 (mire)** → row 9 → E. Crates on col 9 at (10,9)/(11,9). | **row 9** straight | | act2 m03 #07 | **row 12** → col 4 → E | (9,9) → (10,9) → row 10 → (10,6) → (9,6) → row 9 → E | | act2 m04 #08 | **row 12** → (12,4) → col 4 → E | row 9 → (9,6) → col 6 up (deep water) → row 11 → (11,4) → col 4 → E | The raw BFS chains and smoothed waypoints are in scratch `lanes_official.txt`. **Remake deviation (user playtest #2, 2026-09-29).** The user reported the 战场#01 lower-gate enemies walking up the col-9 floor lane, where no operator can block them. `server/sim/grid.js` keeps every official route LENGTH but breaks ties between equal-length routes towards the fewest non-blockable (floor / gate) tiles, and its smoothing never cuts across floor the grid route does not walk. Lanes that change against the table above: - act1 m01 #01, lower gate: (9,10) → (9,8) → **col 8 road up** → (12,8) → row 12 → col 4 → E (was col 9 floor). - act1 m04 #04, lower gate: (9,10) → (9,8) → (10,8) → **row 10** → (10,4) → (9,4) → E (no longer cuts across (10,9)). - Boss fields: the exits from (2,10) take the road on 战场#01 / #04 and the right half of 战场#05, and cross less floor on 战场#02 / #07. Floor the terrain forces stays (gates, (12,9) beside the upper gate, the col-9 floor where walls or a crate block the road: #02 lower (10,9)/(11,9), #04 upper (11,9), #05 upper (11,9)/(10,9), #07 lower (10,9)). Full audit: test/sim/pathing-blockable.test.js. ### 3.3 What we do and the root cause of "upper-gate enemies walk the lower lane" - **Our pathfinder.** `server/sim/grid.js` is **8-directional A\*** with diagonal cost √2 and a no-corner-cut rule. It is limited to the field rect (rows 9–12, cols 0–10). Neighbour order is W, E, S, N, then diagonals. - **Crates and platforms.** Crates and platforms/mounds are **impassable** (with an `ignoreObstacles` fallback) instead of cost 1000. | stage | ours, upper gate | official | effect | |---|---|---|---| | act1 m02 | 12,10 → 12,9 → 12,8 → 12,7 → 12,6 → **11,6 → 10,6 → 9,6** → row 9 (lower road) | row 12 → col 3 (top road) | **Wrong lane.** Two causes: (a) `data/stages.json` marks crates `trap_1105_accrate#001–004` at (12,4), (12,5), (11,7), (11,8) as `active: true`, although the level file has them `hidden: true` and the PRTS 下半 screenshot shows none, so the top road is blocked; (b) even without crates, the 8-dir metric makes col 6 (10.41) shorter than the top road (11). | | act1 m04 | 12,10 → 12,9 → 11,9 → **10,8** → row 10 | row 11 (upper row of the 2-wide band) | Upper-gate enemies join the lower-gate lane at once. | | act1 m03 | … (10,6) → **(9,5)** → row 9 | row 10 → (10,3) (if platforms are walkable) | joins the lower lane 3 tiles early (platform rule [ASSUMED]) | | act1 m01 (lower gate) | (9,9) → (10,8) → col 8 up | col 9 up | small visual difference | | act2 m01–m04 | same lanes as official (diagonal corner cuts only) | — | OK | The historical note on act1 m02 [DATA]: - The **上半** PRTS image shows crates at (12,4) and (12,5). - The **下半** `ver2` image shows none. The current level file has all m02 crates and platforms `hidden`. - The terrain effects `map_m02_1/2` ("阻隔工事变为射击台" / "移除全部阻隔工事") still exist in the 下半 `effectInfoDataDict`. The 战术决策 card pools are server-side, so they may still be offered. - On 下半 m02, applying them can only remove crates that are already hidden, or show the hidden 射击台 via the platform aliases [ASSUMED]. - They are no reason to start with crates. - **The 下半 m02 has no crates at start.** ### 3.4 Pathing spec to implement (replaces `Grid.findPath` for ground enemies) ``` flowField(dest, {obstacleCost = 1000, allowDiagonal = true}): // cache per (dest, grid.version) dist = Int32Array(ROWS*COLS).fill(-1); parent = Int32Array(...).fill(-1); inQ = Uint8Array q = FIFO; dist[dest] = 0; q.push(dest) while q: cur = q.shift(); inQ[cur] = 0 for (dr,dc) of [[+1,0],[0,+1],[-1,0],[0,-1]]: // UP, RIGHT, DOWN, LEFT — do not reorder nb = cur+(dr,dc); if !inMap(nb) || tile(nb).pass !== 'ALL': continue nd = dist[cur] + (isCrate(nb) ? obstacleCost : 1) if dist[nb] < 0 || nd < dist[nb]: dist[nb] = nd; parent[nb] = cur; if !inQ[nb]: q.push(nb); inQ[nb]=1 // smoothing, row-major, in place for r in 0..ROWS-1: for c in 0..COLS-1: n=(r,c); if dist[n]<0 || parent[n]<0: continue b = parent[n]; while parent[b] >= 0 && los(n, parent[b]): b = parent[b]; parent[n] = b los(a,b): Bresenham from a to b; every visited tile passable && !crate; on a diagonal step also both orthogonal neighbours passable && !crate (allowDiagonal=false ⇒ same row or same col only) ``` - A ground enemy at tile t with a leg to D moves straight toward `parent_D[t]`. It re-reads the field on entering a new tile, or when `grid.version` changes. - The whole 19×21 map may be used as the official does. Keeping the field rect is harmless for normal rounds, because no shorter path leaves it. - `remainingDistance` (targeting "closest to goal") should use `dist_D[t]` plus the leg tail. The official exposes `TryCalculatePathFindingDistance` on the same field. - **Performance.** One BFS over 399 tiles per destination and grid version replaces one A\* per enemy leg. It is also deterministic across clients. --- ## 4. Enemy preview (prep phase) [DATA, client `AutoChessEnemyPreviewManager`] ### 4.1 Which enemies use which gate The gate is the start of the action's route. R1–R3 use only the lower gate. Notation: `class count @ preDelay / interval` (game seconds). E.g. `N3@7/5` = 3 enemies of the N slot, the first at 7 s, then one every 5 s. Counts are template counts before replacement (§2.3). | R | tpl | lower gate (9,10) | upper gate (12,10) | |---|---|---|---| | 1 | 01 | 源石虫2@3/5 N2@12/3 NF2@12/3 | — | | 2 | 02 | N3@3/5 N3@15/7 NF4@3/7 NF5@15/7 | — | | 3 | 03 | N10@6/6 E1@18/10 NF9@6/7 EF2@10/10 | — | | 4 | 04 | N2@12/4 E1@13/12 S1@29/7 NF2@3/6 NF3@26/6.5 EF3@17/9 SF2@18/9 | N2@3/6 NF3@7/8 | | 5 | 05 | N3@7/5 N3@27/5 E2@14/7 S1@25/9 NF3@3/6 NF4@16/5 EF3@17/1 SF2@31/6 | N2@3/10 N3@22/8 NF3@5/6 NF3@22/6 | | 6 | 06 | N4@7/8 N3@16/8 E2@20/12 S2@28/14 NF4@2/5 NF4@20/7 NF4@27/5 EF3@17/1 SF3@29/5 | N3@2/8 N3@19/8 NF4@7/8 | | 7 | 07 | N4@8/5 N4@22/9 E4@5/17 S2@12/14 S1@40/14 EF3@17/11 SF1@9/12 SF1@13/12 EF2@41/11 NF2@2/10 NF8@27/4 | N3@2/8 N3@18/10 NF4@5/5 NF1@30/5 | | 8 | h01 | N3@2/3 N3@28/5 N2@62/5 E1@37/9 S3@42/9 NF2@3/5 NF2@25/5 EF2@17/5 SF1@10/20 | N3@11/1 E2@17/10 S1@9/12 NF2@5/7 NF2@13/5 EF3@39/5 SF3@28/12 | | 9 | h02 | N3@2/5 N3@42/7 N3@65/5 E2@17/10 S1@13/16 S1@40/5 NF2@3/3 EF3@28/5 | N3@3/5 N3@39/5 E1@41/10 NF1@3/1 NF2@13/5 NF3@20/5 EF2@41/5 SF1@7/7 SF3@21/12 | | 10 | h03 | N3@3/5 N3@25/5 N3@49/5 E3@17/10 E1@41/10 S1@57/5 NF2@7/5 EF3@28/6 | N2@3/4 N4@5/5 S3@17/13 NF2@3/4 NF3@5/5 NF2@13/5 EF2@41/5 SF1@10/10 SF3@25/10 | | 11 | h04 | N2@5/2 N3@22/2 N2@45/5 N2@50/5 E1@10/9 E3@54/7 S2@17/5 S4@42/5 NF3@27/2 EF3@28/2 EF2@52/2 SF2@17/2 | N2@5/5 N2@10/5 S1@6/20 NF2@3/3 NF2@5/5 NF4@9/5 EF1@20/5 SF1@7/10 SF2@21/10 | | 12 | h05 | N3@5/3 N2@30/3 N2@10/3 N3@35/5 N3@51/4 E2@12/10 E2@37/9 E2@53/7 S3@2/10 S3@10/10 S2@31/10 NF3@10/3 NF3@27/2 EF3@28/2 EF3@47/6 SF2@12/5 SF3@22/6 SF2@35/5 | N2@5/3 E3@10/10 S2@21/12 NF2@5/1 NF3@8/4 NF3@21/5 NF3@43/5 EF2@13/5 EF2@15/5 SF2@17/10 | | 13 | h06 | N3@7/3 N3@10/5 N3@39/5 N3@62/4 E3@11/9 E4@37/9 E3@55/9 S4@2/10 S4@10/7 S3@32/10 NF3@27/2 EF4@28/2 EF3@55/7 SF2@10/5 SF3@22/5 SF2@35/5 | N1@7/1 E2@3/10 S2@21/15 NF2@5/1 NF3@21/4 NF3@50/5 EF2@13/5 EF1@15/5 SF3@17/11 | - FLY actions also start at a gate (their routes start at (9,10) or (12,10)), so they are previewed at that gate. - Only the **valid** actions of the round's movement class appear (§2.3). - Leader rounds use the boss field gates (2,10) and (5,10) with a row offset of 13, which lands in the same pen. ### 4.2 Placement algorithm 1. **Preview entries.** For each valid round action there is one entry `{preDelay, startPosition = routes[action.routeIndex].startPosition, enemyKey, count}`. The boss action is added in leader rounds. Entries are sorted by `preDelay` (`_CompareActionByTime`). 2. **Thinning.** Let `total = Σ count`. - `showRatio = min(1, 50/total)` and `totalShowCnt = min(50, total)`. `MAX_PREVIEW_CNT` = 50. - Per entry, `n = max(1, round(count·showRatio))`. - An enemy whose handbook `enemyLevel` ≠ NORMAL (elites, bosses) always uses ratio 1. - **Each enemy is shown as its own model.** The preview has no count badge. 3. **Pen and zones.** - The pen is `enemy_place_rect ((14,7),(18,13))` (stage `configBlackBoard`). - The zone anchors are the `tile_start` tiles in the rect: **(15,7)** and **(18,7)**. - Every other pen tile with `passableMask` ALL and without `previewNotAlloed` belongs to the Manhattan-nearest anchor. Row 16 is excluded. - **Lower zone** = rows 14–15 (13 tiles). **Upper zone** = rows 17–18 (13 tiles). Each list is in row-major order (low row first, col 7→13). 4. **Zone choice.** - Target = `(start.row + enemy_preview_row_offset, start.col)`. The offset is 6 by default (13 in leader rounds); neither key is set in the stages, so the defaults apply. - The zone is the anchor with the smallest `|anchor.row − target.row| + |anchor.col − target.col|` among anchors with `anchor.row ≤ target.row`. - Result: lower gate (9,10) → the (15,7) zone; upper gate (12,10) → the (18,7) zone. 5. **Tile choice.** The k-th model sent to a zone goes to `zoneTiles[clamp(round(k/totalShowCnt·len) + Random.Range(−1,0))]`. Several models may share a tile. When no zone fits, a random pen tile is used. 6. **Camera.** In the official 3D camera the pen sits top-right, beyond the gates. - For our 2D board, render the pen rows 14–18 (the stage already has them). - Alternatively, render two gate-anchored zones next to the gates: the upper zone beside (12,10) and the lower zone beside (9,10). That is what the user remembers ("右侧上下红门后方"). - Keep the grouping exact: one zone per gate. --- ## 5. Secondary: bounty and 联防 spawn mapping [DATA, client `AutoChessLevelEnemyManager`; partially decoded] **Bounty (悬赏) and extra enemies** (`_InsertSpActionToNormal`, `_GetSpEnemyActionData`): - Each added enemy is attached to the first action of the round's **original** template whose key is `templateEnemyNormal` (walker) or `templateEnemyNormalFly` (flyer). Summoned tokens use `templateEnemyToken` / `templateEnemyTokenFly` instead. - If no action matches, it uses action 0. - All units attached to that action are then spread evenly over its window: `preDelay + i·max(window/n, 0.05·window)`. - So a walking bounty enters at the round's first N action gate. That is the lower gate in every round, because action N#1 always starts at (9,10). **联防:** - Each leaked enemy maps to the escaped template's action as follows: - walkers → the `lrsldr` action (route 3: WALK from (9,18) via (9,10), or from (9,10) in `escaped_single`; preDelay 3, interval 8), - flyers → the `yokai` action (route 0), - tokens → `gopro_2` / `lazerd`. - Elite and special leakers are **not** routed through the E/S actions (the class is ignored). - Timing is recomputed per owner uid (`_CalculateActionPredelayConsiderUid`; constants `ACTION_INTERVAL_FOR_UID` 0.5 s, `MAX_ACTION_INTERVAL_FOR_UID` 5 s). The exact formula was not decoded [ASSUMED: 0.5 s spacing within an owner, capped at 5 s]. - Helper order [WIKI 帮助]: most units on the field (downed included) > has an active bond (?) > highest bond layers (?) > most undowned units. The first helper takes the right-hand field. With 3 perfect players, the top 2 by the same order help. --- ## 6. Deviations from official numbers (custom balance and assumptions) | # | Where (ours) | Current | Official | Source | Action | |---|---|---|---|---|---| | 1 | `server/match/waves.js` `replaceSlot` / `templateSpawns`; `data/factions.json` `generation.kFormula` | each template **enemy** → k copies (k ≤ 5), 0.5 s stagger | each template **action** → `clamp(roundHalfEven(n·BE_t/BE_new),1,5)` enemies, interval `n·iv/n'` | [DATA] | replace (§7-1) | | 2 | `setupMatchWaves` / `pickEntries` | 3 factions per match; **every** round: a ground entry from the chosen types + SPECIAL, **and** a FLY entry | 15-slot schedule (3 types × 3 + 6 SPECIAL, shuffled); **one** entry per round | [DATA] | replace | | 3 | `flyPlaceholders` 'keep' (BALANCE §2.2) | 寒霜 / 暴鸰 / 妖怪 stay in ground rounds (the #1 leak source) | the other movement class is **not spawned** (`isValid=false`) | [DATA] + [VIDEO] | drop the knob; implement the rule | | 4 | `pickEntries` `inactiveIn`, `pickReplacement` filters attached keys | inactive list also filters N/E replacements | only `specialEnemyKey` is filtered | [DATA], [WIKI] 上半 note | change | | 5 | `data/tuning.json → modes[*].enemyHpMul / enemyAtkMul` (all 8 modes, e.g. multi 险境 R13 HP ×0.55) | custom per-round multipliers from `tools/balance.mjs` | none; only the PRTS table (`config.enemyScale`) | [WIKI] | **delete** (keep `titles.comment_3`, which is unrelated) | | 6 | `data/tuning.json → mode_single_funny.bossHpMul 0.6` | leader pool ×0.6 in solo 标准 | none | — | **delete** | | 7 | `config.bossHpScale` `bloodPoint × alive/4`, solo ×0.25 [ASSUMED] | pool scales with alive players | co-op: split leaders share HP, "敌方领袖的总生命值不变" → pool = `bloodPoint` (no alive scaling found); **solo: unknown**. **Re-read 2026-10-01 (DESIGN §20.10):** notice 5114's full sentence is "在联合模拟的最终攻势阶段,部分敌方领袖会同时出现在战场的左右两侧,两侧的敌方领袖共享生命值(敌方领袖的总生命值不变)" — the left / right copies of one pair field share the HP; it says nothing about the number of players. The one note on that is community (巴哈姆特 12294 "聯機隊友(撤退/死掉)變少,最後boss血條也會變少", no proportion); it is kept as the config switch `bossHpScale.aliveScaling` (× alive / 4), **off** until the user confirms it from official play — it would shorten fights after eliminations, the opposite of the playtest report | [WIKI] notice 5114 / PRTS 下半 | co-op: `bloodPoint` whatever the alive count (× alive / 4 behind the switch, [ASSUMED proportion]); solo: keep a config value, flagged unknown | | 8 | `waves.js bountySpawns` `soloMul 0.7` for "perfect" bounties | bounty HP/ATK/DEF ×0.7 **on top of** the solo 0.7 base | no bounty-specific rule; the "70 %" note is the global solo base, already in `enemyScale` | [WIKI] 上半 11/18 log | remove `soloMul` | | 9 | `bountySpawns` timing | t = 4 s, +5 s per bounty, interval 3, `routeByMotion` | attached to the template's first N / NF action window (§5) | [DATA] | change | | 10 | `buildUniteWave` | grouped by slot class N/E/S/…, compressed into 40 % of the time limit | walkers → `lrsldr` action, flyers → `yokai`, tokens → T/TF; per-owner 0.5 s spacing | [DATA] (partial) | change | | 11 | `config.unite.helperOrder` `highestLpThenSeat` [ASSUMED] | by LP | most units on the field > active bond > layers > undowned units | [WIKI] 帮助 | change | | 12 | `gamedata.combatTimeLimit` ×2 (`COMBAT_TIME_SCALE`) | `maxPlayTime` read as real seconds | unit not stated. The official count rule keeps each action's window, e.g. FLY R2 still spawns its last flyer at ≈ 43 s of "45", so ×2 remains necessary. | [ASSUMED], supported by spawn schedules | keep, but re-check after the count fix | | 13 | `server/sim/grid.js` 8-dir A\*, √2 | see §3.3 | 4-dir SPFA flow field + LOS smoothing | [DATA] | replace (§7-3) | | 14 | `data/stages.json` act1 m02 crates `trap_1105_accrate#001–004` `active:true` | top road blocked | hidden in the 下半 level; absent in the 下半 screenshot | [DATA] + [VIDEO] | set `active:false` | | 15 | crates impassable + `ignoreObstacles` fallback | — | passable at cost 1000, block LOS; blocked enemy destroys the crate | [DATA] | change | | 16 | 射击台 / mounds block ground movement [ASSUMED] | — | unknown (crate and platform token data are identical; behaviour is in the prefab) | — | keep, flagged | | 17 | `config.enemyScale` (HP/ATK only) | — | 下半 text: HP/ATK; 上半 11/18 also named DEF | [WIKI] | keep; optional DEF | | 18 | `economy.income` `min(3+r, 12)` | R4+ assumed | BWIKI (hidden text, older season): "从第2回合开始每回合获得资金数+1,上限12" | [WIKI], weak | keep | | 19 | `previewOf` | one list per key, no gate | per-action models placed in a gate zone (§4) | [DATA] | change | Everything in `docs/BALANCE.md` §4 (the tuned tables) and the `tools/balance.mjs --tuning` path becomes obsolete once #1–#6 are applied. The "competent board" model can still be used to *measure* difficulty, but no longer to tune it. --- ## 7. Changes required in our implementation (precise diffs vs current behaviour) 1. **`server/match/waves.js` + `data/factions.json`** (and the data generator in `tools/`): - **Match setup.** `setupMatchWaves` returns `{stageId, factions: [3 types], typeSlots: [15 types], bossId, hiddenBossId}`. - Shuffle the 6 `involveRandom` types and take 3. - Push each `count` (3) times, fill to `maxLevelCnt` (15) with SPECIAL, then Fisher-Yates-shuffle with the match rng. - Store `typeSlots` in match state. It is visible in the briefing as the 3 types, and the slot order stays hidden. - **Round pick.** Add `pickRoundEntry(gd, rng, typeSlots, r)`: - half = `r <= 7`; - candidates = `specialEnemyInfoDict` of that type and half, whose **special** key is not in `mode.inactiveEnemyKeys`; - weighted by `randomWeight`; - `normal = attachedNormal[randInt]`, `elite = attachedElite[randInt]`. - Generate the picks for all 15 rounds at match start (like the client), so the preview and the server agree. - **Replacement.** Replace `templateSpawns` / `replaceSlot` as follows: - For each template SPAWN action whose key is a template placeholder: if `isFly(newKey) !== isFlySlot`, **skip the action** (no spawn, no preview). - Otherwise `n' = clamp(roundHalfEven(f32(f32(n·P(t))/f(t)) / f32(P(new)/f(new))), 1, 5)`, and each unit i spawns at `time + i·max(n·iv/n', 0.05·n·iv)`. - `P = hp + 5·atk + 3·def + 3·res` from the `enemy_database` level-0 attributes (not the autochess overrides). - `roundHalfEven(x) = (x − floor(x) === 0.5) ? (floor(x) % 2 ? floor(x)+1 : floor(x)) : Math.floor(x + 0.5)`. - Keep literal keys and the T/TF tokens unchanged. - Remove `COPY_STAGGER`, the `k` fields (`kS`, `N[].k`, `E[].k`), `flyPlaceholders`, `groundRouteLike` and the `inactiveIn` filtering of attached keys. - **Leader rounds.** `buildBossWave` uses the pick of slot 14 (or 15, or 9 in solo 标准) with the same rule, so only E **or** EF escorts spawn. - **Unit tests** (fixtures from §2.6 / §2.7): - R1 高级军用猎狗 → {源石虫 2, 猎狗pro 4}; - R1 炽焰源石虫 → {源石虫 2, 灼热源石虫 5}; - R1 萨卡兹大剑组长 → {源石虫 2, 萨卡兹刀兵 3}; - R3 FLY 护障 → {妖怪 5, 妖怪MKII 4} and no walkers; - an elite action "2 × 灵幛" → 隐形弩手组长 ×**4** (the tie case); - multi 险境 R3 total ≤ 10 for every entry. 2. **Stat scaling and balance.** - Delete every `enemyHpMul`, `enemyAtkMul` and `bossHpMul` in `data/tuning.json`. Keep the `titles` block. - Remove the `soloMul` in `bountySpawns`. - `config.bossHpScale`: co-op pool = `bloodPoint[difficulty]` with no alive-player factor; solo stays a flagged config value. (2026-10-01: × alive / 4 available behind `aliveScaling`, off, see §6 #7.) - Keep `enemyScale` (PRTS table) and the 终极 speed ×1.15 from R3. - Re-run `tools/balance.mjs --tuning off` only to report, not to tune. 3. **Pathing** (`server/sim/grid.js`, `server/sim/ai.js`, `server/sim/Battle.js`): - Add `Grid.flowField(dest)` as in §3.4, cached per `(dest, version)`. - `planLeg` for WALK: follow the smoothed parents from the enemy's tile to the leg target and re-plan on version change. That is the same trigger as today. - Crates: `setObstacle` marks a cost-1000 obstacle-like tile, not an impassable one. The existing "blocked enemy attacks the crate" logic stays. - `data/stages.json`: set act1 m02 `trap_1105_accrate#001…#004` to `active:false`. - Keep platforms and mounds blocking [ASSUMED]. - Add tests: the upper gate on act1 m02 reaches E through (12,3)→(9,3), and on act1 m04 it stays on row 11 down to col 4. 4. **Preview** (`previewOf` and the client prep view): - Emit one entry per valid action: `{enemyKey, count, gate: route.start, preDelay, elite}`. - The client places models with §4.2: two zones, ≤ 50 models, elites always shown. - Show upper-gate enemies in the upper zone and lower-gate enemies in the lower zone. - The HUD count stays Σ count. 5. **Bounty and 联防**: - `bountySpawns`: attach bounties to the round template's first `lrsldr` / `yokai` action (gate, preDelay, window spread). - `buildUniteWave`: walkers → the escaped template's `lrsldr` action route; flyers → `yokai`; tokens → `gopro_2` / `lazerd`; 0.5 s spacing per owner. - Change `unite.helperOrder` to the PRTS rule (§5). 6. **Performance (user item 4).** - Items 1 and 3 cut enemies per board by ≈ 55–70 % (R12: 80 → 35) and replace per-enemy A\* with one BFS per destination. - Measure again before adding other optimisations. --- ## 8. Open questions | # | Question | Default | |---|---|---| | 1 | Do 射击台 (act1 m03 (10,3)/(10,4)) and 土石结构 block ground movement? | Block [ASSUMED] | | 2 | Leader HP pool in solo, and in co-op with fewer than 4 alive players | co-op `bloodPoint` (× alive / 4 behind `aliveScaling`, off — ask the user, 2026-10-01); solo config value (flagged) | | 3 | Unit of `maxPlayTime` | Real seconds (×2 game) [ASSUMED] | | 4 | Exact 联防 predelay formula (`_CalculateActionPredelayConsiderUid`) | 0.5 s per owner [ASSUMED] | | 5 | DEF reduction in the 70 % / 80 % bases | HP/ATK only (下半 text) | Everything else in §2–§4 is decoded from official data or client code. --- ## Appendix A. Official client/server split for battles (relevant to user item 5) [DATA, metadata names] - The protocol types show that **each client simulates its own battle locally** and reports events to the server: - `AutoChessBattleSceneSelfBattleKillEnemyUp {enemyInstId, attackerInstId, damageSrc}` - `…SelfBattleEnemyEscapeUp {enemyInstId, isToken}` - `…SelfBattleAddBondUp {charInstId, layerDelta, bondIndexList}` - `…SelfBattleFinishUp {round, escapedInstIds, escapedTokenInstIds, killedInsts, charBattleStatusList}` - `…SelfBattleInfoUp {round, charBattleStatusList, charBattleInfoList}` - 联防 uses the same pattern: - `…HelpBattleKillEnemyUp {enemyInstId, isToken, killedByPlayer, attackerInstId, damageSrc}` - `…HelpBattleEnemyEscapeUp` - `…HelpBattleFinishUp {round, escapedEnemies, killInfos}` - The server sends: - `AutoChessBattleSelfBattleInfo {enemyInfos: [{enemyId, actionIndex, instIdList}]}` - `AutoChessBattleHelpBattleInfo {escapedEnemies: [{ownerPlayerIndex, enemyId, enemyInstId, isToken}], players: [{deployment, charBattleStatusList}]}` - **The helper client receives the partner's deployment and end-of-battle status and replays 联防 locally.** - Round enemy data is generated client-side with a shared seed (`RandomEnemyGenerater`). Actions go to the server only if `ShouldActionUpToServer`. ## Appendix B. Client method RVAs (Windows build in the user's CrossOver bottle, metadata v29) for re-verification | method | RVA | |---|---| | `RandomEnemyGenerater._DoReplaceActionDataClient` | 0x180b640f0 | | `_CalculateEnemyCountByBattleEffectiveness` | 0x180b63e70 | | `_CalculateActionBattleEffectiveness` | 0x180b63d50 | | `_GetEnemyAttrPower` | 0x180b65e10 | | `_GenerateSpecialEnemyTypesLocal` | 0x180b655c0 | | `_GenerateRandomEnemyData` | 0x180b64870 | | `_DoReplaceLevelDataClient` | 0x180b64580 | | `_InitData` | 0x180b663f0 | | `SPFA._GenerateNextMapImpl` | 0x180893720 | | `SPFA._PostprocessAndMakeNextMapSmoothly` | 0x1808940c0 | | `SPFA._RaycastSegmentLine` | 0x180894c80 | | `Tile.get_moveCost` | 0x18089cdc0 (obstacle-like ? 1000 : 1) | | `GridPosition..cctor` | 0x18210a800 (FOUR_WAYS = UP, RIGHT, DOWN, LEFT) | | `AutoChessEnemyPreviewManager._GeneratePreviewData` | 0x180b3cad0 | | `AutoChessEnemyPreviewManager._GetBetterGridToSpawn` | 0x180b3d180 | | `AutoChessEnemyPreviewManager._InitIfNot` | 0x180b3d710 | | `AutoChessEnemyPreviewManager..ctor` | 0x180b3e190 (row offsets 6 / 13) | | `<_SpawnPreviewEnemies>d__16.MoveNext` | 0x180b4fc40 (cap 50) | | `AutoChessLevelEnemyManager._CalculateActionPredelay` | 0x180b453c0 (ratio 0.05) | | `AutoChessLevelEnemyManager._GetSpEnemyActionData` | 0x180b466f0 | `Il2CppCodeGenModule` for Assembly-CSharp is at VA 0x186e69af0 (217 790 method pointers at 0x188216460).