fix: Fixes criminals having guards called on them (#2348)

## Summary

Fixes three bugs in `GuardedRegion.CallGuards`:

- **Operator precedence bug**: The condition `!m.Region.IsPartOf(this) && !m_GuardCandidates.ContainsKey(m)`
  was inverted from intent. Replaced with explicit split: dictionary members are targeted regardless of
  region; permanent candidates (reds/AlwaysMurderer) must be inside the region.
- **Premature `break`**: Only the first guard candidate was ever processed per "guards" call.
  Removed the `break` so all valid candidates in range get a guard spawned.
- **Misleading message for permanent reds**: "Guards can no longer be called on you." (502276)
  was sent to permanent reds, but guards can *always* be called on them. Now only sent to
  temporary criminals whose guard window is actually consumed.

Also extracts `IsAlwaysGuardCandidate()` helper and simplifies `IsGuardCandidate()`.

## Edge cases verified (by analysis)

- **Multiple players call guards on same red**: `BaseGuard.Spawn` dedup (scans 15 tiles for
  existing guard with same `Focus`) prevents duplicate guards. Message suppression eliminates spam.
- **Red fights spawned guard → criminal → guards called again**: Same dedup prevents infinite
  guard spawns. Existing guard already has `Focus == red`.

## Test plan

- [ ] Temporary criminal in guarded region → guard spawns, receives "Guards can no longer be called on you."
- [ ] Permanent red (5+ kills) in guarded region → guard spawns, does NOT receive the message
- [ ] Multiple players call "guards" on same red → only 1 guard spawns
- [ ] Criminal outside region but in dictionary → still targeted by guards call from inside region
- [ ] Red outside guarded region → NOT targeted by guards call (must be inside region)
This commit is contained in:
Kamron Batman 2026-03-13 17:35:47 -07:00 committed by GitHub
parent 4f9bc1d9f6
commit 1e4cdc4ab6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -282,7 +282,11 @@ public class GuardedRegion : BaseRegion
BaseGuard.Spawn(fakeCall, m);
timer.Stop();
m_GuardCandidates.Remove(m);
m.SendLocalizedMessage(502276); // Guards can no longer be called on you.
if (!IsAlwaysGuardCandidate(m))
{
m.SendLocalizedMessage(502276); // Guards can no longer be called on you.
}
}
}
else
@ -298,28 +302,38 @@ public class GuardedRegion : BaseRegion
foreach (var m in Map.GetMobilesInRange(p, 14))
{
if (!IsGuardCandidate(m) || !m.Region.IsPartOf(this) && !m_GuardCandidates.ContainsKey(m))
if (!IsGuardCandidate(m))
{
continue;
}
if (m_GuardCandidates.Remove(m, out var timer))
// Dictionary members can be targeted regardless of region (guards reach outside).
// Non-dictionary permanent candidates (reds) must be in the region.
if (m_GuardCandidates.ContainsKey(m) || m.Region.IsPartOf(this) && IsAlwaysGuardCandidate(m))
{
timer.Stop();
}
if (m_GuardCandidates.Remove(m, out var timer))
{
timer.Stop();
}
queue.Enqueue(m);
break;
queue.Enqueue(m);
}
}
while (queue.Count > 0)
{
var m = queue.Dequeue();
BaseGuard.Spawn(this, m);
m.SendLocalizedMessage(502276); // Guards can no longer be called on you.
if (!IsAlwaysGuardCandidate(m))
{
m.SendLocalizedMessage(502276); // Guards can no longer be called on you.
}
}
}
private bool IsAlwaysGuardCandidate(Mobile m) => !AllowReds && m.Murderer;
public bool IsGuardCandidate(Mobile m)
{
if (m is BaseGuard || !m.Alive || m.AccessLevel > AccessLevel.Player || m.Blessed)
@ -327,14 +341,7 @@ public class GuardedRegion : BaseRegion
return false;
}
var bc = m as BaseCreature;
if (bc?.IsInvulnerable == true)
{
return false;
}
return !AllowReds && m.Murderer || m.Criminal;
return (m as BaseCreature)?.IsInvulnerable != true && (IsAlwaysGuardCandidate(m) || m.Criminal);
}
private class GuardTimer : Timer