From 597c81345e8413f885cf88f7538d94cce2a08f96 Mon Sep 17 00:00:00 2001 From: Jack Date: Sun, 26 Apr 2026 06:19:57 +1200 Subject: [PATCH] feat: Adjusts fame and karma system with era gates for OSI accuracy (#2389) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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` --- .../PlayerMurderSystem.cs | 3 + .../ReportMurdererGump.cs | 21 +---- .../UOContent/Items/Construction/Ankhs.cs | 2 +- .../UOContent/Items/Misc/Corpses/Corpse.cs | 5 ++ .../Items/Skill Items/Misc/Bandage.cs | 6 ++ .../Veteran Rewards/AnkhOfSacrifice.cs | 2 +- Projects/UOContent/Misc/Titles.cs | 89 ++++++++++++------- Projects/UOContent/Mobiles/BaseCreature.cs | 23 ++--- .../UOContent/Mobiles/Guards/BaseGuard.cs | 2 +- Projects/UOContent/Skills/Provocation.cs | 8 ++ Projects/UOContent/Skills/Stealing.cs | 3 + Projects/UOContent/Spells/Base/Spell.cs | 6 ++ .../UOContent/Spells/Eighth/SummonDaemon.cs | 3 + 13 files changed, 107 insertions(+), 66 deletions(-) diff --git a/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs b/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs index fd03f7f8a..b5a1b77d5 100644 --- a/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs +++ b/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs @@ -5,6 +5,7 @@ using System.Runtime.InteropServices; using ModernUO.CodeGeneratedEvents; using Server.Collections; using Server.Logging; +using Server.Misc; using Server.Mobiles; using Server.SkillHandlers; @@ -298,6 +299,8 @@ public class PlayerMurderSystem : GenericPersistence var wasMurderer = killer.Murderer; OnPlayerMurder(pk); + Titles.SetKarma(pk, pk.Kills * (Core.AOS ? -2000 : -1000), true); + pk.SendLocalizedMessage(1049067); // You have been reported for murder! if (!wasMurderer && killer.Murderer) diff --git a/Projects/UOContent/Engines/Player Murder System/ReportMurdererGump.cs b/Projects/UOContent/Engines/Player Murder System/ReportMurdererGump.cs index 6eeb7bcf8..2ef3e4073 100644 --- a/Projects/UOContent/Engines/Player Murder System/ReportMurdererGump.cs +++ b/Projects/UOContent/Engines/Player Murder System/ReportMurdererGump.cs @@ -60,28 +60,11 @@ public class ReportMurdererGump : StaticGump if (toGive?.Count > 0) { + var (fameAward, _) = Titles.ComputeKillAwards(m, m.Map); + foreach (var g in toGive) { - var n = Notoriety.Compute(g, m); - - var ourKarma = g.Karma; - var innocent = n == Notoriety.Innocent; - var criminal = n is Notoriety.Criminal or Notoriety.Murderer; - - var fameAward = m.Fame / 200; - var karmaAward = 0; - - if (innocent) - { - karmaAward = ourKarma > -2500 ? -850 : -110 - m.Karma / 100; - } - else if (criminal) - { - karmaAward = 50; - } - Titles.AwardFame(g, fameAward, false); - Titles.AwardKarma(g, karmaAward, true); } } diff --git a/Projects/UOContent/Items/Construction/Ankhs.cs b/Projects/UOContent/Items/Construction/Ankhs.cs index 985092f43..80105a16a 100644 --- a/Projects/UOContent/Items/Construction/Ankhs.cs +++ b/Projects/UOContent/Items/Construction/Ankhs.cs @@ -14,7 +14,7 @@ namespace Server.Items public static void GetContextMenuEntries(Mobile from, Item item, ref PooledRefList list) { - if (from is PlayerMobile mobile) + if (Core.UOTD && !Core.AOS && from is PlayerMobile mobile) { list.Add(new LockKarmaEntry(mobile.KarmaLocked)); } diff --git a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs index 534f44b64..d186d7999 100644 --- a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs +++ b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs @@ -409,6 +409,11 @@ public partial class Corpse : Container, ICarvable if (IsCriminalAction(from)) { from.CriminalAction(true); + Titles.AwardKarma(from, -70, true); + } + else + { + Titles.AwardKarma(from, -20, true); } } else if (dead is BaseCreature creature) diff --git a/Projects/UOContent/Items/Skill Items/Misc/Bandage.cs b/Projects/UOContent/Items/Skill Items/Misc/Bandage.cs index 77a7997e2..bcf59d04d 100644 --- a/Projects/UOContent/Items/Skill Items/Misc/Bandage.cs +++ b/Projects/UOContent/Items/Skill Items/Misc/Bandage.cs @@ -4,6 +4,7 @@ using ModernUO.Serialization; using Server.Engines.ConPVP; using Server.Factions; using Server.Gumps; +using Server.Misc; using Server.Mobiles; using Server.Targeting; @@ -443,6 +444,11 @@ public class BandageContext : Timer Healer.CheckSkill(secondarySkill, 0.0, 120.0); Healer.CheckSkill(primarySkill, 0.0, 120.0); } + + if (Healer != Patient && Patient.Karma > 0) + { + Titles.AwardKarma(Healer, Patient.Karma / 5, true); + } } public static BandageContext BeginHeal(Mobile healer, Mobile patient) diff --git a/Projects/UOContent/Items/Special/Veteran Rewards/AnkhOfSacrifice.cs b/Projects/UOContent/Items/Special/Veteran Rewards/AnkhOfSacrifice.cs index 49950b13c..bf2b302e0 100644 --- a/Projects/UOContent/Items/Special/Veteran Rewards/AnkhOfSacrifice.cs +++ b/Projects/UOContent/Items/Special/Veteran Rewards/AnkhOfSacrifice.cs @@ -23,7 +23,7 @@ public partial class AnkhOfSacrificeComponent : AddonComponent { base.GetContextMenuEntries(from, ref list); - if (from is PlayerMobile mobile) + if (Core.UOTD && !Core.AOS && from is PlayerMobile mobile) { list.Add(new Ankhs.LockKarmaEntry(mobile.KarmaLocked)); } diff --git a/Projects/UOContent/Misc/Titles.cs b/Projects/UOContent/Misc/Titles.cs index 9595665b5..c5f9f2a18 100644 --- a/Projects/UOContent/Misc/Titles.cs +++ b/Projects/UOContent/Misc/Titles.cs @@ -116,6 +116,20 @@ namespace Server.Misc ) }; + public static (int Fame, int Karma) ComputeKillAwards(Mobile killed, Map map) + { + var totalFame = killed.Fame / 100; + var totalKarma = -killed.Karma / 100; + + if (Core.LBR && map == Map.Felucca) + { + totalFame += totalFame / 10 * 3; + totalKarma += totalKarma / 10 * 3; + } + + return (totalFame, totalKarma); + } + public static void AwardFame(Mobile m, int offset, bool message) { if (offset > 0) @@ -183,13 +197,29 @@ namespace Server.Misc } } + public static void SetKarma(Mobile m, int newKarma, bool message) + { + newKarma = Math.Clamp(newKarma, MinKarma, MaxKarma); + var offset = newKarma - m.Karma; + var wasPositiveKarma = m.Karma >= 0; + + m.Karma = newKarma; + + if (message) + { + SendKarmaMessage(m, offset); + } + + CheckKarmaLock(m, wasPositiveKarma); + } + public static void AwardKarma(Mobile m, int offset, bool message) { var pm = m as PlayerMobile; if (offset > 0) { - if (pm?.KarmaLocked == true) + if (Core.UOTD && !Core.AOS && pm?.KarmaLocked == true) { return; } @@ -224,41 +254,32 @@ namespace Server.Misc if (message) { - if (offset > 40) - { - m.SendLocalizedMessage(1019062); // You have gained a lot of karma. - } - else if (offset > 20) - { - m.SendLocalizedMessage(1019061); // You have gained a good amount of karma. - } - else if (offset > 10) - { - m.SendLocalizedMessage(1019060); // You have gained some karma. - } - else if (offset > 0) - { - m.SendLocalizedMessage(1019059); // You have gained a little karma. - } - else if (offset < -40) - { - m.SendLocalizedMessage(1019066); // You have lost a lot of karma. - } - else if (offset < -20) - { - m.SendLocalizedMessage(1019065); // You have lost a good amount of karma. - } - else if (offset < -10) - { - m.SendLocalizedMessage(1019064); // You have lost some karma. - } - else if (offset < 0) - { - m.SendLocalizedMessage(1019063); // You have lost a little karma. - } + SendKarmaMessage(m, offset); } - if (!Core.AOS && wasPositiveKarma && m.Karma < 0 && pm?.KarmaLocked == false) + CheckKarmaLock(m, wasPositiveKarma); + } + + private static void SendKarmaMessage(Mobile m, int offset) + { + var message = offset switch + { + > 40 => 1019062, // You have gained a lot of karma. + > 20 => 1019061, // You have gained a good amount of karma. + > 10 => 1019060, // You have gained some karma. + > 0 => 1019059, // You have gained a little karma. + < -40 => 1019066, // You have lost a lot of karma. + < -20 => 1019065, // You have lost a good amount of karma. + < -10 => 1019064, // You have lost some karma. + _ => 1019063, // You have lost a little karma. + }; + + m.SendLocalizedMessage(message); + } + + private static void CheckKarmaLock(Mobile m, bool wasPositiveKarma) + { + if (Core.UOTD && !Core.AOS && wasPositiveKarma && m.Karma < 0 && m is PlayerMobile { KarmaLocked: false } pm) { pm.KarmaLocked = true; // Karma is locked. A mantra spoken at a shrine will unlock it again. diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index be1733290..3fc9c0d8d 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -3286,14 +3286,7 @@ namespace Server.Mobiles if (!Summoned && !NoKillAwards) { - var totalFame = Fame / 100; - var totalKarma = -Karma / 100; - - if (Map == Map.Felucca) - { - totalFame += totalFame / 10 * 3; - totalKarma += totalKarma / 10 * 3; - } + var (totalFame, totalKarma) = Titles.ComputeKillAwards(this, Map); var list = GetLootingRights(DamageEntries, HitsMax); var titles = new List(); @@ -3313,9 +3306,19 @@ namespace Server.Mobiles continue; } - var party = Engines.PartySystem.Party.Get(ds.m_Mobile); + if (!Core.UOR) + { + var killer = LastKiller is BaseCreature bc ? bc.GetDamageMaster(this) : LastKiller; - if (party != null) + if (ds.m_Mobile == killer) + { + // If the titles system gets feature flagged, it will be supported + titles.Add(ds.m_Mobile); + fame.Add(totalFame); + karma.Add(totalKarma); + } + } + else if (Engines.PartySystem.Party.Get(ds.m_Mobile) is { } party) { var divedFame = totalFame / party.Members.Count; var divedKarma = totalKarma / party.Members.Count; diff --git a/Projects/UOContent/Mobiles/Guards/BaseGuard.cs b/Projects/UOContent/Mobiles/Guards/BaseGuard.cs index ad63338fd..1fe8c89ac 100644 --- a/Projects/UOContent/Mobiles/Guards/BaseGuard.cs +++ b/Projects/UOContent/Mobiles/Guards/BaseGuard.cs @@ -173,7 +173,7 @@ public abstract partial class BaseGuard : Mobile { PlayerMurderSystem.ClearBounty(target); Banker.Deposit(from, bounty); - Titles.AwardKarma(from, 2000, true); + 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. } diff --git a/Projects/UOContent/Skills/Provocation.cs b/Projects/UOContent/Skills/Provocation.cs index 0cd329333..9215a7f61 100644 --- a/Projects/UOContent/Skills/Provocation.cs +++ b/Projects/UOContent/Skills/Provocation.cs @@ -1,5 +1,6 @@ using System; using Server.Items; +using Server.Misc; using Server.Mobiles; using Server.Targeting; @@ -51,6 +52,13 @@ namespace Server.SkillHandlers 1062488 ); // The instrument you are trying to play is no longer in your backpack! } + else if (Notoriety.Compute(from, creature) == Notoriety.Innocent) + { + // Think not that I fail to see what thou art doing! + creature.Say(501591); + + Titles.AwardKarma(from, -75, true); + } else if (creature.Controlled) { from.SendLocalizedMessage(501590); // They are too loyal to their master to be provoked. diff --git a/Projects/UOContent/Skills/Stealing.cs b/Projects/UOContent/Skills/Stealing.cs index 692376b8b..99c92a4c9 100644 --- a/Projects/UOContent/Skills/Stealing.cs +++ b/Projects/UOContent/Skills/Stealing.cs @@ -5,6 +5,7 @@ using Server.Engines.ConPVP; using Server.Engines.Stealables; using Server.Factions; using Server.Items; +using Server.Misc; using Server.Mobiles; using Server.Spells; using Server.Spells.Fifth; @@ -361,6 +362,8 @@ public static class Stealing _thief.SendLocalizedMessage(502710); // You can't steal that! } + Titles.AwardKarma(from, -50, true); + var mobRoot = root as Mobile; if (stolen != null) diff --git a/Projects/UOContent/Spells/Base/Spell.cs b/Projects/UOContent/Spells/Base/Spell.cs index 50927ae36..d3f666530 100644 --- a/Projects/UOContent/Spells/Base/Spell.cs +++ b/Projects/UOContent/Spells/Base/Spell.cs @@ -847,6 +847,12 @@ namespace Server.Spells if (Caster.CanBeBeneficial(target, true, allowDead) && CheckSequence()) { Caster.DoBeneficial(target); + + if (Caster != target) + { + Titles.AwardKarma(Caster, target.Karma / 5, true); + } + return true; } diff --git a/Projects/UOContent/Spells/Eighth/SummonDaemon.cs b/Projects/UOContent/Spells/Eighth/SummonDaemon.cs index 0edc0de83..022796a11 100644 --- a/Projects/UOContent/Spells/Eighth/SummonDaemon.cs +++ b/Projects/UOContent/Spells/Eighth/SummonDaemon.cs @@ -1,4 +1,5 @@ using System; +using Server.Misc; using Server.Mobiles; namespace Server.Spells.Eighth @@ -51,6 +52,8 @@ namespace Server.Spells.Eighth }; SpellHelper.Summon(new SummonedDaemon(), Caster, 0x216, duration, false, false); + + Titles.AwardKarma(Caster, -70, true); } FinishSequence();