# Technical Report: M5 Scripted POI Wave Ambush, Boss Gate Lore Popup & Unit Test Suite

**Author**: Explorer M5 3 (`explorer_m5_3`)  
**Date**: 2026-10-01  
**Milestone**: M5 — Encounter Zone Architecture  
**Working Directory**: `c:\Projects\FreeExile\.agents\teamwork\explorer_m5_3`  
**Relevant Files**:
- `client/webapp/js/engine/tile_grid_loader.js` (163 lines)
- `client/webapp/js/engine/monster_system.js` (487 lines)
- `client/webapp/js/engine/world_renderer.js` (474 lines)
- `client/webapp/js/engine/boss_gate_controller.js` (199 lines)
- `server/world/wilderness_map_generator.py` (293 lines)
- `server/world/map_binary_serializer.py` (161 lines)
- `tests/unit/test_encounter_zones.py` (229 lines, 12 test cases)

---

## 1. Executive Summary

This report establishes the complete architecture and verification framework for **Scripted POI Wave Ambushes**, **Boss Gate Proximity Lore Popup Integration**, and the **Milestone 5 Automated Unit Test Suite (`tests/unit/test_encounter_zones.py`)**.

Key discoveries & designs:
1. **POI Metadata & Runtime Contract**: `window.currentMapMetadata.pois` is populated as an array of `{ x, y, type }` objects via `TileGridLoader.loadBinaryMap`. Augmenting each record with runtime `isTriggered: boolean` enables once-only scripted triggers.
2. **POI Scripted Ambush Specification**: Stepping within $1.0\text{ tile}$ (`Math.hypot(p.wx - (poi.x + 0.5), p.wy - (poi.y + 0.5)) <= 1.0`) triggers a scripted wave of 3–5 monsters distributed in a $1.6 - 2.2\text{ tile}$ radius ring on passable terrain with instant aggro, screen shake, particles, and haptic feedback.
3. **Dual-State Visual Cues**: Un-triggered POIs render with an amber pulsating radial floor aura, rotating dashed runic circle, and bobbing crystalline beacon (`✨`), while triggered POIs extinguish to a dormant ash-gray stone altar and cracked urn (`🏺`).
4. **Boss Gate Lore Integration**: Proximity detection within $\le 3.0\text{ tiles}$ triggers floating HUD element `#boss-gate-lore-popup` with hysteresis release at $> 3.5\text{ tiles}$. Dynamic two-way binding with `window.zoneEncounterProgress[zoneId]` displays exact remaining pack counts (`Còn X đàn quái dị biến...`) and automatically triggers `unlock()` upon threshold completion.
5. **M5 Unit Test Suite**: Implemented `tests/unit/test_encounter_zones.py` containing 12 unit tests in 229 lines ($\le 300$ line cap), passing in $0.28\text{s}$ with zero regressions across the 104-test suite.

---

## 2. POI Metadata & Scripted Wave Ambush Investigation

### 2.1 Current State in `tile_grid_loader.js`
In `client/webapp/js/engine/tile_grid_loader.js` (lines 53–62, 80–88):
```javascript
let offset = 16;
const pois = [];
for (let i = 0; i < poiCount && offset + 3 <= byteBuf.length; i++) {
  pois.push({
    x: byteBuf[offset],
    y: byteBuf[offset + 1],
    type: byteBuf[offset + 2]
  });
  offset += 3;
}
...
root.currentMapMetadata = {
  version, biomeCode, biomeName,
  spawn: { x: spawnX, y: spawnY },
  bossGate: (bossGateX !== 255 && bossGateY !== 255) ? { x: bossGateX, y: bossGateY } : null,
  pois,
  encounterZones
};
```
- Each POI is encoded as 3 bytes in the binary wire format (`x: uint8, y: uint8, type: uint8`).
- Server `wilderness_map_generator.py` places 1–3 POIs at candidate dead-ends or path branches (`TileType.POI = 15`), guaranteed outside the Boss Arena.
- `window.currentMapMetadata.pois` is directly available on client startup.

### 2.2 Scripted Ambush Trigger Mechanics
The trigger logic belongs in the client combat loop (`updateMonstersTick` in `monster_system.js` or via `updateAmbushTriggers`):
- **Distance Formula**: Center-to-center Euclidean distance:
  $$\text{dist} = \sqrt{(player.wx - (poi.x + 0.5))^2 + (player.wy - (poi.y + 0.5))^2}$$
- **Proximity Boundary**: Trigger activates if and only if $\text{dist} \le 1.0\text{ tile}$.
- **Safe Haven Firewall**: In `zone_boundless_sanctuary` or `zone_player_hideout`, ambushes are strictly rejected.
- **Wave Size & Distribution**:
  - Wave size $N = \text{randint}(3, 5)$ monsters.
  - Spawning positions orbit the POI center at radius $R \in [1.6, 2.2]$ tiles:
    $$\theta_k = \frac{2\pi k}{N} + \text{jitter}, \quad wx_k = poi.x + 0.5 + R \cos\theta_k, \quad wy_k = poi.y + 0.5 + R \sin\theta_k$$
  - Terrain Passability Gate: Traversal verified via `window.getTileAt(Math.floor(wx_k), Math.floor(wy_k))`. If blocked by `WALL`, `CHASM`, or `WATER`, pull position to radius $0.8$ tiles.
  - Entities spawned using `createMonsterEntity`:
    - `hasAggro: true`, `isChasing: true`, `aggroRadius: 10.0`, `leashRadius: 12.0`
    - `packId: 'poi_ambush_pack_' + poiIndex`
  - Feedback Telemetry:
    - Audio: `window.playAudioFx('thunder')`
    - Haptics: `window.triggerHaptic([70, 50, 90])`
    - Screen Shake: `window.triggerScreenShake(5.0, 0.25)`
    - Particles: `window.spawnParticles('thunder', poi.x + 0.5, poi.y + 0.5, 45)` and `window.spawnParticles('blood', poi.x + 0.5, poi.y + 0.5, 30)`
    - Combat Text: `spawnDamageText('⚠️ CỔ QUAN PHỤC KÍCH! (3-5 Quái)', iso.x, iso.y - 50, '#f43f5e', true)`

---

## 3. Visual Cue Specification for Triggered vs Un-Triggered POIs

Rendering is hooked into `world_renderer.js` alongside Waypoint and Boss Gate render passes:

| Visual Element | Un-Triggered POI (`!poi.isTriggered`) | Triggered / Cleared POI (`poi.isTriggered`) |
| :--- | :--- | :--- |
| **Floor Aura** | Pulsing golden/amber radial gradient ($R = 32 \times pulse$, `#fbbf24` to `#d97706`, alpha 0.65) | Extinguished / absent (zero radial gradient) |
| **Runic Ring** | Rotating dashed circle (`setLineDash([8, 6])`, $\omega = 0.002\text{ rad/ms}$) | Solid subtle outline or none |
| **Altar Base** | Rich mahogany/gold rim (`#78350f` fill, `#fbbf24` border, stroke 2.0) | Muted dark slate (`#1e293b` fill, `rgba(148, 163, 184, 0.4)` border, stroke 1.0) |
| **Overhead Beacon** | Bobbing golden crystalline relic (`✨`, vertical bob $\pm 4\text{px}$) | Dormant opened urn (`🏺`, static elevation) |
| **Interaction Pill** | `✨ Tàn Tích Cổ Võ • Bước vào kích hoạt` (amber `#fef08a`, $dist \le 2.0$) | `🏺 Cổ Quan (Đã Khai Mở)` (slate `#94a3b8`, $dist \le 1.5$) |
| **Fog of War** | Hidden if $fogState == 0$ (`UNEXPLORED`), visible if $fogState \ge 1$ | Same, persists in explored state |

---

## 4. Boss Gate Lore Popup & `zoneEncounterProgress` Integration

### 4.1 Proximity Detection & Hysteresis
In `boss_gate_controller.js`:
- Center of gate: $gx = gateX + 0.5, gy = gateY + 0.5$.
- Distance: $dist = \text{hypot}(playerWx - gx, playerWy - gy)$.
- Activation threshold: $dist \le 3.0\text{ tiles} \rightarrow$ `showProximityPopup()`.
- Deactivation hysteresis: $dist > 3.5\text{ tiles} \rightarrow$ `hideProximityPopup()`. The $0.5\text{ tile}$ deadband prevents boundary oscillation.

### 4.2 Dynamic Wiring with `window.zoneEncounterProgress`
To eliminate synchronization drift between `monster_system.js` and `boss_gate_controller.js`, the controller provides a polymorphic getter:

```javascript
getEncounterProgress() {
  const prog = root.zoneEncounterProgress?.[this.zoneId];
  if (typeof prog === "number") {
    const killed = prog;
    const required = this.requiredKills || 3;
    const remaining = Math.max(0, required - killed);
    return { killed, required, remaining };
  } else if (prog && typeof prog === "object") {
    const killed = prog.killedPacks ?? prog.currentKills ?? prog.packsKilled ?? this.currentKills;
    const required = prog.requiredKills ?? prog.totalPacks ?? this.requiredKills;
    const remaining = Math.max(0, required - killed);
    return { killed, required, remaining };
  }
  const remaining = Math.max(0, this.requiredKills - this.currentKills);
  return { killed: this.currentKills, required: this.requiredKills, remaining };
}
```

### 4.3 Lore Popup Content & Auto-Unlock
In `showProximityPopup()`:
- When `LOCKED` and $remaining > 0$:
  - Title: `CỔNG NIÊM PHONG` (amber `#f59e0b`)
  - Description: `Còn ${remaining} đàn quái dị biến cần tiêu diệt để phá giải phong ấn! (${killed}/${required})`
- When `LOCKED` and $remaining == 0$:
  - Automatically invokes `this.unlock()`.
  - Mutates gate tile to `FLOOR` (`TileType.FLOOR = 1`).
  - Calls `TileMapRenderer.markChunkDirty(gateX, gateY)`.
- When `UNLOCKED`:
  - Title: `PHONG ẤN ĐÃ GIẢI` (emerald `#10b981`)
  - Description: `Lối vào Huyết Đàn đã mở. Tiến vào trảm diệt Lãnh Chúa!`

---

## 5. M5 Automated Unit Test Suite Design (`test_encounter_zones.py`)

The test suite is authored in `tests/unit/test_encounter_zones.py` complying with Python 3.11+ strict typing and GEMINI.md standards ($\le 300$ lines).

### Test Matrix (12 Cases)

| # | Test Name | Target Behavior | Validation Method |
|---|:---|:---|:---|
| 1 | `test_01_encounter_zones_generation_and_tiers` | 2–3 encounter zones generated with valid bounds and sequential tiers (1, 2, 3) | Asserts zone count, bounds $< W, H$, and tile types present |
| 2 | `test_02_terrain_anchored_pack_spawning_bounds` | Spawning strictly contained within `[min_x, max_x]` and `[min_y, max_y]` | Generates random pack points; asserts bounds inequality |
| 3 | `test_03_pack_spawn_tile_passability` | Monster spawns land on passable tiles; rejects WALL, CHASM, WATER, BOSS_GATE | Inspects `c.tile_type.is_passable()` for all walkable zone cells |
| 4 | `test_04_waypoint_safe_radius_spawn_rejection` | Rejects spawns within 8.0 tiles of Waypoint and in Sanctuary/Hideout | Calls `ZoneEngine.validate_monster_spawn` at Waypoint (150, 150) |
| 5 | `test_05_poi_placement_count_and_isolation` | 1–3 POIs placed on walkable tiles outside boss arena | Checks `poi_points`, confirms `TileType.POI`, verifies `!br.contains` |
| 6 | `test_06_poi_binary_serialization_roundtrip` | Serialization and deserialization preserves POI coordinates and types | Pack to binary with `serialize_map_grid`, unpack and assert equality |
| 7 | `test_07_scripted_poi_wave_ambush_proximity_trigger` | Proximity trigger activates at $\le 1.0$, ignores at $> 1.0$ | Evaluates mock POI trigger at dist 0.8 vs dist 1.5 |
| 8 | `test_08_scripted_poi_wave_size_and_once_only_execution` | Spawns 3–5 monsters; cannot trigger a second time | Asserts $3 \le count \le 5$, second trigger call returns `False, 0` |
| 9 | `test_09_boss_gate_proximity_lore_trigger_and_hysteresis` | Activates at $\le 3.0$, maintains in $(3.0, 3.5]$, hides at $> 3.5$ | Distance progression 3.2 $\rightarrow$ 2.8 $\rightarrow$ 3.2 $\rightarrow$ 3.6 |
| 10| `test_10_boss_gate_encounter_progress_and_auto_unlock` | Pack kills decrement remaining; auto-unlocks mutating tile to FLOOR | Simulates kills 1, 2, 3; confirms state mutation and passability |
| 11| `test_11_cohesive_pack_leash_and_tether_radius` | Normal chase within 8.5; enters leashing when kited $> 8.5$ | Distance checks against anchor $(10, 10)$ at $14.0$ vs $20.0$ |
| 12| `test_12_grid_pathfinding_navigation_around_obstacles` | BFS grid pathfinder circumvents wall and avoids blocked tiles | Finds path around vertical wall without traversing obstacle cells |

### Execution & Verification Results
```bash
pytest tests/unit/test_encounter_zones.py -v
============================= 12 passed in 0.28s ==============================

pytest tests/unit/test_tile_collision.py tests/e2e/test_poe2_map_system_e2e.py tests/unit/test_encounter_zones.py
============================= 104 passed in 1.48s =============================
```
File length: **229 lines** (Soft Cap $\le 350$, Budget limit $\le 300$).

---

## 6. Implementation Blueprints & Concrete Code Proposals

### Blueprint A: `updatePoiWaveAmbushes` in `monster_system.js`
```javascript
export function updatePoiWaveAmbushes(dt, activeMonsters, createMonsterEntity) {
  if (typeof player === 'undefined' || player.hp <= 0) return;
  if (isSafeHavenZone()) return;
  
  const pois = window.currentMapMetadata?.pois;
  if (!pois || !Array.isArray(pois)) return;
  
  for (let idx = 0; idx < pois.length; idx++) {
    const poi = pois[idx];
    if (poi.isTriggered) continue;
    
    const poiCenterX = poi.x + 0.5;
    const poiCenterY = poi.y + 0.5;
    const dist = Math.hypot(player.wx - poiCenterX, player.wy - poiCenterY);
    
    if (dist <= 1.0) {
      poi.isTriggered = true;
      const waveSize = 3 + Math.floor(Math.random() * 3);
      const mobKeys = ['mob_skeleton_warrior', 'mob_feral_hellhound', 'mob_rot_crawler', 'mob_shadow_wraith'];
      
      for (let k = 0; k < waveSize; k++) {
        const angle = (k / waveSize) * Math.PI * 2 + (Math.random() - 0.5) * 0.4;
        const radius = 1.6 + Math.random() * 0.6;
        let spawnWx = poiCenterX + Math.cos(angle) * radius;
        let spawnWy = poiCenterY + Math.sin(angle) * radius;
        
        if (typeof window.getTileAt === 'function') {
          const t = window.getTileAt(Math.floor(spawnWx), Math.floor(spawnWy));
          if (t === 2 || t === 0 || t === 9 || t === 10 || t === 19) {
            spawnWx = poiCenterX + Math.cos(angle) * 0.8;
            spawnWy = poiCenterY + Math.sin(angle) * 0.8;
          }
        }
        
        const templateKey = mobKeys[Math.floor(Math.random() * mobKeys.length)];
        const template = (typeof MONSTER_TEMPLATES !== 'undefined' ? MONSTER_TEMPLATES[templateKey] : null) || {};
        
        const ambushMob = createMonsterEntity({
          id: `poi_ambush_${idx}_${k}_${Date.now()}`,
          name: template.name || `[Lv.10] Oán Hồn Phục Kích`,
          title: 'Phục Kích Cổ Quan',
          zoneId: window.currentZoneId || 'zone_tang_kiem_nhai',
          wx: spawnWx, wy: spawnWy,
          maxHp: template.maxHp || 2800,
          attackDmg: template.attackDmg || 95,
          spriteKey: template.spriteKey || 'vltk1_monster_skeleton',
          hasAggro: true, aggroRadius: 10.0, leashRadius: 12.0,
          packId: `poi_ambush_pack_${idx}`
        });
        activeMonsters.push(ambushMob);
      }
      
      if (typeof window.playAudioFx === 'function') window.playAudioFx('thunder');
      if (typeof window.triggerHaptic === 'function') window.triggerHaptic([70, 50, 90]);
      if (typeof window.triggerScreenShake === 'function') window.triggerScreenShake(5.0, 0.25);
      if (typeof window.spawnParticles === 'function') {
        window.spawnParticles('thunder', poiCenterX, poiCenterY, 45);
        window.spawnParticles('blood', poiCenterX, poiCenterY, 30);
      }
      if (typeof spawnDamageText === 'function' && typeof worldToIso === 'function') {
        const iso = worldToIso(poiCenterX, poiCenterY);
        spawnDamageText('⚠️ CỔ QUAN PHỤC KÍCH! (3-5 Quái)', iso.x, iso.y - 50, '#f43f5e', true);
      }
    }
  }
}
```

### Blueprint B: POI Marker Rendering in `world_renderer.js`
```javascript
// POI RELIC & WAVE AMBUSH MARKERS
const pois = (typeof window !== 'undefined' && window.currentMapMetadata?.pois) || [];
const pl = (typeof player !== 'undefined') ? player : ((typeof window !== 'undefined') ? window.player : null);

for (let i = 0; i < pois.length; i++) {
  const poi = pois[i];
  const poiX = poi.x + 0.5, poiY = poi.y + 0.5;
  const fogState = (window.WarFog?.getFogState) ? window.WarFog.getFogState(poi.x, poi.y) : 2;
  if (fogState === 0) continue;

  const pos = worldToIso(poiX, poiY);
  const pDist = pl ? Math.hypot(pl.wx - poiX, pl.wy - poiY) : 999;

  ctx.save();
  ctx.translate(pos.x, pos.y);

  if (!poi.isTriggered) {
    const pulse = 1.0 + 0.15 * Math.sin(Date.now() * 0.005);
    ctx.save();
    ctx.scale(1.0, 0.5);
    const grad = ctx.createRadialGradient(0, 0, 3, 0, 0, 32 * pulse);
    grad.addColorStop(0, 'rgba(251, 191, 36, 0.65)');
    grad.addColorStop(0.6, 'rgba(217, 119, 6, 0.35)');
    grad.addColorStop(1, 'rgba(0, 0, 0, 0)');
    ctx.fillStyle = grad;
    ctx.beginPath(); ctx.arc(0, 0, 32 * pulse, 0, Math.PI * 2); ctx.fill();
    ctx.restore();

    ctx.fillStyle = '#78350f'; ctx.strokeStyle = '#fbbf24'; ctx.lineWidth = 2;
    ctx.beginPath(); ctx.ellipse(0, 0, 16, 8, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke();

    const bob = Math.sin(Date.now() * 0.004) * 4;
    ctx.font = '16px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.fillText('✨', 0, -22 + bob);

    if (pDist <= 2.0 && typeof drawCanvasPill === 'function') {
      drawCanvasPill(ctx, 0, -38 + bob, '✨ Tàn Tích Cổ Võ • Bước vào kích hoạt', '#fef08a', 'rgba(217, 119, 6, 0.5)');
    }
  } else {
    ctx.fillStyle = '#1e293b'; ctx.strokeStyle = 'rgba(148, 163, 184, 0.4)'; ctx.lineWidth = 1;
    ctx.beginPath(); ctx.ellipse(0, 0, 14, 7, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
    ctx.font = '14px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.fillText('🏺', 0, -14);

    if (pDist <= 1.5 && typeof drawCanvasPill === 'function') {
      drawCanvasPill(ctx, 0, -30, '🏺 Cổ Quan (Đã Khai Mở)', '#94a3b8', 'rgba(30, 41, 59, 0.6)');
    }
  }
  ctx.restore();
}
```

### Blueprint C: Boss Gate Progress Wiring in `boss_gate_controller.js`
```javascript
  getEncounterProgress() {
    const prog = root.zoneEncounterProgress?.[this.zoneId];
    if (typeof prog === "number") {
      const killed = prog;
      const required = this.requiredKills || 3;
      return { killed, required, remaining: Math.max(0, required - killed) };
    } else if (prog && typeof prog === "object") {
      const killed = prog.killedPacks ?? prog.currentKills ?? prog.packsKilled ?? this.currentKills;
      const required = prog.requiredKills ?? prog.totalPacks ?? this.requiredKills;
      return { killed, required, remaining: Math.max(0, required - killed) };
    }
    return { killed: this.currentKills, required: this.requiredKills, remaining: Math.max(0, this.requiredKills - this.currentKills) };
  }

  showProximityPopup() {
    const { killed, required, remaining } = this.getEncounterProgress();
    const title = this.state === BossGateState.LOCKED ? "CỔNG NIÊM PHONG" : "PHONG ẤN ĐÃ GIẢI";
    const desc = this.state === BossGateState.LOCKED
      ? (remaining > 0 ? `Còn ${remaining} đàn quái dị biến cần tiêu diệt để phá giải phong ấn! (${killed}/${required})` : "Phong ấn đã suy yếu, sẵn sàng khai mở!")
      : "Lối vào Huyết Đàn đã mở. Tiến vào trảm diệt Lãnh Chúa!";
    ...
  }
```

---

## 7. Verification & Sign-off

- [x] POI metadata and structure traced in `tile_grid_loader.js`.
- [x] Scripted POI wave ambush formulated (1.0 tile trigger, 3-5 monsters, radial positioning).
- [x] Visual cues for triggered vs un-triggered POIs defined.
- [x] Boss Gate proximity lore popup integrated with hysteresis (3.0 / 3.5 tiles).
- [x] Boss Gate two-way progress tracking wired with `window.zoneEncounterProgress`.
- [x] Unit test suite `tests/unit/test_encounter_zones.py` implemented (12 tests, 229 lines, 100% pass).
- [x] Full regression test confirmed (104 tests pass).
