ModernUO/Projects/UOContent/Mobiles/Guards/BaseGuard.cs
Jack 597c81345e
feat: Adjusts fame and karma system with era gates for OSI accuracy (#2389)
## Summary

Overhauls the fame and karma system to be more era-accurate, based on original design documents and publish notes.

### Karma on player kill → karma on murder report
- Removes karma gain/loss on player kill (was immediate on death)
- Karma is now set to `Kills * -1000` on murder **report** instead
- Fame on player kill now uses the same formula as monster kills (`Fame / 100`)

This karma loss on murder report behaviour was tested on both the demo and live servers, behaviour was matching in terms of karma loss on report. Official UO servers karma loss AMOUNT match with my memory of T2A/UOR with one caveat - it's doubled on live servers (-2000 * kill count). I'm not sure when this changed and this behaviour has always had very poor and incorrect documentation, even 10-20 years ago.  I was obsessed with the dread lord title on OSI and the only way I knew how to get it was reach 10 kills then macro them off. Even in publish 16 (when "The Murderer" title was removed) it still required 10 kills. Maybe it changed to -2000*Kills in AOS - that's where I've put the era gating diff.

### Era gates
- **Karma lock** (ankh toggle + auto-lock on negative karma) gated to `Core.UOTD && !Core.AOS` — [didn't exist before Jan 28, 2001](https://web.archive.org/web/20010128092700/http://update.uo.com/design_300.html)
- **Felucca fame/karma +30% bonus** gated to `Core.LBR` — [added in Publish 16, July 2002](https://uo.com/wiki/ultima-online-wiki/technical/previous-publishes/2002-2/publish-16-part-2-5-23rd-july/)
- **Fame/karma splitting** among damage dealers gated to `Core.UOR` — pre-UOR awards go to last hit only

### Skill karma penalties
- **Provocation** on innocent NPC: NPC says cliloc 501591, karma loss (floor -7500)
- **Stealing** attempt: karma loss on every attempt (floor -5000). Stealing did not cause karma loss at all before!
- **Summon Daemon**: karma loss on successful cast (floor -7000)
- **Corpse carving** (human): -70 for innocent corpses (floor -7000), -20 for freely-aggressable (floor -2000)
- **Bounty head turn-in**: karma gain capped at 2000 (was awarding flat +2000)

### Beneficial action karma
- **Beneficial spells** (heal, cure, etc.): `AwardKarma(caster, target.Karma / 5)` — healing good targets raises karma, healing evil targets lowers it - source is UO98 demo scripts
- **Bandages**: same formula but gain only (skipped if target karma ≤ 0) - see stratics link ("only ever gain karma, not lose it")

Sources: UO98 Demo scripts and playing, [Fame and Karma wiki](https://uo.com/wiki/ultima-online-wiki/player/fame-and-karma/), [UO design doc (Jan 2001)](https://web.archive.org/web/20010128092700/http://update.uo.com/design_300.html), [Publish 16](https://uo.com/wiki/ultima-online-wiki/technical/previous-publishes/2002-2/publish-16-part-2-5-23rd-july/), [Stratics healing reference](https://web.archive.org/web/20001209014200fw_/http://uo.stratics.com/heal.shtml)

### Refactoring
- Extracted `Titles.ComputeKillAwards(killed, map)` shared by player kill and creature kill paths
- Extracted `Titles.SetKarma(m, value, message)` for direct karma assignment (used by murder report)
- Extracted `SendKarmaMessage` and `CheckKarmaLock` helpers from `AwardKarma`
2026-04-25 11:19:57 -07:00

336 lines
8 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Runtime.CompilerServices;
using ModernUO.Serialization;
using Server.Engines.PlayerMurderSystem;
using Server.Items;
using Server.Misc;
namespace Server.Mobiles;
[SerializationGenerator(0)]
public abstract partial class BaseGuard : Mobile
{
public static bool GuardsInstantKill { get; private set; }
public static void Configure()
{
GuardsInstantKill = ServerConfiguration.GetSetting("guards.instantKill", true);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Spawn(Mobile caller, Mobile target, int amount = 1, bool onlyAdditional = false) =>
Spawn(caller.Region, target, amount, onlyAdditional);
public static void Spawn(Region region, Mobile target, int amount = 1, bool onlyAdditional = false)
{
if (target?.Deleted != false)
{
return;
}
foreach (var g in target.GetMobilesInRange<BaseGuard>(15))
{
if (g.Focus == null) // idling
{
g.Focus = target;
--amount;
}
else if (g.Focus == target && !onlyAdditional)
{
--amount;
}
}
while (amount-- > 0)
{
region.MakeGuard(target);
}
}
public static void TeleportTo(Mobile source, Point3D to)
{
Effects.SendLocationParticles(
EffectItem.Create(source.Location, source.Map, EffectItem.DefaultDuration),
0x3728,
10,
10,
2023
);
source.Location = to;
Effects.SendLocationParticles(
EffectItem.Create(to, source.Map, EffectItem.DefaultDuration),
0x3728,
10,
10,
5023
);
source.PlaySound(0x1FE);
}
private GuardIdleTimer _idleTimer;
private GuardAttackTimer _attackTimer;
public BaseGuard(Mobile target)
{
Title = "the guard";
if (target != null)
{
Location = target.Location;
Map = target.Map;
Effects.SendLocationParticles(
EffectItem.Create(Location, Map, EffectItem.DefaultDuration),
0x3728,
10,
10,
5023
);
Focus = target;
}
}
protected GuardAttackTimer AttackTimer
{
get => _attackTimer;
set
{
_attackTimer?.Stop();
_attackTimer = value;
_attackTimer?.Start();
}
}
protected GuardIdleTimer IdleTimer
{
get => _idleTimer;
set
{
_idleTimer?.Stop();
_idleTimer = value;
_idleTimer?.Start();
}
}
public abstract Mobile Focus { get; set; }
public override void OnAfterDelete()
{
AttackTimer = null;
IdleTimer = null;
base.OnAfterDelete();
}
public override bool OnBeforeDeath()
{
Effects.SendLocationParticles(
EffectItem.Create(Location, Map, EffectItem.DefaultDuration),
0x3728,
10,
10,
2023
);
PlaySound(0x1FE);
Delete();
return false;
}
public abstract void NonLethalAttack(Mobile target);
public override bool OnDragDrop(Mobile from, Item dropped)
{
if (PlayerMurderSystem.BountiesEnabled && dropped is Head head && head.PlayerName != null)
{
var target = head.BountyTarget;
if (target == null || Core.Now - head.CarvedTime > TimeSpan.FromHours(24))
{
SayNonBountyHeadResponse();
head.Delete();
return true;
}
Say(500670); // Ah, a head! Let me check to see if there is a bounty on this.
head.Delete();
ClaimBounty(from, target);
return true;
}
return base.OnDragDrop(from, dropped);
}
private void ClaimBounty(Mobile from, PlayerMobile target)
{
var bounty = PlayerMurderSystem.GetBounty(target);
if (bounty > 0)
{
PlayerMurderSystem.ClearBounty(target);
Banker.Deposit(from, bounty);
Titles.AwardKarma(from, 20, true);
Say(1042855, $"{target.Name}\t{bounty}"); // The bounty on ~1_PLAYER_NAME~ was ~2_AMOUNT~ gold, and has been credited to your account.
}
else
{
Say(1042854, target.Name); // There was no bounty on ~1_PLAYER_NAME~.
}
}
private void SayNonBountyHeadResponse()
{
if (Utility.Random(5) == 0)
{
Say(500661 + Utility.Random(9)); // 500661500669: silly guard responses
}
else
{
Say(500654 + Utility.Random(7)); // 500654500660: normal guard responses
}
}
[AfterDeserialization]
private void AfterDeserialization()
{
if (Focus != null)
{
AttackTimer = new GuardAttackTimer(this);
}
else
{
IdleTimer = new GuardIdleTimer(this);
}
}
}
public class GuardAvengeTimer : Timer
{
private readonly Mobile _focus;
public GuardAvengeTimer(Mobile focus) : base(TimeSpan.FromSeconds(2.5), TimeSpan.FromSeconds(1.0), 3) =>
_focus = focus;
protected override void OnTick() => BaseGuard.Spawn(_focus, _focus, 1, true);
}
public class GuardIdleTimer : Timer
{
private readonly BaseGuard _owner;
private int m_Stage;
public GuardIdleTimer(BaseGuard owner) : base(TimeSpan.FromSeconds(2.0), TimeSpan.FromSeconds(2.5)) =>
_owner = owner;
protected override void OnTick()
{
if (_owner.Deleted)
{
Stop();
return;
}
if (m_Stage++ % 4 == 0 || !_owner.Move(_owner.Direction))
{
_owner.Direction = (Direction)Utility.Random(8);
}
if (m_Stage > 16)
{
Effects.SendLocationParticles(
EffectItem.Create(_owner.Location, _owner.Map, EffectItem.DefaultDuration),
0x3728,
10,
10,
2023
);
_owner.PlaySound(0x1FE);
if (_owner.Spawner == null)
{
_owner.Delete();
}
else
{
BaseGuard.TeleportTo(_owner, _owner.Spawner.Location);
}
Stop();
}
}
}
public class GuardAttackTimer : Timer
{
private readonly BaseGuard _owner;
public GuardAttackTimer(BaseGuard owner) : base(TimeSpan.FromSeconds(0.25), TimeSpan.FromSeconds(0.1)) =>
_owner = owner;
public void DoOnTick()
{
OnTick();
}
protected override void OnTick()
{
if (_owner.Deleted)
{
Stop();
return;
}
_owner.Criminal = false;
_owner.Kills = 0;
_owner.Stam = _owner.StamMax;
var target = _owner.Focus;
if (target != null && (target.Deleted || !target.Alive || !_owner.CanBeHarmful(target)))
{
_owner.Focus = null;
Stop();
return;
}
if (target != null && _owner.Combatant != target)
{
_owner.Combatant = target;
}
if (target == null)
{
Stop();
}
else if (BaseGuard.GuardsInstantKill)
{
BaseGuard.TeleportTo(_owner, target.Location);
target.BoltEffect(0);
if (target is BaseCreature creature)
{
creature.NoKillAwards = true;
}
target.Damage(target.HitsMax, _owner);
target.Kill(); // just in case, maybe Damage is overridden on some shard
if (target.Corpse != null && !target.Player)
{
target.Corpse.Delete();
}
_owner.Focus = null;
Stop();
}
else
{
_owner.NonLethalAttack(target);
}
}
}