# Milestone M5 Investigation Report: Terrain-Anchored Monster Pack Spawning, Cohesive Pack Leashing & Encounter Progress Tracking

**Date**: 2026-10-01T22:20:00Z  
**Agent**: Explorer M5 1 (`explorer_m5_1`)  
**Target Scope**: Milestone M5 Encounter Zone Architecture (`monster_system.js`, `monster_pack_system.js`, `wilderness_zone_packs.js`, `boss_gate_controller.js`, `grid_pathfinder.js`)

---

## 1. Executive Summary

Milestone M5 completes the transition of FreeExile's client combat runtime from legacy flat-arena coordinates to a **PoE2-style procedural tile-anchored encounter ecosystem**. 

Our investigation reveals three primary findings:
1. **Coordinate Divergence**: Legacy packs in `wilderness_zone_packs.js` use static coordinates (e.g. `(9.5, -4.5)`) tailored to the old (0,0)-centered flat map. The procedural map engine generates wilderness grids ($60 \times 45$ to $120 \times 90$) with spawn around `(10, 32)` and boss gate around `(43, 7)`, with encounter clusters stamped as `TileType.ENCOUNTER_LOW (16)`, `ENCOUNTER_MEDIUM (17)`, `ENCOUNTER_HIGH (18)` and `POI (15)` in `window.currentMapMetadata.encounterZones`.
2. **Strict Line Budget Constraint**: `client/webapp/js/engine/monster_system.js` is currently **486 lines** long. Automated hygiene tests (`test_monster_poise_and_leash.py:118` and `test_waypoint_safe_radius.py:219`) enforce an explicit ceiling of **$\le 490$ lines** (with a 500-line hard cap). Any direct addition of pack generation, leashing, or encounter tracking to `monster_system.js` without architectural delegation will immediately break the test suite.
3. **Synergistic Modular Delegation**: `client/webapp/js/engine/monster_pack_system.js` is currently only **134 lines** (soft cap 350) and `client/webapp/js/data/wilderness_zone_packs.js` is **254 lines** (soft cap 350). By delegating terrain-anchored pack generation, cohesive leashing, and encounter tracking to `monster_pack_system.js` and `wilderness_zone_packs.js`, and adding `grid_pathfinder.js` ($\le 220$ lines), `monster_system.js` can be streamlined down to **$\approx 430$ lines** while strictly preserving all 114 passing unit/E2E test assertions.

---

## 2. Current State & Codebase Analysis

### 2.1 Metadata & Grid Representation in Client Runtime
- In `client/webapp/js/engine/tile_grid_loader.js`:
  - Parses binary map payloads into `root.currentMapMetadata`:
    ```javascript
    root.currentMapMetadata = {
      version, biomeCode, biomeName,
      spawn: { x: spawnX, y: spawnY },
      bossGate: { x: bossGateX, y: bossGateY },
      pois: [ { x, y, type }, ... ],
      encounterZones: [ { minX, minY, maxX, maxY, tier }, ... ]
    };
    ```
  - Exposes `root.currentMapGrid` (`Uint8Array`, row-major) and `window.getTileAt(tx, ty)`:
    - `TileType.FLOOR = 1`, `TileType.WALL = 2`, `TileType.PATH = 13`
    - `TileType.POI = 15`, `TileType.BOSS_GATE = 10`, `TileType.BOSS_ALTAR = 11`, `TileType.RUNIC_FLOOR = 12`
    - `TileType.ENCOUNTER_LOW = 16`, `TileType.ENCOUNTER_MEDIUM = 17`, `TileType.ENCOUNTER_HIGH = 18`
- In `server/world/wilderness_map_generator.py` (`_build_encounter_zones`):
  - 2 to 3 encounter clusters are placed along the winding path between spawn and boss gate:
    - Tier 1 (LOW): $\approx 30\%$ along path, stamped with tile 16.
    - Tier 2 (MEDIUM): $\approx 58\%$ along path, stamped with tile 17.
    - Tier 3 (HIGH): $\approx 82\%$ along path, stamped with tile 18.
  - 1 to 3 POIs (`cache_shrine`, `merchant_post`, `ancient_tablet`) placed in branching dead-ends.

### 2.2 Monster Spawning Flow
- `monster_system.js` `initZoneMonsters(zoneId)` currently executes:
  ```javascript
  if (z === 'zone_player_hideout' || z === 'zone_boundless_sanctuary') {
    // Spawns dummy
  } else if (typeof window.getWildernessZoneMonsters === 'function') {
    activeMonsters.push(...window.getWildernessZoneMonsters(z, createMonsterEntity));
    if (typeof window.initZoneAmbushTriggers === 'function') window.initZoneAmbushTriggers(z);
  }
  ```
- `window.getWildernessZoneMonsters` points to `populateZonePacks(zoneId, createMonsterEntity)` in `monster_pack_system.js`.
- Currently, `populateZonePacks` simply reads `WILDERNESS_PACK_TEMPLATES[zoneId]`. It does not inspect `window.currentMapMetadata.encounterZones` or `window.currentMapGrid`!

### 2.3 Boss Gate Controller & Existing Kill Reporting
- `client/webapp/js/engine/boss_gate_controller.js` lines 61-66:
  ```javascript
  reportKill(count = 1) {
    this.currentKills += count;
    if (!root.zoneEncounterProgress) root.zoneEncounterProgress = {};
    root.zoneEncounterProgress[this.zoneId] = this.currentKills;
    if (this.state === BossGateState.LOCKED && this.currentKills >= this.requiredKills) this.unlock();
  }
  ```
- When `unlock()` is called:
  - State transitions `LOCKED -> UNLOCKED`.
  - Mutates `gateX, gateY` tile in `currentMapGrid` from `BOSS_GATE` (10) to `FLOOR` (1).
  - Calls `TileMapRenderer.markChunkDirty(gateX, gateY)`.
  - Dispatches `bossgate:unlocked` event.
  - Shows notification / updates proximity popup.

---

## 3. Formulation of Milestone M5 Architecture

### 3.1 Pack Generation: Terrain & Cluster Anchored
Instead of hardcoding world coordinates, packs are dynamically generated per encounter cluster anchor:

#### Three Encounter Archetypes
1. `ENCOUNTER_PACK` (Tier 1 / LOW):
   - Composition: 3–4 standard monsters (1 Squad Leader + 2–3 Minions).
   - Stats: Standard damage and HP, basic patrol radius 0.8 tiles.
2. `ENCOUNTER_ELITE` (Tier 2/3 / MEDIUM/HIGH):
   - Composition: 4–5 monsters (1 Rare Pack Leader with Leader Aura + 3–4 tethered Minions).
   - Leader Auras:
     - `DAMAGE_RESISTANCE` (Cyan aura decal, -30% damage taken)
     - `HASTE` (Purple aura decal, +25% move speed)
     - `ELEMENTAL_EMPOWERMENT` (Amber aura decal, elemental damage bonus)
     - `VAMPIRIC` (Crimson aura decal, life steal on hit)
     - `MORTAL_MIGHT` (Pink aura decal, increased attack damage)
3. `ENCOUNTER_AMBUSH` (POI / Corrupted Rift):
   - Composition: 3–4 ambush monsters spawned when player steps within 3.2 tiles of POI / Rift anchor.
   - Behavior: Immediate aggro (`hasAggro: true`), surprise particle burst, audio fx.

#### Anchor Resolution & Dispersal Algorithm
```javascript
export function getEncounterAnchors(zoneId, mapMetadata, mapGrid) {
  if (isSafeHavenZone(zoneId)) return [];
  const meta = mapMetadata || (typeof window !== 'undefined' ? window.currentMapMetadata : null);
  
  // If procedural encounter zones are present
  if (meta && Array.isArray(meta.encounterZones) && meta.encounterZones.length > 0) {
    const anchors = [];
    meta.encounterZones.forEach((ez, idx) => {
      const anchorX = Math.round((ez.minX + ez.maxX) / 2);
      const anchorY = Math.round((ez.minY + ez.maxY) / 2);
      const encType = ez.tier >= 2 ? 'ENCOUNTER_ELITE' : 'ENCOUNTER_PACK';
      anchors.push({
        id: `enc_pack_${zoneId}_${idx + 1}`,
        type: encType,
        tier: ez.tier || 1,
        anchorX, anchorY,
        minX: ez.minX, minY: ez.minY, maxX: ez.maxX, maxY: ez.maxY,
        leashRadius: 9.0
      });
    });
    return anchors;
  }
  
  // Backward compatibility fallback for unit tests without tile grid
  return null;
}
```

#### Monster Dispersal around Anchor
For an anchor at `(anchorX, anchorY)`:
- Leader spawns at `(anchorX, anchorY)`.
- $N$ minions ($N = 2..4$) spawn around the leader using radial offsets:
  $\theta_i = \frac{2\pi i}{N} + \text{jitter}$, $r_i \in [0.8, 1.6]$ tiles.
- Each position is verified to be passable (`isPassableTile(wx, wy)`), in bounds, and outside `isInWaypointSafeRadius`.
- Each monster entity receives:
  ```javascript
  {
    packId: anchor.id,
    packAnchorX: anchor.anchorX,
    packAnchorY: anchor.anchorY,
    originWx: spawnWx, originWy: spawnWy,
    encounterType: anchor.type,
    leashRadius: anchor.leashRadius || 9.0,
    isPackLeader: isLeader,
    leaderAura: isLeader ? chosenAura : null
  }
  ```

---

### 3.2 Cohesive Pack Behavior & Leashing

PoE2 leashing prevents infinite kiting and guarantees pack cohesion:

1. **Pack-Wide Coordinated Aggro**:
   - If any alive non-dummy member of a pack detects the player (`distToPlayer < aggroRadius`), all alive members in that pack immediately gain `hasAggro = true`.
2. **Dual-Condition Leash Trigger**:
   - Condition A (Mob over-extension): `dist(m.wx, m.wy, m.packAnchorX, m.packAnchorY) > leashRadius` (~8.0–10.0 tiles).
   - Condition B (Player escape / sanctuary entry):
     - `dist(player.wx, player.wy, m.packAnchorX, m.packAnchorY) > leashRadius + 2.0`, OR
     - `playerInSafeZone` (player steps into waypoint safe radius or sanctuary).
   - When leash triggers:
     - All alive members of the pack enter `isLeashing = true`.
     - `hasAggro = false`, `isInvulnerable = true`, all status ailments cleared (`burnTimer=0, shockTimer=0, freezeTimer=0`).
     - Active telegraphs cancelled: `cancelTelegraph(m.id)`.
     - HP regenerates at `30% maxHp / sec`.
     - Movement directed back to `(m.originWx, m.originWy)` at 1.5x chase speed (`step = dt * 2.7`).
3. **Leash Arrival Reset**:
   - When `dist(m.wx, m.wy, m.originWx, m.originWy) <= 0.6`:
     - `m.isLeashing = false`
     - `m.isInvulnerable = false`
     - Snap to exact origin: `m.wx = m.originWx; m.wy = m.originWy;`

---

### 3.3 Zone Encounter Progress Tracking (`window.zoneEncounterProgress`)

`window.zoneEncounterProgress` tracks real-time encounter completion per zone:

#### State Object Schema
```javascript
window.zoneEncounterProgress[zoneId] = {
  zoneId: zoneId,
  totalPacks: totalPacks,       // Total packs in zone (typically 2-3)
  alivePacks: alivePacks,       // Remaining alive packs
  clearedPacks: clearedPacks,   // Packs fully wiped out
  totalKills: totalKills,       // Total monsters slain
  requiredPacks: totalPacks,    // Packs needed to unlock boss gate
  requiredKills: requiredKills, // Optional kill threshold
  // Backward compatibility: acts as integer when evaluated numerically:
  valueOf() { return this.totalKills; },
  toString() { return `${this.clearedPacks}/${this.totalPacks} Packs (${this.totalKills} Kills)`; }
};
```

#### Kill & Pack Clearance Hook
In `monster_pack_system.js` / `monster_system.js`:
```javascript
export function registerMonsterKill(monster, activeMonsters, zoneId) {
  if (!monster || monster.isDummy) return;
  const z = zoneId || monster.zoneId || window.currentZoneId || 'zone_tang_kiem_nhai';
  const prog = window.zoneEncounterProgress?.[z];
  if (!prog) return;

  prog.totalKills++;

  // Synchronize with bossGateController if present
  if (window.bossGateController && typeof window.bossGateController.reportKill === 'function') {
    // reportKill will also unlock if requiredKills met
    window.bossGateController.currentKills = prog.totalKills;
  }

  // Check if monster's pack is completely cleared
  if (monster.packId) {
    const packMobs = activeMonsters.filter(m => m.packId === monster.packId);
    const allDead = packMobs.every(m => m.hp <= 0);
    if (allDead) {
      prog.clearedPacks++;
      prog.alivePacks = Math.max(0, prog.totalPacks - prog.clearedPacks);

      if (typeof window.onPackDefeated === 'function') {
        window.onPackDefeated(monster.packId, z);
      }
      if (typeof window.dispatchEvent === 'function') {
        window.dispatchEvent(new CustomEvent('encounter:pack_cleared', {
          detail: { packId: monster.packId, zoneId: z, remainingPacks: prog.alivePacks }
        }));
      }

      // Check zone completion
      if (prog.alivePacks <= 0 || prog.clearedPacks >= prog.requiredPacks) {
        if (window.bossGateController && typeof window.bossGateController.unlock === 'function') {
          window.bossGateController.unlock();
        }
        if (typeof window.dispatchEvent === 'function') {
          window.dispatchEvent(new CustomEvent('encounter:zone_cleared', { detail: { zoneId: z } }));
        }
      }
    }
  }
}
```

---

## 4. Line Budget Strategy for `monster_system.js` ($\le 490$ Lines)

`monster_system.js` is currently 486 lines. Automated tests strictly enforce:
- `test_monster_poise_and_leash.py:118`: `self.assertLessEqual(len(lines), 490)`
- `test_waypoint_safe_radius.py:219`: `self.assertLessEqual(len(lines), 490)`
- Hard Cap: `self.assertLessEqual(len(lines), 500)`

### Division of Responsibilities
| Module | Current Lines | Target Lines | Roles & Responsibilities |
|---|---|---|---|
| `monster_pack_system.js` | 134 | 260–290 | Terrain pack anchor resolution, cluster generation, cohesive pack AI & leashing, `zoneEncounterProgress` tracking, boss gate unlock dispatch. |
| `wilderness_zone_packs.js` | 254 | 290–320 | Catalog of pack formations, mob templates, aura assignments, and backward-compatible static templates. |
| `grid_pathfinder.js` (NEW) | 0 | 120–160 | Lightweight BFS/A* grid pathfinder for monster chase around tile walls and obstacles. |
| `monster_system.js` | 486 | 425–445 | Core monster runtime state, update loop, tick integration, combat response. Calls into `monster_pack_system.js`. Redundant helpers streamlined to save 40+ lines. |

### Preserved Test Assertions in `monster_system.js`
The following exact strings are strictly verified by existing unit tests and MUST NOT be altered or deleted:
1. `if (m.staggerTimer <= 0) m.poise = m.maxPoise;` (`test_monster_poise_and_leash.py:41`)
2. `else if (m.poise <= 0)` (`test_monster_poise_and_leash.py:42`)
3. `m.poise = m.maxPoise;` (`test_monster_poise_and_leash.py:43`)
4. `cancelTelegraph(m.id)` (`test_monster_poise_and_leash.py:47`)
5. `m.anim.isActionLocked = false` (`test_monster_poise_and_leash.py:51`)
6. `m.isLeashing` (`test_monster_poise_and_leash.py:56`)
7. `distToOrigin <= 0.6` (`test_monster_poise_and_leash.py:57`)
8. `m.isInvulnerable = true` (`test_monster_poise_and_leash.py:61`)
9. `0.30 * dt` (`test_monster_poise_and_leash.py:62`)
10. `if (typeof cancelTelegraph === 'function') cancelTelegraph(m.id);` (`test_monster_poise_and_leash.py:66`)
11. `playerInSafeZone` (`test_waypoint_safe_radius.py:197`)
12. `0.02 * dt` (`test_waypoint_safe_radius.py:198`)
13. `isInWaypointSafeRadius` (`test_waypoint_safe_radius.py:199`)
14. `m.isLeashing = true` (`test_waypoint_safe_radius.py:200`)
15. `if (player.godMode || isSafeHavenZone() || (window.isInWaypointSafeRadius && window.isInWaypointSafeRadius(player.wx, player.wy))) return;` (`test_waypoint_safe_radius.py:205`)
16. `Nghiêm cấm sản sinh quái vật trong khu vực An Toàn` (`test_zone_monster_spawning_rules.py:230`)
17. `isSafeHavenZone` (`test_zone_monster_spawning_rules.py:231`)
18. `notifySafeHavenBlocked` (`test_zone_monster_spawning_rules.py:232`)
19. `isDummy: true` (`test_zone_monster_spawning_rules.py:221`)
20. `if (m.isDummy) continue;` (`test_poe2_zone_and_encounter_e2e.py:86`)

---

## 5. Conclusion & Recommendations

1. **Implement M5 via Modular Delegation**:
   - Keep `monster_system.js` lean ($\le 445$ lines) by placing terrain pack anchor generation and encounter progress logic in `monster_pack_system.js`.
   - Update `wilderness_zone_packs.js` to provide the encounter archetypes (`ENCOUNTER_PACK`, `ENCOUNTER_ELITE`, `ENCOUNTER_AMBUSH`) while preserving the existing regex-checked static pack templates.
   - Implement `grid_pathfinder.js` for clean BFS navigation around walls.
2. **Wire Boss Gate Unlock**:
   - Connecting `registerMonsterKill` to `window.bossGateController?.unlock()` ensures end-to-end integration between monster encounters and boss arena access.
3. **Zero Regression**:
   - Maintaining fallbacks guarantees that all 114 existing unit and E2E tests continue to pass without a single failure.
