diff --git a/client/webapp/index.html b/client/webapp/index.html
--- a/client/webapp/index.html
+++ b/client/webapp/index.html
@@ -141,6 +141,7 @@
+
diff --git a/client/webapp/js/engine/combat_skills.js b/client/webapp/js/engine/combat_skills.js
--- a/client/webapp/js/engine/combat_skills.js
+++ b/client/webapp/js/engine/combat_skills.js
@@ -161,9 +161,16 @@
const dist = Math.hypot(player.wx - target.wx, player.wy - target.wy);
if (dist < 7.5) {
- target.hp = Math.max(0, target.hp - dmg);
+ if (target.isDummy) {
+ target.hp = target.maxHp;
+ if (typeof window.TargetDummyTelemetry !== 'undefined') {
+ window.TargetDummyTelemetry.recordHit(dmg, isCrit, Date.now());
+ }
+ } else {
+ target.hp = Math.max(0, target.hp - dmg);
+ target.hasAggro = true; // Retaliate!
+ }
target.hurtTimer = 0.38;
- target.hasAggro = true; // Retaliate!
// Trigger visceral flinch animation on target
if (typeof AnimationEngine !== 'undefined' && target.anim) {
@@ -194,8 +201,10 @@
// Apply element-specific status reactions
if (elem === 'fire') {
target.burnTimer = 2.0;
- target.knockbackX = (target.wx >= player.wx ? 0.35 : -0.35);
- target.wx += target.knockbackX;
+ if (!target.isDummy) {
+ target.knockbackX = (target.wx >= player.wx ? 0.35 : -0.35);
+ target.wx += target.knockbackX;
+ }
} else if (elem === 'thunder') {
target.shockTimer = 1.8;
triggerScreenShake(5.5, 0.2);
@@ -215,10 +224,8 @@
const col = elem === 'fire' ? '#f97316' : (elem === 'thunder' ? '#fbbf24' : '#38bdf8');
spawnDamageText(prefix + dmg.toLocaleString(), mPos.x, mPos.y - 45, col, isCrit);
- if (target.hp <= 0) {
- if (typeof window.recordMonsterKillForQuests === 'function') {
- window.recordMonsterKillForQuests(target.name);
- }
+ if (target.hp <= 0 && !target.isDummy) {
+ if (typeof window.recordMonsterKillForQuests === 'function') window.recordMonsterKillForQuests(target.name);
if (typeof MonsterLootDropper !== 'undefined') {
MonsterLootDropper.spawnMonsterLoot(target);
} else {
diff --git a/client/webapp/js/engine/monster_loot_dropper.js b/client/webapp/js/engine/monster_loot_dropper.js
--- a/client/webapp/js/engine/monster_loot_dropper.js
+++ b/client/webapp/js/engine/monster_loot_dropper.js
@@ -26,7 +26,7 @@
];
function spawnMonsterLoot(monster) {
- if (!monster || typeof groundDrops === 'undefined') return [];
+ if (!monster || monster.isDummy || typeof groundDrops === 'undefined') return [];
const isBoss = !!(monster.isBoss || monster.maxHp >= 5000 || (monster.id && monster.id.includes('boss')));
const mobLevel = monster.level || 80;
diff --git a/client/webapp/js/engine/monster_system.js b/client/webapp/js/engine/monster_system.js
--- a/client/webapp/js/engine/monster_system.js
+++ b/client/webapp/js/engine/monster_system.js
@@ -165,6 +165,11 @@
function updateMonstersTick(dt) {
if (typeof player === 'undefined') return;
+ // Target Dummy Telemetry tick
+ if (typeof window.TargetDummyTelemetry !== 'undefined' && typeof window.TargetDummyTelemetry.update === 'function') {
+ window.TargetDummyTelemetry.update(dt);
+ }
+
// Player potion cooldown
if (player.potionCooldown > 0) {
player.potionCooldown -= dt;
@@ -197,6 +202,17 @@
for (let i = 0; i < activeMonsters.length; i++) {
const m = activeMonsters[i];
+ // PoE2 Strict Standard: Target Dummy is immortal & anchored to spawn origin (zero drift)
+ if (m.isDummy) {
+ m.hp = m.maxHp;
+ m.wx = m.originWx;
+ m.wy = m.originWy;
+ m.burnTimer = 0;
+ m.shockTimer = 0;
+ m.freezeTimer = 0;
+ continue;
+ }
+
// Handle dead monster respawn cycle
if (m.hp <= 0) {
m.respawnTimer += dt;
@@ -220,13 +236,11 @@
if (m.shockTimer > 0) m.shockTimer -= dt;
if (m.freezeTimer > 0) m.freezeTimer -= dt;
if (m.staggerTimer > 0) {
m.staggerTimer -= dt;
+ if (m.staggerTimer <= 0) m.poise = m.maxPoise;
continue; // Stunned during stagger window!
}
if (m.attackCooldown > 0) m.attackCooldown -= dt;
-
- // Training Dummy does not move or attack
- if (m.isDummy) continue;
// Disengage if player is dead
@@ -410,6 +424,14 @@
// QA Spawner helpers for testing in browser
function spawnQAEnemy(type) {
+ const z = typeof currentZoneId !== 'undefined' ? currentZoneId : 'zone_boundless_sanctuary';
+ const isSafe = (z === 'zone_player_hideout' || z === 'zone_boundless_sanctuary');
+ if (isSafe && type !== 'dummy') {
+ if (typeof showNoticeModal === 'function') {
+ showNoticeModal('🛡️ Khu Vực An Toàn (Safe Haven)\n\nKhông thể triệu hồi quái vật thù địch tại đây theo chuẩn PoE2!\nChỉ được phép triệu hồi Mộc Nhân Thử Chiêu.');
+ }
+ return;
+ }
const spawnWx = player.wx + (player.facing * 1.8);
const spawnWy = player.wy;
@@ -455,6 +477,11 @@
// Direct spawner by key for console & QA
function spawnSpecificMonster(key, wx, wy) {
+ const z = typeof currentZoneId !== 'undefined' ? currentZoneId : 'zone_boundless_sanctuary';
+ if ((z === 'zone_player_hideout' || z === 'zone_boundless_sanctuary') && !key.startsWith('dummy_')) {
+ console.warn(`[MonsterSystem] Cannot spawn hostile monster (${key}) in safe haven: ${z}`);
+ return null;
+ }
const tmpl = MONSTER_TEMPLATES[key];
if (!tmpl) {
console.warn(`[MonsterSystem] Unknown monster template key: ${key}`);
diff --git a/client/webapp/js/ui/hideout_gates.js b/client/webapp/js/ui/hideout_gates.js
--- a/client/webapp/js/ui/hideout_gates.js
+++ b/client/webapp/js/ui/hideout_gates.js
@@ -131,6 +131,10 @@
} else {
safeShowNotice(`🚪 [BƯỚC VÀO TÀN ĐỒ HOANG VỰC]\n\nĐã bước qua cổng Tàn Đồ Bậc ${hideoutState.mapDevice.tier}!\nTiêu hao 1 cổng. Hiện còn: ${hideoutState.mapDevice.portalsRemaining}/6 Cổng khả dụng.`);
}
closeHideoutModal();
+ const targetZone = hideoutState.mapDevice.targetZoneId || 'zone_tang_kiem_nhai';
+ if (typeof window.teleportToZone === 'function') {
+ window.teleportToZone(targetZone);
+ }
}
export function enterWorldGate() {
diff --git a/client/webapp/js/ui/worldmap_dungeons.js b/client/webapp/js/ui/worldmap_dungeons.js
--- a/client/webapp/js/ui/worldmap_dungeons.js
+++ b/client/webapp/js/ui/worldmap_dungeons.js
@@ -116,6 +116,21 @@
if (typeof window.playAudioFx === 'function') window.playAudioFx('dodge');
if (typeof window.triggerHaptic === 'function') window.triggerHaptic([60, 40, 80]);
+ // PoE2 Standard: Flush active ground drops, flying projectiles, and monster entities
+ if (typeof groundDrops !== 'undefined') groundDrops.length = 0;
+ if (typeof window.groundDrops !== 'undefined') window.groundDrops.length = 0;
+ if (typeof vfx_slashes !== 'undefined') vfx_slashes.length = 0;
+ if (typeof window.vfx_slashes !== 'undefined') window.vfx_slashes.length = 0;
+ if (typeof vfx_lightnings !== 'undefined') vfx_lightnings.length = 0;
+ if (typeof window.vfx_lightnings !== 'undefined') window.vfx_lightnings.length = 0;
+ if (typeof vfx_spikes !== 'undefined') vfx_spikes.length = 0;
+ if (typeof window.vfx_spikes !== 'undefined') window.vfx_spikes.length = 0;
+ if (typeof damageTexts !== 'undefined') damageTexts.length = 0;
+ if (typeof window.damageTexts !== 'undefined') window.damageTexts.length = 0;
+ if (window.TargetDummyTelemetry && typeof window.TargetDummyTelemetry.reset === 'function') {
+ window.TargetDummyTelemetry.reset();
+ }
+
// PoE2 Standard: Flush & refresh monster ecosystem matching target zone
if (typeof window.initZoneMonsters === 'function') {
window.initZoneMonsters(zoneId);