@rpgjs/action-battle 5.0.0-beta.12 → 5.0.0-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ai.server.ts CHANGED
@@ -60,6 +60,31 @@ type RpgEventWithBattleAi = RpgEvent & {
60
60
  battleAi?: BattleAi;
61
61
  };
62
62
 
63
+ type ResolvedMoveTarget =
64
+ | {
65
+ kind: "entity";
66
+ target: ActionBattleEntity;
67
+ id?: string;
68
+ x?: number;
69
+ y?: number;
70
+ signature: string;
71
+ }
72
+ | {
73
+ kind: "position";
74
+ target: { x: number; y: number };
75
+ x: number;
76
+ y: number;
77
+ signature: string;
78
+ };
79
+
80
+ const resolveMoveCoordinate = (value: unknown): number | undefined => {
81
+ const raw = typeof value === "function" ? (value as () => unknown)() : value;
82
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined;
83
+ };
84
+
85
+ const createMoveSignature = (x: number, y: number): string =>
86
+ `position:${Math.round(x)}:${Math.round(y)}`;
87
+
63
88
  export interface BattleAiRewardItem {
64
89
  item?: any;
65
90
  itemId?: string;
@@ -334,16 +359,10 @@ export const AiDebug = {
334
359
  * @param data - Optional additional data
335
360
  */
336
361
  log(category: string, eventId: string | undefined, message: string, data?: any): void {
337
- if (!this.enabled) return;
338
- if (this.filterEventId && eventId !== this.filterEventId) return;
339
- if (this.categories.length > 0 && !this.categories.includes(category)) return;
340
-
341
- const prefix = `[AI:${category}]${eventId ? ` [${eventId.substring(0, 8)}]` : ''}`;
342
- if (data !== undefined) {
343
- console.log(prefix, message, data);
344
- } else {
345
- console.log(prefix, message);
346
- }
362
+ void category;
363
+ void eventId;
364
+ void message;
365
+ void data;
347
366
  }
348
367
  };
349
368
 
@@ -497,6 +516,37 @@ export class BattleAi {
497
516
  AiDebug.log(category, this.event.id, message, data);
498
517
  }
499
518
 
519
+ private traceLog(category: string, message: string, data?: any): void {
520
+ void category;
521
+ void message;
522
+ void data;
523
+ }
524
+
525
+ private lockActionUntil(until: number, reason: string, data?: any): void {
526
+ if (until <= this.actionLockedUntil) return;
527
+ this.actionLockedUntil = until;
528
+ this.traceLog("state", "action locked", {
529
+ reason,
530
+ lockedMs: Math.max(0, until - Date.now()),
531
+ ...data,
532
+ });
533
+ }
534
+
535
+ private lockForAttack(
536
+ profile: NormalizedActionBattleAttackProfile,
537
+ pattern: AttackPattern
538
+ ): void {
539
+ this.isMovingToTarget = false;
540
+ this.event.stopMoveTo();
541
+ this.lockActionUntil(Date.now() + profile.totalDurationMs, "attack", {
542
+ pattern,
543
+ totalDurationMs: profile.totalDurationMs,
544
+ startupMs: profile.startupMs,
545
+ activeMs: profile.activeMs,
546
+ recoveryMs: profile.recoveryMs,
547
+ });
548
+ }
549
+
500
550
  // State machine
501
551
  private state: AiState = AiState.Idle;
502
552
  private stateStartTime: number = 0;
@@ -570,6 +620,11 @@ export class BattleAi {
570
620
  private lastMoveToTime: number = 0;
571
621
  private retreatCooldown: number = 600;
572
622
  private lastRetreatTime: number = 0;
623
+ private actionLockedUntil: number = 0;
624
+ private lastActionLockTraceTime: number = 0;
625
+ private lastMoveToCooldownTraceTime: number = 0;
626
+ private lastMoveToCooldownTraceSignature: string | null = null;
627
+ private lastTargetMovementSkipTraceTime: number = 0;
573
628
  private timers: ReturnType<typeof setTimeout>[] = [];
574
629
  private behaviorKey?: string;
575
630
  private behaviorTree?: ActionBattleAiTreeNode;
@@ -581,6 +636,7 @@ export class BattleAi {
581
636
  private visionSetupRetries: number = 0;
582
637
  private maxVisionSetupRetries: number = 20;
583
638
  private destroyed: boolean = false;
639
+ private lastNoTargetTraceTime: number = 0;
584
640
 
585
641
  /**
586
642
  * Create a new Battle AI Controller
@@ -790,6 +846,10 @@ export class BattleAi {
790
846
  map?.physic?.getEntityByUUID &&
791
847
  !map.physic.getEntityByUUID(this.event.id)
792
848
  ) {
849
+ this.traceLog("vision", "physics body not ready", {
850
+ retries: this.visionSetupRetries,
851
+ hasMap: !!map,
852
+ });
793
853
  return false;
794
854
  }
795
855
 
@@ -800,14 +860,29 @@ export class BattleAi {
800
860
  height: diameter,
801
861
  angle: 360,
802
862
  });
803
- if (!shape) return false;
863
+ if (!shape) {
864
+ this.traceLog("vision", "attachShape returned no shape", {
865
+ retries: this.visionSetupRetries,
866
+ visionRange: this.visionRange,
867
+ });
868
+ return false;
869
+ }
804
870
  this.visionShape = shape;
871
+ this.traceLog("vision", "vision attached", {
872
+ shapeId: (shape as any)?.id,
873
+ visionRange: this.visionRange,
874
+ });
805
875
  return true;
806
876
  }
807
877
 
808
878
  private scheduleVisionSetup() {
809
879
  if (this.destroyed || this.setupVision()) return;
810
- if (this.visionSetupRetries >= this.maxVisionSetupRetries) return;
880
+ if (this.visionSetupRetries >= this.maxVisionSetupRetries) {
881
+ this.traceLog("vision", "vision setup gave up", {
882
+ retries: this.visionSetupRetries,
883
+ });
884
+ return;
885
+ }
811
886
 
812
887
  this.visionSetupRetries++;
813
888
  this.schedule(() => {
@@ -848,10 +923,19 @@ export class BattleAi {
848
923
 
849
924
  if (!validTransitions[this.state].includes(newState)) {
850
925
  this.debugLog('state', `INVALID transition ${this.state} -> ${newState}`);
926
+ this.traceLog("state", "invalid transition", {
927
+ from: this.state,
928
+ to: newState,
929
+ });
851
930
  return;
852
931
  }
853
932
 
854
933
  this.debugLog('state', `STATE change: ${this.state} -> ${newState}`);
934
+ this.traceLog("state", "state change", {
935
+ from: this.state,
936
+ to: newState,
937
+ targetId: this.target?.id,
938
+ });
855
939
  this.state = newState;
856
940
  this.stateStartTime = Date.now();
857
941
 
@@ -905,6 +989,20 @@ export class BattleAi {
905
989
  return;
906
990
  }
907
991
 
992
+ if (currentTime < this.actionLockedUntil) {
993
+ if (this.target) this.faceTarget();
994
+ if (currentTime - this.lastActionLockTraceTime > 250) {
995
+ this.lastActionLockTraceTime = currentTime;
996
+ this.traceLog("state", "waiting action recovery", {
997
+ remainingMs: this.actionLockedUntil - currentTime,
998
+ state: this.state,
999
+ targetId: this.target?.id,
1000
+ });
1001
+ }
1002
+ this.checkDamageTaken();
1003
+ return;
1004
+ }
1005
+
908
1006
  // Berserker: faster attacks when HP is low
909
1007
  if (this.enemyType === EnemyType.Berserker && this.event.param[MAXHP]) {
910
1008
  const hpPercent = this.event.hp / this.event.param[MAXHP];
@@ -982,8 +1080,28 @@ export class BattleAi {
982
1080
  this.faceTarget();
983
1081
 
984
1082
  const distance = this.getDistance(this.event, this.target);
1083
+ this.traceLog("movement", "alert update", {
1084
+ targetId: this.target.id,
1085
+ distance,
1086
+ attackRange: this.attackRange,
1087
+ visionRange: this.visionRange,
1088
+ isMovingToTarget: this.isMovingToTarget,
1089
+ });
985
1090
  if (distance <= this.attackRange * 1.5) {
1091
+ if (this.isMovingToTarget) {
1092
+ this.isMovingToTarget = false;
1093
+ this.event.stopMoveTo();
1094
+ }
986
1095
  this.changeState(AiState.Combat);
1096
+ } else if (distance <= this.visionRange * 1.5) {
1097
+ if (!this.isMovingToTarget) {
1098
+ this.debugLog('movement', `Alert approach (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
1099
+ this.requestTargetMovement();
1100
+ }
1101
+ } else {
1102
+ this.debugLog('combat', `Alert target out of range (dist=${distance.toFixed(1)})`);
1103
+ this.clearTarget();
1104
+ this.changeState(AiState.Idle);
987
1105
  }
988
1106
  } else {
989
1107
  this.changeState(AiState.Idle);
@@ -1001,6 +1119,15 @@ export class BattleAi {
1001
1119
  }
1002
1120
 
1003
1121
  const distance = this.getDistance(this.event, this.target);
1122
+ this.traceLog("combat", "combat update", {
1123
+ targetId: this.target.id,
1124
+ distance,
1125
+ attackRange: this.attackRange,
1126
+ visionRange: this.visionRange,
1127
+ isMovingToTarget: this.isMovingToTarget,
1128
+ behaviorEnabled: this.behaviorEnabled,
1129
+ behaviorMode: this.behaviorMode,
1130
+ });
1004
1131
 
1005
1132
  // Check if target is still in range
1006
1133
  if (distance > this.visionRange * 1.5) {
@@ -1055,8 +1182,7 @@ export class BattleAi {
1055
1182
  } else if (distance > this.attackRange) {
1056
1183
  if (!this.isMovingToTarget) {
1057
1184
  this.debugLog('movement', `Moving to target (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
1058
- this.isMovingToTarget = true;
1059
- this.requestMoveTo(this.target);
1185
+ this.requestTargetMovement();
1060
1186
  }
1061
1187
  } else {
1062
1188
  if (this.isMovingToTarget) {
@@ -1069,8 +1195,7 @@ export class BattleAi {
1069
1195
  if (distance > this.attackRange) {
1070
1196
  if (!this.isMovingToTarget) {
1071
1197
  this.debugLog('movement', `Moving to target (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
1072
- this.isMovingToTarget = true;
1073
- this.requestMoveTo(this.target);
1198
+ this.requestTargetMovement();
1074
1199
  }
1075
1200
  } else {
1076
1201
  if (this.isMovingToTarget) {
@@ -1217,15 +1342,9 @@ export class BattleAi {
1217
1342
  const profile = this.getAttackProfile(AttackPattern.Melee);
1218
1343
 
1219
1344
  this.faceTarget({ force: true });
1345
+ this.lockForAttack(profile, AttackPattern.Melee);
1220
1346
  this.telegraphAttack(profile);
1221
- withActionBattleAnimationUnlocked(this.event, () => {
1222
- emitActionBattleClientVisual({
1223
- moment: "attack",
1224
- entity: this.event,
1225
- target: this.target,
1226
- animations: this.animations,
1227
- });
1228
- });
1347
+ this.playAttackVisual(profile, AttackPattern.Melee);
1229
1348
 
1230
1349
  this.scheduleAttackStartup(profile, () => {
1231
1350
  this.executeMeleeAttack(profile, AttackPattern.Melee);
@@ -1439,12 +1558,12 @@ export class BattleAi {
1439
1558
  // Visual feedback
1440
1559
  withActionBattleAnimationUnlocked(target, () => {
1441
1560
  emitActionBattleClientVisual({
1442
- moment: "hit",
1561
+ moment: "hurt",
1443
1562
  entity: this.event,
1444
1563
  target,
1564
+ attacker: this.event,
1445
1565
  damage: hitResult.damage,
1446
1566
  result: hitResult,
1447
- animations: this.animations,
1448
1567
  });
1449
1568
  });
1450
1569
  setActionBattleInvincibility(
@@ -1526,15 +1645,9 @@ export class BattleAi {
1526
1645
  this.comboCount++;
1527
1646
  const profile = this.getAttackProfile(AttackPattern.Combo);
1528
1647
  this.faceTarget({ force: true });
1648
+ this.lockForAttack(profile, AttackPattern.Combo);
1529
1649
  this.telegraphAttack(profile);
1530
- withActionBattleAnimationUnlocked(this.event, () => {
1531
- emitActionBattleClientVisual({
1532
- moment: "attack",
1533
- entity: this.event,
1534
- target: this.target,
1535
- animations: this.animations,
1536
- });
1537
- });
1650
+ this.playAttackVisual(profile, AttackPattern.Combo);
1538
1651
  this.scheduleAttackStartup(profile, () => {
1539
1652
  this.executeMeleeAttack(profile, AttackPattern.Combo);
1540
1653
  });
@@ -1561,16 +1674,9 @@ export class BattleAi {
1561
1674
 
1562
1675
  this.chargingAttack = true;
1563
1676
  this.faceTarget({ force: true });
1677
+ this.lockForAttack(profile, AttackPattern.Charged);
1564
1678
  this.telegraphAttack(profile);
1565
- withActionBattleAnimationUnlocked(this.event, () => {
1566
- emitActionBattleClientVisual({
1567
- moment: "attack",
1568
- entity: this.event,
1569
- target: this.target,
1570
- animations: this.animations,
1571
- animationDefaults: { repeat: 2 },
1572
- });
1573
- });
1679
+ this.playAttackVisual(profile, AttackPattern.Charged, { repeat: 2 });
1574
1680
 
1575
1681
  this.scheduleAttackStartup(profile, () => {
1576
1682
  if (!this.target || this.state !== AiState.Combat) {
@@ -1589,15 +1695,9 @@ export class BattleAi {
1589
1695
  */
1590
1696
  private performZoneAttack() {
1591
1697
  const profile = this.getAttackProfile(AttackPattern.Zone);
1698
+ this.lockForAttack(profile, AttackPattern.Zone);
1592
1699
  this.telegraphAttack(profile);
1593
- withActionBattleAnimationUnlocked(this.event, () => {
1594
- emitActionBattleClientVisual({
1595
- moment: "attack",
1596
- entity: this.event,
1597
- target: this.target ?? undefined,
1598
- animations: this.animations,
1599
- });
1600
- });
1700
+ this.playAttackVisual(profile, AttackPattern.Zone);
1601
1701
 
1602
1702
  const eventX = this.event.x();
1603
1703
  const eventY = this.event.y();
@@ -1651,7 +1751,9 @@ export class BattleAi {
1651
1751
  const dirY = dy / dist;
1652
1752
 
1653
1753
  this.faceTarget({ force: true });
1754
+ this.lockForAttack(profile, AttackPattern.DashAttack);
1654
1755
  this.telegraphAttack(profile);
1756
+ this.playAttackVisual(profile, AttackPattern.DashAttack);
1655
1757
 
1656
1758
  this.scheduleAttackStartup(profile, () => {
1657
1759
  if (!this.target || this.state !== AiState.Combat) return;
@@ -1671,6 +1773,28 @@ export class BattleAi {
1671
1773
  ] ?? this.attackProfiles.melee;
1672
1774
  }
1673
1775
 
1776
+ private playAttackVisual(
1777
+ profile: NormalizedActionBattleAttackProfile,
1778
+ pattern: AttackPattern,
1779
+ animationDefaults?: { animationName?: string; repeat?: number }
1780
+ ): void {
1781
+ const moment =
1782
+ profile.animationKey === "castSkill" || profile.animationKey === "castSpell"
1783
+ ? "castSkill"
1784
+ : "attack";
1785
+
1786
+ withActionBattleAnimationUnlocked(this.event, () => {
1787
+ emitActionBattleClientVisual({
1788
+ moment,
1789
+ entity: this.event,
1790
+ target: this.target ?? undefined,
1791
+ pattern,
1792
+ animations: this.animations,
1793
+ animationDefaults,
1794
+ });
1795
+ });
1796
+ }
1797
+
1674
1798
  private telegraphAttack(profile: NormalizedActionBattleAttackProfile) {
1675
1799
  if (profile.startupMs <= 0) return;
1676
1800
  this.event.flash({
@@ -1820,8 +1944,8 @@ export class BattleAi {
1820
1944
  if (dist === 0) return;
1821
1945
 
1822
1946
  const fleeTarget = {
1823
- x: () => this.event.x() + (dx / dist) * 200,
1824
- y: () => this.event.y() + (dy / dist) * 200
1947
+ x: this.event.x() + (dx / dist) * 200,
1948
+ y: this.event.y() + (dy / dist) * 200
1825
1949
  };
1826
1950
 
1827
1951
  this.requestMoveTo(fleeTarget as any);
@@ -1868,7 +1992,7 @@ export class BattleAi {
1868
1992
  if (this.patrolWaypoints.length === 0) return;
1869
1993
 
1870
1994
  const waypoint = this.patrolWaypoints[this.currentPatrolIndex];
1871
- this.requestMoveTo({ x: () => waypoint.x, y: () => waypoint.y } as any);
1995
+ this.requestMoveTo({ x: waypoint.x, y: waypoint.y } as any);
1872
1996
  }
1873
1997
 
1874
1998
  /**
@@ -1940,7 +2064,7 @@ export class BattleAi {
1940
2064
  );
1941
2065
 
1942
2066
  if (distanceToFormation > 20) {
1943
- this.requestMoveTo({ x: () => formationX, y: () => formationY } as any);
2067
+ this.requestMoveTo({ x: formationX, y: formationY } as any);
1944
2068
  }
1945
2069
  }
1946
2070
 
@@ -1948,12 +2072,28 @@ export class BattleAi {
1948
2072
  * Handle player entering vision
1949
2073
  */
1950
2074
  onDetectInShape(target: ActionBattleEntity, shape: any) {
1951
- if (!this.canTarget(target) || this.isTargetDefeated(target)) return;
2075
+ const canTarget = this.canTarget(target);
2076
+ const defeated = this.isTargetDefeated(target);
2077
+ this.traceLog("vision", "detect in shape", {
2078
+ targetId: target?.id,
2079
+ shapeId: shape?.id,
2080
+ canTarget,
2081
+ defeated,
2082
+ state: this.state,
2083
+ targetHp: (target as any)?.hp,
2084
+ });
2085
+ if (!canTarget || defeated) return;
1952
2086
  this.debugLog('vision', `Target ${target.id} entered vision (state=${this.state})`);
1953
2087
  this.engageTarget(target);
1954
2088
  }
1955
2089
 
1956
2090
  private engageTarget(target: ActionBattleEntity) {
2091
+ this.traceLog("target", "engage target", {
2092
+ targetId: target.id,
2093
+ previousTargetId: this.target?.id,
2094
+ state: this.state,
2095
+ distance: this.getDistance(this.event, target),
2096
+ });
1957
2097
  this.target = target;
1958
2098
 
1959
2099
  if (this.state === AiState.Idle) {
@@ -1968,6 +2108,12 @@ export class BattleAi {
1968
2108
  */
1969
2109
  onDetectOutShape(target: ActionBattleEntity, shape: any) {
1970
2110
  this.debugLog('vision', `Target ${target.id} left vision (wasTarget=${this.target === target})`);
2111
+ this.traceLog("vision", "detect out shape", {
2112
+ targetId: target?.id,
2113
+ shapeId: shape?.id,
2114
+ wasTarget: this.target === target,
2115
+ state: this.state,
2116
+ });
1971
2117
  if (this.target === target) {
1972
2118
  this.clearTarget();
1973
2119
  this.changeState(AiState.Idle);
@@ -1998,8 +2144,21 @@ export class BattleAi {
1998
2144
  }
1999
2145
  ): boolean {
2000
2146
  if (this.defeated) return true;
2001
- const damage = damageResult.damage;
2147
+ const damage = Number.isFinite(damageResult.damage) ? damageResult.damage : 0;
2002
2148
  this.debugLog('damage', `Took ${damage} damage from ${attacker.id} (HP: ${this.event.hp}/${this.event.param[MAXHP] || '?'})`);
2149
+ const canRetaliate = attacker ? this.canTarget(attacker) : false;
2150
+ const attackerDefeated = this.isTargetDefeated(attacker);
2151
+ this.traceLog("damage", "handle damage", {
2152
+ attackerId: attacker?.id,
2153
+ damage,
2154
+ defeated: damageResult.defeated,
2155
+ eventHp: this.event.hp,
2156
+ maxHp: this.event.param[MAXHP],
2157
+ state: this.state,
2158
+ canRetaliate,
2159
+ attackerDefeated,
2160
+ currentTargetId: this.target?.id,
2161
+ });
2003
2162
 
2004
2163
  // Visual feedback
2005
2164
  withActionBattleAnimationUnlocked(this.event, () => {
@@ -2018,10 +2177,36 @@ export class BattleAi {
2018
2177
  // Track damage
2019
2178
  this.recentDamageTaken += damage;
2020
2179
 
2180
+ if (
2181
+ attacker &&
2182
+ this.canTarget(attacker) &&
2183
+ !this.isTargetDefeated(attacker) &&
2184
+ this.state !== AiState.Flee
2185
+ ) {
2186
+ this.traceLog("target", "retaliate against attacker", {
2187
+ attackerId: attacker.id,
2188
+ previousTargetId: this.target?.id,
2189
+ state: this.state,
2190
+ });
2191
+ this.target = attacker;
2192
+ if (this.state === AiState.Idle || this.state === AiState.Alert) {
2193
+ this.isMovingToTarget = false;
2194
+ this.changeState(AiState.Combat);
2195
+ }
2196
+ }
2197
+
2021
2198
  const reaction = damageResult.reaction;
2022
2199
  const staggerPower = reaction?.staggerPower ?? damage;
2023
2200
  const hitstunMs = reaction?.hitstunMs ?? this.hitstunMs;
2024
- const shouldStun = staggerPower >= this.poise && hitstunMs > 0;
2201
+ const shouldStun =
2202
+ (damage > 0 || (reaction?.staggerPower ?? 0) > 0) &&
2203
+ staggerPower >= this.poise &&
2204
+ hitstunMs > 0;
2205
+ this.lockActionUntil(Date.now() + Math.max(220, hitstunMs + 120), "damage recovery", {
2206
+ attackerId: attacker?.id,
2207
+ damage,
2208
+ hitstunMs,
2209
+ });
2025
2210
  setActionBattleInvincibility(
2026
2211
  this.event,
2027
2212
  reaction?.invincibilityMs ?? this.invincibilityMs
@@ -2157,7 +2342,10 @@ export class BattleAi {
2157
2342
 
2158
2343
  private findNearestTarget(): ActionBattleEntity | null {
2159
2344
  const map = this.event.getCurrentMap();
2160
- if (!map) return null;
2345
+ if (!map) {
2346
+ this.traceLog("target", "find nearest skipped: no map");
2347
+ return null;
2348
+ }
2161
2349
 
2162
2350
  const candidates: ActionBattleEntity[] = [];
2163
2351
  map.getPlayers?.().forEach((player: RpgPlayer) => candidates.push(player));
@@ -2175,6 +2363,31 @@ export class BattleAi {
2175
2363
  }
2176
2364
  }
2177
2365
 
2366
+ const now = Date.now();
2367
+ if (nearest) {
2368
+ this.traceLog("target", "nearest target found", {
2369
+ targetId: nearest.id,
2370
+ distance: nearestDistance,
2371
+ visionRange: this.visionRange,
2372
+ candidates: candidates.length,
2373
+ });
2374
+ } else if (now - this.lastNoTargetTraceTime > 1000) {
2375
+ this.lastNoTargetTraceTime = now;
2376
+ this.traceLog("target", "no target found", {
2377
+ visionRange: this.visionRange,
2378
+ candidates: candidates.map((candidate) => {
2379
+ const distance = this.getDistance(this.event, candidate);
2380
+ return {
2381
+ id: candidate.id,
2382
+ hp: (candidate as any).hp,
2383
+ canTarget: this.canTarget(candidate),
2384
+ defeated: this.isTargetDefeated(candidate),
2385
+ distance,
2386
+ };
2387
+ }),
2388
+ });
2389
+ }
2390
+
2178
2391
  return nearest;
2179
2392
  }
2180
2393
 
@@ -2370,8 +2583,7 @@ export class BattleAi {
2370
2583
  return consumes;
2371
2584
  case "moveToTarget":
2372
2585
  if (!this.target) return false;
2373
- this.isMovingToTarget = true;
2374
- this.requestMoveTo(this.target);
2586
+ this.requestTargetMovement();
2375
2587
  return consumes;
2376
2588
  case "fleeFromTarget":
2377
2589
  if (!this.target) return false;
@@ -2404,8 +2616,7 @@ export class BattleAi {
2404
2616
  return consumes;
2405
2617
  }
2406
2618
  if (distance > intent.distance + tolerance) {
2407
- this.isMovingToTarget = true;
2408
- this.requestMoveTo(this.target);
2619
+ this.requestTargetMovement();
2409
2620
  return consumes;
2410
2621
  }
2411
2622
  if (this.isMovingToTarget) {
@@ -2473,8 +2684,7 @@ export class BattleAi {
2473
2684
  if (distance > maxRange) {
2474
2685
  if (!this.isMovingToTarget) {
2475
2686
  this.debugLog('movement', `Tactical approach (dist=${distance.toFixed(1)}, maxRange=${maxRange.toFixed(1)})`);
2476
- this.isMovingToTarget = true;
2477
- this.requestMoveTo(this.target);
2687
+ this.requestTargetMovement();
2478
2688
  }
2479
2689
  return;
2480
2690
  }
@@ -2491,8 +2701,7 @@ export class BattleAi {
2491
2701
  if (distance > this.attackRange) {
2492
2702
  if (!this.isMovingToTarget) {
2493
2703
  this.debugLog('movement', `Assault approach (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
2494
- this.isMovingToTarget = true;
2495
- this.requestMoveTo(this.target);
2704
+ this.requestTargetMovement();
2496
2705
  }
2497
2706
  return;
2498
2707
  }
@@ -2504,16 +2713,114 @@ export class BattleAi {
2504
2713
  }
2505
2714
  }
2506
2715
 
2716
+ private resolveMoveTarget(target: any): ResolvedMoveTarget | null {
2717
+ if (!target) return null;
2718
+
2719
+ const targetId =
2720
+ target.id !== undefined && target.id !== null ? String(target.id) : undefined;
2721
+ const x = resolveMoveCoordinate(target.x);
2722
+ const y = resolveMoveCoordinate(target.y);
2723
+
2724
+ if (targetId) {
2725
+ return {
2726
+ kind: "entity",
2727
+ target,
2728
+ id: targetId,
2729
+ x,
2730
+ y,
2731
+ signature: `entity:${targetId}`,
2732
+ };
2733
+ }
2734
+
2735
+ if (x === undefined || y === undefined) {
2736
+ return null;
2737
+ }
2738
+
2739
+ return {
2740
+ kind: "position",
2741
+ target: { x, y },
2742
+ x,
2743
+ y,
2744
+ signature: createMoveSignature(x, y),
2745
+ };
2746
+ }
2747
+
2507
2748
  private requestMoveTo(target: any): boolean {
2508
2749
  const currentTime = Date.now();
2750
+ const resolvedTarget = this.resolveMoveTarget(target);
2751
+ if (!resolvedTarget) {
2752
+ this.traceLog("movement", "moveTo skipped: invalid target", {
2753
+ target,
2754
+ });
2755
+ return false;
2756
+ }
2757
+
2509
2758
  if (currentTime - this.lastMoveToTime < this.moveToCooldown) {
2759
+ if (
2760
+ this.lastMoveToCooldownTraceSignature !== resolvedTarget.signature ||
2761
+ currentTime - this.lastMoveToCooldownTraceTime > 1000
2762
+ ) {
2763
+ this.lastMoveToCooldownTraceTime = currentTime;
2764
+ this.lastMoveToCooldownTraceSignature = resolvedTarget.signature;
2765
+ this.traceLog("movement", "moveTo skipped: cooldown", {
2766
+ targetKind: resolvedTarget.kind,
2767
+ targetId:
2768
+ resolvedTarget.kind === "entity" ? resolvedTarget.id : undefined,
2769
+ targetPosition: {
2770
+ x: resolvedTarget.x,
2771
+ y: resolvedTarget.y,
2772
+ },
2773
+ elapsed: currentTime - this.lastMoveToTime,
2774
+ moveToCooldown: this.moveToCooldown,
2775
+ });
2776
+ }
2510
2777
  return false;
2511
2778
  }
2512
- this.event.moveTo(target as any);
2779
+ const map = this.event.getCurrentMap?.() as any;
2780
+ const hasBody =
2781
+ !!map?.physic?.getEntityByUUID?.(this.event.id) ||
2782
+ !!map?.getBody?.(this.event.id);
2783
+ this.traceLog("movement", "moveTo requested", {
2784
+ targetKind: resolvedTarget.kind,
2785
+ targetId:
2786
+ resolvedTarget.kind === "entity" ? resolvedTarget.id : undefined,
2787
+ eventPosition: {
2788
+ x: this.event.x?.(),
2789
+ y: this.event.y?.(),
2790
+ },
2791
+ targetPosition: {
2792
+ x: resolvedTarget.x,
2793
+ y: resolvedTarget.y,
2794
+ },
2795
+ hasMap: !!map,
2796
+ hasMovementBody: hasBody,
2797
+ });
2798
+ this.event.moveTo(resolvedTarget.target as any);
2513
2799
  this.lastMoveToTime = currentTime;
2514
2800
  return true;
2515
2801
  }
2516
2802
 
2803
+ private requestTargetMovement(target: ActionBattleEntity | null = this.target): boolean {
2804
+ if (!target) {
2805
+ this.traceLog("movement", "target movement skipped: no target");
2806
+ return false;
2807
+ }
2808
+ const started = this.requestMoveTo(target);
2809
+ if (started) {
2810
+ this.isMovingToTarget = true;
2811
+ } else {
2812
+ const now = Date.now();
2813
+ if (now - this.lastTargetMovementSkipTraceTime > 1000) {
2814
+ this.lastTargetMovementSkipTraceTime = now;
2815
+ this.traceLog("movement", "target movement did not start", {
2816
+ targetId: target.id,
2817
+ isMovingToTarget: this.isMovingToTarget,
2818
+ });
2819
+ }
2820
+ }
2821
+ return started;
2822
+ }
2823
+
2517
2824
  private schedule(callback: () => void, delay: number) {
2518
2825
  const timer = setTimeout(() => {
2519
2826
  this.timers = this.timers.filter((entry) => entry !== timer);