From be18beebd059ec39ce347e893a45e426f9c8ce30 Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 16:32:55 +0000 Subject: [PATCH 01/42] Menu lines no longer show their key twice Ten labels carried their own key marker while the code writing them added one too: - the Inn's patron list: "[0] [0] Return to inn menu"; - the screen-reader main menu's language line: "L. L. Language"; - an inventory item's Equip, Drop and Back options: "[E] [E] Equip Item"; - the Dark Alley's loaded-dice calls: "[1] [1] Call now". Each label was used only there, so the marker is removed from the text, in all five languages. A test checks every place where the code writes the marker, so a label carrying one again fails. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Localization/en.json | 20 +++++++------- Localization/es.json | 20 +++++++------- Localization/fr.json | 20 +++++++------- Localization/hu.json | 20 +++++++------- Localization/it.json | 20 +++++++------- Tests/MenuMarkerTests.cs | 57 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 107 insertions(+), 50 deletions(-) create mode 100644 Tests/MenuMarkerTests.cs diff --git a/Localization/en.json b/Localization/en.json index d4b41cad..0a3cb9d3 100644 --- a/Localization/en.json +++ b/Localization/en.json @@ -16292,7 +16292,7 @@ "inn.patrons_matching": "Patrons matching \"{0}\":", "inn.patrons_no_match": "Nobody here answers to \"{0}\".", "inn.patrons_no_number": "There is no patron {0}; choose 1 to {1}.", - "inn.return_to_menu": "[0] Return to inn menu", + "inn.return_to_menu": "Return to inn menu", "inn.choose_patron": "Choose someone to approach (0-8): ", "inn.interacting_with": "Interacting with {0}", "inn.alignment_kindred": " Alignment: Kindred spirits (excellent rapport)", @@ -16345,10 +16345,10 @@ "inventory.manage_item": "MANAGE ITEM", "inventory.value": "Value", "inventory.type": "Type", - "inventory.equip_item": " [E] Equip Item", - "inventory.equip_identify_first": " [E] Equip Item (must identify first)", - "inventory.drop_item": " [D] Drop Item", - "inventory.back": " [Q] Back", + "inventory.equip_item": "Equip Item", + "inventory.equip_identify_first": "Equip Item (must identify first)", + "inventory.drop_item": "Drop Item", + "inventory.back": "Back", "inventory.cant_equip_unidentified": "You can't equip an unidentified item.", "inventory.visit_magic_shop": "Take it to the Magic Shop to have it identified first.", "inventory.cursed_cant_drop": "The {0} is CURSED! You cannot drop it.", @@ -16765,7 +16765,7 @@ "street_encounter.romance.admirer_dashing": "Dashing rogue", "street_encounter.romance.admirer_mysterious_m": "Mysterious man", "street_encounter.romance.admirer_charming_m": "Charming gentleman", - "engine.main_language_sr": " L. Language ({0})", + "engine.main_language_sr": "Language ({0})", "engine.main_language_visual": "Language ({0})", "item.dagger": "Dagger", "item.stiletto": "Stiletto", @@ -23839,11 +23839,11 @@ "dark_alley.bm_gear_purchased": "Bought: {0} for {1} gold.", "dark_alley.spot_intro": "The dealer rolls. The dice clatter loud and false. Watch the pattern. Call when you've seen enough.", "dark_alley.spot_round_result": "Round {0}: {1} and {2} -- total {3}.", - "dark_alley.spot_call_now": "[1] Call now ({0}x payout, DC {1} to read)", - "dark_alley.spot_wait": "[2] Wait another round", + "dark_alley.spot_call_now": "Call now ({0}x payout, DC {1} to read)", + "dark_alley.spot_wait": "Wait another round", "dark_alley.spot_which_way": "Which way are the dice loaded?", - "dark_alley.spot_loaded_high": "[1] High (8-12)", - "dark_alley.spot_loaded_low": "[2] Low (2-6)", + "dark_alley.spot_loaded_high": "High (8-12)", + "dark_alley.spot_loaded_low": "Low (2-6)", "dark_alley.spot_check_line": "Read: d20({0}) + WIS({1}) = {2} vs DC {3}.", "dark_alley.spot_reveal_high": "The dealer's hand drops a weighted die. The bias was HIGH.", "dark_alley.spot_reveal_low": "The dealer's hand drops a weighted die. The bias was LOW.", diff --git a/Localization/es.json b/Localization/es.json index ac8c338c..3638e893 100644 --- a/Localization/es.json +++ b/Localization/es.json @@ -16106,7 +16106,7 @@ "inn.patrons_matching": "Parroquianos que coinciden con \"{0}\":", "inn.patrons_no_match": "Nadie aquí responde al nombre \"{0}\".", "inn.patrons_no_number": "No hay parroquiano {0}; elige de 1 a {1}.", - "inn.return_to_menu": "[0] Volver al menu de la posada", + "inn.return_to_menu": "Volver al menu de la posada", "inn.choose_patron": "Elige a quien acercarte (0-8): ", "inn.interacting_with": "Interactuando con {0}", "inn.alignment_kindred": " Alineación: Almas afines (excelente afinidad)", @@ -16159,10 +16159,10 @@ "inventory.manage_item": "ADMINISTRAR OBJETO", "inventory.value": "Valor", "inventory.type": "Tipo", - "inventory.equip_item": " [E] Equipar Objeto", - "inventory.equip_identify_first": " [E] Equipar Objeto (identificar primero)", - "inventory.drop_item": " [D] Soltar Objeto", - "inventory.back": " [Q] Volver", + "inventory.equip_item": "Equipar Objeto", + "inventory.equip_identify_first": "Equipar Objeto (identificar primero)", + "inventory.drop_item": "Soltar Objeto", + "inventory.back": "Volver", "inventory.cant_equip_unidentified": "No puedes equipar un objeto sin identificar.", "inventory.visit_magic_shop": "Llevalo a la Tienda Mágica para identificarlo primero.", "inventory.cursed_cant_drop": "El {0} está MALDITO! No puedes soltarlo.", @@ -16579,7 +16579,7 @@ "street_encounter.romance.admirer_dashing": "Picaro audaz", "street_encounter.romance.admirer_mysterious_m": "Hombre misterioso", "street_encounter.romance.admirer_charming_m": "Caballero encantador", - "engine.main_language_sr": " L. Idioma ({0})", + "engine.main_language_sr": "Idioma ({0})", "engine.main_language_visual": "Idioma ({0})", "item.dagger": "Daga", "item.stiletto": "Estilete", @@ -23407,11 +23407,11 @@ "dark_alley.bm_gear_purchased": "Comprado: {0} por {1} de oro.", "dark_alley.spot_intro": "El crupier tira. Los dados repiquetean, sonoros y traicioneros. Observa el patrón. Canta cuando hayas visto suficiente.", "dark_alley.spot_round_result": "Ronda {0}: {1} y {2}, total {3}.", - "dark_alley.spot_call_now": "[1] Cantar ahora (pago x{0}, CD {1} para leerlo)", - "dark_alley.spot_wait": "[2] Esperar otra ronda", + "dark_alley.spot_call_now": "Cantar ahora (pago x{0}, CD {1} para leerlo)", + "dark_alley.spot_wait": "Esperar otra ronda", "dark_alley.spot_which_way": "¿Hacia qué lado están cargados los dados?", - "dark_alley.spot_loaded_high": "[1] Altos (8-12)", - "dark_alley.spot_loaded_low": "[2] Bajos (2-6)", + "dark_alley.spot_loaded_high": "Altos (8-12)", + "dark_alley.spot_loaded_low": "Bajos (2-6)", "dark_alley.spot_check_line": "Lectura: d20({0}) + SAB({1}) = {2} contra CD {3}.", "dark_alley.spot_reveal_high": "La mano del crupier deja caer un dado cargado. La carga era ALTA.", "dark_alley.spot_reveal_low": "La mano del crupier deja caer un dado cargado. La carga era BAJA.", diff --git a/Localization/fr.json b/Localization/fr.json index 59ca8736..36b91ce9 100644 --- a/Localization/fr.json +++ b/Localization/fr.json @@ -16095,7 +16095,7 @@ "inn.patrons_matching": "Clients correspondant à « {0} » :", "inn.patrons_no_match": "Personne ici ne répond au nom de « {0} ».", "inn.patrons_no_number": "Il n'y a pas de client {0} ; choisissez de 1 à {1}.", - "inn.return_to_menu": "[0] Retour au menu de l’auberge", + "inn.return_to_menu": "Retour au menu de l’auberge", "inn.choose_patron": "Choisissez quelqu’un à approcher (0-8): ", "inn.interacting_with": "Interaction avec {0}", "inn.alignment_kindred": " Alignement : Âmes apparentées (excellent rapport)", @@ -16148,10 +16148,10 @@ "inventory.manage_item": "GÉRER L’OBJET", "inventory.value": "Valeur", "inventory.type": "Type", - "inventory.equip_item": " [E] Équiper l’objet", - "inventory.equip_identify_first": " [E] Équiper l’objet (identification requise)", - "inventory.drop_item": " [D] Laisser tomber", - "inventory.back": " [Q] Retour", + "inventory.equip_item": "Équiper l’objet", + "inventory.equip_identify_first": "Équiper l’objet (identification requise)", + "inventory.drop_item": "Laisser tomber", + "inventory.back": "Retour", "inventory.cant_equip_unidentified": "Vous ne pouvez pas équiper un objet non identifié.", "inventory.visit_magic_shop": "Apportez‑le à la Boutique de Magie pour l’identifier.", "inventory.cursed_cant_drop": "Le/La {0} est MAUDIT(E)! Impossible de le/la laisser tomber.", @@ -16568,7 +16568,7 @@ "street_encounter.romance.admirer_dashing": "Vaurien séduisant", "street_encounter.romance.admirer_mysterious_m": "Homme mystérieux", "street_encounter.romance.admirer_charming_m": "Gentleman charmant", - "engine.main_language_sr": " L. Langue ({0})", + "engine.main_language_sr": "Langue ({0})", "engine.main_language_visual": "Langue ({0})", "item.dagger": "Dague", "item.stiletto": "Stylet", @@ -23396,11 +23396,11 @@ "dark_alley.bm_gear_purchased": "Acheté : {0} pour {1} pièces d'or.", "dark_alley.spot_intro": "Le croupier lance. Les dés claquent fort et faux. Observe le motif. Annonce quand tu en auras assez vu.", "dark_alley.spot_round_result": "Manche {0} : {1} et {2} -- total {3}.", - "dark_alley.spot_call_now": "[1] Annoncer maintenant (gain x{0}, DD {1} pour lire)", - "dark_alley.spot_wait": "[2] Attendre une manche de plus", + "dark_alley.spot_call_now": "Annoncer maintenant (gain x{0}, DD {1} pour lire)", + "dark_alley.spot_wait": "Attendre une manche de plus", "dark_alley.spot_which_way": "De quel côté les dés sont-ils pipés ?", - "dark_alley.spot_loaded_high": "[1] Haut (8-12)", - "dark_alley.spot_loaded_low": "[2] Bas (2-6)", + "dark_alley.spot_loaded_high": "Haut (8-12)", + "dark_alley.spot_loaded_low": "Bas (2-6)", "dark_alley.spot_check_line": "Lecture : d20({0}) + SAG({1}) = {2} contre DD {3}.", "dark_alley.spot_reveal_high": "La main du croupier laisse tomber un dé lesté. Le biais était HAUT.", "dark_alley.spot_reveal_low": "La main du croupier laisse tomber un dé lesté. Le biais était BAS.", diff --git a/Localization/hu.json b/Localization/hu.json index 1ca8222b..4c99b42c 100644 --- a/Localization/hu.json +++ b/Localization/hu.json @@ -16118,7 +16118,7 @@ "inn.patrons_matching": "A(z) \"{0}\" névre illő vendégek:", "inn.patrons_no_match": "Senki sem hallgat itt a(z) \"{0}\" névre.", "inn.patrons_no_number": "Nincs {0}. számú vendég; válassz 1 és {1} között.", - "inn.return_to_menu": "[0] Vissza a fogadó menüjéhez", + "inn.return_to_menu": "Vissza a fogadó menüjéhez", "inn.choose_patron": "Válassz valakit (0-8): ", "inn.interacting_with": "Társalgás vele: {0}", "inn.alignment_kindred": " Beállítottság: Rokonlelkek (kiváló egyetértés)", @@ -16171,10 +16171,10 @@ "inventory.manage_item": "TÁRGY KEZELÉSE", "inventory.value": "Érték", "inventory.type": "Típus", - "inventory.equip_item": " [E] Tárgy használata", - "inventory.equip_identify_first": " [E] Használat (előbb azonosítás kell)", - "inventory.drop_item": " [D] Tárgy eldobása", - "inventory.back": " [Q] Vissza", + "inventory.equip_item": "Tárgy használata", + "inventory.equip_identify_first": "Használat (előbb azonosítás kell)", + "inventory.drop_item": "Tárgy eldobása", + "inventory.back": "Vissza", "inventory.cant_equip_unidentified": "Nem használhatsz ismeretlen tárgyat.", "inventory.visit_magic_shop": "Vidd el a Mágia Boltba azonosításra.", "inventory.cursed_cant_drop": "A(z) {0} ELÁTKOZOTT! Nem válhatsz meg tőle.", @@ -16591,7 +16591,7 @@ "street_encounter.romance.admirer_dashing": "Rendíthetetlen zsivány", "street_encounter.romance.admirer_mysterious_m": "Titokzatos férfi", "street_encounter.romance.admirer_charming_m": "Bájos úriember", - "engine.main_language_sr": " Nyelv ({0})", + "engine.main_language_sr": "Nyelv ({0})", "engine.main_language_visual": "Nyelv ({0})", "item.dagger": "Tőr", "item.stiletto": "Stiletto", @@ -23700,11 +23700,11 @@ "dark_alley.bm_gear_purchased": "Megvéve: {0}, ára {1} arany.", "dark_alley.spot_intro": "A kereskedő dob. A kockák hangosan és hamisan csörögnek. Figyeld a mintát. Szólj, ha eleget láttál.", "dark_alley.spot_round_result": "{0}. kör: {1} és {2} -- összesen {3}.", - "dark_alley.spot_call_now": "[1] Tipp most ({0}x nyeremény, DC {1} a kiolvasáshoz)", - "dark_alley.spot_wait": "[2] Várj még egy kört", + "dark_alley.spot_call_now": "Tipp most ({0}x nyeremény, DC {1} a kiolvasáshoz)", + "dark_alley.spot_wait": "Várj még egy kört", "dark_alley.spot_which_way": "Melyik irányba vannak cinkelve a kockák?", - "dark_alley.spot_loaded_high": "[1] Magas (8-12)", - "dark_alley.spot_loaded_low": "[2] Alacsony (2-6)", + "dark_alley.spot_loaded_high": "Magas (8-12)", + "dark_alley.spot_loaded_low": "Alacsony (2-6)", "dark_alley.spot_check_line": "Olvasás: d20({0}) + BÖL({1}) = {2} vs DC {3}.", "dark_alley.spot_reveal_high": "A kereskedő keze egy súlyozott kockát ejt. A csalás MAGAS volt.", "dark_alley.spot_reveal_low": "A kereskedő keze egy súlyozott kockát ejt. A csalás ALACSONY volt.", diff --git a/Localization/it.json b/Localization/it.json index cd4e7397..20a9870e 100644 --- a/Localization/it.json +++ b/Localization/it.json @@ -16095,7 +16095,7 @@ "inn.patrons_matching": "Avventori che corrispondono a \"{0}\":", "inn.patrons_no_match": "Nessuno qui risponde al nome \"{0}\".", "inn.patrons_no_number": "Non c'è l'avventore {0}; scegli da 1 a {1}.", - "inn.return_to_menu": "[0] Torna al menu della locanda", + "inn.return_to_menu": "Torna al menu della locanda", "inn.choose_patron": "Scegli qualcuno da avvicinare (0-8): ", "inn.interacting_with": "Interazione con {0}", "inn.alignment_kindred": " Allineamento: Spiriti affini (ottimo rapporto)", @@ -16148,10 +16148,10 @@ "inventory.manage_item": "GESTISCI OGGETTO", "inventory.value": "Valore", "inventory.type": "Tipo", - "inventory.equip_item": " [E] Equipaggia Oggetto", - "inventory.equip_identify_first": " [E] Equipaggia Oggetto (identifica prima)", - "inventory.drop_item": " [D] Getta Oggetto", - "inventory.back": " [Q] Indietro", + "inventory.equip_item": "Equipaggia Oggetto", + "inventory.equip_identify_first": "Equipaggia Oggetto (identifica prima)", + "inventory.drop_item": "Getta Oggetto", + "inventory.back": "Indietro", "inventory.cant_equip_unidentified": "Non puoi equipaggiare un oggetto non identificato.", "inventory.visit_magic_shop": "Portalo al Negozio Magico per farlo identificare.", "inventory.cursed_cant_drop": "Il {0} è MALEDETTO! Non puoi gettarlo.", @@ -16568,7 +16568,7 @@ "street_encounter.romance.admirer_dashing": "Affascinante mascalzone", "street_encounter.romance.admirer_mysterious_m": "Uomo misterioso", "street_encounter.romance.admirer_charming_m": "Gentiluomo affascinante", - "engine.main_language_sr": " L. Lingua ({0})", + "engine.main_language_sr": "Lingua ({0})", "engine.main_language_visual": "Lingua ({0})", "item.dagger": "Pugnale", "item.stiletto": "Stiletto", @@ -23396,11 +23396,11 @@ "dark_alley.bm_gear_purchased": "Comprato: {0} per {1} oro.", "dark_alley.spot_intro": "Il banchiere tira. I dadi rimbalzano forti e falsi. Studia lo schema. Chiama quando ne hai visto abbastanza.", "dark_alley.spot_round_result": "Mano {0}: {1} e {2} -- totale {3}.", - "dark_alley.spot_call_now": "[1] Chiama adesso (pagamento {0}x, CD {1} per leggere)", - "dark_alley.spot_wait": "[2] Aspetta un'altra mano", + "dark_alley.spot_call_now": "Chiama adesso (pagamento {0}x, CD {1} per leggere)", + "dark_alley.spot_wait": "Aspetta un'altra mano", "dark_alley.spot_which_way": "Da che parte sono truccati i dadi?", - "dark_alley.spot_loaded_high": "[1] Alti (8-12)", - "dark_alley.spot_loaded_low": "[2] Bassi (2-6)", + "dark_alley.spot_loaded_high": "Alti (8-12)", + "dark_alley.spot_loaded_low": "Bassi (2-6)", "dark_alley.spot_check_line": "Lettura: d20({0}) + SAG({1}) = {2} contro CD {3}.", "dark_alley.spot_reveal_high": "La mano del banchiere lascia cadere un dado truccato. Il trucco era sugli ALTI.", "dark_alley.spot_reveal_low": "La mano del banchiere lascia cadere un dado truccato. Il trucco era sui BASSI.", diff --git a/Tests/MenuMarkerTests.cs b/Tests/MenuMarkerTests.cs new file mode 100644 index 00000000..a78c1156 --- /dev/null +++ b/Tests/MenuMarkerTests.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; +using FluentAssertions; +using Xunit; + +namespace UsurperReborn.Tests; + +/// +/// v1.1.11: a menu line whose code writes the key marker ("[0] ", "L. ", or WriteSRMenuOption's own +/// "[1]") around a localized label whose text also starts with one showed it twice, e.g. the Inn's +/// "[0] [0] Return to inn menu". Where the code writes the marker, the label must not carry one, in +/// any language. +/// +public class MenuMarkerTests +{ + private static string RepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !(Directory.Exists(Path.Combine(dir.FullName, "Scripts")) && Directory.Exists(Path.Combine(dir.FullName, "Localization")))) + dir = dir.Parent; + dir.Should().NotBeNull("the test must run inside the repository"); + return dir!.FullName; + } + + private static readonly Regex StartsWithMarker = new(@"^\s*(\[[^\]]{1,3}\]|[0-9A-Z]\.)\s"); + + // the code writes the marker: "[X] {Loc.Get("key"" or "X. {Loc.Get("key"" in an interpolated string, + // or the label of WriteSRMenuOption("X", Loc.Get("key" + private static readonly Regex MarkerInCode = new( + "(?:(?:\\[[^\\]\\s\"{}]{1,3}\\]|\\b[0-9A-Z]\\.) ?\\{Loc\\.Get\\(\\s*\"([^\"]+)\")|(?:WriteSRMenuOption\\(\\s*\"[^\"]{1,3}\"\\s*,\\s*Loc\\.Get\\(\\s*\"([^\"]+)\")"); + + [Fact] + public void WhereTheCodeWritesTheMarker_TheLabelDoesNotCarryOne() + { + string root = RepoRoot(); + var languages = new[] { "en", "es", "fr", "hu", "it" }.ToDictionary(l => l, + l => JsonSerializer.Deserialize>(File.ReadAllText(Path.Combine(root, "Localization", l + ".json")))!); + var problems = new List(); + foreach (var file in Directory.EnumerateFiles(Path.Combine(root, "Scripts"), "*.cs", SearchOption.AllDirectories)) + { + string src = File.ReadAllText(file); + foreach (Match m in MarkerInCode.Matches(src)) + { + string key = m.Groups[1].Success ? m.Groups[1].Value : m.Groups[2].Value; + int line = src.Take(m.Index).Count(c => c == '\n') + 1; + foreach (var (lang, table) in languages) + if (table.TryGetValue(key, out var v) && v.ValueKind == JsonValueKind.String && StartsWithMarker.IsMatch(v.GetString()!)) + problems.Add($"{Path.GetRelativePath(root, file)}:{line} {key} ({lang}): \"{v.GetString()}\""); + } + } + problems.Should().BeEmpty(); + } +} From 632912c349e2733f865b04c0e0eb6aa3b9389fac Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 16:48:55 +0000 Subject: [PATCH 02/42] Team rankings count the real members, and empty teams are removed The rankings read a stored member count that was refreshed only when someone opened that team's roster, so teams with players in them showed 0 members. The count is now counted each time the teams are read. A team that no player's save names (banned players included), no NPC carries and no online player has is now removed with the world save, with its upgrades and vault, so a later team of the same name starts fresh. The delete rechecks the player saves in the same statement. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Systems/SqlSaveBackend.cs | 75 ++++++++++++++++++- Scripts/Systems/WorldSimService.cs | 43 +++++++++++ Tests/EmptyTeamCleanupTests.cs | 111 +++++++++++++++++++++++++++++ 3 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 Tests/EmptyTeamCleanupTests.cs diff --git a/Scripts/Systems/SqlSaveBackend.cs b/Scripts/Systems/SqlSaveBackend.cs index aec631b3..f6519a7d 100644 --- a/Scripts/Systems/SqlSaveBackend.cs +++ b/Scripts/Systems/SqlSaveBackend.cs @@ -5445,7 +5445,16 @@ public async Task> GetPlayerTeams() { using var connection = OpenConnection(); using var cmd = connection.CreateCommand(); - cmd.CommandText = "SELECT team_name, created_by, member_count, controls_turf, created_at FROM player_teams ORDER BY member_count DESC;"; + // v1.1.11: the member count is counted here; the stored column was refreshed only when someone + // opened that team's roster, so the rankings showed teams with players in them as empty + cmd.CommandText = @" + SELECT t.team_name, t.created_by, + (SELECT COUNT(*) FROM players p + WHERE (CASE WHEN json_valid(p.player_data) THEN json_extract(p.player_data, '$.player.team') END) = t.team_name + AND p.player_data != '{}' AND LENGTH(p.player_data) > 2 + AND p.is_banned = 0 AND p.username NOT LIKE 'emergency_%') AS members, + t.controls_turf, t.created_at + FROM player_teams t ORDER BY members DESC;"; using var reader = await Task.Run(() => cmd.ExecuteReader()); while (reader.Read()) { @@ -5526,6 +5535,70 @@ AND p.username NOT LIKE 'emergency_%' return members; } + /// + /// v1.1.11: player teams that no player's save names, banned players included (a ban can be lifted). + /// Whether an NPC or an online player still carries the name is for the caller to check + /// (WorldSimService.PruneEmptyTeams); only it knows the live roster. + /// + public List GetTeamsWithoutPlayerMembers() + { + var teams = new List(); + try + { + using var connection = OpenConnection(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" + SELECT t.team_name FROM player_teams t + WHERE NOT EXISTS (SELECT 1 FROM players p + WHERE (CASE WHEN json_valid(p.player_data) THEN json_extract(p.player_data, '$.player.team') END) = t.team_name);"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) teams.Add(reader.GetString(0)); + } + catch (Exception ex) + { + DebugLogger.Instance.LogError("SQL", $"Failed to list teams without player members: {ex.Message}"); + } + return teams; + } + + /// + /// v1.1.11: removes a team nobody is in, with its upgrades and vault, in one transaction; only if no + /// player's save names it at the moment of the delete. True when it was removed. + /// + public bool DeleteEmptyTeam(string teamName) + { + try + { + using var connection = OpenConnection(); + using var tx = connection.BeginTransaction(); + using (var team = connection.CreateCommand()) + { + team.Transaction = tx; + team.CommandText = @" + DELETE FROM player_teams WHERE team_name = @team + AND NOT EXISTS (SELECT 1 FROM players p + WHERE (CASE WHEN json_valid(p.player_data) THEN json_extract(p.player_data, '$.player.team') END) = @team);"; + team.Parameters.AddWithValue("@team", teamName); + if (team.ExecuteNonQuery() != 1) return false; + } + foreach (var table in new[] { "team_upgrades", "team_vault" }) + { + using var rest = connection.CreateCommand(); + rest.Transaction = tx; + rest.CommandText = $"DELETE FROM {table} WHERE team_name = @team;"; + rest.Parameters.AddWithValue("@team", teamName); + rest.ExecuteNonQuery(); + } + tx.Commit(); + return true; + } + catch (Exception ex) + { + DebugLogger.Instance.LogError("SQL", $"Failed to remove empty team '{teamName}': {ex.Message}"); + return false; + } + } + public async Task DeletePlayerTeam(string teamName) { try diff --git a/Scripts/Systems/WorldSimService.cs b/Scripts/Systems/WorldSimService.cs index 5c53456b..cc679578 100644 --- a/Scripts/Systems/WorldSimService.cs +++ b/Scripts/Systems/WorldSimService.cs @@ -566,6 +566,9 @@ private async Task SaveWorldState() // Clean up orphaned data from deleted players await sqlBackend.PruneOrphanedPlayerData(); + + // v1.1.11: teams nobody is in any more + PruneEmptyTeams(); } catch (Exception ex) { @@ -1392,6 +1395,46 @@ private void LoadSettlementState() } } + /// + /// v1.1.11: removes player teams that nobody is in: no player's save names the team, no NPC + /// (living or dead) carries it, and no player online has it in hand, e.g. between joining and + /// the save that records it. The team's upgrades and vault go with it. + /// + internal int PruneEmptyTeams() + { + int removed = 0; + try + { + var npcTeams = new HashSet(NPCSpawnSystem.Instance.ActiveNPCs + .Where(n => !string.IsNullOrEmpty(n.Team)).Select(n => n.Team!)); + var sessions = UsurperRemake.Server.MudServer.Instance?.ActiveSessions.Values; + var onlineTeams = new HashSet(sessions == null ? Enumerable.Empty() + : sessions.Select(s => s.Context?.Player?.Team).Where(t => !string.IsNullOrEmpty(t)).Select(t => t!)); + var gone = new List(); + foreach (var team in sqlBackend.GetTeamsWithoutPlayerMembers()) + { + if (npcTeams.Contains(team) || onlineTeams.Contains(team)) continue; + if (!sqlBackend.DeleteEmptyTeam(team)) continue; + gone.Add(team); + DebugLogger.Instance.LogInfo("WORLDSIM", $"Removed empty team '{team}'"); + } + removed = gone.Count; + if (removed > 0) + { + // the protection list ignores case: keep a name another team or an NPC still has + var kept = sqlBackend.GetPlayerTeams().GetAwaiter().GetResult().Select(t => t.TeamName) + .Concat(npcTeams).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var team in gone) + if (!kept.Contains(team)) WorldSimulator.UnregisterPlayerTeam(team); + } + } + catch (Exception ex) + { + DebugLogger.Instance.LogError("WORLDSIM", $"Failed to prune empty teams: {ex.Message}"); + } + return removed; + } + /// /// Load all player team names from player_teams table so WorldSimulator /// can protect them from NPC AI dissolution and unauthorized modification. diff --git a/Tests/EmptyTeamCleanupTests.cs b/Tests/EmptyTeamCleanupTests.cs new file mode 100644 index 00000000..f4f36fa7 --- /dev/null +++ b/Tests/EmptyTeamCleanupTests.cs @@ -0,0 +1,111 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using UsurperRemake.Systems; +using Xunit; + +namespace UsurperReborn.Tests; + +/// +/// v1.1.11: the team rankings read a stored member count that was refreshed only when someone opened +/// that team's roster, so teams with players in them were listed with 0 members; and a team everyone +/// had left stayed listed for ever. The count is now counted when the teams are read, and a team that +/// no player's save, no NPC and no online player names is removed, with its upgrades and vault. +/// +[Collection("SharedGameSingletons")] +public class EmptyTeamCleanupTests : IDisposable +{ + private readonly string _path = Path.Combine(Path.GetTempPath(), $"usurper-etc-{Guid.NewGuid():N}.db"); + private readonly SqlSaveBackend _db; + + public EmptyTeamCleanupTests() { _db = new SqlSaveBackend(_path); } + + public void Dispose() + { + SqliteConnection.ClearAllPools(); + try { File.Delete(_path); } catch { } + } + + private void Exec(string sql) + { + using var conn = new SqliteConnection($"Data Source={_path}"); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } + + private int Count(string sql) + { + using var conn = new SqliteConnection($"Data Source={_path}"); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + return Convert.ToInt32(cmd.ExecuteScalar()); + } + + private void Player(string key, string? team, bool banned = false) => + Exec($"INSERT INTO players (username, display_name, player_data, is_banned) VALUES ('{key}', '{key}', " + + $"'{{\"player\":{{\"level\":10{(team != null ? $",\"team\":\"{team}\"" : "")}}}}}', {(banned ? 1 : 0)});"); + + private void Team(string name, int storedCount = 0) => + Exec($"INSERT INTO player_teams (team_name, password_hash, created_by, member_count) VALUES ('{name}', 'x', 'founder', {storedCount});"); + + [Fact] + public async Task TheRankings_CountTheMembers_NotTheStoredNumber() + { + Player("bran", "Black Band"); + Player("tomas", "Black Band"); + Team("Black Band", storedCount: 0); // the stale column that showed 0 + var team = (await _db.GetPlayerTeams()).Single(t => t.TeamName == "Black Band"); + team.MemberCount.Should().Be(2); + } + + [Fact] + public void OnlyATeamNoPlayersSaveNames_IsACandidate_AndABannedMemberStillCounts() + { + Player("bran", "Kept"); + Player("exiled", "Banned Only", banned: true); + Team("Kept"); + Team("Banned Only"); + Team("Nobody Left"); + _db.GetTeamsWithoutPlayerMembers().Should().BeEquivalentTo(new[] { "Nobody Left" }); + } + + [Fact] + public void RemovingAnEmptyTeam_TakesItsUpgradesAndVault_AndOnlyIfStillEmpty() + { + Team("Nobody Left"); + Exec("INSERT INTO team_upgrades (team_name, upgrade_type, level) VALUES ('Nobody Left', 'armory', 2);"); + Exec("INSERT INTO team_vault (team_name, gold) VALUES ('Nobody Left', 100);"); + Team("Rejoined"); + Player("tomas", "Rejoined"); // joined after the list was read + + _db.DeleteEmptyTeam("Rejoined").Should().BeFalse("a player's save names it"); + Count("SELECT COUNT(*) FROM player_teams WHERE team_name = 'Rejoined'").Should().Be(1); + + _db.DeleteEmptyTeam("Nobody Left").Should().BeTrue(); + Count("SELECT COUNT(*) FROM player_teams WHERE team_name = 'Nobody Left'").Should().Be(0); + Count("SELECT COUNT(*) FROM team_upgrades WHERE team_name = 'Nobody Left'").Should().Be(0, "a new team of that name must not inherit them"); + Count("SELECT COUNT(*) FROM team_vault WHERE team_name = 'Nobody Left'").Should().Be(0); + } + + [Fact] + public void TheWorldSave_RemovesEmptyTeams_ButNotOneAnNPCIsIn() + { + Team("Nobody Left"); + Team("NPC Held"); + var npc = new NPC { ID = "npc_empty_team_test", Name1 = "Hold", Name2 = "Hold", Team = "NPC Held", Level = 5, HP = 50, MaxHP = 50 }; + NPCSpawnSystem.Instance.ActiveNPCs.Add(npc); + try + { + new WorldSimService(_db).PruneEmptyTeams().Should().Be(1); + Count("SELECT COUNT(*) FROM player_teams WHERE team_name = 'Nobody Left'").Should().Be(0); + Count("SELECT COUNT(*) FROM player_teams WHERE team_name = 'NPC Held'").Should().Be(1, "an NPC is still on the team"); + } + finally { NPCSpawnSystem.Instance.ActiveNPCs.Remove(npc); } + } +} From f5b9760b1c31f4880f5f7509b6186178d62212fa Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 16:54:58 +0000 Subject: [PATCH 03/42] Team HQ levels are the team's current ones, read when they matter The levels were read at login and after the buyer's own upgrade only, so a player who joined a team had no bonus until the next login, a teammate's upgrade reached nobody else, and a player who left a team kept the bonus for the rest of the session. TeamHQBonus now holds the four bonuses and reads the team's levels in one query. They are read at login, on create and join, after an upgrade, and at the start of every fight (both duellists, a defender's by its own team). They count only while their team is still the player's team, and never go into the save. The existing readers take their level from it; the bonuses' reach is unchanged here. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Core/Character.cs | 2 + Scripts/Core/GameEngine.cs | 11 +-- Scripts/Locations/TeamCornerLocation.cs | 8 +- Scripts/Systems/CombatEngine.cs | 50 ++++++---- Scripts/Systems/SqlSaveBackend.cs | 20 ++++ Scripts/Systems/TeamHQBonus.cs | 68 ++++++++++++++ Tests/TeamHQBonusTests.cs | 118 ++++++++++++++++++++++++ 7 files changed, 245 insertions(+), 32 deletions(-) create mode 100644 Scripts/Systems/TeamHQBonus.cs create mode 100644 Tests/TeamHQBonusTests.cs diff --git a/Scripts/Core/Character.cs b/Scripts/Core/Character.cs index 7251df8b..465fca2b 100644 --- a/Scripts/Core/Character.cs +++ b/Scripts/Core/Character.cs @@ -594,6 +594,8 @@ public void AddMaterial(string materialId, int count = 1) public int HQBarracksLevel { get; set; } // +5% defense per level public int HQTrainingLevel { get; set; } // +5% XP per level public int HQInfirmaryLevel { get; set; } // +10% healing per level + /// v1.1.11: the team the HQ levels above were read for; they count only while it is still the character's team (TeamHQBonus). + public string HQLevelsTeam { get; set; } = ""; // Herb pouch inventory (v0.48.5) public int HerbHealing { get; set; } // Healing Herbs (garden lv1) diff --git a/Scripts/Core/GameEngine.cs b/Scripts/Core/GameEngine.cs index 42d70e34..7783918f 100644 --- a/Scripts/Core/GameEngine.cs +++ b/Scripts/Core/GameEngine.cs @@ -6269,16 +6269,7 @@ private Character RestorePlayerFromSaveDataCore(PlayerData playerData) // Cache team HQ upgrade levels for combat bonuses (online mode only) if (UsurperRemake.BBS.DoorMode.IsOnlineMode) - { - var hqBackend = SaveSystem.Instance.Backend as SqlSaveBackend; - if (hqBackend != null) - { - player.HQArmoryLevel = hqBackend.GetTeamUpgradeLevel(player.Team, "armory"); - player.HQBarracksLevel = hqBackend.GetTeamUpgradeLevel(player.Team, "barracks"); - player.HQTrainingLevel = hqBackend.GetTeamUpgradeLevel(player.Team, "training"); - player.HQInfirmaryLevel = hqBackend.GetTeamUpgradeLevel(player.Team, "infirmary"); - } - } + TeamHQBonus.RefreshLevels(player); } // Apply player's color theme preference diff --git a/Scripts/Locations/TeamCornerLocation.cs b/Scripts/Locations/TeamCornerLocation.cs index e1ab86fa..91a7dd92 100644 --- a/Scripts/Locations/TeamCornerLocation.cs +++ b/Scripts/Locations/TeamCornerLocation.cs @@ -963,6 +963,7 @@ private async Task CreateTeam() currentPlayer.Team = teamName; currentPlayer.TeamPW = password; currentPlayer.CTurf = false; + TeamHQBonus.RefreshLevels(currentPlayer); // v1.1.11 currentPlayer.TeamRec = 0; await PersistTeamMembershipChange(); @@ -1056,6 +1057,7 @@ private async Task JoinTeam() currentPlayer.Team = teamName; currentPlayer.TeamPW = password; currentPlayer.CTurf = false; + TeamHQBonus.RefreshLevels(currentPlayer); // v1.1.11: the team's upgrades count from joining await PersistTeamMembershipChange(); WorldSimulator.RegisterPlayerTeam(teamName); @@ -1111,6 +1113,7 @@ private async Task JoinTeam() currentPlayer.Team = teamName; currentPlayer.TeamPW = npcPassword; currentPlayer.CTurf = teamMember.CTurf; + TeamHQBonus.RefreshLevels(currentPlayer); // v1.1.11 await PersistTeamMembershipChange(); WorldSimulator.RegisterPlayerTeam(teamName); @@ -3572,10 +3575,7 @@ private async Task UpgradeFacility(SqlSaveBackend backend, string teamName) terminal.WriteLine(Loc.Get("team.facility_upgraded", Loc.Get(def.NameKey), currentLevel + 1)); // Refresh cached HQ upgrade levels on the player - currentPlayer.HQArmoryLevel = backend.GetTeamUpgradeLevel(teamName, "armory"); - currentPlayer.HQBarracksLevel = backend.GetTeamUpgradeLevel(teamName, "barracks"); - currentPlayer.HQTrainingLevel = backend.GetTeamUpgradeLevel(teamName, "training"); - currentPlayer.HQInfirmaryLevel = backend.GetTeamUpgradeLevel(teamName, "infirmary"); + TeamHQBonus.RefreshLevels(currentPlayer, backend); await Task.Delay(2000); } diff --git a/Scripts/Systems/CombatEngine.cs b/Scripts/Systems/CombatEngine.cs index 899fb36b..6f26cf22 100644 --- a/Scripts/Systems/CombatEngine.cs +++ b/Scripts/Systems/CombatEngine.cs @@ -630,6 +630,12 @@ public async Task PlayerVsPlayer(Character attacker, Character def CombatLog = new List() }; _combatOwner = attacker; + if (DoorMode.IsOnlineMode) + { + // v1.1.11: each fighter's own Team HQ levels, the defender's read by its team from the database + TeamHQBonus.RefreshLevels(attacker); + TeamHQBonus.RefreshLevels(defender); + } // v1.1.1: EndPvPCombat (buff consumption, disarm restore, scrub) runs in the finally // so a thrown disconnect mid-duel cannot leave either side with stale combat state. try @@ -825,6 +831,14 @@ public async Task PlayerVsMonsters( // Store player reference for combat speed setting currentPlayer = player; + // v1.1.11: the Team HQ levels of this moment (a teammate may have upgraded, or the player changed team) + if (DoorMode.IsOnlineMode) + { + TeamHQBonus.RefreshLevels(player); + if (teammates != null) + foreach (var mate in teammates.Where(t => t is not NPC)) TeamHQBonus.RefreshLevels(mate); + } + // v0.65.6 Death's Door: once-per-combat burst rescue resets at combat start. player.DeathsDoorUsedThisCombat = false; player.DeathsDoorFiredThisRound = false; @@ -3829,9 +3843,9 @@ private async Task ExecuteSingleAttack(Character attacker, Monster target, Comba } // Team HQ Armory bonus: +5% attack per level - if (attacker.HQArmoryLevel > 0) + if (TeamHQBonus.Armory(attacker) > 0) { - attackPower += (long)(attackPower * (attacker.HQArmoryLevel * 0.05)); + attackPower += (long)(attackPower * (TeamHQBonus.Armory(attacker) * 0.05)); } // Knighthood bonus: +5% damage for knighted players @@ -4153,8 +4167,8 @@ private async Task ExecuteHeal(Character player, CombatResult result, bool quick long healAmount = 30 + player.Level * 5 + random.Next(10, 30); if (player.Class == CharacterClass.Alchemist) healAmount = (long)(healAmount * (1.0 + GameConfig.AlchemistPotionMasteryBonus)); - if (player.HQInfirmaryLevel > 0) - healAmount = (long)(healAmount * (1.0 + player.HQInfirmaryLevel * 0.10)); + if (TeamHQBonus.Infirmary(player) > 0) + healAmount = (long)(healAmount * (1.0 + TeamHQBonus.Infirmary(player) * 0.10)); healAmount = DifficultySystem.ApplyHealingMultiplier(healAmount); healAmount = Math.Min(healAmount, player.MaxHP - player.HP); player.HP += healAmount; @@ -4197,8 +4211,8 @@ private async Task ExecuteHeal(Character player, CombatResult result, bool quick long healAmount = 30 + player.Level * 5 + random.Next(10, 30); if (player.Class == CharacterClass.Alchemist) healAmount = (long)(healAmount * (1.0 + GameConfig.AlchemistPotionMasteryBonus)); - if (player.HQInfirmaryLevel > 0) - healAmount = (long)(healAmount * (1.0 + player.HQInfirmaryLevel * 0.10)); + if (TeamHQBonus.Infirmary(player) > 0) + healAmount = (long)(healAmount * (1.0 + TeamHQBonus.Infirmary(player) * 0.10)); healAmount = DifficultySystem.ApplyHealingMultiplier(healAmount); healAmount = Math.Min(healAmount, player.MaxHP - player.HP); player.HP += healAmount; @@ -5309,9 +5323,9 @@ private async Task ProcessMonsterAction(Monster monster, Character player, Comba } // Team HQ Barracks bonus: +5% defense per level - if (player.HQBarracksLevel > 0) + if (TeamHQBonus.Barracks(player) > 0) { - playerDefense += (long)(playerDefense * (player.HQBarracksLevel * 0.05)); + playerDefense += (long)(playerDefense * (TeamHQBonus.Barracks(player) * 0.05)); } // Knighthood bonus: +5% defense for knighted players @@ -7216,9 +7230,9 @@ private async Task HandleVictory(CombatResult result) } // Team HQ Training bonus: +5% XP per level - if (result.Player.HQTrainingLevel > 0) + if (TeamHQBonus.Training(result.Player) > 0) { - expReward += (long)(expReward * (result.Player.HQTrainingLevel * 0.05)); + expReward += (long)(expReward * (TeamHQBonus.Training(result.Player) * 0.05)); } // Fatigue XP penalty — Exhausted tier only (single-player only) @@ -12994,8 +13008,8 @@ private long ComputePlayerSwingDamage(Character player, Monster target, bool isO attackPower += (long)(attackPower * GameConfig.PoisonCoatingDamageBonus); if (player.PermanentDamageBonus > 0) attackPower += (long)(attackPower * (player.PermanentDamageBonus / 100.0)); - if (player.HQArmoryLevel > 0) - attackPower += (long)(attackPower * (player.HQArmoryLevel * 0.05)); + if (TeamHQBonus.Armory(player) > 0) + attackPower += (long)(attackPower * (TeamHQBonus.Armory(player) * 0.05)); if (player.IsKnighted) attackPower += (long)(attackPower * GameConfig.KnightDamageBonus); // v0.60.11: Grand Champion +3% damage stacks with knighthood. @@ -20606,9 +20620,9 @@ private async Task HandleVictoryMultiMonster(CombatResult result, bool offerMonk } // Team HQ Training bonus: +5% XP per level — multi-monster path - if (result.Player.HQTrainingLevel > 0) + if (TeamHQBonus.Training(result.Player) > 0) { - adjustedExp += (long)(adjustedExp * (result.Player.HQTrainingLevel * 0.05)); + adjustedExp += (long)(adjustedExp * (TeamHQBonus.Training(result.Player) * 0.05)); } // Fatigue XP penalty — Exhausted tier only (single-player only) @@ -21198,9 +21212,9 @@ private async Task HandlePartialVictory(CombatResult result, bool offerMonkEncou } // Team HQ Training bonus: +5% XP per level — berserker/special path - if (result.Player.HQTrainingLevel > 0) + if (TeamHQBonus.Training(result.Player) > 0) { - adjustedExp += (long)(adjustedExp * (result.Player.HQTrainingLevel * 0.05)); + adjustedExp += (long)(adjustedExp * (TeamHQBonus.Training(result.Player) * 0.05)); } // v0.64.1 early-game XP multiplier (berserker / special path). @@ -30413,8 +30427,8 @@ private async Task DistributeGroupRewards(CombatResult result, long totalBaseExp } // Team HQ Training bonus: +5% XP per level - if (groupedPlayer.HQTrainingLevel > 0) - playerExp += (long)(playerExp * (groupedPlayer.HQTrainingLevel * 0.05)); + if (TeamHQBonus.Training(groupedPlayer) > 0) + playerExp += (long)(playerExp * (TeamHQBonus.Training(groupedPlayer) * 0.05)); // v0.64.1 early-game XP multiplier (per-grouped-player path). // Each grouped player's level keys into the curve independently diff --git a/Scripts/Systems/SqlSaveBackend.cs b/Scripts/Systems/SqlSaveBackend.cs index f6519a7d..78ada5b0 100644 --- a/Scripts/Systems/SqlSaveBackend.cs +++ b/Scripts/Systems/SqlSaveBackend.cs @@ -6856,6 +6856,26 @@ public async Task WithdrawFromTeamVault(string teamName, long amount) catch (Exception ex) { DebugLogger.Instance.LogError("SQL", $"Failed to withdraw from vault: {ex.Message}"); return false; } } + /// v1.1.11: every upgrade level of a team in one read (TeamHQBonus.RefreshLevels). + public Dictionary GetTeamUpgradeLevels(string teamName) + { + var levels = new Dictionary(); + try + { + using var connection = OpenConnection(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT upgrade_type, level FROM team_upgrades WHERE team_name = @team;"; + cmd.Parameters.AddWithValue("@team", teamName); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) levels[reader.GetString(0)] = reader.GetInt32(1); + } + catch (Exception ex) + { + DebugLogger.Instance.LogError("SQL", $"Failed to read the upgrades of team '{teamName}': {ex.Message}"); + } + return levels; + } + public int GetTeamUpgradeLevel(string teamName, string upgradeType) { try diff --git a/Scripts/Systems/TeamHQBonus.cs b/Scripts/Systems/TeamHQBonus.cs new file mode 100644 index 00000000..43c20283 --- /dev/null +++ b/Scripts/Systems/TeamHQBonus.cs @@ -0,0 +1,68 @@ +using System; + +namespace UsurperRemake.Systems +{ + /// + /// v1.1.11: the Team HQ upgrade bonuses a player gets from their team (online only). + /// Armory +5% of the damage the player deals, every way they deal it + /// Barracks +5% defence: the damage the player takes is divided by 1 + 5% per level + /// Training +5% of combat XP + /// Infirmary +10% of what a healing potion restores + /// Order, the same at every site: the bonus is applied after every other modifier of that damage, + /// XP or healing, and before any floor, cap or minimum that follows, so it composes with the Old God + /// dialogue factor and the rest the same way everywhere. + /// + /// The levels live on the Character at run time only, never in the save. They are the levels of the + /// team they were read for (HQLevelsTeam): a player who leaves or changes team gets nothing from them + /// until they are read again, which happens at login, when a fight starts, on the status screen and + /// after an upgrade. + /// + public static class TeamHQBonus + { + public const double ArmoryPerLevel = 0.05; + public const double BarracksPerLevel = 0.05; + public const double TrainingPerLevel = 0.05; + public const double InfirmaryPerLevel = 0.10; + + private static bool Current(Character c) => !string.IsNullOrEmpty(c.Team) && c.Team == c.HQLevelsTeam; + + public static int Armory(Character c) => Current(c) ? c.HQArmoryLevel : 0; + public static int Barracks(Character c) => Current(c) ? c.HQBarracksLevel : 0; + public static int Training(Character c) => Current(c) ? c.HQTrainingLevel : 0; + public static int Infirmary(Character c) => Current(c) ? c.HQInfirmaryLevel : 0; + + public static double AttackMultiplier(Character c) => 1.0 + Armory(c) * ArmoryPerLevel; + public static double DefenseMultiplier(Character c) => 1.0 + Barracks(c) * BarracksPerLevel; + public static double XPMultiplier(Character c) => 1.0 + Training(c) * TrainingPerLevel; + public static double PotionHealMultiplier(Character c) => 1.0 + Infirmary(c) * InfirmaryPerLevel; + + /// Damage dealt by the player, after every other modifier. + public static long ApplyAttack(Character c, long damage) => Armory(c) > 0 ? (long)Math.Round(damage * AttackMultiplier(c)) : damage; + /// Damage taken by the player, after every other modifier and before any floor. + public static long ApplyDefense(Character c, long damage) => Barracks(c) > 0 ? (long)Math.Round(damage / DefenseMultiplier(c)) : damage; + public static long ApplyXP(Character c, long xp) => Training(c) > 0 ? (long)Math.Round(xp * XPMultiplier(c)) : xp; + public static long ApplyPotionHeal(Character c, long heal) => Infirmary(c) > 0 ? (long)Math.Round(heal * PotionHealMultiplier(c)) : heal; + + /// + /// Reads the levels of the character's team. A character with no team, an NPC, or a game without + /// the online database gets none. A PvP defender loaded from a save gets its own team's levels. + /// + public static void RefreshLevels(Character c, SqlSaveBackend? backend = null) + { + backend ??= SaveSystem.Instance?.Backend as SqlSaveBackend; + if (c is NPC || string.IsNullOrEmpty(c.Team) || backend == null) + { + c.HQArmoryLevel = c.HQBarracksLevel = c.HQTrainingLevel = c.HQInfirmaryLevel = 0; + c.HQLevelsTeam = ""; + return; + } + var levels = backend.GetTeamUpgradeLevels(c.Team); + int Level(string type) => levels.TryGetValue(type, out int l) ? l : 0; + c.HQArmoryLevel = Level("armory"); + c.HQBarracksLevel = Level("barracks"); + c.HQTrainingLevel = Level("training"); + c.HQInfirmaryLevel = Level("infirmary"); + c.HQLevelsTeam = c.Team; + } + } +} diff --git a/Tests/TeamHQBonusTests.cs b/Tests/TeamHQBonusTests.cs new file mode 100644 index 00000000..6a78333f --- /dev/null +++ b/Tests/TeamHQBonusTests.cs @@ -0,0 +1,118 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using UsurperRemake.Systems; +using Xunit; + +namespace UsurperReborn.Tests; + +/// +/// v1.1.11: Team HQ upgrade levels were read at login and after the buyer's own upgrade only, so a +/// player who joined a team had none until the next login, a teammate's upgrade reached nobody else, +/// and a player who left kept the bonus for the rest of the session. They are read by team from the +/// database (TeamHQBonus.RefreshLevels) and count only while that is still the player's team. +/// +[Collection("SharedGameSingletons")] +public class TeamHQBonusTests : IDisposable +{ + private readonly string _path = Path.Combine(Path.GetTempPath(), $"usurper-hq-{Guid.NewGuid():N}.db"); + private readonly SqlSaveBackend _db; + + public TeamHQBonusTests() + { + _db = new SqlSaveBackend(_path); + Exec("INSERT INTO team_upgrades (team_name, upgrade_type, level) VALUES " + + "('Iron Wolves', 'armory', 3), ('Iron Wolves', 'barracks', 2), ('Iron Wolves', 'training', 1), ('Iron Wolves', 'infirmary', 4), ('Iron Wolves', 'vault', 5);"); + } + + public void Dispose() + { + SqliteConnection.ClearAllPools(); + try { File.Delete(_path); } catch { } + } + + private void Exec(string sql) + { + using var conn = new SqliteConnection($"Data Source={_path}"); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } + + private static Character Member(string team) => new Character { Name1 = "wolf", Name2 = "Wolf", Team = team, Level = 20 }; + + [Fact] + public void TheLevels_AreReadForTheTeam() + { + var c = Member("Iron Wolves"); + TeamHQBonus.RefreshLevels(c, _db); + TeamHQBonus.Armory(c).Should().Be(3); + TeamHQBonus.Barracks(c).Should().Be(2); + TeamHQBonus.Training(c).Should().Be(1); + TeamHQBonus.Infirmary(c).Should().Be(4); + TeamHQBonus.ApplyAttack(c, 1000).Should().Be(1150); + TeamHQBonus.ApplyXP(c, 1000).Should().Be(1050); + TeamHQBonus.ApplyPotionHeal(c, 1000).Should().Be(1400); + TeamHQBonus.ApplyDefense(c, 1100).Should().Be(1000); + } + + [Fact] + public void APlayerWhoLeavesOrChangesTeam_LosesTheBonusAtOnce() + { + var c = Member("Iron Wolves"); + TeamHQBonus.RefreshLevels(c, _db); + c.Team = ""; + TeamHQBonus.Armory(c).Should().Be(0, "leaving the team took the bonus with it"); + c.Team = "Other Team"; + TeamHQBonus.Armory(c).Should().Be(0, "these are another team's levels"); + TeamHQBonus.ApplyAttack(c, 1000).Should().Be(1000); + } + + [Fact] + public void ATeammatesUpgrade_IsSeenAtTheNextRead() + { + var c = Member("Iron Wolves"); + TeamHQBonus.RefreshLevels(c, _db); + Exec("UPDATE team_upgrades SET level = 5 WHERE team_name = 'Iron Wolves' AND upgrade_type = 'armory';"); + TeamHQBonus.RefreshLevels(c, _db); + TeamHQBonus.Armory(c).Should().Be(5); + } + + [Fact] + public void ADuelDefenderLoadedFromASave_GetsItsOwnTeamsLevels() + { + // A defender built from its save has no levels in memory; its team's are read by name. + var defender = Member("Iron Wolves"); + defender.HQArmoryLevel.Should().Be(0); + TeamHQBonus.RefreshLevels(defender, _db); + TeamHQBonus.Barracks(defender).Should().Be(2); + } + + [Fact] + public void NobodyWithoutATeam_AndNoNPC_GetsTheLevels() + { + var loner = Member(""); + TeamHQBonus.RefreshLevels(loner, _db); + TeamHQBonus.Armory(loner).Should().Be(0); + + var npc = new NPC { Name1 = "Hold", Name2 = "Hold", Team = "Iron Wolves" }; + TeamHQBonus.RefreshLevels(npc, _db); + TeamHQBonus.Armory(npc).Should().Be(0, "an NPC teammate is not the team's player"); + + var offline = Member("Iron Wolves"); + TeamHQBonus.RefreshLevels(offline, backend: null); // no online database in this process + TeamHQBonus.Armory(offline).Should().Be(0); + } + + [Fact] + public void TheLevels_AreNeverInTheSave() + { + // They are the team's, read from the database; a save would carry a stale copy. + typeof(PlayerData).GetMembers(BindingFlags.Public | BindingFlags.Instance) + .Where(m => m.Name.StartsWith("HQ", StringComparison.Ordinal)).Should().BeEmpty(); + } +} From bef2253ece3384bc1fd55255a9c2d6effc1d882e Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 16:58:36 +0000 Subject: [PATCH 04/42] Empty-team cleanup waits while any save is malformed A malformed save cannot say which team it names, so its player could be the last member of a team the cleanup would remove. While any save is malformed the cleanup removes nothing and logs whose saves they are; the delete itself rechecks this in the same statement (supervisor review). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Systems/SqlSaveBackend.cs | 19 +++++++++++++++++-- Tests/EmptyTeamCleanupTests.cs | 24 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/Scripts/Systems/SqlSaveBackend.cs b/Scripts/Systems/SqlSaveBackend.cs index 78ada5b0..62fee257 100644 --- a/Scripts/Systems/SqlSaveBackend.cs +++ b/Scripts/Systems/SqlSaveBackend.cs @@ -5547,6 +5547,19 @@ public List GetTeamsWithoutPlayerMembers() { using var connection = OpenConnection(); using var cmd = connection.CreateCommand(); + // A malformed save cannot say which team it names, so while one exists no team counts as empty. + using (var bad = connection.CreateCommand()) + { + bad.CommandText = "SELECT username FROM players WHERE NOT json_valid(player_data);"; + using var badReader = bad.ExecuteReader(); + var badKeys = new List(); + while (badReader.Read()) badKeys.Add(badReader.GetString(0)); + if (badKeys.Count > 0) + { + DebugLogger.Instance.LogWarning("SQL", $"Empty-team cleanup skipped: malformed save(s) for {string.Join(", ", badKeys)}"); + return teams; + } + } cmd.CommandText = @" SELECT t.team_name FROM player_teams t WHERE NOT EXISTS (SELECT 1 FROM players p @@ -5563,7 +5576,8 @@ WHERE NOT EXISTS (SELECT 1 FROM players p /// /// v1.1.11: removes a team nobody is in, with its upgrades and vault, in one transaction; only if no - /// player's save names it at the moment of the delete. True when it was removed. + /// player's save names it at the moment of the delete, and no save is malformed (it could name the + /// team). True when it was removed. /// public bool DeleteEmptyTeam(string teamName) { @@ -5577,7 +5591,8 @@ public bool DeleteEmptyTeam(string teamName) team.CommandText = @" DELETE FROM player_teams WHERE team_name = @team AND NOT EXISTS (SELECT 1 FROM players p - WHERE (CASE WHEN json_valid(p.player_data) THEN json_extract(p.player_data, '$.player.team') END) = @team);"; + WHERE NOT json_valid(p.player_data) + OR (CASE WHEN json_valid(p.player_data) THEN json_extract(p.player_data, '$.player.team') END) = @team);"; team.Parameters.AddWithValue("@team", teamName); if (team.ExecuteNonQuery() != 1) return false; } diff --git a/Tests/EmptyTeamCleanupTests.cs b/Tests/EmptyTeamCleanupTests.cs index f4f36fa7..1fc8abf4 100644 --- a/Tests/EmptyTeamCleanupTests.cs +++ b/Tests/EmptyTeamCleanupTests.cs @@ -108,4 +108,28 @@ public void TheWorldSave_RemovesEmptyTeams_ButNotOneAnNPCIsIn() } finally { NPCSpawnSystem.Instance.ActiveNPCs.Remove(npc); } } + + [Fact] + public void WhileASaveIsMalformed_NoTeamIsRemoved() + { + // A malformed save cannot say which team it names; its player may be the last member. + using (var conn = new SqliteConnection($"Data Source={_path}")) + { + conn.Open(); + var names = new System.Collections.Generic.List(); + using (var q = conn.CreateCommand()) + { + q.CommandText = "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'players' AND sql LIKE '%json%';"; + using var r = q.ExecuteReader(); + while (r.Read()) names.Add(r.GetString(0)); + } + foreach (var name in names) { using var d = conn.CreateCommand(); d.CommandText = $"DROP INDEX \"{name}\";"; d.ExecuteNonQuery(); } + } + Exec("INSERT INTO players (username, display_name, player_data) VALUES ('broken', 'Broken', '{not json');"); + Team("Nobody Left"); + + _db.GetTeamsWithoutPlayerMembers().Should().BeEmpty(); + _db.DeleteEmptyTeam("Nobody Left").Should().BeFalse(); + Count("SELECT COUNT(*) FROM player_teams WHERE team_name = 'Nobody Left'").Should().Be(1); + } } From bdb6a2903f4f24f8508792b5edc72c229ebe410c Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 17:03:55 +0000 Subject: [PATCH 05/42] The status screen always shows the team's HQ bonuses The Active Buffs section appeared only when another buff or one of certain classes turned it on, so a Warrior, Barbarian, Assassin, Ranger, Sage, Bard or Mystic Shaman whose only bonus was the team's never saw it. The team's bonuses now turn the section on, are read fresh when the screen opens, and are shown in all five languages. The Infirmary's upgrade text says "potion healing", which is what it improves. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Localization/en.json | 6 +++- Localization/es.json | 6 +++- Localization/fr.json | 6 +++- Localization/hu.json | 6 +++- Localization/it.json | 6 +++- Scripts/Locations/BaseLocation.cs | 28 +++++++++++------- Tests/TeamHQBonusTests.cs | 48 +++++++++++++++++++++++++++++++ 7 files changed, 90 insertions(+), 16 deletions(-) diff --git a/Localization/en.json b/Localization/en.json index 0a3cb9d3..3d4549ba 100644 --- a/Localization/en.json +++ b/Localization/en.json @@ -13187,7 +13187,7 @@ "team.upgrade_vault": "Vault", "team.upgrade_vault_desc": "+50,000 vault capacity/lv", "team.upgrade_infirmary": "Infirmary", - "team.upgrade_infirmary_desc": "+10% healing per level", + "team.upgrade_infirmary_desc": "+10% potion healing per level", "combat.status_effects_label": "Status Effects:", "combat.flee_label": "Flee ", "combat.disarm_label": "Disarm ", @@ -15205,6 +15205,10 @@ "base.effect_raging": " - RAGING! (+Strength, +HP, -AC)", "base.effect_smite": " - Smite Evil: {0} charges", "base.stat_active_buffs": "Active Buffs:", + "base.hq_armory": "Team Armory Lv {0}: +{1}% attack", + "base.hq_barracks": "Team Barracks Lv {0}: +{1}% defense", + "base.hq_training": "Team Training Grounds Lv {0}: +{1}% XP", + "base.hq_infirmary": "Team Infirmary Lv {0}: +{1}% potion healing", "base.buff_potion_mastery": " - Potion Mastery: +50% healing (potions, herbs, elixirs)", "base.buff_arcane_mastery": " - Arcane Mastery: +15% spell damage", "base.buff_tricksters_luck": " - Trickster's Luck: 20% chance of random bonus", diff --git a/Localization/es.json b/Localization/es.json index 3638e893..8deda5d8 100644 --- a/Localization/es.json +++ b/Localization/es.json @@ -13002,7 +13002,7 @@ "team.upgrade_vault": "Bóveda", "team.upgrade_vault_desc": "+50.000 capacidad/nv", "team.upgrade_infirmary": "Enfermería", - "team.upgrade_infirmary_desc": "+10% curación por nivel", + "team.upgrade_infirmary_desc": "+10% curación con pociones por nivel", "combat.status_effects_label": "Efectos de Estado:", "combat.flee_label": "Huir ", "combat.disarm_label": "Desarmar ", @@ -15020,6 +15020,10 @@ "base.effect_raging": " ¡EN FURIA!", "base.effect_smite": " Castigo Divino: {0} carga(s)", "base.stat_active_buffs": "Mejoras Activas:", + "base.hq_armory": "Armería del equipo Nv {0}: +{1}% ataque", + "base.hq_barracks": "Barracas del equipo Nv {0}: +{1}% defensa", + "base.hq_training": "Campo de Entrenamiento del equipo Nv {0}: +{1}% XP", + "base.hq_infirmary": "Enfermería del equipo Nv {0}: +{1}% curación con pociones", "base.buff_potion_mastery": " - Maestria en Pociones: +50% curación (pociones, hierbas, elixires)", "base.buff_arcane_mastery": " - Maestria Arcana: +15% daño de hechizos", "base.buff_tricksters_luck": " - Suerte del Embaucador: 20% probabilidad de bonus aleatorio", diff --git a/Localization/fr.json b/Localization/fr.json index 36b91ce9..543865fd 100644 --- a/Localization/fr.json +++ b/Localization/fr.json @@ -13002,7 +13002,7 @@ "team.upgrade_vault": "Coffre", "team.upgrade_vault_desc": "+50 000 capacité du coffre/ niv", "team.upgrade_infirmary": "Infirmerie", - "team.upgrade_infirmary_desc": "+10% guérison par niveau", + "team.upgrade_infirmary_desc": "+10% soin des potions par niveau", "combat.status_effects_label": "Effets d’État :", "combat.flee_label": "Fuir ", "combat.disarm_label": "Désarmer ", @@ -15009,6 +15009,10 @@ "base.effect_raging": " - ENRAGÉ! (+Force, +PV, -AC)", "base.effect_smite": " - Châtiment : {0} charges", "base.stat_active_buffs": "Bonus Actifs :", + "base.hq_armory": "Armurerie de l’équipe Niv {0} : +{1}% attaque", + "base.hq_barracks": "Caserne de l’équipe Niv {0} : +{1}% défense", + "base.hq_training": "Terrain d’Entraînement de l’équipe Niv {0} : +{1}% XP", + "base.hq_infirmary": "Infirmerie de l’équipe Niv {0} : +{1}% soin des potions", "base.buff_potion_mastery": " - Maîtrise des Potions : +50% soins (potions, herbes, élixirs)", "base.buff_arcane_mastery": " - Maîtrise Arcanique : +15% dégâts de sorts", "base.buff_tricksters_luck": " - Chance du Filou : 20% de chance de bonus aléatoire", diff --git a/Localization/hu.json b/Localization/hu.json index 4c99b42c..ee07e99a 100644 --- a/Localization/hu.json +++ b/Localization/hu.json @@ -13010,7 +13010,7 @@ "team.upgrade_vault": "Kincstár", "team.upgrade_vault_desc": "+50.000 kincstári kapacitás/szint", "team.upgrade_infirmary": "Ispotály", - "team.upgrade_infirmary_desc": "+10% gyógyítás szintenként", + "team.upgrade_infirmary_desc": "+10% bájitalos gyógyítás szintenként", "combat.status_effects_label": "Állapotjelzők:", "combat.flee_label": "Menekülés ", "combat.disarm_label": "Lefegyverzés ", @@ -15028,6 +15028,10 @@ "base.effect_raging": " - DÜHÖNGÉS! (+Erő, +ÉP, -VÉD)", "base.effect_smite": " - Gonosz sújtása: {0} töltet", "base.stat_active_buffs": "Aktív erősítések:", + "base.hq_armory": "Csapat fegyvertár, {0}. szint: +{1}% támadás", + "base.hq_barracks": "Csapat laktanya, {0}. szint: +{1}% védelem", + "base.hq_training": "Csapat gyakorlótér, {0}. szint: +{1}% XP", + "base.hq_infirmary": "Csapat ispotály, {0}. szint: +{1}% bájitalos gyógyítás", "base.buff_potion_mastery": " - Főzetmesterség: +50% gyógyulás (főzetek, gyógynövények, elixírek)", "base.buff_arcane_mastery": " - Mágikus mesterség: +15% varázssebzés", "base.buff_tricksters_luck": " - A szélhámos szerencséje: 20% esély véletlen bónuszra", diff --git a/Localization/it.json b/Localization/it.json index 20a9870e..f2700795 100644 --- a/Localization/it.json +++ b/Localization/it.json @@ -13002,7 +13002,7 @@ "team.upgrade_vault": "Forziere", "team.upgrade_vault_desc": "+50.000 capienza forziere/lv", "team.upgrade_infirmary": "Infermeria", - "team.upgrade_infirmary_desc": "+10% guarigione per livello", + "team.upgrade_infirmary_desc": "+10% guarigione da pozioni per livello", "combat.status_effects_label": "Effetti di Stato:", "combat.flee_label": "Fuga ", "combat.disarm_label": "Disarma ", @@ -15009,6 +15009,10 @@ "base.effect_raging": " - IN FURIA! (+Forza, +PV, -CA)", "base.effect_smite": " - Punizione Sacra: {0} cariche", "base.stat_active_buffs": "Potenziamenti Attivi:", + "base.hq_armory": "Armeria della squadra Lv {0}: +{1}% attacco", + "base.hq_barracks": "Caserma della squadra Lv {0}: +{1}% difesa", + "base.hq_training": "Campo di Addestramento della squadra Lv {0}: +{1}% PE", + "base.hq_infirmary": "Infermeria della squadra Lv {0}: +{1}% guarigione da pozioni", "base.buff_potion_mastery": " - Maestria Pozioni: +50% cura (pozioni, erbe, elisir)", "base.buff_arcane_mastery": " - Maestria Arcana: +15% danno incantesimi", "base.buff_tricksters_luck": " - Fortuna del Furfante: 20% probabilità di bonus casuale", diff --git a/Scripts/Locations/BaseLocation.cs b/Scripts/Locations/BaseLocation.cs index 2888528c..e7a5c430 100644 --- a/Scripts/Locations/BaseLocation.cs +++ b/Scripts/Locations/BaseLocation.cs @@ -6660,7 +6660,12 @@ protected virtual async Task ShowStatus() } // Show temporary combat buffs (well-rested, god slayer, song, herbs) - bool hasAnyBuff = currentPlayer.IsKnighted + // v1.1.11: the team's current HQ levels (a teammate may have upgraded since they were read) + if (UsurperRemake.BBS.DoorMode.IsOnlineMode) TeamHQBonus.RefreshLevels(currentPlayer); + bool hasTeamHQBonus = TeamHQBonus.Armory(currentPlayer) > 0 || TeamHQBonus.Barracks(currentPlayer) > 0 + || TeamHQBonus.Training(currentPlayer) > 0 || TeamHQBonus.Infirmary(currentPlayer) > 0; + bool hasAnyBuff = hasTeamHQBonus // v1.1.11: a class with no other buff never saw them + || currentPlayer.IsKnighted || currentPlayer.ArenaChampionTier >= (int)UsurperRemake.Data.GauntletChampionData.ArenaTier.GrandChampion || currentPlayer.HasActiveShrineAttunement || currentPlayer.WellRestedCombats > 0 || currentPlayer.HasGodSlayerBuff @@ -6897,18 +6902,19 @@ protected virtual async Task ShowStatus() terminal.WriteLine($" - Divine Blessing ({currentPlayer.DivineBlessingCombats} combats)"); } // Team HQ upgrade bonuses - if (currentPlayer.HQArmoryLevel > 0 || currentPlayer.HQBarracksLevel > 0 || - currentPlayer.HQTrainingLevel > 0 || currentPlayer.HQInfirmaryLevel > 0) + if (hasTeamHQBonus) { terminal.SetColor("bright_yellow"); - if (currentPlayer.HQArmoryLevel > 0) - terminal.WriteLine($" - Team Armory Lv{currentPlayer.HQArmoryLevel}: +{currentPlayer.HQArmoryLevel * 5}% attack"); - if (currentPlayer.HQBarracksLevel > 0) - terminal.WriteLine($" - Team Barracks Lv{currentPlayer.HQBarracksLevel}: +{currentPlayer.HQBarracksLevel * 5}% defense"); - if (currentPlayer.HQTrainingLevel > 0) - terminal.WriteLine($" - Team Training Lv{currentPlayer.HQTrainingLevel}: +{currentPlayer.HQTrainingLevel * 5}% XP"); - if (currentPlayer.HQInfirmaryLevel > 0) - terminal.WriteLine($" - Team Infirmary Lv{currentPlayer.HQInfirmaryLevel}: +{currentPlayer.HQInfirmaryLevel * 10}% potion healing"); + int armory = TeamHQBonus.Armory(currentPlayer), barracks = TeamHQBonus.Barracks(currentPlayer); + int training = TeamHQBonus.Training(currentPlayer), infirmary = TeamHQBonus.Infirmary(currentPlayer); + if (armory > 0) + terminal.WriteLine($" - {Loc.Get("base.hq_armory", armory, (int)Math.Round(armory * TeamHQBonus.ArmoryPerLevel * 100))}"); + if (barracks > 0) + terminal.WriteLine($" - {Loc.Get("base.hq_barracks", barracks, (int)Math.Round(barracks * TeamHQBonus.BarracksPerLevel * 100))}"); + if (training > 0) + terminal.WriteLine($" - {Loc.Get("base.hq_training", training, (int)Math.Round(training * TeamHQBonus.TrainingPerLevel * 100))}"); + if (infirmary > 0) + terminal.WriteLine($" - {Loc.Get("base.hq_infirmary", infirmary, (int)Math.Round(infirmary * TeamHQBonus.InfirmaryPerLevel * 100))}"); } // Session XP diminishing returns indicator (online mode only) long sessionThreshold = GameConfig.GetSessionXPThreshold(currentPlayer.Level); diff --git a/Tests/TeamHQBonusTests.cs b/Tests/TeamHQBonusTests.cs index 6a78333f..61bb2312 100644 --- a/Tests/TeamHQBonusTests.cs +++ b/Tests/TeamHQBonusTests.cs @@ -115,4 +115,52 @@ public void TheLevels_AreNeverInTheSave() typeof(PlayerData).GetMembers(BindingFlags.Public | BindingFlags.Instance) .Where(m => m.Name.StartsWith("HQ", StringComparison.Ordinal)).Should().BeEmpty(); } + + // ─── the status screen ─── + + private sealed class ScriptedStream : Stream + { + private readonly byte[] _data; private int _pos; + public ScriptedStream(string script) { _data = System.Text.Encoding.UTF8.GetBytes(script); } + public override int Read(byte[] buffer, int offset, int count) + { + if (_pos >= _data.Length) return 0; + int n = Math.Min(count, _data.Length - _pos); Array.Copy(_data, _pos, buffer, offset, n); _pos += n; return n; + } + public override System.Threading.Tasks.Task ReadAsync(byte[] buffer, int offset, int count, System.Threading.CancellationToken ct) => System.Threading.Tasks.Task.FromResult(Read(buffer, offset, count)); + public override bool CanRead => true; public override bool CanSeek => false; public override bool CanWrite => false; + public override long Length => _data.Length; public override long Position { get => _pos; set => throw new NotSupportedException(); } + public override void Flush() { } + public override long Seek(long o, SeekOrigin s) => throw new NotSupportedException(); + public override void SetLength(long v) => throw new NotSupportedException(); + public override void Write(byte[] b, int o, int c) => throw new NotSupportedException(); + } + + [Fact] + public async System.Threading.Tasks.Task AWarriorWithOnlyTheTeamsBonus_SeesItOnTheStatusScreen() + { + // The Active Buffs section appeared only if some other buff (or certain classes) turned it on, + // so a Warrior whose only bonus was the team's never saw it. + var hero = new Character { Name1 = "hq_tester", Name2 = "HQ Tester", Class = CharacterClass.Warrior, Level = 20, HP = 300, MaxHP = 300, Team = "Iron Wolves" }; + TeamHQBonus.RefreshLevels(hero, _db); + var inn = new InnLocation(); + var output = new MemoryStream(); + var term = new TerminalEmulator(new ScriptedStream("\n\n\n\n"), output); + var F = BindingFlags.NonPublic | BindingFlags.Instance; + typeof(BaseLocation).GetField("terminal", F)!.SetValue(inn, term); + typeof(BaseLocation).GetField("currentPlayer", F)!.SetValue(inn, hero); + await (System.Threading.Tasks.Task)typeof(BaseLocation).GetMethod("ShowStatus", F)!.Invoke(inn, null)!; + term.StreamWriterInternal!.Flush(); + var shown = System.Text.Encoding.UTF8.GetString(output.ToArray()); + shown.Should().Contain(Loc.Get("base.hq_armory", 3, 15)); + shown.Should().Contain(Loc.Get("base.hq_infirmary", 4, 40)); + + hero.Team = ""; // left the team + output.SetLength(0); + term = new TerminalEmulator(new ScriptedStream("\n\n\n\n"), output); + typeof(BaseLocation).GetField("terminal", F)!.SetValue(inn, term); + await (System.Threading.Tasks.Task)typeof(BaseLocation).GetMethod("ShowStatus", F)!.Invoke(inn, null)!; + term.StreamWriterInternal!.Flush(); + System.Text.Encoding.UTF8.GetString(output.ToArray()).Should().NotContain(Loc.Get("base.hq_armory", 3, 15)); + } } From c3ef89083df159492e84c23a83b406bbe484573c Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 17:34:58 +0000 Subject: [PATCH 06/42] An Old God kill pays its reward once The fight's victory paid the god monster's Experience and Gold, the god-defeated handler paid Level x 2000 XP and Level x 500 gold again, and the dungeon added them a third time. The handler now pays once, at three times the old per-level amount (Level x 6000 XP, Level x 1500 gold) so a kill is worth what it was, with the Team HQ Training bonus on the XP. Noctura's betrayal fight keeps its reward as it was. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Core/GameConfig.cs | 5 ++ Scripts/Locations/DungeonLocation.cs | 13 +---- Scripts/Systems/OldGodBossSystem.cs | 13 +++-- Tests/OldGodPayoutTests.cs | 86 ++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 15 deletions(-) create mode 100644 Tests/OldGodPayoutTests.cs diff --git a/Scripts/Core/GameConfig.cs b/Scripts/Core/GameConfig.cs index 1560919c..101512b9 100644 --- a/Scripts/Core/GameConfig.cs +++ b/Scripts/Core/GameConfig.cs @@ -1848,6 +1848,11 @@ public static bool IsSmallRace(CharacterRace race) // 1.25 as written). Capped here until the gods are retuned against the dialogue; the softer // answers (down to 0.50) are not capped. public const double OldGodDialogueBossDamageCap = 1.10; + // v1.1.11: an Old God kill paid its reward three times (the fight's victory, the god-defeated + // handler, and the dungeon again). It is paid once now, by the handler, at three times the old + // per-level amount, so a kill is worth what it was. + public const int OldGodDefeatXPPerLevel = 6000; + public const int OldGodDefeatGoldPerLevel = 1500; // v1.1.10: regular dungeon monsters on the floors before an Old God's floor grow tougher, HP and // damage, by this much per floor: +5% five floors out, up to +25% on the floor before the god diff --git a/Scripts/Locations/DungeonLocation.cs b/Scripts/Locations/DungeonLocation.cs index 6187ab50..f73d0677 100644 --- a/Scripts/Locations/DungeonLocation.cs +++ b/Scripts/Locations/DungeonLocation.cs @@ -1985,17 +1985,8 @@ private async Task HandleGodEncounterResult(BossEncounterResult result, Characte StoryProgressionSystem.Instance.CollectedArtifacts.Add(artifactType.Value); } - // XP and gold reward - if (result.XPGained > 0) - { - player.Experience += result.XPGained; - term.WriteLine(Loc.Get("dungeon.xp_gained", result.XPGained), "green"); - } - if (result.GoldGained > 0) - { - player.Gold += result.GoldGained; - term.WriteLine(Loc.Get("dungeon.gold_gained", result.GoldGained), "yellow"); - } + // v1.1.11: the XP and gold were paid by OldGodBossSystem.HandleBossDefeated; adding + // result.XPGained and GoldGained here paid them again // Chivalry impact — v0.57.12: paired movement (killing an Old God is evil, lowers chivalry) AlignmentSystem.Instance.ChangeAlignment(player, 100, isGood: false, "dungeon.old_god_killed"); diff --git a/Scripts/Systems/OldGodBossSystem.cs b/Scripts/Systems/OldGodBossSystem.cs index e1d5c116..8647b254 100644 --- a/Scripts/Systems/OldGodBossSystem.cs +++ b/Scripts/Systems/OldGodBossSystem.cs @@ -938,8 +938,9 @@ private Monster CreateBossMonster(OldGodBossData boss) IsActive = true, CanSpeak = true, Phrase = boss.LocIntro().Length > 0 ? boss.LocIntro()[0] : "", - Experience = boss.Level * 2000, - Gold = boss.Level * 500, + // v1.1.11: the fight's victory pays nothing for the god; HandleBossDefeated pays the reward once + Experience = 0, + Gold = 0, }; // Set special abilities from phase 1 abilities for display @@ -1257,12 +1258,12 @@ private async Task HandleBossDefeated( terminal.WriteLine(""); // Award experience - long xpReward = boss.Level * 2000; + long xpReward = TeamHQBonus.ApplyXP(player, (long)boss.Level * GameConfig.OldGodDefeatXPPerLevel); player.Experience += xpReward; terminal.WriteLine(Loc.Get("old_god.defeated_xp", $"{xpReward:N0}"), "cyan"); // Award gold - int goldReward = boss.Level * 500; + int goldReward = boss.Level * GameConfig.OldGodDefeatGoldPerLevel; player.Gold += goldReward; terminal.WriteLine(Loc.Get("old_god.defeated_gold", $"{goldReward:N0}"), "yellow"); @@ -1377,6 +1378,10 @@ private async Task HandleNocturaBetrayal(Character player, TerminalEmulato // line for line (artifact scaling, base difficulty scale, the Strength and Defence // splits), which a later edit to one could silently leave behind in the other. var noctura = CreateBossMonster(betrayalData); + // v1.1.11: the betrayal keeps its reward as it was: the fight's victory pays this, and the + // flat reward below is paid on top (CreateBossMonster now pays nothing for a god) + noctura.Experience = betrayalData.Level * 2000; + noctura.Gold = betrayalData.Level * 500; // Run combat terminal.Clear(); diff --git a/Tests/OldGodPayoutTests.cs b/Tests/OldGodPayoutTests.cs new file mode 100644 index 00000000..1c8b166b --- /dev/null +++ b/Tests/OldGodPayoutTests.cs @@ -0,0 +1,86 @@ +using System; +using System.IO; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using UsurperRemake.Systems; +using Xunit; + +namespace UsurperReborn.Tests; + +/// +/// v1.1.11: an Old God kill paid its XP and gold three times: the fight's victory paid the god +/// monster's Experience and Gold, HandleBossDefeated paid Level x 2000 and x 500 again, and the dungeon +/// added the result's XPGained and GoldGained a third time. It is paid once, by the handler, at three +/// times the old per-level amount (the maintainer's choice), with the Team HQ Training bonus on it. +/// +[Collection("SharedGameSingletons")] +public class OldGodPayoutTests +{ + private sealed class ScriptedStream : Stream + { + private readonly byte[] _data; private int _pos; + public ScriptedStream(string script) { _data = Encoding.UTF8.GetBytes(script); } + public override int Read(byte[] buffer, int offset, int count) + { + if (_pos >= _data.Length) return 0; + int n = Math.Min(count, _data.Length - _pos); Array.Copy(_data, _pos, buffer, offset, n); _pos += n; return n; + } + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) => Task.FromResult(Read(buffer, offset, count)); + public override bool CanRead => true; public override bool CanSeek => false; public override bool CanWrite => false; + public override long Length => _data.Length; public override long Position { get => _pos; set => throw new NotSupportedException(); } + public override void Flush() { } + public override long Seek(long o, SeekOrigin s) => throw new NotSupportedException(); + public override void SetLength(long v) => throw new NotSupportedException(); + public override void Write(byte[] b, int o, int c) => throw new NotSupportedException(); + } + + private static readonly BindingFlags F = BindingFlags.NonPublic | BindingFlags.Instance; + + [Fact] + public void TheGodsFight_PaysNothingForTheGod() + { + var data = UsurperRemake.Data.OldGodsData.GetGodBossData(OldGodType.Maelketh); + var god = (Monster)typeof(OldGodBossSystem).GetMethod("CreateBossMonster", F)!.Invoke(OldGodBossSystem.Instance, new object[] { data })!; + god.Experience.Should().Be(0, "the victory used to pay it on top of the handler"); + god.Gold.Should().Be(0); + } + + [Fact] + public async Task TheDungeon_DoesNotPayTheGodsRewardAgain() + { + var player = new Character { Name1 = "god_payout", Name2 = "God Payout", Level = 30, HP = 500, MaxHP = 500, Experience = 1000, Gold = 1000 }; + var result = new BossEncounterResult { Success = true, Outcome = BossOutcome.Defeated, God = OldGodType.Maelketh, XPGained = 150_000, GoldGained = 37_500 }; + var term = new TerminalEmulator(new ScriptedStream(new string('\n', 40)), new MemoryStream()); + var dungeon = new DungeonLocation(); + try { await (Task)typeof(DungeonLocation).GetMethod("HandleGodEncounterResult", F)!.Invoke(dungeon, new object[] { result, player, term })!; } + catch (LocationExitException) { } // the defeated god's scene leaves the dungeon + // Other awards on this screen (achievements) depend on state; the god's reward must not be among them. + (player.Experience - 1000).Should().BeLessThan(result.XPGained, "HandleBossDefeated already paid it"); + (player.Gold - 1000).Should().BeLessThan(result.GoldGained); + } + + [Fact] + public void TheOnePayout_IsThreeTimesTheOldPerLevelAmount() + { + GameConfig.OldGodDefeatXPPerLevel.Should().Be(3 * 2000); + GameConfig.OldGodDefeatGoldPerLevel.Should().Be(3 * 500); + } + + [Fact] + public async Task TheHandler_PaysTheKillOnce_WithTheTeamsTrainingBonus() + { + var data = UsurperRemake.Data.OldGodsData.GetGodBossData(OldGodType.Maelketh); + var player = new Character { Name1 = "god_payout2", Name2 = "God Payout", Level = 30, HP = 500, MaxHP = 500, Experience = 0, Gold = 0, Team = "Iron Wolves" }; + player.HQTrainingLevel = 2; player.HQLevelsTeam = "Iron Wolves"; // +10% XP + var term = new TerminalEmulator(new ScriptedStream(new string('\n', 40)), new MemoryStream()); + var res = await (Task)typeof(OldGodBossSystem).GetMethod("HandleBossDefeated", F)! + .Invoke(OldGodBossSystem.Instance, new object[] { player, data, term })!; + long expected = (long)Math.Round(data.Level * 6000 * 1.10); + player.Experience.Should().Be(expected); + player.Gold.Should().Be(data.Level * 1500); + res.XPGained.Should().Be(expected, "reported for the screen, not paid again"); + } +} From 203b91b6c93c6f6d3f17981f904171c3b3293b03 Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 17:52:31 +0000 Subject: [PATCH 07/42] The Team HQ Armory reaches every hit a player makes The Armory multiplied attack power in the basic swing only, before armor and ten other modifiers; Power Attack, Backstab, abilities, spells, PvP, the world boss and the scripted fights had none of it. It now multiplies the damage of each hit, after every other modifier and just before it lands: the four shared PvE helpers, every ability and spell effect that writes enemy HP directly, Corrupting Touch at cast, the Searing Totem, both sides of a duel, the world boss (before the level ratio and the round cap), the castle siege, the prison break and the Malachar fight. Riders, reflect, damage-over-time ticks and repeats of a boosted hit are left as they are. A test reads CombatEngine and fails on any enemy HP write that neither applies it nor says why not. The texts now say "damage dealt" in all five languages. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Localization/en.json | 4 +- Localization/es.json | 4 +- Localization/fr.json | 4 +- Localization/hu.json | 4 +- Localization/it.json | 4 +- Scripts/Locations/CastleLocation.cs | 3 + Scripts/Locations/DungeonLocation.cs | 1 + Scripts/Locations/PrisonWalkLocation.cs | 1 + Scripts/Systems/CombatEngine.cs | 131 ++++++++++----- Scripts/Systems/WorldBossSystem.cs | 5 + Tests/ArmoryCoverageTests.cs | 210 ++++++++++++++++++++++++ 11 files changed, 322 insertions(+), 49 deletions(-) create mode 100644 Tests/ArmoryCoverageTests.cs diff --git a/Localization/en.json b/Localization/en.json index 3d4549ba..f62c83d1 100644 --- a/Localization/en.json +++ b/Localization/en.json @@ -13179,7 +13179,7 @@ "team.facility_upgrade_cost": "[Upgrade: {0}g]", "team.news_war_result": "Team {0} won the war between {1} and {2}! ({3}-{4})", "team.upgrade_armory": "Armory", - "team.upgrade_armory_desc": "+5% attack per level", + "team.upgrade_armory_desc": "+5% damage dealt per level", "team.upgrade_barracks": "Barracks", "team.upgrade_barracks_desc": "+5% defense per level", "team.upgrade_training": "Training Grounds", @@ -15205,7 +15205,7 @@ "base.effect_raging": " - RAGING! (+Strength, +HP, -AC)", "base.effect_smite": " - Smite Evil: {0} charges", "base.stat_active_buffs": "Active Buffs:", - "base.hq_armory": "Team Armory Lv {0}: +{1}% attack", + "base.hq_armory": "Team Armory Lv {0}: +{1}% damage dealt", "base.hq_barracks": "Team Barracks Lv {0}: +{1}% defense", "base.hq_training": "Team Training Grounds Lv {0}: +{1}% XP", "base.hq_infirmary": "Team Infirmary Lv {0}: +{1}% potion healing", diff --git a/Localization/es.json b/Localization/es.json index 8deda5d8..439adc64 100644 --- a/Localization/es.json +++ b/Localization/es.json @@ -12994,7 +12994,7 @@ "team.facility_upgrade_cost": "[Mejora: {0}g]", "team.news_war_result": "¡El equipo {0} ganó la guerra entre {1} y {2}! ({3}-{4})", "team.upgrade_armory": "Armería", - "team.upgrade_armory_desc": "+5% ataque por nivel", + "team.upgrade_armory_desc": "+5% daño infligido por nivel", "team.upgrade_barracks": "Barracas", "team.upgrade_barracks_desc": "+5% defensa por nivel", "team.upgrade_training": "Campo de Entrenamiento", @@ -15020,7 +15020,7 @@ "base.effect_raging": " ¡EN FURIA!", "base.effect_smite": " Castigo Divino: {0} carga(s)", "base.stat_active_buffs": "Mejoras Activas:", - "base.hq_armory": "Armería del equipo Nv {0}: +{1}% ataque", + "base.hq_armory": "Armería del equipo Nv {0}: +{1}% daño infligido", "base.hq_barracks": "Barracas del equipo Nv {0}: +{1}% defensa", "base.hq_training": "Campo de Entrenamiento del equipo Nv {0}: +{1}% XP", "base.hq_infirmary": "Enfermería del equipo Nv {0}: +{1}% curación con pociones", diff --git a/Localization/fr.json b/Localization/fr.json index 543865fd..17124e9f 100644 --- a/Localization/fr.json +++ b/Localization/fr.json @@ -12994,7 +12994,7 @@ "team.facility_upgrade_cost": "[Améliorer : {0}g]", "team.news_war_result": "L’équipe {0} a gagné la guerre entre {1} et {2}! ({3}-{4})", "team.upgrade_armory": "Armurerie", - "team.upgrade_armory_desc": "+5% attaque par niveau", + "team.upgrade_armory_desc": "+5% dégâts infligés par niveau", "team.upgrade_barracks": "Caserne", "team.upgrade_barracks_desc": "+5% défense par niveau", "team.upgrade_training": "Terrain d’Entraînement", @@ -15009,7 +15009,7 @@ "base.effect_raging": " - ENRAGÉ! (+Force, +PV, -AC)", "base.effect_smite": " - Châtiment : {0} charges", "base.stat_active_buffs": "Bonus Actifs :", - "base.hq_armory": "Armurerie de l’équipe Niv {0} : +{1}% attaque", + "base.hq_armory": "Armurerie de l’équipe Niv {0} : +{1}% dégâts infligés", "base.hq_barracks": "Caserne de l’équipe Niv {0} : +{1}% défense", "base.hq_training": "Terrain d’Entraînement de l’équipe Niv {0} : +{1}% XP", "base.hq_infirmary": "Infirmerie de l’équipe Niv {0} : +{1}% soin des potions", diff --git a/Localization/hu.json b/Localization/hu.json index ee07e99a..44d9e4d1 100644 --- a/Localization/hu.json +++ b/Localization/hu.json @@ -13002,7 +13002,7 @@ "team.facility_upgrade_cost": "[Fejlesztés: {0} arany]", "team.news_war_result": "A(z) {0} csapat megnyerte a háborút {1} és {2} között! ({3}-{4})", "team.upgrade_armory": "Fegyvertár", - "team.upgrade_armory_desc": "+5% támadás szintenként", + "team.upgrade_armory_desc": "+5% okozott sebzés szintenként", "team.upgrade_barracks": "Laktanya", "team.upgrade_barracks_desc": "+5% védelem szintenként", "team.upgrade_training": "Gyakorlótér", @@ -15028,7 +15028,7 @@ "base.effect_raging": " - DÜHÖNGÉS! (+Erő, +ÉP, -VÉD)", "base.effect_smite": " - Gonosz sújtása: {0} töltet", "base.stat_active_buffs": "Aktív erősítések:", - "base.hq_armory": "Csapat fegyvertár, {0}. szint: +{1}% támadás", + "base.hq_armory": "Csapat fegyvertár, {0}. szint: +{1}% okozott sebzés", "base.hq_barracks": "Csapat laktanya, {0}. szint: +{1}% védelem", "base.hq_training": "Csapat gyakorlótér, {0}. szint: +{1}% XP", "base.hq_infirmary": "Csapat ispotály, {0}. szint: +{1}% bájitalos gyógyítás", diff --git a/Localization/it.json b/Localization/it.json index f2700795..cea2c0fd 100644 --- a/Localization/it.json +++ b/Localization/it.json @@ -12994,7 +12994,7 @@ "team.facility_upgrade_cost": "[Potenziamento: {0}o]", "team.news_war_result": "La squadra {0} ha vinto la guerra tra {1} e {2}! ({3}-{4})", "team.upgrade_armory": "Armeria", - "team.upgrade_armory_desc": "+5% attacco per livello", + "team.upgrade_armory_desc": "+5% danni inflitti per livello", "team.upgrade_barracks": "Caserma", "team.upgrade_barracks_desc": "+5% difesa per livello", "team.upgrade_training": "Campo di Addestramento", @@ -15009,7 +15009,7 @@ "base.effect_raging": " - IN FURIA! (+Forza, +PV, -CA)", "base.effect_smite": " - Punizione Sacra: {0} cariche", "base.stat_active_buffs": "Potenziamenti Attivi:", - "base.hq_armory": "Armeria della squadra Lv {0}: +{1}% attacco", + "base.hq_armory": "Armeria della squadra Lv {0}: +{1}% danni inflitti", "base.hq_barracks": "Caserma della squadra Lv {0}: +{1}% difesa", "base.hq_training": "Campo di Addestramento della squadra Lv {0}: +{1}% PE", "base.hq_infirmary": "Infermeria della squadra Lv {0}: +{1}% guarigione da pozioni", diff --git a/Scripts/Locations/CastleLocation.cs b/Scripts/Locations/CastleLocation.cs index e70dc8b3..649d7c26 100644 --- a/Scripts/Locations/CastleLocation.cs +++ b/Scripts/Locations/CastleLocation.cs @@ -8601,6 +8601,7 @@ private async Task CastleSiegeMenu() // Team attacks (combined) long teamDmg = Math.Max(1, teamPower - monsterDef); teamDmg = (long)(teamDmg * (0.8 + random.NextDouble() * 0.4)); + teamDmg = TeamHQBonus.ApplyAttack(currentPlayer, teamDmg); // v1.1.11: Team HQ Armory, last. monsterHP -= teamDmg; terminal.SetColor("bright_green"); @@ -8671,6 +8672,7 @@ private async Task CastleSiegeMenu() long teamDmg = Math.Max(1, teamPower - guardDef); teamDmg = (long)(teamDmg * (0.8 + random.NextDouble() * 0.4)); + teamDmg = TeamHQBonus.ApplyAttack(currentPlayer, teamDmg); // v1.1.11: Team HQ Armory, last. guardHP -= teamDmg; terminal.SetColor("bright_green"); @@ -8871,6 +8873,7 @@ private async Task CastleSiegeMenu() // Player attacks (accounts for weapon and armor power) long playerDamage = Math.Max(1, currentPlayer.Strength + currentPlayer.WeapPow - siegeKingDef - siegeKingArmPow); playerDamage = (long)(playerDamage * (0.8 + random.NextDouble() * 0.4)); + playerDamage = TeamHQBonus.ApplyAttack(currentPlayer, playerDamage); // v1.1.11: Team HQ Armory, last. kingHP -= playerDamage; terminal.SetColor("bright_green"); diff --git a/Scripts/Locations/DungeonLocation.cs b/Scripts/Locations/DungeonLocation.cs index f73d0677..8ad2ebaa 100644 --- a/Scripts/Locations/DungeonLocation.cs +++ b/Scripts/Locations/DungeonLocation.cs @@ -16645,6 +16645,7 @@ private async Task FightMalachar(Character player, UsurperRemake.Systems.Compani if (playerJoins) { long playerDmg = player.Strength + player.WeapPow + dungeonRandom.Next(50); + playerDmg = TeamHQBonus.ApplyAttack(player, playerDmg); // v1.1.11: Team HQ Armory, last. malachar.HP -= (int)playerDmg; terminal.WriteLine(Loc.Get("quest.aldric_ghosts.player_strike", playerDmg), "bright_cyan"); } diff --git a/Scripts/Locations/PrisonWalkLocation.cs b/Scripts/Locations/PrisonWalkLocation.cs index f1f24136..d4d7370c 100644 --- a/Scripts/Locations/PrisonWalkLocation.cs +++ b/Scripts/Locations/PrisonWalkLocation.cs @@ -485,6 +485,7 @@ private async Task BattlePrisonGuards(Character player, List gu { // Player attacks first int playerDamage = CalculateDamage(player, guard, random); + playerDamage = (int)TeamHQBonus.ApplyAttack(player, playerDamage); // v1.1.11: Team HQ Armory, last. guard.HP = Math.Max(0, guard.HP - playerDamage); await terminal.WriteAsync(Loc.Get("prison_walk.you_strike")); diff --git a/Scripts/Systems/CombatEngine.cs b/Scripts/Systems/CombatEngine.cs index 6f26cf22..d78d3ce2 100644 --- a/Scripts/Systems/CombatEngine.cs +++ b/Scripts/Systems/CombatEngine.cs @@ -3842,11 +3842,6 @@ private async Task ExecuteSingleAttack(Character attacker, Monster target, Comba attackPower += (long)(attackPower * (attacker.PermanentDamageBonus / 100.0)); } - // Team HQ Armory bonus: +5% attack per level - if (TeamHQBonus.Armory(attacker) > 0) - { - attackPower += (long)(attackPower * (TeamHQBonus.Armory(attacker) * 0.05)); - } // Knighthood bonus: +5% damage for knighted players if (attacker.IsKnighted) @@ -4716,7 +4711,7 @@ private async Task ProcessMonsterAction(Monster monster, Character player, Comba { int baseDmg = Math.Max(4, (int)(monster.MaxHP * (0.04 + random.NextDouble() * 0.02))); int dmg = baseDmg + random.Next(1, player.Level / 5 + 2); - monster.HP = Math.Max(0, monster.HP - dmg); + monster.HP = Math.Max(0, monster.HP - dmg); // hq-armory: out (burn tick, no caster recorded) monster.BurnRounds--; terminal.WriteLine(Loc.Get("combat.fire_burn", monster.Name, dmg), "red"); if (!monster.IsAlive) @@ -4734,7 +4729,7 @@ private async Task ProcessMonsterAction(Monster monster, Character player, Comba { int baseDmg = Math.Max(3, (int)(monster.MaxHP * (0.03 + random.NextDouble() * 0.02))); int dmg = baseDmg + random.Next(1, player.Level / 5 + 2); - monster.HP = Math.Max(0, monster.HP - dmg); + monster.HP = Math.Max(0, monster.HP - dmg); // hq-armory: out (poison tick, no caster recorded) monster.PoisonRounds--; terminal.WriteLine(Loc.Get("combat.poison_burn", monster.Name, dmg), "dark_green"); if (!monster.IsAlive) @@ -4754,7 +4749,7 @@ private async Task ProcessMonsterAction(Monster monster, Character player, Comba if (firstActionThisRound && monster.CorruptingDotRounds > 0) { long corruptDmg = Math.Max(1, monster.CorruptingDotTickDamage); - monster.HP = Math.Max(0, monster.HP - corruptDmg); + monster.HP = Math.Max(0, monster.HP - corruptDmg); // hq-armory: out (Corrupting Touch tick, scaled once at cast) long heal = Math.Max(1, corruptDmg / 2); long oldHP = player.HP; player.HP = Math.Min(player.MaxHP, player.HP + heal); @@ -4943,7 +4938,7 @@ private async Task ProcessMonsterAction(Monster monster, Character player, Comba else if (random.Next(100) < 25) { long selfDmg = Math.Max(1, monster.Strength / 3); - monster.HP = Math.Max(0, monster.HP - selfDmg); + monster.HP = Math.Max(0, monster.HP - selfDmg); // hq-armory: out (confusion self-hit, not player damage) terminal.SetColor("magenta"); terminal.WriteLine(Loc.Get("combat.confusion_self_damage", monster.Name, selfDmg)); if (monster.HP <= 0) @@ -5510,7 +5505,7 @@ private async Task ProcessMonsterAction(Monster monster, Character player, Comba long reflect = (long)(actualDamage * (player.TempThornReflectPercent / 100.0)); if (reflect > 0) { - monster.HP = Math.Max(0, monster.HP - reflect); + monster.HP = Math.Max(0, monster.HP - reflect); // hq-armory: out (thorn reflect, derived from damage taken) terminal.WriteLine(Loc.Get("combat.divine_mandate_reflect", monster.Name, reflect), "bright_magenta"); // If reflect kills the monster, credit the kill so XP/gold/quests register if (monster.HP <= 0 && !result.DefeatedMonsters.Contains(monster)) @@ -5556,7 +5551,7 @@ private async Task ProcessMonsterAction(Monster monster, Character player, Comba { float reflectPct = IsManweBattle && ArtifactSystem.Instance.HasVoidKey() ? 0.30f : 0.15f; long reflectedDamage = Math.Max(1, (long)(actualDamage * reflectPct)); - monster.HP = Math.Max(0, monster.HP - reflectedDamage); + monster.HP = Math.Max(0, monster.HP - reflectedDamage); // hq-armory: out (Scales of Law reflect) terminal.WriteLine(Loc.Get("combat.scales_reflect", reflectedDamage, monster.Name), "gray"); if (monster.HP <= 0) { @@ -5571,7 +5566,7 @@ private async Task ProcessMonsterAction(Monster monster, Character player, Comba if (thornsPct > 0 && actualDamage > 0 && monster.IsAlive) { long thornsDamage = Math.Max(1, actualDamage * thornsPct / 100); - monster.HP = Math.Max(0, monster.HP - thornsDamage); + monster.HP = Math.Max(0, monster.HP - thornsDamage); // hq-armory: out (equipment thorns) terminal.SetColor("yellow"); terminal.WriteLine(Loc.Get("combat.thorns_reflect", thornsDamage, monster.Name)); if (monster.HP <= 0) @@ -5589,7 +5584,7 @@ private async Task ProcessMonsterAction(Monster monster, Character player, Comba ? GameConfig.VoidreaverReflectionPercent : GameConfig.WavecallerReflectionPercent; long reflectDamage = Math.Max(1, (long)(actualDamage * reflectPercent)); - monster.HP = Math.Max(0, monster.HP - reflectDamage); + monster.HP = Math.Max(0, monster.HP - reflectDamage); // hq-armory: out (Reflecting status) if (player.Class == CharacterClass.Voidreaver) { terminal.SetColor("dark_red"); @@ -5790,7 +5785,7 @@ private async Task TryMonsterSpecialAbility(Monster monster, Character pla long reflect = (long)(actualDamage * (player.TempThornReflectPercent / 100.0)); if (reflect > 0) { - monster.HP = Math.Max(0, monster.HP - reflect); + monster.HP = Math.Max(0, monster.HP - reflect); // hq-armory: out (thorn reflect) terminal.WriteLine(Loc.Get("combat.divine_mandate_reflect", monster.Name, reflect), "bright_magenta"); if (monster.HP <= 0 && !result.DefeatedMonsters.Contains(monster)) result.DefeatedMonsters.Add(monster); @@ -5969,7 +5964,7 @@ private async Task TryMonsterSpecialAbility(Monster monster, Character pla long reflect = (long)(damage * (player.TempThornReflectPercent / 100.0)); if (reflect > 0) { - monster.HP = Math.Max(0, monster.HP - reflect); + monster.HP = Math.Max(0, monster.HP - reflect); // hq-armory: out (thorn reflect) terminal.WriteLine(Loc.Get("combat.divine_mandate_reflect", monster.Name, reflect), "bright_magenta"); if (monster.HP <= 0 && !result.DefeatedMonsters.Contains(monster)) result.DefeatedMonsters.Add(monster); @@ -6082,7 +6077,7 @@ private long ApplyBossAbilityDamageToPlayer(Monster monster, Character player, l long reflect = (long)(actualDamage * (player.TempThornReflectPercent / 100.0)); if (reflect > 0) { - monster.HP = Math.Max(0, monster.HP - reflect); + monster.HP = Math.Max(0, monster.HP - reflect); // hq-armory: out (thorn reflect) terminal.WriteLine(Loc.Get("combat.divine_mandate_reflect", monster.Name, reflect), "bright_magenta"); if (monster.HP <= 0 && !result.DefeatedMonsters.Contains(monster)) result.DefeatedMonsters.Add(monster); @@ -8183,6 +8178,9 @@ private long ApplyHandlerAbilityDamage(Character player, Monster target, long ra terminal.WriteLine(Loc.Get("combat.marked_bonus", markedBonus)); } + // v1.1.11: Team HQ Armory, after every other modifier of the hit. + actualDamage = TeamHQBonus.ApplyAttack(player, actualDamage); + target.HP -= actualDamage; result.TotalDamageDealt += actualDamage; result.Player?.Statistics.RecordDamageDealt(actualDamage, crit || alreadyCrit); @@ -8214,7 +8212,7 @@ private void ApplyPostHitEnchantments(Character attacker, Monster target, long d if (!isSpellDamage && attacker.IsPet && attacker.PetSpeciesId == "storm_eagle" && target.IsAlive) { long lightningBonus = Math.Max(1, damage / 10); - target.HP = Math.Max(0, target.HP - lightningBonus); + target.HP = Math.Max(0, target.HP - lightningBonus); // hq-armory: out (Storm Eagle pet chip) terminal.WriteLine(Loc.Get("combat.storm_eagle_lightning", attackerName, target.Name, lightningBonus), "bright_cyan"); if (target.IsAlive && random.Next(100) < 15) @@ -8382,7 +8380,7 @@ long ApplyLifestealSlice(long requested) && (target.MonsterClass == MonsterClass.Undead || target.MonsterClass == MonsterClass.Demon)) { long smite = Math.Max(1, (long)(damage * smiteBonus)); - target.HP = Math.Max(0, target.HP - smite); + target.HP = Math.Max(0, target.HP - smite); // hq-armory: out (post-hit rider) terminal.WriteLine(Loc.Get("combat.holy_smite_passive", target.Name, smite), "bright_yellow"); } } @@ -8575,7 +8573,7 @@ private void CheckElementalEnchantProcs(Character attacker, Monster target, long if (targetAlive && weapon.HasFireEnchant && random.NextDouble() < GameConfig.FireEnchantProcChance) { long fireDamage = Math.Max(1, (long)(damage * GameConfig.FireEnchantDamageMultiplier)); - target.HP = Math.Max(0, target.HP - fireDamage); + target.HP = Math.Max(0, target.HP - fireDamage); // hq-armory: out (post-hit rider) terminal.SetColor("bright_red"); terminal.WriteLine(isPlayer ? Loc.Get("combat.enchant_fire", fireDamage) @@ -8610,7 +8608,7 @@ private void CheckElementalEnchantProcs(Character attacker, Monster target, long if (targetAlive && weapon.HasLightningEnchant && random.NextDouble() < GameConfig.LightningEnchantProcChance) { long lightningDamage = Math.Max(1, (long)(damage * GameConfig.LightningEnchantDamageMultiplier)); - target.HP = Math.Max(0, target.HP - lightningDamage); + target.HP = Math.Max(0, target.HP - lightningDamage); // hq-armory: out (post-hit rider) // v0.60.0 stun-lock audit: route through TryStunMonster. Lightning enchant // was the highest-volume cheese vector (15% per attack, multi-attack/dual-wield // = ~50% per round, refresh-stack onto an already-stunned target). @@ -8636,7 +8634,7 @@ private void CheckElementalEnchantProcs(Character attacker, Monster target, long { int poisonValue = weapon.PoisonDamage > 0 ? weapon.PoisonDamage : (int)(damage * 0.10); long poisonDamage = Math.Max(1, poisonValue); - target.HP = Math.Max(0, target.HP - poisonDamage); + target.HP = Math.Max(0, target.HP - poisonDamage); // hq-armory: out (post-hit rider) terminal.SetColor("green"); // v0.60.10 (druidah report): attribute teammate procs by name (see frost note above). terminal.WriteLine(isPlayer @@ -8666,7 +8664,7 @@ private void CheckElementalEnchantProcs(Character attacker, Monster target, long if (attacker.IsAttunedTo("aurelion")) holyMult *= (1f + GameConfig.ShrineAurelionHolyProcBonus); long holyDamage = Math.Max(1, (long)(damage * holyMult)); - target.HP = Math.Max(0, target.HP - holyDamage); + target.HP = Math.Max(0, target.HP - holyDamage); // hq-armory: out (post-hit rider) terminal.SetColor("bright_white"); // v0.60.10 (druidah report): attribute teammate procs by name (see frost note above). if (isPlayer) @@ -8694,7 +8692,7 @@ private void CheckElementalEnchantProcs(Character attacker, Monster target, long else { long shadowDamage = Math.Max(1, (long)(damage * GameConfig.ShadowEnchantDamageMultiplier)); - target.HP = Math.Max(0, target.HP - shadowDamage); + target.HP = Math.Max(0, target.HP - shadowDamage); // hq-armory: out (post-hit rider) terminal.SetColor("dark_magenta"); // v0.60.10 (druidah report): attribute teammate procs by name (see frost note above). terminal.WriteLine(isPlayer @@ -8712,7 +8710,7 @@ private void CheckElementalEnchantProcs(Character attacker, Monster target, long && !IsDemonMonster(target) && random.NextDouble() < 0.12) { long wispDamage = Math.Max(1, (long)(damage * 0.10)); - target.HP = Math.Max(0, target.HP - wispDamage); + target.HP = Math.Max(0, target.HP - wispDamage); // hq-armory: out (post-hit rider) terminal.SetColor("dark_magenta"); terminal.WriteLine(Loc.Get("combat.bog_wisp_proc", wispDamage)); result.TotalDamageDealt += wispDamage; @@ -12478,6 +12476,11 @@ private async Task ApplyAoEDamage(List monsters, long totalDamage, Comb actualDamage += sleepBonus; } + // v1.1.11: Team HQ Armory, last. A null attacker is the player's own spell. + var armoryOwner = attacker ?? currentPlayer; + if (armoryOwner != null) + actualDamage = TeamHQBonus.ApplyAttack(armoryOwner, actualDamage); + monster.HP -= actualDamage; // Track damage dealt statistics @@ -12660,6 +12663,10 @@ private async Task ApplySingleMonsterDamage(Monster target, long damage, C terminal.WriteLine(Loc.Get("combat.sleep_bonus_damage", target.Name, sleepBonus)); } + // v1.1.11: Team HQ Armory, after every other modifier of the hit (0 for NPCs and companions). + if (attacker != null) + actualDamage = TeamHQBonus.ApplyAttack(attacker, actualDamage); + target.HP -= actualDamage; // Track damage dealt statistics (only for player attacks) @@ -12783,7 +12790,7 @@ private void ApplyPlayerSwingOnHitEffects(Character player, Monster target, long long shamanWeapPowMM = shamanWeaponMM?.WeaponPower ?? 0; long enchantDamage = (long)(shamanWeapPowMM * player.ShamanEnchantPower / 100.0); enchantDamage = Math.Max(1, enchantDamage); - target.HP -= (int)enchantDamage; + target.HP -= (int)enchantDamage; // hq-armory: out (post-hit rider) result.TotalDamageDealt += enchantDamage; string enchantMsg = player.ShamanEnchantType switch @@ -13008,8 +13015,6 @@ private long ComputePlayerSwingDamage(Character player, Monster target, bool isO attackPower += (long)(attackPower * GameConfig.PoisonCoatingDamageBonus); if (player.PermanentDamageBonus > 0) attackPower += (long)(attackPower * (player.PermanentDamageBonus / 100.0)); - if (TeamHQBonus.Armory(player) > 0) - attackPower += (long)(attackPower * (TeamHQBonus.Armory(player) * 0.05)); if (player.IsKnighted) attackPower += (long)(attackPower * GameConfig.KnightDamageBonus); // v0.60.11: Grand Champion +3% damage stacks with knighthood. @@ -14396,6 +14401,9 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar terminal.WriteLine(Loc.Get("combat.marked_bonus", markedBonus)); } + // v1.1.11: Team HQ Armory, after every other modifier of the hit. + actualDamage = TeamHQBonus.ApplyAttack(player, actualDamage); + target.HP -= actualDamage; result.TotalDamageDealt += actualDamage; @@ -14701,6 +14709,7 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar if (hpPercentMM < 0.30) { long executeBonusMM = abilityResult.Damage; + executeBonusMM = TeamHQBonus.ApplyAttack(player, executeBonusMM); // v1.1.11: Team HQ Armory, last. target.HP = Math.Max(0, target.HP - executeBonusMM); result.TotalDamageDealt += executeBonusMM; result.Player?.Statistics.RecordDamageDealt(executeBonusMM, false); @@ -14710,6 +14719,7 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar else if (hpPercentMM < 0.50) { long executeBonusMM = abilityResult.Damage / 2; + executeBonusMM = TeamHQBonus.ApplyAttack(player, executeBonusMM); // v1.1.11: Team HQ Armory, last. target.HP = Math.Max(0, target.HP - executeBonusMM); result.TotalDamageDealt += executeBonusMM; result.Player?.Statistics.RecordDamageDealt(executeBonusMM, false); @@ -14864,6 +14874,7 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar { long desperateDmg = (long)(abilityResult.Damage * 0.5); desperateDmg = Math.Max(1, desperateDmg - target.Defence / 4); + desperateDmg = TeamHQBonus.ApplyAttack(player, desperateDmg); // v1.1.11: Team HQ Armory, last. target.HP -= desperateDmg; result.TotalDamageDealt += desperateDmg; result.Player?.Statistics.RecordDamageDealt(desperateDmg, false); @@ -14887,6 +14898,7 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar if (fireVulnerable) { long fireBonusDmg = abilityResult.Damage / 2; + fireBonusDmg = TeamHQBonus.ApplyAttack(player, fireBonusDmg); // v1.1.11: Team HQ Armory, last. target.HP -= fireBonusDmg; result.TotalDamageDealt += fireBonusDmg; result.Player?.Statistics.RecordDamageDealt(fireBonusDmg, false); @@ -14926,6 +14938,7 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar // v0.61.0 Druid's Shrines: Aurelion attunement = +5% holy ability damage. if (result.Player != null && result.Player.IsAttunedTo("aurelion")) holyBonusDmg = (long)(holyBonusDmg * (1f + GameConfig.ShrineAurelionHolyDamageBonus)); + holyBonusDmg = TeamHQBonus.ApplyAttack(player, holyBonusDmg); // v1.1.11: Team HQ Armory, last. target.HP -= holyBonusDmg; result.TotalDamageDealt += holyBonusDmg; result.Player?.Statistics.RecordDamageDealt(holyBonusDmg, false); @@ -14952,6 +14965,7 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar (target.MonsterClass == MonsterClass.Undead || target.MonsterClass == MonsterClass.Demon || target.Undead > 0)) { long avengerBonusDmg = (long)(abilityResult.Damage * 0.75); + avengerBonusDmg = TeamHQBonus.ApplyAttack(player, avengerBonusDmg); // v1.1.11: Team HQ Armory, last. target.HP -= avengerBonusDmg; result.TotalDamageDealt += avengerBonusDmg; result.Player?.Statistics.RecordDamageDealt(avengerBonusDmg, false); @@ -15017,6 +15031,7 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar holyAoeDmg = (long)(holyAoeDmg * 1.5); long actualHolyDmg = Math.Max(1, holyAoeDmg - m.ArmPow / 2); if (m.IsSleeping) { actualHolyDmg += actualHolyDmg / 2; } + actualHolyDmg = TeamHQBonus.ApplyAttack(player, actualHolyDmg); // v1.1.11: Team HQ Armory, last. m.HP -= actualHolyDmg; result.Player?.Statistics.RecordDamageDealt(actualHolyDmg, false); result.TotalDamageDealt += actualHolyDmg; @@ -15130,6 +15145,7 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar { long frenzyDmg = Math.Max(1, abilityResult.Damage / 3 - target.Defence / 4); frenzyDmg = (long)(frenzyDmg * (0.8 + random.NextDouble() * 0.4)); + frenzyDmg = TeamHQBonus.ApplyAttack(player, frenzyDmg); // v1.1.11: Team HQ Armory, last. target.HP -= frenzyDmg; result.TotalDamageDealt += frenzyDmg; result.Player?.Statistics.RecordDamageDealt(frenzyDmg, false); @@ -15159,6 +15175,7 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar bool isExecute = m.HP < m.MaxHP * 0.3; if (isExecute) blossomDmg *= 2; if (m.IsSleeping) blossomDmg += blossomDmg / 2; + blossomDmg = TeamHQBonus.ApplyAttack(player, blossomDmg); // v1.1.11: Team HQ Armory, last. m.HP -= blossomDmg; result.TotalDamageDealt += blossomDmg; result.Player?.Statistics.RecordDamageDealt(blossomDmg, false); @@ -15362,6 +15379,7 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar var corrodeTarget = corrodeTargets[ci]; long corrodeDmg = abilityResult.Damage > 0 ? abilityResult.Damage : 50 + player.Intelligence * 2; corrodeDmg = (long)(corrodeDmg * GetAoEDiminishingMultiplier(ci)); + corrodeDmg = TeamHQBonus.ApplyAttack(player, corrodeDmg); // v1.1.11: Team HQ Armory, last. corrodeTarget.HP -= (int)corrodeDmg; result.TotalDamageDealt += corrodeDmg; corrodeTarget.IsCorroded = true; @@ -15543,6 +15561,7 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar // AoE diminishing returns per target float aoeMult = targetIdx switch { 0 => 1.0f, 1 => 0.75f, 2 => 0.50f, _ => 0.25f }; dmg = Math.Max(1, (int)(dmg * aoeMult)); + dmg = (int)TeamHQBonus.ApplyAttack(player, dmg); // v1.1.11: Team HQ Armory, last. m.HP -= dmg; totalHeal += (int)(dmg * 0.20); terminal.SetColor(isHoly ? "bright_yellow" : "bright_cyan"); @@ -15700,6 +15719,7 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar { var m = crescLiving[ci]; int dmg = (int)((abilityResult.Damage + bonusDmg) * GetAoEDiminishingMultiplier(ci)); + dmg = (int)TeamHQBonus.ApplyAttack(player, dmg); // v1.1.11: Team HQ Armory, last. m.HP -= dmg; terminal.SetColor("bright_cyan"); terminal.WriteLine(Loc.Get("combat.ability_crescendo_aoe", m.Name, dmg)); @@ -15745,6 +15765,7 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar { var m = gfLiving[gi]; int dmg = (int)(abilityResult.Damage * GetAoEDiminishingMultiplier(gi)); + dmg = (int)TeamHQBonus.ApplyAttack(player, dmg); // v1.1.11: Team HQ Armory, last. m.HP -= dmg; terminal.SetColor("bright_magenta"); terminal.WriteLine(Loc.Get("combat.ability_grand_finale", m.Name, dmg)); @@ -15817,6 +15838,7 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar foreach (var m in monsters.Where(m => m.IsAlive)) { int dmg = (int)(abilityResult.Damage * multiplier); + dmg = (int)TeamHQBonus.ApplyAttack(player, dmg); // v1.1.11: Team HQ Armory, last. m.HP -= dmg; terminal.SetColor("bright_cyan"); terminal.WriteLine(Loc.Get("combat.ability_resonance_cascade", m.Name, dmg)); @@ -15881,6 +15903,7 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar foreach (var m in monsters.Where(m => m.IsAlive)) { int dmg = abilityResult.Damage + bonusDmg; + dmg = (int)TeamHQBonus.ApplyAttack(player, dmg); // v1.1.11: Team HQ Armory, last. m.HP -= dmg; terminal.SetColor("bright_yellow"); terminal.WriteLine(Loc.Get("combat.ability_grand_finale", m.Name, dmg)); @@ -15947,7 +15970,7 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar terminal.WriteLine(Loc.Get("combat.ability_echo_25", target.Name, dmg)); if (random.Next(100) < 25) { - target.HP -= dmg; + target.HP -= dmg; // hq-armory: out (echo reuses the boosted helper hit) terminal.SetColor("bright_magenta"); terminal.WriteLine(Loc.Get("combat.ability_echo_25_echo", dmg)); } @@ -15979,6 +16002,7 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar { var m = entropyLiving[ei]; int dmg = (int)(abilityResult.Damage * GetAoEDiminishingMultiplier(ei)); + dmg = (int)TeamHQBonus.ApplyAttack(player, dmg); // v1.1.11: Team HQ Armory, last. m.HP -= dmg; m.IsMarked = true; m.MarkedDuration = Math.Max(m.MarkedDuration, abilityResult.Duration > 0 ? abilityResult.Duration : 4); @@ -16041,6 +16065,7 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar { int dmg = abilityResult.Damage; if (m.Stunned || m.IsStunned) dmg *= 2; + dmg = (int)TeamHQBonus.ApplyAttack(player, dmg); // v1.1.11: Team HQ Armory, last. m.HP -= dmg; terminal.SetColor("bright_magenta"); terminal.WriteLine(Loc.Get(m.Stunned ? "combat.ability_singularity_stun" : "combat.ability_singularity", m.Name, dmg)); @@ -16115,6 +16140,8 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar { int dotRounds = abilityResult.Duration > 0 ? abilityResult.Duration : 5; long tickDmg = Math.Max(1, abilityResult.Damage); + // v1.1.11: Team HQ Armory, once at cast; the tick has no caster to read. + tickDmg = TeamHQBonus.ApplyAttack(player, tickDmg); target.CorruptingDotRounds = Math.Max(target.CorruptingDotRounds, dotRounds); target.CorruptingDotTickDamage = Math.Max(target.CorruptingDotTickDamage, tickDmg); terminal.SetColor("dark_red"); @@ -16164,7 +16191,7 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar { var m = overflowLiving[oi]; int spreadDmg = (int)(spreadDmgBase * GetAoEDiminishingMultiplier(oi)); - m.HP -= spreadDmg; + m.HP -= spreadDmg; // hq-armory: out (spread is derived from the boosted helper hit) terminal.SetColor("bright_red"); terminal.WriteLine(Loc.Get("combat.ability_overflow_aoe_spread", m.Name, spreadDmg)); if (m.HP <= 0) @@ -16216,6 +16243,7 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar foreach (var m in monsters.Where(m => m.IsAlive)) { int dmg = abilityResult.Damage; + dmg = (int)TeamHQBonus.ApplyAttack(player, dmg); // v1.1.11: Team HQ Armory, last. m.HP -= dmg; m.Poisoned = true; m.PoisonRounds = Math.Max(m.PoisonRounds, 3); @@ -16293,6 +16321,7 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar foreach (var m in monsters.Where(m => m.IsAlive)) { int dmg = abilityResult.Damage; + dmg = (int)TeamHQBonus.ApplyAttack(player, dmg); // v1.1.11: Team HQ Armory, last. m.HP -= dmg; terminal.SetColor("bright_red"); terminal.WriteLine(Loc.Get("combat.ability_abyss_unchained", m.Name, dmg)); @@ -16475,6 +16504,7 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar { var m = vrLiving[vi]; int dmg = (int)(abilityResult.Damage * GetAoEDiminishingMultiplier(vi)); + dmg = (int)TeamHQBonus.ApplyAttack(player, dmg); // v1.1.11: Team HQ Armory, last. m.HP -= dmg; terminal.SetColor("bright_red"); terminal.WriteLine(Loc.Get("combat.ability_void_rupture", m.Name, dmg)); @@ -16492,6 +16522,7 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar foreach (var m in monsters.Where(m => m.IsAlive)) { int explosionDmg = explosionBase * kills; + explosionDmg = (int)TeamHQBonus.ApplyAttack(player, explosionDmg); // v1.1.11: Team HQ Armory, last. m.HP -= explosionDmg; terminal.SetColor("bright_red"); terminal.WriteLine(Loc.Get("combat.ability_void_rupture_explode", kills, m.Name, explosionDmg)); @@ -16645,9 +16676,10 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar // Hit primary target if (target != null && target.IsAlive) { - target.HP -= (int)chainDamage; - result.TotalDamageDealt += chainDamage; - terminal.WriteLine(Loc.Get("combat.shaman_lightning_bolt", target.Name, chainDamage), "bright_yellow"); + long primaryHit = TeamHQBonus.ApplyAttack(player, chainDamage); // v1.1.11: Team HQ Armory, last. + target.HP -= (int)primaryHit; + result.TotalDamageDealt += primaryHit; + terminal.WriteLine(Loc.Get("combat.shaman_lightning_bolt", target.Name, primaryHit), "bright_yellow"); } // Chain to other living monsters at reduced damage if (monsters != null) @@ -16658,6 +16690,7 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar { var m = chainTargets[chi]; long chainHit = (long)(chainReduced * GetAoEDiminishingMultiplier(chi)); + chainHit = TeamHQBonus.ApplyAttack(player, chainHit); // v1.1.11: Team HQ Armory, last. m.HP -= (int)chainHit; result.TotalDamageDealt += chainHit; terminal.WriteLine(Loc.Get("combat.shaman_chain_lightning", m.Name, chainHit), "yellow"); @@ -17274,6 +17307,7 @@ private void HandleSpecialSpellEffectOnMonster(Monster target, string effect, in if (target.MonsterClass == MonsterClass.Undead || target.MonsterClass == MonsterClass.Demon) { long holyBonus = (long)(spellDamage * 0.5); + holyBonus = TeamHQBonus.ApplyAttack(player, holyBonus); // v1.1.11: Team HQ Armory, last. target.HP -= holyBonus; terminal.WriteLine(Loc.Get("combat.spell_holy_bonus", target.Name, holyBonus), "bright_yellow"); result.CombatLog.Add($"Holy bonus: {holyBonus} vs {target.MonsterClass}"); @@ -17343,6 +17377,7 @@ private void HandleSpecialSpellEffectOnMonster(Monster target, string effect, in if (target.IsAlive && target.ArmPow > 0) { long bonusDmg = Math.Max(1, target.ArmPow); + bonusDmg = TeamHQBonus.ApplyAttack(player, bonusDmg); // v1.1.11: Team HQ Armory, last. target.HP -= bonusDmg; terminal.SetColor("dark_red"); terminal.WriteLine(Loc.Get("combat.void_bolt_pierce", bonusDmg)); @@ -17360,6 +17395,7 @@ private void HandleSpecialSpellEffectOnMonster(Monster target, string effect, in if (target.IsAlive && target.ArmPow > 0) { long shadowBonus = Math.Max(1, target.ArmPow); + shadowBonus = TeamHQBonus.ApplyAttack(player, shadowBonus); // v1.1.11: Team HQ Armory, last. target.HP -= shadowBonus; terminal.WriteLine(Loc.Get("combat.shadow_strike_bypass", shadowBonus), "dark_red"); if (target.HP <= 0) @@ -17396,6 +17432,7 @@ private void HandleSpecialSpellEffectOnMonster(Monster target, string effect, in if (target.IsAlive && target.ArmPow > 0) { long halfDefBonus = Math.Max(1, target.ArmPow / 2); + halfDefBonus = TeamHQBonus.ApplyAttack(player, halfDefBonus); // v1.1.11: Team HQ Armory, last. target.HP -= halfDefBonus; terminal.SetColor("cyan"); terminal.WriteLine(Loc.Get("combat.future_echo", target.Name, halfDefBonus)); @@ -17444,6 +17481,7 @@ private void HandleSpecialSpellEffectOnMonster(Monster target, string effect, in if (target.IsAlive && target.ArmPow > 0) { long pierceDmg = Math.Max(1, target.ArmPow); + pierceDmg = TeamHQBonus.ApplyAttack(player, pierceDmg); // v1.1.11: Team HQ Armory, last. target.HP -= pierceDmg; result.TotalDamageDealt += pierceDmg; terminal.WriteLine(Loc.Get("combat.flames_penetrate_armor", pierceDmg), "bright_red"); @@ -17503,7 +17541,7 @@ private void HandleSpecialSpellEffectOnMonster(Monster target, string effect, in long paradoxBonus = (long)(result.TotalDamageDealt * 0.10); if (paradoxBonus > 0) { - target.HP -= (int)paradoxBonus; + target.HP -= (int)paradoxBonus; // hq-armory: out (derived from TotalDamageDealt, which already carries it) terminal.WriteLine(Loc.Get("combat.paradox_collapse_bonus", paradoxBonus), "bright_magenta"); if (target.HP <= 0) { @@ -18819,6 +18857,7 @@ private async Task TryTeammateOffensiveSpell(Character teammate, List TryTeammateOffensiveSpell(Character teammate, List 0) { - monster.HP = Math.Max(0, monster.HP - reflect); + monster.HP = Math.Max(0, monster.HP - reflect); // hq-armory: out (thorn reflect) if (monster.HP <= 0 && !result.DefeatedMonsters.Contains(monster)) result.DefeatedMonsters.Add(monster); terminal.WriteLine(Loc.Get("combat.divine_mandate_reflect", monster.Name, reflect), "bright_magenta"); @@ -19955,7 +19995,7 @@ private async Task MonsterAttacksCompanion(Monster monster, Character companion, { float reflectPercent = GameConfig.WavecallerReflectionPercent; long reflectDamage = Math.Max(1, (long)(actualDamage * reflectPercent)); - monster.HP = Math.Max(0, monster.HP - reflectDamage); + monster.HP = Math.Max(0, monster.HP - reflectDamage); // hq-armory: out (Reflecting status) terminal.SetColor("bright_cyan"); terminal.WriteLine($" {companion.DisplayName}'s tidal barrier reflects {reflectDamage} damage back at {monster.Name}!"); if (monster.HP <= 0) @@ -25050,6 +25090,8 @@ private void ProcessShamanTotemEffects(Character player, List monsters, foreach (var m in allLiving) { long searingDmg = player.ActiveTotemPower + random.Next(20); + // v1.1.11: Team HQ Armory of the totem's owner. + searingDmg = TeamHQBonus.ApplyAttack(player, searingDmg); m.HP -= (int)searingDmg; totalSearingDmg += searingDmg; result.TotalDamageDealt += searingDmg; @@ -25373,7 +25415,8 @@ private async Task ExecutePvPSingleHit(Character attacker, Character defender, C defense = (long)(defense * 1.5); } - long damage = Math.Max(1, attackPower - defense); + // v1.1.11: Team HQ Armory on the hit, inside the floor. + long damage = Math.Max(1, TeamHQBonus.ApplyAttack(attacker, attackPower - defense)); defender.HP = Math.Max(0, defender.HP - damage); // Track statistics @@ -25475,8 +25518,10 @@ private async Task ExecutePvPSpell(Character attacker, Character defender, Comba // Apply damage to defender if (spellResult.Damage > 0) { - defender.HP = Math.Max(0, defender.HP - spellResult.Damage); - terminal.WriteLine(Loc.Get("combat.pvp_magical_damage", defender.DisplayName, spellResult.Damage), "bright_magenta"); + // v1.1.11: Team HQ Armory, last. + long spellDamage = TeamHQBonus.ApplyAttack(attacker, spellResult.Damage); + defender.HP = Math.Max(0, defender.HP - spellDamage); + terminal.WriteLine(Loc.Get("combat.pvp_magical_damage", defender.DisplayName, spellDamage), "bright_magenta"); } // Apply healing to self @@ -25707,10 +25752,15 @@ private async Task ExecutePvPAbility(Character attacker, Character defender, Com } // Apply defense (abilities partially bypass defense) + // v1.1.11: Team HQ Armory, last, and inside the floor. if (abilityResult.SpecialEffect != "armor_pierce") { long defense = defender.Defence / 2; - actualDamage = Math.Max(1, actualDamage - defense); + actualDamage = Math.Max(1, TeamHQBonus.ApplyAttack(attacker, actualDamage - defense)); + } + else + { + actualDamage = TeamHQBonus.ApplyAttack(attacker, actualDamage); } defender.HP = Math.Max(0, defender.HP - actualDamage); @@ -25824,6 +25874,7 @@ private async Task ProcessComputerPlayerAction(Character computer, Character opp long spellDamage = spellResult.Damage; if (opponent.IsDefending || opponent.HasStatus(StatusEffect.Defending)) spellDamage = (long)Math.Ceiling(spellDamage / 2.0); + spellDamage = TeamHQBonus.ApplyAttack(computer, spellDamage); // v1.1.11: Team HQ Armory, last. opponent.HP = Math.Max(0, opponent.HP - spellDamage); terminal.WriteLine(Loc.Get("combat.pvp_magical_damage", opponent.DisplayName, spellDamage), "bright_magenta"); } @@ -25868,6 +25919,7 @@ private async Task ProcessComputerPlayerAction(Character computer, Character opp // human-attacker path, where the AI can never be defending). if (opponent.IsDefending || opponent.HasStatus(StatusEffect.Defending)) actualDamage = (long)Math.Ceiling(actualDamage / 2.0); + actualDamage = TeamHQBonus.ApplyAttack(computer, actualDamage); // v1.1.11: Team HQ Armory, last. opponent.HP = Math.Max(0, opponent.HP - actualDamage); terminal.WriteLine(Loc.Get("combat.pvp_ai_uses_ability", computer.DisplayName, chosen.Name, actualDamage), "bright_red"); } @@ -25921,6 +25973,7 @@ private async Task ProcessComputerPlayerAction(Character computer, Character opp // Honor the human's Defend stance in the AI's basic attack path. if (opponent.IsDefending || opponent.HasStatus(StatusEffect.Defending)) damage = (long)Math.Ceiling(damage / 2.0); + damage = TeamHQBonus.ApplyAttack(computer, damage); // v1.1.11: Team HQ Armory, last. opponent.HP = Math.Max(0, opponent.HP - damage); terminal.WriteLine(Loc.Get("combat.pvp_ai_strikes", computer.DisplayName, damage), "red"); result.CombatLog.Add($"{computer.DisplayName} hits {opponent.DisplayName} for {damage}"); diff --git a/Scripts/Systems/WorldBossSystem.cs b/Scripts/Systems/WorldBossSystem.cs index 8218611a..77f4d6eb 100644 --- a/Scripts/Systems/WorldBossSystem.cs +++ b/Scripts/Systems/WorldBossSystem.cs @@ -878,6 +878,9 @@ private void DrawLeaderboard(TerminalEmulator terminal, List +/// v1.1.11: the Team HQ Armory (+5% per level) multiplies every hit a player makes on an enemy, PvE +/// and PvP, after every other modifier of that hit and just before it leaves the enemy's HP. There is +/// no single choke point, so every enemy-HP write in CombatEngine must either apply +/// TeamHQBonus.ApplyAttack within the eight lines above it, sit in a method listed below with its +/// reason, or carry an "hq-armory: out (reason)" comment for a derived write (riders, reflect, DoT +/// ticks, a hit that reuses an already boosted number). +/// +[Collection("SharedGameSingletons")] +public class ArmoryCoverageTests +{ + private static string RepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !(Directory.Exists(Path.Combine(dir.FullName, "Scripts")) && Directory.Exists(Path.Combine(dir.FullName, "Localization")))) + dir = dir.Parent; + dir.Should().NotBeNull("the test must run inside the repository"); + return dir!.FullName; + } + + // ".HP -= ..." or ".HP = Math.Max(0, .HP - ...)" + private static readonly Regex HpWrite = new(@"\b([A-Za-z_][A-Za-z_0-9]*)\.HP\s*(?:-=|=\s*Math\.Max\(\s*0\s*,\s*\1\.HP\s*-)"); + + private static readonly Regex MethodSignature = new(@"^\s*(?:public|private|internal|protected)\b[^=;]*?(\w+)\s*(?:<[^>]*>)?\s*\("); + + // The variables that hold the player's side as the victim: a write to one of them is damage taken. + // The PvP victims (defender, and opponent in ProcessComputerPlayerAction) are enemies here. + private static readonly HashSet PlayerSide = new() { "player", "currentPlayer", "companion", "teammate", "tm", "member", "ally", "groupedPlayer" }; + + private static readonly Dictionary ExcludedMethods = new() + { + ["ExecuteSingleAttack"] = "dead: only reached from ProcessPlayerAction, which throws DeadCombatPathGuard", + ["ExecuteBackstab"] = "dead: only called from ProcessPlayerAction", + ["ExecuteSoulStrike"] = "dead: only called from ProcessPlayerAction", + ["ExecutePowerAttack"] = "dead: only called from ProcessPlayerAction", + ["ExecutePreciseStrike"] = "dead: only called from ProcessPlayerAction", + ["ExecuteRangedAttack"] = "dead: only called from ProcessPlayerAction", + ["ExecuteSmite"] = "dead: only called from ProcessPlayerAction", + ["ExecuteFightToDeath"] = "dead: only called from ProcessPlayerAction", + ["ApplyAbilityEffects"] = "dead: the single-monster ability path throws DeadCombatPathGuard", + ["ApplySpellEffects"] = "dead monster branch: every live caller passes a null monster", + ["ExecuteLearnedAbility"] = "dead: no callers", + ["ProcessTeammateAction"] = "dead: the single-monster teammate turn has no callers", + ["CheckElementalEnchantProcsMonster"] = "dead: no callers", + ["ProcessCorruptionTick"] = "the target is a player-side Character, not an enemy", + ["ProcessBossAoE"] = "the target is a player-side Character, not an enemy", + }; + + private static string MethodAt(string[] lines, int index) + { + for (int j = index; j >= 0; j--) + { + var m = MethodSignature.Match(lines[j]); + if (m.Success) return m.Groups[1].Value; + } + return "?"; + } + + private static List<(int line, string method, string text)> EnemyHpWrites(string[] lines) + { + var hits = new List<(int, string, string)>(); + for (int i = 0; i < lines.Length; i++) + { + string code = lines[i].TrimStart(); + if (code.StartsWith("//") || code.StartsWith("*")) continue; + var m = HpWrite.Match(lines[i]); + if (!m.Success || PlayerSide.Contains(m.Groups[1].Value)) continue; + hits.Add((i + 1, MethodAt(lines, i), lines[i].Trim())); + } + return hits; + } + + [Fact] + public void EveryEnemyHpWriteInCombatEngine_AppliesTheArmoryOrSaysWhyNot() + { + string path = Path.Combine(RepoRoot(), "Scripts", "Systems", "CombatEngine.cs"); + var lines = File.ReadAllLines(path); + var hits = EnemyHpWrites(lines); + hits.Count.Should().BeGreaterThan(80, "the scan must find the enemy-HP writes, or it proves nothing"); + + var problems = new List(); + foreach (var (line, method, text) in hits) + { + if (ExcludedMethods.ContainsKey(method)) continue; + int from = Math.Max(0, line - 1 - 8); + bool applied = lines.Skip(from).Take(line - from).Any(l => l.Contains("TeamHQBonus.ApplyAttack")); + int comment = text.IndexOf("//", StringComparison.Ordinal); + bool markedOut = comment >= 0 && Regex.IsMatch(text.Substring(comment), @"hq-armory: out \(\S[^)]*\)"); + if (!applied && !markedOut) + problems.Add($"CombatEngine.cs:{line} in {method}: {text}"); + } + problems.Should().BeEmpty("each enemy-HP write applies TeamHQBonus.ApplyAttack just before it or is marked out with a reason"); + } + + [Fact] + public void TheExcludedMethods_StillExist() + { + var lines = File.ReadAllLines(Path.Combine(RepoRoot(), "Scripts", "Systems", "CombatEngine.cs")); + var methods = lines.Select(l => MethodSignature.Match(l)).Where(m => m.Success).Select(m => m.Groups[1].Value).ToHashSet(); + ExcludedMethods.Keys.Where(k => !methods.Contains(k)).Should().BeEmpty("a stale exclusion would hide a new method of the same name"); + } + + [Fact] + public void TheOldArmoryReaders_AreGone() + { + string src = File.ReadAllText(Path.Combine(RepoRoot(), "Scripts", "Systems", "CombatEngine.cs")); + src.Should().NotContain("TeamHQBonus.Armory(", "Armory added to attack power before armor counted twice with ApplyAttack"); + } + + private static string MethodBody(string file, string method) + { + var lines = File.ReadAllLines(Path.Combine(RepoRoot(), file)); + int start = Array.FindIndex(lines, l => { var m = MethodSignature.Match(l); return m.Success && m.Groups[1].Value == method; }); + start.Should().BeGreaterThanOrEqualTo(0, $"{method} must exist in {file}"); + int end = start + 1; + while (end < lines.Length && !MethodSignature.IsMatch(lines[end])) end++; + return string.Join("\n", lines.Skip(start).Take(end - start)); + } + + private static int Count(string text, string what) => Regex.Matches(text, Regex.Escape(what)).Count; + + [Theory] + [InlineData("Scripts/Systems/WorldBossSystem.cs", "RunWorldBossCombat", 1)] + [InlineData("Scripts/Locations/CastleLocation.cs", "CastleSiegeMenu", 3)] + [InlineData("Scripts/Locations/PrisonWalkLocation.cs", "BattlePrisonGuards", 1)] + [InlineData("Scripts/Locations/DungeonLocation.cs", "FightMalachar", 1)] + public void TheFightsOutsideCombatEngine_ApplyTheArmory(string file, string method, int sites) + { + Count(MethodBody(file, method), "TeamHQBonus.ApplyAttack(").Should().BeGreaterThanOrEqualTo(sites); + } + + [Fact] + public void TheWorldBoss_AppliesTheArmoryBeforeTheRatioAndCap_AndReadsTheLevels() + { + string body = MethodBody("Scripts/Systems/WorldBossSystem.cs", "RunWorldBossCombat"); + int armory = body.IndexOf("TeamHQBonus.ApplyAttack(player, roundDamage)", StringComparison.Ordinal); + int applied = body.IndexOf("WorldBossMath.Applied(roundDamage", StringComparison.Ordinal); + armory.Should().BeGreaterThan(0); + applied.Should().BeGreaterThan(armory); + body.Should().Contain("TeamHQBonus.RefreshLevels(player)"); + } + + // Behaviour: the real helpers, a hit on a monster with armor 200. + private static readonly BindingFlags F = BindingFlags.NonPublic | BindingFlags.Instance; + + private static CombatEngine Engine() => new CombatEngine(new TerminalEmulator(new MemoryStream(), new MemoryStream())); + + private static Monster Dummy() => new Monster { Name = "Stone Dummy", Level = 1, HP = 1_000_000, MaxHP = 1_000_000, ArmPow = 200, IsActive = true }; + + private static Character Hero(int armory, string levelsTeam = "X") => new Character + { + Name1 = "armory", Name2 = "Armory", Class = CharacterClass.Warrior, Level = 10, HP = 500, MaxHP = 500, + Team = "X", HQLevelsTeam = armory > 0 ? levelsTeam : "", HQArmoryLevel = armory, + }; + + private static async Task SingleHit(Character attacker) + { + var target = Dummy(); + long before = target.HP; + await (Task)typeof(CombatEngine).GetMethod("ApplySingleMonsterDamage", F)! + .Invoke(Engine(), new object?[] { target, 1200L, new CombatResult(), "attack", attacker, false })!; + return before - target.HP; + } + + [Fact] + public async Task ASingleHit_GainsFivePercentPerArmoryLevel_AfterArmor() + { + long plain = await SingleHit(Hero(0)); + long armory = await SingleHit(Hero(2)); + plain.Should().Be(1000, "1,200 against 200 armor"); + armory.Should().Be(1100, "Armory 2 is +10% of the hit after armor, not of the attack before it"); + } + + [Fact] + public async Task LevelsReadForAnotherTeam_GiveNothing() + { + (await SingleHit(Hero(2, levelsTeam: "Y"))).Should().Be(1000); + } + + [Fact] + public async Task ThePlayersOwnAoESpell_UsesTheCurrentPlayersArmory() + { + async Task AoE(Character caster) + { + var engine = Engine(); + typeof(CombatEngine).GetField("currentPlayer", F)!.SetValue(engine, caster); + var target = Dummy(); + long before = target.HP; + await (Task)typeof(CombatEngine).GetMethod("ApplyAoEDamage", F)! + .Invoke(engine, new object?[] { new List { target }, 1200L, new CombatResult(), "spell", true, null })!; + return before - target.HP; + } + (await AoE(Hero(0))).Should().Be(1000); + (await AoE(Hero(2))).Should().Be(1100); + } +} From c2476733f397f4205c40186f042cbc6cc7256a5d Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 18:13:05 +0000 Subject: [PATCH 08/42] The Team HQ Barracks reaches every hit a player takes The Barracks added to defence in the monster basic attack only, before the minimum floor and the caps; specials, boss abilities, Manwe, the boss channel, AoE and corruption, grouped players, PvP, the world boss and the scripted fights had none of it. It now divides the damage of each hit by 1 + 5% per level, as the last modifier before it lands and before anything computed from the final number (divine intervention, sacrifice, reflect, drain heals). Where the path has an anti-tank minimum, the minimum is applied again afterwards, without ever raising a hit above its value before the Barracks. In a duel the attacker's Armory applies first, then the defender's Barracks. Damage-over-time ticks, disease, drugs and the world boss's telegraphed strikes (answered by bracing) are left as they are. A test reads CombatEngine and fails on any player HP write that neither applies it nor says why not. The status screen shows the real reduction ("-9% damage taken" at level 2), and the texts say "damage taken" in all five languages. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Localization/en.json | 4 +- Localization/es.json | 4 +- Localization/fr.json | 4 +- Localization/hu.json | 4 +- Localization/it.json | 4 +- Scripts/Locations/BaseLocation.cs | 2 +- Scripts/Locations/CastleLocation.cs | 2 + Scripts/Locations/PrisonWalkLocation.cs | 1 + Scripts/Systems/CombatEngine.cs | 78 ++++--- Scripts/Systems/WorldBossSystem.cs | 10 +- Tests/BarracksCoverageTests.cs | 281 ++++++++++++++++++++++++ Tests/TeamHQBonusTests.cs | 1 + 12 files changed, 352 insertions(+), 43 deletions(-) create mode 100644 Tests/BarracksCoverageTests.cs diff --git a/Localization/en.json b/Localization/en.json index f62c83d1..d587fb0f 100644 --- a/Localization/en.json +++ b/Localization/en.json @@ -13181,7 +13181,7 @@ "team.upgrade_armory": "Armory", "team.upgrade_armory_desc": "+5% damage dealt per level", "team.upgrade_barracks": "Barracks", - "team.upgrade_barracks_desc": "+5% defense per level", + "team.upgrade_barracks_desc": "Less damage taken per level (+5% defense)", "team.upgrade_training": "Training Grounds", "team.upgrade_training_desc": "+5% XP bonus per level", "team.upgrade_vault": "Vault", @@ -15206,7 +15206,7 @@ "base.effect_smite": " - Smite Evil: {0} charges", "base.stat_active_buffs": "Active Buffs:", "base.hq_armory": "Team Armory Lv {0}: +{1}% damage dealt", - "base.hq_barracks": "Team Barracks Lv {0}: +{1}% defense", + "base.hq_barracks": "Team Barracks Lv {0}: -{1}% damage taken", "base.hq_training": "Team Training Grounds Lv {0}: +{1}% XP", "base.hq_infirmary": "Team Infirmary Lv {0}: +{1}% potion healing", "base.buff_potion_mastery": " - Potion Mastery: +50% healing (potions, herbs, elixirs)", diff --git a/Localization/es.json b/Localization/es.json index 439adc64..6cb5de41 100644 --- a/Localization/es.json +++ b/Localization/es.json @@ -12996,7 +12996,7 @@ "team.upgrade_armory": "Armería", "team.upgrade_armory_desc": "+5% daño infligido por nivel", "team.upgrade_barracks": "Barracas", - "team.upgrade_barracks_desc": "+5% defensa por nivel", + "team.upgrade_barracks_desc": "Menos daño recibido por nivel (+5% defensa)", "team.upgrade_training": "Campo de Entrenamiento", "team.upgrade_training_desc": "+5% bonif. XP por nivel", "team.upgrade_vault": "Bóveda", @@ -15021,7 +15021,7 @@ "base.effect_smite": " Castigo Divino: {0} carga(s)", "base.stat_active_buffs": "Mejoras Activas:", "base.hq_armory": "Armería del equipo Nv {0}: +{1}% daño infligido", - "base.hq_barracks": "Barracas del equipo Nv {0}: +{1}% defensa", + "base.hq_barracks": "Barracas del equipo Nv {0}: -{1}% daño recibido", "base.hq_training": "Campo de Entrenamiento del equipo Nv {0}: +{1}% XP", "base.hq_infirmary": "Enfermería del equipo Nv {0}: +{1}% curación con pociones", "base.buff_potion_mastery": " - Maestria en Pociones: +50% curación (pociones, hierbas, elixires)", diff --git a/Localization/fr.json b/Localization/fr.json index 17124e9f..753f8fe4 100644 --- a/Localization/fr.json +++ b/Localization/fr.json @@ -12996,7 +12996,7 @@ "team.upgrade_armory": "Armurerie", "team.upgrade_armory_desc": "+5% dégâts infligés par niveau", "team.upgrade_barracks": "Caserne", - "team.upgrade_barracks_desc": "+5% défense par niveau", + "team.upgrade_barracks_desc": "Moins de dégâts subis par niveau (+5% défense)", "team.upgrade_training": "Terrain d’Entraînement", "team.upgrade_training_desc": "+5% bonus XP par niveau", "team.upgrade_vault": "Coffre", @@ -15010,7 +15010,7 @@ "base.effect_smite": " - Châtiment : {0} charges", "base.stat_active_buffs": "Bonus Actifs :", "base.hq_armory": "Armurerie de l’équipe Niv {0} : +{1}% dégâts infligés", - "base.hq_barracks": "Caserne de l’équipe Niv {0} : +{1}% défense", + "base.hq_barracks": "Caserne de l’équipe Niv {0} : -{1}% dégâts subis", "base.hq_training": "Terrain d’Entraînement de l’équipe Niv {0} : +{1}% XP", "base.hq_infirmary": "Infirmerie de l’équipe Niv {0} : +{1}% soin des potions", "base.buff_potion_mastery": " - Maîtrise des Potions : +50% soins (potions, herbes, élixirs)", diff --git a/Localization/hu.json b/Localization/hu.json index 44d9e4d1..59a93c23 100644 --- a/Localization/hu.json +++ b/Localization/hu.json @@ -13004,7 +13004,7 @@ "team.upgrade_armory": "Fegyvertár", "team.upgrade_armory_desc": "+5% okozott sebzés szintenként", "team.upgrade_barracks": "Laktanya", - "team.upgrade_barracks_desc": "+5% védelem szintenként", + "team.upgrade_barracks_desc": "Kevesebb elszenvedett sebzés szintenként (+5% védelem)", "team.upgrade_training": "Gyakorlótér", "team.upgrade_training_desc": "+5% TP bónusz szintenként", "team.upgrade_vault": "Kincstár", @@ -15029,7 +15029,7 @@ "base.effect_smite": " - Gonosz sújtása: {0} töltet", "base.stat_active_buffs": "Aktív erősítések:", "base.hq_armory": "Csapat fegyvertár, {0}. szint: +{1}% okozott sebzés", - "base.hq_barracks": "Csapat laktanya, {0}. szint: +{1}% védelem", + "base.hq_barracks": "Csapat laktanya, {0}. szint: -{1}% elszenvedett sebzés", "base.hq_training": "Csapat gyakorlótér, {0}. szint: +{1}% XP", "base.hq_infirmary": "Csapat ispotály, {0}. szint: +{1}% bájitalos gyógyítás", "base.buff_potion_mastery": " - Főzetmesterség: +50% gyógyulás (főzetek, gyógynövények, elixírek)", diff --git a/Localization/it.json b/Localization/it.json index cea2c0fd..b41058dd 100644 --- a/Localization/it.json +++ b/Localization/it.json @@ -12996,7 +12996,7 @@ "team.upgrade_armory": "Armeria", "team.upgrade_armory_desc": "+5% danni inflitti per livello", "team.upgrade_barracks": "Caserma", - "team.upgrade_barracks_desc": "+5% difesa per livello", + "team.upgrade_barracks_desc": "Meno danni subiti per livello (+5% difesa)", "team.upgrade_training": "Campo di Addestramento", "team.upgrade_training_desc": "+5% bonus PE per livello", "team.upgrade_vault": "Forziere", @@ -15010,7 +15010,7 @@ "base.effect_smite": " - Punizione Sacra: {0} cariche", "base.stat_active_buffs": "Potenziamenti Attivi:", "base.hq_armory": "Armeria della squadra Lv {0}: +{1}% danni inflitti", - "base.hq_barracks": "Caserma della squadra Lv {0}: +{1}% difesa", + "base.hq_barracks": "Caserma della squadra Lv {0}: -{1}% danni subiti", "base.hq_training": "Campo di Addestramento della squadra Lv {0}: +{1}% PE", "base.hq_infirmary": "Infermeria della squadra Lv {0}: +{1}% guarigione da pozioni", "base.buff_potion_mastery": " - Maestria Pozioni: +50% cura (pozioni, erbe, elisir)", diff --git a/Scripts/Locations/BaseLocation.cs b/Scripts/Locations/BaseLocation.cs index e7a5c430..b276c842 100644 --- a/Scripts/Locations/BaseLocation.cs +++ b/Scripts/Locations/BaseLocation.cs @@ -6910,7 +6910,7 @@ protected virtual async Task ShowStatus() if (armory > 0) terminal.WriteLine($" - {Loc.Get("base.hq_armory", armory, (int)Math.Round(armory * TeamHQBonus.ArmoryPerLevel * 100))}"); if (barracks > 0) - terminal.WriteLine($" - {Loc.Get("base.hq_barracks", barracks, (int)Math.Round(barracks * TeamHQBonus.BarracksPerLevel * 100))}"); + terminal.WriteLine($" - {Loc.Get("base.hq_barracks", barracks, (int)Math.Round((1.0 - 1.0 / TeamHQBonus.DefenseMultiplier(currentPlayer)) * 100))}"); // v1.1.11: the real reduction (damage / (1 + 5% per level)) if (training > 0) terminal.WriteLine($" - {Loc.Get("base.hq_training", training, (int)Math.Round(training * TeamHQBonus.TrainingPerLevel * 100))}"); if (infirmary > 0) diff --git a/Scripts/Locations/CastleLocation.cs b/Scripts/Locations/CastleLocation.cs index 649d7c26..7235977b 100644 --- a/Scripts/Locations/CastleLocation.cs +++ b/Scripts/Locations/CastleLocation.cs @@ -8612,6 +8612,7 @@ private async Task CastleSiegeMenu() // Monster retaliates long monsterDmg = Math.Max(1, monsterStr - teamDefense / memberCount); monsterDmg = (long)(monsterDmg * (0.8 + random.NextDouble() * 0.4)); + monsterDmg = TeamHQBonus.ApplyDefense(currentPlayer, monsterDmg); // v1.1.11: Team HQ Barracks, last. teamHP -= monsterDmg; terminal.SetColor("red"); @@ -8682,6 +8683,7 @@ private async Task CastleSiegeMenu() long guardDmg = Math.Max(1, guardStr - teamDefense / memberCount); guardDmg = (long)(guardDmg * (0.8 + random.NextDouble() * 0.4)); + guardDmg = TeamHQBonus.ApplyDefense(currentPlayer, guardDmg); // v1.1.11: Team HQ Barracks, last. teamHP -= guardDmg; terminal.SetColor("red"); diff --git a/Scripts/Locations/PrisonWalkLocation.cs b/Scripts/Locations/PrisonWalkLocation.cs index d4d7370c..e6bf7560 100644 --- a/Scripts/Locations/PrisonWalkLocation.cs +++ b/Scripts/Locations/PrisonWalkLocation.cs @@ -501,6 +501,7 @@ private async Task BattlePrisonGuards(Character player, List gu // Guard counter-attacks int guardDamage = CalculateDamage(guard, player, random); + guardDamage = (int)TeamHQBonus.ApplyDefense(player, guardDamage); // v1.1.11: Team HQ Barracks, last. player.HP = Math.Max(0, player.HP - guardDamage); await terminal.WriteAsync(Loc.Get("prison_walk.strikes_back", guard.Name2)); diff --git a/Scripts/Systems/CombatEngine.cs b/Scripts/Systems/CombatEngine.cs index d78d3ce2..3364a80f 100644 --- a/Scripts/Systems/CombatEngine.cs +++ b/Scripts/Systems/CombatEngine.cs @@ -1520,7 +1520,7 @@ public async Task PlayerVsMonsters( long drainAmount = Math.Min(drugEffects.HPDrain, player.HP - 1); // Don't kill from drain (min 1 HP) if (drainAmount > 0) { - player.HP -= drainAmount; + player.HP -= drainAmount; // hq-barracks: out (drug drain, self-inflicted) terminal.SetColor("dark_red"); terminal.WriteLine(Loc.Get("combat.drug_drain", drainAmount)); } @@ -1535,7 +1535,7 @@ public async Task PlayerVsMonsters( int poisonIntensity = player.Poison / 5; int poisonDmg = Math.Min(poisonBase + poisonLevel + poisonIntensity, (int)Math.Max(3, player.MaxHP / 10)); - player.HP = Math.Max(0, player.HP - poisonDmg); + player.HP = Math.Max(0, player.HP - poisonDmg); // hq-barracks: out (trap and event poison counter) terminal.SetColor("dark_green"); terminal.WriteLine(Loc.Get("combat.poison_courses", poisonDmg)); @@ -5317,12 +5317,6 @@ private async Task ProcessMonsterAction(Monster monster, Character player, Comba playerDefense += (long)(playerDefense * (player.PermanentDefenseBonus / 100.0)); } - // Team HQ Barracks bonus: +5% defense per level - if (TeamHQBonus.Barracks(player) > 0) - { - playerDefense += (long)(playerDefense * (TeamHQBonus.Barracks(player) * 0.05)); - } - // Knighthood bonus: +5% defense for knighted players if (player.IsKnighted) { @@ -5460,6 +5454,10 @@ private async Task ProcessMonsterAction(Monster monster, Character player, Comba // v0.65.8 (R3): failed flee this round -> guarded half-round actualDamage = ApplyFleeGrace(player, actualDamage); + // v1.1.11: Team HQ Barracks last, before divine intervention, sacrifice and reflect read the + // number; the anti-tank minimum holds where it held before. + actualDamage = Math.Max(Math.Min(actualDamage, minDamage), TeamHQBonus.ApplyDefense(player, actualDamage)); + // Invulnerable: divine shield blocks all damage. Checked BEFORE divine // intervention and companion sacrifice -- the sacrifice used to fire first, // permanently killing a companion to absorb a hit the shield would have @@ -5776,6 +5774,8 @@ private async Task TryMonsterSpecialAbility(Monster monster, Character pla // v0.65.8 (R3): failed flee this round -> guarded half-round actualDamage = ApplyFleeGrace(player, actualDamage); + // v1.1.11: Team HQ Barracks last; the anti-tank minimum holds where it held before. + actualDamage = Math.Max(Math.Min(actualDamage, GetMinIncomingDamage(player, abilityResult.DirectDamage)), TeamHQBonus.ApplyDefense(player, actualDamage)); player.HP = Math.Max(0, player.HP - actualDamage); @@ -5893,6 +5893,8 @@ private async Task TryMonsterSpecialAbility(Monster monster, Character pla // v0.65.8 (R3): failed flee this round -> guarded half-round damage = ApplyFleeGrace(player, damage); + // v1.1.11: Team HQ Barracks last, before the drain heal reads it; the minimum holds where it held. + damage = Math.Max(Math.Min(damage, GetMinIncomingDamage(player, rawLifeStealDamage)), TeamHQBonus.ApplyDefense(player, damage)); player.HP -= damage; long healAmount = damage * abilityResult.LifeStealPercent / 100; @@ -5954,6 +5956,8 @@ private async Task TryMonsterSpecialAbility(Monster monster, Character pla // v0.65.8 (R3): failed flee this round -> guarded half-round damage = ApplyFleeGrace(player, damage); + // v1.1.11: Team HQ Barracks last; the anti-tank minimum holds where it held before. + damage = Math.Max(Math.Min(damage, GetMinIncomingDamage(player, rawDamage)), TeamHQBonus.ApplyDefense(player, damage)); player.HP -= damage; terminal.WriteLine(Loc.Get("combat.you_take_damage", damage), "red"); @@ -6068,6 +6072,7 @@ private long ApplyBossAbilityDamageToPlayer(Monster monster, Character player, l // Failed flee this round -> guarded half-round actualDamage = ApplyFleeGrace(player, actualDamage); + actualDamage = TeamHQBonus.ApplyDefense(player, actualDamage); // v1.1.11: Team HQ Barracks, last player.HP = Math.Max(0, player.HP - actualDamage); @@ -6443,7 +6448,7 @@ private async Task TryManweBossAbility(Monster monster, Character player, case "Unmake": { - long damage = Math.Min(maxDmg, Math.Max(1, (long)(baseDamage * 2.5) - player.Defence)); + long damage = TeamHQBonus.ApplyDefense(player, Math.Min(maxDmg, Math.Max(1, (long)(baseDamage * 2.5) - player.Defence))); // v1.1.11: Team HQ Barracks, last if (player.HasStatus(StatusEffect.Invulnerable)) { terminal.WriteLine($" {Loc.Get("combat.manwe_unmake_shield")}", "bright_white"); @@ -6473,7 +6478,7 @@ private async Task TryManweBossAbility(Monster monster, Character player, { flavor = $" {Loc.Get("combat.manwe_judgment_normal")}"; } - long damage = Math.Min(maxDmg, Math.Max(1, (long)(baseDamage * mult) - player.Defence)); + long damage = TeamHQBonus.ApplyDefense(player, Math.Min(maxDmg, Math.Max(1, (long)(baseDamage * mult) - player.Defence))); // v1.1.11: Team HQ Barracks, last if (player.HasStatus(StatusEffect.Invulnerable)) { terminal.WriteLine($" {Loc.Get("combat.manwe_judgment_block")}", "bright_white"); @@ -6528,7 +6533,7 @@ private async Task TryManweBossAbility(Monster monster, Character player, else if (effect == 1) { // 1.5x damage - long damage = Math.Min(maxDmg, Math.Max(1, (long)(baseDamage * 1.5) - player.Defence)); + long damage = TeamHQBonus.ApplyDefense(player, Math.Min(maxDmg, Math.Max(1, (long)(baseDamage * 1.5) - player.Defence))); // v1.1.11: Team HQ Barracks, last if (!player.HasStatus(StatusEffect.Invulnerable)) { player.HP -= damage; @@ -6556,7 +6561,7 @@ private async Task TryManweBossAbility(Monster monster, Character player, } else { - long damage = Math.Min(maxDmg, Math.Max(1, (long)(baseDamage * 1.5) - player.Defence)); + long damage = TeamHQBonus.ApplyDefense(player, Math.Min(maxDmg, Math.Max(1, (long)(baseDamage * 1.5) - player.Defence))); // v1.1.11: Team HQ Barracks, last if (!player.HasStatus(StatusEffect.Invulnerable)) { player.HP -= damage; @@ -6603,7 +6608,7 @@ private async Task TryManweBossAbility(Monster monster, Character player, else { // Already split — do a heavy damage attack instead - long damage = Math.Min(maxDmg, Math.Max(1, (long)(baseDamage * 2.0) - player.Defence)); + long damage = TeamHQBonus.ApplyDefense(player, Math.Min(maxDmg, Math.Max(1, (long)(baseDamage * 2.0) - player.Defence))); // v1.1.11: Team HQ Barracks, last if (!player.HasStatus(StatusEffect.Invulnerable)) { player.HP -= damage; @@ -6617,7 +6622,7 @@ private async Task TryManweBossAbility(Monster monster, Character player, case "Light Incarnate": { - long damage = Math.Min(maxDmg, Math.Max(1, (long)(baseDamage * 3.0) - player.Defence)); + long damage = TeamHQBonus.ApplyDefense(player, Math.Min(maxDmg, Math.Max(1, (long)(baseDamage * 3.0) - player.Defence))); // v1.1.11: Team HQ Barracks, last if (player.HasStatus(StatusEffect.Invulnerable)) { terminal.WriteLine($" {Loc.Get("combat.manwe_light_block")}", "bright_white"); @@ -6635,7 +6640,7 @@ private async Task TryManweBossAbility(Monster monster, Character player, case "Shadow Incarnate": { - long damage = Math.Min(maxDmg, Math.Max(1, (long)(baseDamage * 2.0) - player.Defence)); + long damage = TeamHQBonus.ApplyDefense(player, Math.Min(maxDmg, Math.Max(1, (long)(baseDamage * 2.0) - player.Defence))); // v1.1.11: Team HQ Barracks, last long healAmt = damage * 30 / 100; if (player.HasStatus(StatusEffect.Invulnerable)) { @@ -6671,7 +6676,7 @@ private async Task TryManweBossAbility(Monster monster, Character player, else { // The question IS the attack - long damage = Math.Min(maxDmg, Math.Max(1, (long)(baseDamage * 3.0) - player.Defence)); + long damage = TeamHQBonus.ApplyDefense(player, Math.Min(maxDmg, Math.Max(1, (long)(baseDamage * 3.0) - player.Defence))); // v1.1.11: Team HQ Barracks, last if (!player.HasStatus(StatusEffect.Invulnerable)) { player.HP -= damage; @@ -6688,7 +6693,7 @@ private async Task TryManweBossAbility(Monster monster, Character player, case "Final Word": { - long damage = Math.Min(maxDmg, Math.Max(1, (long)(baseDamage * 4.0) - player.Defence)); + long damage = TeamHQBonus.ApplyDefense(player, Math.Min(maxDmg, Math.Max(1, (long)(baseDamage * 4.0) - player.Defence))); // v1.1.11: Team HQ Barracks, last if (player.HasStatus(StatusEffect.Invulnerable)) { terminal.WriteLine($" {Loc.Get("combat.manwe_final_word_block")}", "bright_white"); @@ -6712,7 +6717,7 @@ private async Task TryManweBossAbility(Monster monster, Character player, // Check for Worldstone protection if (ArtifactSystem.Instance.HasArtifact(ArtifactType.Worldstone)) { - long damage = Math.Min(maxDmg, Math.Max(1, (long)(baseDamage * 3.0) - player.Defence)); + long damage = TeamHQBonus.ApplyDefense(player, Math.Min(maxDmg, Math.Max(1, (long)(baseDamage * 3.0) - player.Defence))); // v1.1.11: Team HQ Barracks, last if (!player.HasStatus(StatusEffect.Invulnerable)) { player.HP -= damage; @@ -6735,7 +6740,7 @@ private async Task TryManweBossAbility(Monster monster, Character player, else { // Player HP too high for instant kill — heavy damage instead - long damage = Math.Min(maxDmg, Math.Max(1, (long)(baseDamage * 3.5) - player.Defence)); + long damage = TeamHQBonus.ApplyDefense(player, Math.Min(maxDmg, Math.Max(1, (long)(baseDamage * 3.5) - player.Defence))); // v1.1.11: Team HQ Barracks, last if (!player.HasStatus(StatusEffect.Invulnerable)) { player.HP -= damage; @@ -6788,7 +6793,7 @@ private async Task TryManweBossAbility(Monster monster, Character player, else { // Already offered — use Final Word instead - long damage = Math.Min(maxDmg, Math.Max(1, (long)(baseDamage * 4.0) - player.Defence)); + long damage = TeamHQBonus.ApplyDefense(player, Math.Min(maxDmg, Math.Max(1, (long)(baseDamage * 4.0) - player.Defence))); // v1.1.11: Team HQ Barracks, last if (!player.HasStatus(StatusEffect.Invulnerable)) { player.HP -= damage; @@ -19681,7 +19686,10 @@ private long MitigateCompanionAbilityHit(Monster monster, Character companion, l } } - return CapTeammateDamageInOldGodFight(companion, damage); + damage = CapTeammateDamageInOldGodFight(companion, damage); + // v1.1.11: Team HQ Barracks last (0 for NPCs); the boss minimum holds where it held before. + long bossFloor = monster.IsBoss ? (long)(monster.Level * 1.5) : 1; + return Math.Max(Math.Min(damage, bossFloor), TeamHQBonus.ApplyDefense(companion, damage)); } private async Task MonsterAttacksCompanion(Monster monster, Character companion, CombatResult result, List? liveMonsterList = null) @@ -19913,7 +19921,8 @@ private async Task MonsterAttacksCompanion(Monster monster, Character companion, // v0.57.14: companion incoming-damage floor scales with companion MaxHP for the same // reason it does for the player — high-tank companions can't tank indefinitely. - long actualDamage = Math.Max(GetMinIncomingDamage(companion, monsterAttack), monsterAttack - companionDefense); + long minDamage = GetMinIncomingDamage(companion, monsterAttack); + long actualDamage = Math.Max(minDamage, monsterAttack - companionDefense); // Show defense calculation if (companionDefense > 0 && companionDefense < monsterAttack) @@ -19970,6 +19979,10 @@ private async Task MonsterAttacksCompanion(Monster monster, Character companion, // Old God fight cap applied last so tightest cap wins actualDamage = CapTeammateDamageInOldGodFight(companion, actualDamage); + // v1.1.11: Team HQ Barracks last (0 for NPCs); the floors hold where they held before. + long companionFloor = monster.IsBoss ? Math.Max(minDamage, (long)(monster.Level * 1.5)) : minDamage; + actualDamage = Math.Max(Math.Min(actualDamage, companionFloor), TeamHQBonus.ApplyDefense(companion, actualDamage)); + // Apply damage to companion RecordAllyHit(companion, actualDamage); // v1.1.3 companion.HP = Math.Max(0, companion.HP - actualDamage); @@ -25415,8 +25428,8 @@ private async Task ExecutePvPSingleHit(Character attacker, Character defender, C defense = (long)(defense * 1.5); } - // v1.1.11: Team HQ Armory on the hit, inside the floor. - long damage = Math.Max(1, TeamHQBonus.ApplyAttack(attacker, attackPower - defense)); + // v1.1.11: Team HQ Armory on the hit, then the defender's Barracks, inside the floor. + long damage = Math.Max(1, TeamHQBonus.ApplyDefense(defender, TeamHQBonus.ApplyAttack(attacker, attackPower - defense))); defender.HP = Math.Max(0, defender.HP - damage); // Track statistics @@ -25518,8 +25531,8 @@ private async Task ExecutePvPSpell(Character attacker, Character defender, Comba // Apply damage to defender if (spellResult.Damage > 0) { - // v1.1.11: Team HQ Armory, last. - long spellDamage = TeamHQBonus.ApplyAttack(attacker, spellResult.Damage); + // v1.1.11: Team HQ Armory, then the defender's Barracks, last. + long spellDamage = TeamHQBonus.ApplyDefense(defender, TeamHQBonus.ApplyAttack(attacker, spellResult.Damage)); defender.HP = Math.Max(0, defender.HP - spellDamage); terminal.WriteLine(Loc.Get("combat.pvp_magical_damage", defender.DisplayName, spellDamage), "bright_magenta"); } @@ -25756,11 +25769,11 @@ private async Task ExecutePvPAbility(Character attacker, Character defender, Com if (abilityResult.SpecialEffect != "armor_pierce") { long defense = defender.Defence / 2; - actualDamage = Math.Max(1, TeamHQBonus.ApplyAttack(attacker, actualDamage - defense)); + actualDamage = Math.Max(1, TeamHQBonus.ApplyDefense(defender, TeamHQBonus.ApplyAttack(attacker, actualDamage - defense))); } else { - actualDamage = TeamHQBonus.ApplyAttack(attacker, actualDamage); + actualDamage = TeamHQBonus.ApplyDefense(defender, TeamHQBonus.ApplyAttack(attacker, actualDamage)); } defender.HP = Math.Max(0, defender.HP - actualDamage); @@ -25875,6 +25888,7 @@ private async Task ProcessComputerPlayerAction(Character computer, Character opp if (opponent.IsDefending || opponent.HasStatus(StatusEffect.Defending)) spellDamage = (long)Math.Ceiling(spellDamage / 2.0); spellDamage = TeamHQBonus.ApplyAttack(computer, spellDamage); // v1.1.11: Team HQ Armory, last. + spellDamage = TeamHQBonus.ApplyDefense(opponent, spellDamage); // v1.1.11: then the human's Barracks. opponent.HP = Math.Max(0, opponent.HP - spellDamage); terminal.WriteLine(Loc.Get("combat.pvp_magical_damage", opponent.DisplayName, spellDamage), "bright_magenta"); } @@ -25920,6 +25934,7 @@ private async Task ProcessComputerPlayerAction(Character computer, Character opp if (opponent.IsDefending || opponent.HasStatus(StatusEffect.Defending)) actualDamage = (long)Math.Ceiling(actualDamage / 2.0); actualDamage = TeamHQBonus.ApplyAttack(computer, actualDamage); // v1.1.11: Team HQ Armory, last. + actualDamage = TeamHQBonus.ApplyDefense(opponent, actualDamage); // v1.1.11: then the human's Barracks. opponent.HP = Math.Max(0, opponent.HP - actualDamage); terminal.WriteLine(Loc.Get("combat.pvp_ai_uses_ability", computer.DisplayName, chosen.Name, actualDamage), "bright_red"); } @@ -25974,6 +25989,7 @@ private async Task ProcessComputerPlayerAction(Character computer, Character opp if (opponent.IsDefending || opponent.HasStatus(StatusEffect.Defending)) damage = (long)Math.Ceiling(damage / 2.0); damage = TeamHQBonus.ApplyAttack(computer, damage); // v1.1.11: Team HQ Armory, last. + damage = TeamHQBonus.ApplyDefense(opponent, damage); // v1.1.11: then the human's Barracks. opponent.HP = Math.Max(0, opponent.HP - damage); terminal.WriteLine(Loc.Get("combat.pvp_ai_strikes", computer.DisplayName, damage), "red"); result.CombatLog.Add($"{computer.DisplayName} hits {opponent.DisplayName} for {damage}"); @@ -28230,7 +28246,7 @@ private async Task ProcessPlagueDamage(Character player, CombatResult result) if (plagueDamage > 0) { plagueDamage = Math.Max(1, plagueDamage); - player.HP = Math.Max(0, player.HP - plagueDamage); + player.HP = Math.Max(0, player.HP - plagueDamage); // hq-barracks: out (disease tick, not an enemy hit) terminal.SetColor("yellow"); terminal.WriteLine($" {diseaseMessage} (-{plagueDamage} HP)"); @@ -28933,6 +28949,7 @@ private void ProcessCorruptionTick(Character target, CombatResult result) int damagePerStack = BossContext?.CorruptionDamagePerStack ?? GameConfig.ModBossCorruptionDamageBase; long corruptionDamage = target.CorruptionStacks * damagePerStack; + corruptionDamage = TeamHQBonus.ApplyDefense(target, corruptionDamage); // v1.1.11: Team HQ Barracks (a boss hit, 0 for NPCs) target.HP = Math.Max(0, target.HP - corruptionDamage); terminal.SetColor("dark_magenta"); @@ -29035,6 +29052,7 @@ private void ProcessBossChannel(Monster boss, Character player, CombatResult res long playerDmg = Math.Max(1, damage - (long)(Math.Sqrt(player.Defence) * 3)); // v0.65.8 (R3): failed flee this round -> guarded half-round playerDmg = ApplyFleeGrace(player, playerDmg); + playerDmg = TeamHQBonus.ApplyDefense(player, playerDmg); // v1.1.11: Team HQ Barracks, last player.HP = Math.Max(0, player.HP - playerDmg); terminal.WriteLine($" {player.DisplayName} takes {playerDmg} damage!"); } @@ -29046,6 +29064,7 @@ private void ProcessBossChannel(Monster boss, Character player, CombatResult res { long tmDmg = Math.Max(1, damage - (long)(Math.Sqrt(tm.Defence) * 3)); tmDmg = CapTeammateDamageInOldGodFight(tm, tmDmg); + tmDmg = TeamHQBonus.ApplyDefense(tm, tmDmg); // v1.1.11: Team HQ Barracks, last (0 for NPCs) RecordAllyHit(tm, tmDmg); // v1.1.3: the channel hits everyone; not a targeting choice tm.HP = Math.Max(0, tm.HP - tmDmg); terminal.WriteLine($" {tm.DisplayName} takes {tmDmg} damage!"); @@ -29149,6 +29168,7 @@ private async Task ProcessBossAoE(Monster boss, Character player, CombatResult r // v0.65.8 (R3): failed flee this round -> guarded half-round (player only) if (target == player) dmg = ApplyFleeGrace(player, dmg); + dmg = TeamHQBonus.ApplyDefense(target, dmg); // v1.1.11: Team HQ Barracks, last (0 for NPCs) target.HP = Math.Max(0, target.HP - dmg); string tankTag = (tank != null && target == tank) ? " [ABSORBING]" : ""; terminal.WriteLine($" {target.DisplayName} takes {dmg} damage!{tankTag}"); diff --git a/Scripts/Systems/WorldBossSystem.cs b/Scripts/Systems/WorldBossSystem.cs index 77f4d6eb..3f947842 100644 --- a/Scripts/Systems/WorldBossSystem.cs +++ b/Scripts/Systems/WorldBossSystem.cs @@ -1955,13 +1955,18 @@ private void ProcessBossActions(WorldBossDefinition bossDef, WorldBossRuntimeDat double mult = focused ? GameConfig.WorldBossFocusMultiplier : GameConfig.WorldBossOffFocusMultiplier; for (int i = 0; i < attacks && player.HP > 0; i++) { - long bossDmg = Math.Max(1, (long)(CalculateBossBasicDamage(bossData, player, rng, defendingRounds) * mult)); + long beforeBarracks = Math.Max(1, (long)(CalculateBossBasicDamage(bossData, player, rng, defendingRounds) * mult)); + // v1.1.11: Team HQ Barracks last; the 20%-of-STR minimum holds where it held before. + long bossDmg = Math.Max(Math.Min(beforeBarracks, BossMinimumDamage(bossData)), TeamHQBonus.ApplyDefense(player, beforeBarracks)); player.HP = Math.Max(0, player.HP - bossDmg); terminal.SetColor("bright_red"); terminal.WriteLine($" {Loc.Get(focused ? "world_boss.boss_strikes_focused" : "world_boss.boss_strikes", bossDef.Name, $"{bossDmg:N0}", player.HP, player.MaxHP)}"); } } + // Defense never takes a boss basic attack below 20% of the boss's strength. + private static long BossMinimumDamage(WorldBossRuntimeData bossData) => Math.Max(1, bossData.ScaledStrength / 5); + private long CalculateBossBasicDamage(WorldBossRuntimeData bossData, Character player, Random rng, int defendingRounds) { @@ -1982,8 +1987,7 @@ private long CalculateBossBasicDamage(WorldBossRuntimeData bossData, Character p // Defense can reduce damage but never below 20% of boss strength // World bosses are meant to be dangerous — pure defense stacking shouldn't trivialize them long raw = Math.Max(1, bossStr - playerDef / 2); - long minDamage = Math.Max(1, bossStr / 5); - raw = Math.Max(raw, minDamage); + raw = Math.Max(raw, BossMinimumDamage(bossData)); double variance = 0.7 + rng.NextDouble() * 0.6; long final = Math.Max(1, (long)(raw * variance)); diff --git a/Tests/BarracksCoverageTests.cs b/Tests/BarracksCoverageTests.cs new file mode 100644 index 00000000..10d82ff4 --- /dev/null +++ b/Tests/BarracksCoverageTests.cs @@ -0,0 +1,281 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using FluentAssertions; +using UsurperRemake; +using UsurperRemake.Systems; +using Xunit; + +namespace UsurperReborn.Tests; + +/// +/// v1.1.11: the Team HQ Barracks divides every combat hit an enemy lands on a player by 1 + 5% per +/// level, PvE and PvP, as the last modifier of that hit, just before it leaves the player's HP. A +/// minimum-damage floor earlier in the chain is re-applied after it, so the anti-tank minimum holds. +/// There is no single choke point, so every player-side HP write in CombatEngine must either apply +/// TeamHQBonus.ApplyDefense within the eight lines above it (or call a helper that does), sit in a +/// method listed below with its reason, or carry an "hq-barracks: out (reason)" comment. +/// +[Collection("SharedGameSingletons")] +public class BarracksCoverageTests +{ + private static string RepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !(Directory.Exists(Path.Combine(dir.FullName, "Scripts")) && Directory.Exists(Path.Combine(dir.FullName, "Localization")))) + dir = dir.Parent; + dir.Should().NotBeNull("the test must run inside the repository"); + return dir!.FullName; + } + + private static string[] CombatEngineLines() => File.ReadAllLines(Path.Combine(RepoRoot(), "Scripts", "Systems", "CombatEngine.cs")); + + // ".HP -= ..." or ".HP = Math.Max(0, .HP - ...)" + private static readonly Regex HpWrite = new(@"\b([A-Za-z_][A-Za-z_0-9]*)\.HP\s*(?:-=|=\s*Math\.Max\(\s*0\s*,\s*\1\.HP\s*-)"); + + private static readonly Regex MethodSignature = new(@"^\s*(?:public|private|internal|protected)\b[^=;]*?(\w+)\s*(?:<[^>]*>)?\s*\("); + + // The variables that only ever hold an enemy. Every other name is treated as the player's side, + // so a write through a new variable name fails until someone looks at it. "target" is an enemy + // only where the method declares "Monster target" or is listed in MonsterTargetMethods. + private static readonly HashSet EnemySide = new() { "monster", "m", "corrodeTarget" }; + + private static readonly Dictionary MonsterTargetMethods = new() + { + ["TryTeammateOffensiveSpell"] = "its target is picked from the living monsters", + }; + + private static readonly Dictionary ExcludedMethods = new() + { + ["ExecuteFightToDeath"] = "dead: only called from ProcessPlayerAction, which throws DeadCombatPathGuard", + }; + + // Helpers that apply the Barracks inside them; a call within the window counts as applying it. + private static readonly string[] ApplyingHelpers = { "MitigateCompanionAbilityHit(" }; + + // Writes whose Barracks sits further up than the window, each pinned by its own ordering test. + private static readonly Dictionary FarApplications = new() + { + ["ProcessMonsterAction"] = "applied after the flee grace and before divine intervention and the companion sacrifice; see TheBasicAttack_AppliesTheBarracks_BeforeTheSurviveAtOneLogic", + }; + + private static string MethodAt(string[] lines, int index, out int start) + { + for (int j = index; j >= 0; j--) + { + var m = MethodSignature.Match(lines[j]); + if (m.Success) { start = j; return m.Groups[1].Value; } + } + start = 0; + return "?"; + } + + private static List<(int line, string method, string text)> PlayerHpWrites(string[] lines) + { + var hits = new List<(int, string, string)>(); + for (int i = 0; i < lines.Length; i++) + { + string code = lines[i].TrimStart(); + if (code.StartsWith("//") || code.StartsWith("*")) continue; + var m = HpWrite.Match(lines[i]); + if (!m.Success) continue; + string variable = m.Groups[1].Value; + if (EnemySide.Contains(variable)) continue; + string method = MethodAt(lines, i, out int start); + if (variable == "target" && (MonsterTargetMethods.ContainsKey(method) || Regex.IsMatch(lines[start], @"\bMonster\??\s+target\b"))) + continue; + hits.Add((i + 1, method, lines[i].Trim())); + } + return hits; + } + + [Fact] + public void EveryPlayerHpWriteInCombatEngine_AppliesTheBarracksOrSaysWhyNot() + { + var lines = CombatEngineLines(); + var hits = PlayerHpWrites(lines); + hits.Count.Should().BeGreaterThan(30, "the scan must find the player-side HP writes, or it proves nothing"); + + var problems = new List(); + foreach (var (line, method, text) in hits) + { + if (ExcludedMethods.ContainsKey(method) || FarApplications.ContainsKey(method)) continue; + int from = Math.Max(0, line - 1 - 8); + bool applied = lines.Skip(from).Take(line - from) + .Any(l => l.Contains("TeamHQBonus.ApplyDefense") || ApplyingHelpers.Any(h => l.Contains(h))); + int comment = text.IndexOf("//", StringComparison.Ordinal); + bool markedOut = comment >= 0 && Regex.IsMatch(text.Substring(comment), @"hq-barracks: out \(\S[^)]*\)"); + if (!applied && !markedOut) + problems.Add($"CombatEngine.cs:{line} in {method}: {text}"); + } + problems.Should().BeEmpty("each player-side HP write applies TeamHQBonus.ApplyDefense just before it or is marked out with a reason"); + } + + [Fact] + public void TheScan_SeesThePvPAndTeammateWrites() + { + // A guard on the variable rules: these are the writes most easily lost by a narrower filter. + var methods = PlayerHpWrites(CombatEngineLines()).Select(h => h.method).ToHashSet(); + methods.Should().Contain(new[] { "ExecutePvPSingleHit", "ProcessComputerPlayerAction", "MonsterAttacksCompanion", "ProcessBossChannel", "ProcessBossAoE", "ProcessCorruptionTick", "TryManweBossAbility" }); + } + + [Fact] + public void TheListedMethods_StillExist() + { + var methods = CombatEngineLines().Select(l => MethodSignature.Match(l)).Where(m => m.Success).Select(m => m.Groups[1].Value).ToHashSet(); + ExcludedMethods.Keys.Concat(MonsterTargetMethods.Keys).Concat(FarApplications.Keys) + .Where(k => !methods.Contains(k)).Should().BeEmpty("a stale entry would hide a new method of the same name"); + } + + [Fact] + public void TheOldBarracksReader_IsGone() + { + string src = File.ReadAllText(Path.Combine(RepoRoot(), "Scripts", "Systems", "CombatEngine.cs")); + src.Should().NotContain("TeamHQBonus.Barracks(", "Barracks added to defence before the floor counted twice with ApplyDefense"); + } + + private static string MethodBody(string file, string method) + { + var lines = File.ReadAllLines(Path.Combine(RepoRoot(), file)); + int start = Array.FindIndex(lines, l => { var m = MethodSignature.Match(l); return m.Success && m.Groups[1].Value == method; }); + start.Should().BeGreaterThanOrEqualTo(0, $"{method} must exist in {file}"); + int end = start + 1; + while (end < lines.Length && !MethodSignature.IsMatch(lines[end])) end++; + return string.Join("\n", lines.Skip(start).Take(end - start)); + } + + private static int Count(string text, string what) => Regex.Matches(text, Regex.Escape(what)).Count; + + [Fact] + public void TheBasicAttack_AppliesTheBarracks_BeforeTheSurviveAtOneLogic() + { + string body = MethodBody("Scripts/Systems/CombatEngine.cs", "ProcessMonsterAction"); + int grace = body.IndexOf("ApplyFleeGrace(player, actualDamage)", StringComparison.Ordinal); + int barracks = body.IndexOf("TeamHQBonus.ApplyDefense(player, actualDamage)", StringComparison.Ordinal); + int divine = body.IndexOf("CheckDivineIntervention(", StringComparison.Ordinal); + int sacrifice = body.IndexOf("CheckCompanionSacrifice(", StringComparison.Ordinal); + int write = body.IndexOf("player.HP = Math.Max(0, player.HP - actualDamage)", StringComparison.Ordinal); + grace.Should().BeGreaterThan(0); + barracks.Should().BeGreaterThan(grace, "the Barracks is the last modifier, after the flee grace"); + divine.Should().BeGreaterThan(barracks, "survive-at-1 must see the final number, not have it divided"); + sacrifice.Should().BeGreaterThan(barracks); + write.Should().BeGreaterThan(barracks); + Count(body, "TeamHQBonus.ApplyDefense(").Should().Be(1); + } + + [Fact] + public void TheCompanionAbilityHelper_AppliesTheBarracks() + { + MethodBody("Scripts/Systems/CombatEngine.cs", "MitigateCompanionAbilityHit").Should().Contain("TeamHQBonus.ApplyDefense(companion, damage)"); + } + + [Fact] + public void ShadowIncarnate_ReducesTheHitBeforeTheHealReadsIt() + { + string body = MethodBody("Scripts/Systems/CombatEngine.cs", "TryManweBossAbility"); + Count(body, "TeamHQBonus.ApplyDefense(player,").Should().Be(12, "every named Manwe hit"); + int shadow = body.IndexOf("\"Shadow Incarnate\"", StringComparison.Ordinal); + int barracks = body.IndexOf("TeamHQBonus.ApplyDefense", shadow, StringComparison.Ordinal); + int heal = body.IndexOf("long healAmt = damage", shadow, StringComparison.Ordinal); + barracks.Should().BeGreaterThan(shadow); + heal.Should().BeGreaterThan(barracks); + } + + [Theory] + [InlineData("Scripts/Systems/WorldBossSystem.cs", "ProcessBossActions", 1)] + [InlineData("Scripts/Locations/PrisonWalkLocation.cs", "BattlePrisonGuards", 1)] + [InlineData("Scripts/Locations/CastleLocation.cs", "CastleSiegeMenu", 2)] + public void TheFightsOutsideCombatEngine_ApplyTheBarracks(string file, string method, int sites) + { + Count(MethodBody(file, method), "TeamHQBonus.ApplyDefense(").Should().BeGreaterThanOrEqualTo(sites); + } + + [Fact] + public void TheWorldBoss_ReappliesItsStrengthMinimum_AfterTheBarracks() + { + string body = MethodBody("Scripts/Systems/WorldBossSystem.cs", "ProcessBossActions"); + body.Should().Contain("BossMinimumDamage(bossData)"); + body.IndexOf("TeamHQBonus.ApplyDefense(player,", StringComparison.Ordinal) + .Should().BeLessThan(body.IndexOf("player.HP = Math.Max(0, player.HP - bossDmg)", StringComparison.Ordinal)); + } + + [Fact] + public void TheHelper_NeverTakesOneToZero_AtTheTopLevel() + { + var c = new Character { Team = "X", HQLevelsTeam = "X", HQBarracksLevel = 10 }; + TeamHQBonus.ApplyDefense(c, 1).Should().Be(1, "every Math.Max(1) floor before it therefore still holds"); + } + + // Behaviour: the real monster basic attack, same dice with and without the Barracks. + private static readonly BindingFlags F = BindingFlags.NonPublic | BindingFlags.Instance; + + private static Monster Brute() => new Monster { Name = "Brute", Level = 20, HP = 1_000_000, MaxHP = 1_000_000, Strength = 1000, WeapPow = 200, IsActive = true }; + + private static Character Hero(int barracks, long defence = 0) => new Character + { + Name1 = "barracks", Name2 = "Barracks", Class = CharacterClass.Warrior, Level = 20, HP = 100_000, MaxHP = 100_000, + Defence = defence, CombatSpeed = CombatSpeed.Instant, + Team = "X", HQLevelsTeam = barracks > 0 ? "X" : "", HQBarracksLevel = barracks, + }; + + // The damage of one monster swing with the engine's dice seeded, or -1 when it missed or was dodged. + private static async Task Swing(Character hero, int seed) + { + var engine = new CombatEngine(new TerminalEmulator(new MemoryStream(), new MemoryStream())); + typeof(CombatEngine).GetField("random", F)!.SetValue(engine, new Random(seed)); + typeof(CombatEngine).GetField("currentPlayer", F)!.SetValue(engine, hero); + long before = hero.HP; + await (Task)typeof(CombatEngine).GetMethod("ProcessMonsterAction", F)! + .Invoke(engine, new object?[] { Brute(), hero, new CombatResult(), null })!; + long taken = before - hero.HP; + return taken > 0 ? taken : -1; + } + + private static async Task> Pairs(long defence) + { + var pairs = new List<(long, long)>(); + for (int seed = 1; seed <= 200 && pairs.Count < 20; seed++) + { + long plain = await Swing(Hero(0, defence), seed); + long barracks = await Swing(Hero(2, defence), seed); + if (plain > 0 && barracks > 0) pairs.Add((plain, barracks)); + } + pairs.Should().NotBeEmpty("some swings must land for the comparison to mean anything"); + return pairs; + } + + [Fact] + public async Task AMonsterBasicAttack_IsDividedByTheBarracks() + { + foreach (var (plain, barracks) in await Pairs(defence: 0)) + { + plain.Should().BeGreaterThan(300, "the hit sits well above the anti-tank minimum"); + barracks.Should().Be((long)Math.Round(plain / 1.1), "Barracks 2 divides the final hit by 1.1"); + } + } + + [Fact] + public async Task TheAntiTankMinimum_StillHolds_AfterTheBarracks() + { + // Defence far above the attack: the hit is the minimum, 0.25% of 100,000 MaxHP or 5% of the attack. + foreach (var (plain, barracks) in await Pairs(defence: 10_000_000)) + { + plain.Should().BeLessThan(1000, "the hit is the floor"); + barracks.Should().Be(plain, "the Barracks never takes a hit below the minimum it had already reached"); + } + } + + [Fact] + public async Task LevelsReadForAnotherTeam_GiveNothing() + { + var other = Hero(2); + other.HQLevelsTeam = "Y"; + long plain = await Swing(Hero(0), 7); + long stale = await Swing(other, 7); + stale.Should().Be(plain); + } +} diff --git a/Tests/TeamHQBonusTests.cs b/Tests/TeamHQBonusTests.cs index 61bb2312..204097f8 100644 --- a/Tests/TeamHQBonusTests.cs +++ b/Tests/TeamHQBonusTests.cs @@ -154,6 +154,7 @@ public async System.Threading.Tasks.Task AWarriorWithOnlyTheTeamsBonus_SeesItOnT var shown = System.Text.Encoding.UTF8.GetString(output.ToArray()); shown.Should().Contain(Loc.Get("base.hq_armory", 3, 15)); shown.Should().Contain(Loc.Get("base.hq_infirmary", 4, 40)); + shown.Should().Contain(Loc.Get("base.hq_barracks", 2, 9), "Barracks 2 divides damage by 1.1: about 9% less"); hero.Team = ""; // left the team output.SetLength(0); From c571e02d7f93fdfd659c35499c0f52ee992a3c06 Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 18:25:42 +0000 Subject: [PATCH 09/42] The Team HQ Infirmary reaches every healing potion The Infirmary improved the quick-heal command only, and before the difficulty multiplier. Drinking by the item key, the automatic potions after a fight, potions given to allies, the quick potion in town, Home, every dungeon potion menu, a group follower's potions, a duel defender's potion and the world boss had none of it. It now increases what each healing potion restores, after every other modifier and before the cap to missing HP, using the bonus of the potion's owner (the character whose potion is used). The potion menus' estimates of what a potion heals and how many are needed use the same number. A test reads every file under Scripts and fails on any healing-potion use that neither applies it nor says why not. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Locations/BaseLocation.cs | 1 + Scripts/Locations/DungeonLocation.cs | 11 +- Scripts/Locations/HomeLocation.cs | 3 +- Scripts/Systems/CombatEngine.cs | 18 +- Scripts/Systems/WorldBossSystem.cs | 1 + Tests/InfirmaryCoverageTests.cs | 272 +++++++++++++++++++++++++++ 6 files changed, 300 insertions(+), 6 deletions(-) create mode 100644 Tests/InfirmaryCoverageTests.cs diff --git a/Scripts/Locations/BaseLocation.cs b/Scripts/Locations/BaseLocation.cs index b276c842..2da31cd1 100644 --- a/Scripts/Locations/BaseLocation.cs +++ b/Scripts/Locations/BaseLocation.cs @@ -3987,6 +3987,7 @@ protected async Task UseQuickPotion() // Use one potion long healAmount = 30 + currentPlayer.Level * 5 + Random.Shared.Next(10, 30); + healAmount = TeamHQBonus.ApplyPotionHeal(currentPlayer, healAmount); // v1.1.11: Infirmary, before the cap healAmount = Math.Min(healAmount, currentPlayer.MaxHP - currentPlayer.HP); currentPlayer.HP += healAmount; currentPlayer.Healing--; diff --git a/Scripts/Locations/DungeonLocation.cs b/Scripts/Locations/DungeonLocation.cs index 8ad2ebaa..2c70ff5c 100644 --- a/Scripts/Locations/DungeonLocation.cs +++ b/Scripts/Locations/DungeonLocation.cs @@ -11686,7 +11686,7 @@ private async Task UsePotions() potions = player is Player pp ? pp.Healing : 0, maxPotions = player is Player pp2 ? pp2.MaxPotions : 0, gold = player.Gold, - healAmount = player.MaxHP / 4, + healAmount = TeamHQBonus.ApplyPotionHeal(player, player.MaxHP / 4), // v1.1.11: Infirmary potionCost = 50 + (player.Level * 10), teammates = memberData, }); @@ -11743,6 +11743,7 @@ private async Task UsePotions() // Calculate heal amount (potions heal 25% of max HP) long healAmount = player.MaxHP / 4; + healAmount = TeamHQBonus.ApplyPotionHeal(player, healAmount); // v1.1.11: Infirmary terminal.SetColor("white"); terminal.WriteLine(Loc.Get("dungeon.options")); @@ -12442,6 +12443,7 @@ private async Task HealTeammate(Character player, List companions) // Calculate potions needed long missingHP = target.MaxHP - target.HP; int healPerPotion = 30 + player.Level * 5 + 20; + healPerPotion = (int)TeamHQBonus.ApplyPotionHeal(player, healPerPotion); // v1.1.11: the giver's Infirmary int potionsNeeded = (int)Math.Ceiling((double)missingHP / healPerPotion); potionsNeeded = Math.Min(potionsNeeded, (int)player.Healing); @@ -12506,6 +12508,7 @@ private async Task HealTeammate(Character player, List companions) { player.Healing--; int healAmount = 30 + player.Level * 5 + dungeonRandom.Next(10, 31); + healAmount = (int)TeamHQBonus.ApplyPotionHeal(player, healAmount); // v1.1.11: the giver's Infirmary target.HP = Math.Min(target.MaxHP, target.HP + healAmount); } long totalHeal = target.HP - oldHP; @@ -12545,6 +12548,7 @@ private async Task HealTeammate(Character player, List companions) private async Task HealEntireParty(Character player, List companions) { int healPerPotion = 30 + player.Level * 5 + 20; + healPerPotion = (int)TeamHQBonus.ApplyPotionHeal(player, healPerPotion); // v1.1.11: the giver's Infirmary int totalPotionsUsed = 0; long totalHealing = 0; @@ -12628,6 +12632,7 @@ private async Task HealEntireParty(Character player, List companions) player.Healing--; totalPotionsUsed++; int healAmount = 30 + player.Level * 5 + dungeonRandom.Next(10, 31); + healAmount = (int)TeamHQBonus.ApplyPotionHeal(player, healAmount); // v1.1.11: Infirmary player.HP = Math.Min(player.MaxHP, player.HP + healAmount); } long healed = player.HP - oldHP; @@ -12647,6 +12652,7 @@ private async Task HealEntireParty(Character player, List companions) player.Healing--; totalPotionsUsed++; int healAmount = 30 + player.Level * 5 + dungeonRandom.Next(10, 31); + healAmount = (int)TeamHQBonus.ApplyPotionHeal(player, healAmount); // v1.1.11: the giver's Infirmary companion.HP = Math.Min(companion.MaxHP, companion.HP + healAmount); } long healed = companion.HP - oldHP; @@ -12702,6 +12708,7 @@ private async Task UseHealingPotion(Character player) // Use one potion player.Healing--; long healAmount = player.MaxHP / 4; + healAmount = TeamHQBonus.ApplyPotionHeal(player, healAmount); // v1.1.11: Infirmary, before the cap long oldHP = player.HP; player.HP = Math.Min(player.MaxHP, player.HP + healAmount); long actualHeal = player.HP - oldHP; @@ -12741,6 +12748,7 @@ private async Task HealToFull(Character player) } long healAmount = player.MaxHP / 4; + healAmount = TeamHQBonus.ApplyPotionHeal(player, healAmount); // v1.1.11: Infirmary, so potionsNeeded matches int potionsNeeded = (int)Math.Ceiling((double)(player.MaxHP - player.HP) / healAmount); int potionsToUse = Math.Min(potionsNeeded, (int)player.Healing); @@ -18653,6 +18661,7 @@ private static async Task UseFollowerPotion(Character player, TerminalEmulator t { long oldHP = player.HP; int healAmount = 30 + player.Level * 5 + Random.Shared.Next(10, 31); + healAmount = (int)TeamHQBonus.ApplyPotionHeal(player, healAmount); // v1.1.11: the follower's own Infirmary player.HP = Math.Min(player.MaxHP, player.HP + healAmount); long actualHeal = player.HP - oldHP; player.Healing--; diff --git a/Scripts/Locations/HomeLocation.cs b/Scripts/Locations/HomeLocation.cs index 53df41e8..09ac73b7 100644 --- a/Scripts/Locations/HomeLocation.cs +++ b/Scripts/Locations/HomeLocation.cs @@ -1354,7 +1354,8 @@ private async Task UseHealingPotion() // Use a potion currentPlayer.Healing--; - long healAmount = Math.Max(50, currentPlayer.MaxHP / 4); // Heal 25% or at least 50 HP + // v1.1.11: the Infirmary goes inside the 50 HP floor + long healAmount = Math.Max(50, TeamHQBonus.ApplyPotionHeal(currentPlayer, currentPlayer.MaxHP / 4)); // Heal 25% or at least 50 HP long oldHP = currentPlayer.HP; currentPlayer.HP = Math.Min(currentPlayer.HP + healAmount, currentPlayer.MaxHP); long actualHeal = currentPlayer.HP - oldHP; diff --git a/Scripts/Systems/CombatEngine.cs b/Scripts/Systems/CombatEngine.cs index 3364a80f..e11f317d 100644 --- a/Scripts/Systems/CombatEngine.cs +++ b/Scripts/Systems/CombatEngine.cs @@ -4162,9 +4162,9 @@ private async Task ExecuteHeal(Character player, CombatResult result, bool quick long healAmount = 30 + player.Level * 5 + random.Next(10, 30); if (player.Class == CharacterClass.Alchemist) healAmount = (long)(healAmount * (1.0 + GameConfig.AlchemistPotionMasteryBonus)); - if (TeamHQBonus.Infirmary(player) > 0) - healAmount = (long)(healAmount * (1.0 + TeamHQBonus.Infirmary(player) * 0.10)); healAmount = DifficultySystem.ApplyHealingMultiplier(healAmount); + // v1.1.11: Team HQ Infirmary, the last modifier before the cap + healAmount = TeamHQBonus.ApplyPotionHeal(player, healAmount); healAmount = Math.Min(healAmount, player.MaxHP - player.HP); player.HP += healAmount; player.Statistics?.RecordPotionUsed(healAmount); @@ -4181,6 +4181,7 @@ private async Task ExecuteHeal(Character player, CombatResult result, bool quick // Regular heal - ask how many potions to use for full control long missingHP = player.MaxHP - player.HP; long avgHealPerPotion = 50 + player.Level * 5; // Average heal: 30 + level*5 + avg(10-30) + avgHealPerPotion = TeamHQBonus.ApplyPotionHeal(player, avgHealPerPotion); // v1.1.11: Infirmary int potionsToFullHeal = (int)Math.Ceiling((double)missingHP / avgHealPerPotion); potionsToFullHeal = Math.Min(potionsToFullHeal, (int)player.Healing); @@ -4206,9 +4207,9 @@ private async Task ExecuteHeal(Character player, CombatResult result, bool quick long healAmount = 30 + player.Level * 5 + random.Next(10, 30); if (player.Class == CharacterClass.Alchemist) healAmount = (long)(healAmount * (1.0 + GameConfig.AlchemistPotionMasteryBonus)); - if (TeamHQBonus.Infirmary(player) > 0) - healAmount = (long)(healAmount * (1.0 + TeamHQBonus.Infirmary(player) * 0.10)); healAmount = DifficultySystem.ApplyHealingMultiplier(healAmount); + // v1.1.11: Team HQ Infirmary, the last modifier before the cap + healAmount = TeamHQBonus.ApplyPotionHeal(player, healAmount); healAmount = Math.Min(healAmount, player.MaxHP - player.HP); player.HP += healAmount; totalHeal += healAmount; @@ -8893,6 +8894,7 @@ private void AutoHealWithPotions(Character player) // Don't waste a potion if the deficit is trivial — only auto-heal // when missing at least half a potion's average healing value long avgPotionHeal = 30 + player.Level * 5 + 20; // midpoint of random(10,30) + avgPotionHeal = TeamHQBonus.ApplyPotionHeal(player, avgPotionHeal); // v1.1.11: Infirmary long hpDeficit = player.MaxHP - player.HP; if (hpDeficit < avgPotionHeal / 2) return; @@ -8907,6 +8909,7 @@ private void AutoHealWithPotions(Character player) break; long healAmount = 30 + player.Level * 5 + random.Next(10, 30); + healAmount = TeamHQBonus.ApplyPotionHeal(player, healAmount); // v1.1.11: Infirmary, before the cap healAmount = Math.Min(healAmount, player.MaxHP - player.HP); player.HP += healAmount; totalHealed += healAmount; @@ -17898,6 +17901,7 @@ private void HandleSpecialSpellEffectOnMonster(Monster target, string effect, in // Calculate how much HP is missing long missingHP = targetAlly.MaxHP - targetAlly.HP; int healPerPotion = 30 + player.Level * 5 + 20; // Average heal per potion + healPerPotion = (int)TeamHQBonus.ApplyPotionHeal(player, healPerPotion); // v1.1.11: the giver's Infirmary // Ask if player wants to fully heal or use 1 potion int potionsNeeded = (int)Math.Ceiling((double)missingHP / healPerPotion); @@ -17947,6 +17951,7 @@ private void HandleSpecialSpellEffectOnMonster(Monster target, string effect, in { player.Healing--; int healAmount = 30 + player.Level * 5 + random.Next(10, 30); + healAmount = (int)TeamHQBonus.ApplyPotionHeal(player, healAmount); // v1.1.11: the giver's Infirmary targetAlly.HP = Math.Min(targetAlly.MaxHP, targetAlly.HP + healAmount); } @@ -18627,6 +18632,7 @@ private async Task TeammateHealWithSpell(Character teammate, Character tar /// private async Task TeammateHealWithPotion(Character teammate, Character target, CombatResult result, bool fromPlayerBelt = false) { + Character potionOwner = teammate; // v1.1.11: whose Infirmary the potion carries if (fromPlayerBelt) { // v1.1.3 (council ruling 4): the ally drinks one of the player's potions. The player's @@ -18634,6 +18640,7 @@ private async Task TeammateHealWithPotion(Character teammate, Character ta var owner = _combatOwner ?? currentPlayer; if (owner == null || !CanBorrowFromBelt(owner, teammate)) return false; owner.Healing--; + potionOwner = owner; _borrowedThisFight++; StatsFor(teammate).PotionsFromPlayer++; } @@ -18650,6 +18657,7 @@ private async Task TeammateHealWithPotion(Character teammate, Character ta // Potion heals a fixed amount plus some randomness (same formula as player potions) int healAmount = 30 + teammate.Level * 5 + random.Next(10, 30); + healAmount = (int)TeamHQBonus.ApplyPotionHeal(potionOwner, healAmount); // v1.1.11: the potion owner's Infirmary long oldHP = target.HP; target.HP = Math.Min(target.MaxHP, target.HP + healAmount); long actualHeal = target.HP - oldHP; @@ -22483,6 +22491,7 @@ private async Task ExecuteUseItem(Character player, CombatResult result) long hpNeeded = player.MaxHP - player.HP; int healPerPotion = 30 + player.Level * 5 + random.Next(10, 30); + healPerPotion = (int)TeamHQBonus.ApplyPotionHeal(player, healPerPotion); // v1.1.11: Infirmary, so potionsNeeded shrinks too int potionsNeeded = (int)Math.Ceiling((double)hpNeeded / healPerPotion); potionsNeeded = Math.Min(potionsNeeded, (int)player.Healing); long actualHealing = Math.Min((long)potionsNeeded * healPerPotion, hpNeeded); @@ -25855,6 +25864,7 @@ private async Task ProcessComputerPlayerAction(Character computer, Character opp { computer.Healing--; long heal = 30 + computer.Level * 5 + random.Next(10, 30); + heal = TeamHQBonus.ApplyPotionHeal(computer, heal); // v1.1.11: the defender's own Infirmary heal = Math.Min(heal, computer.MaxHP - computer.HP); computer.HP += heal; terminal.WriteLine(Loc.Get("combat.pvp_ai_heals", computer.DisplayName, heal), "green"); diff --git a/Scripts/Systems/WorldBossSystem.cs b/Scripts/Systems/WorldBossSystem.cs index 3f947842..1081d176 100644 --- a/Scripts/Systems/WorldBossSystem.cs +++ b/Scripts/Systems/WorldBossSystem.cs @@ -1749,6 +1749,7 @@ private async Task DrinkHealingPotions(Character player, TerminalEmulator termin qty = Math.Min(qty, available); long perPotion = (long)(player.MaxHP * 0.3); + perPotion = TeamHQBonus.ApplyPotionHeal(player, perPotion); // v1.1.11: Infirmary, before the cap long totalHealed = 0; int drank = 0; for (int i = 0; i < qty && player.Healing > 0 && player.HP < player.MaxHP; i++) diff --git a/Tests/InfirmaryCoverageTests.cs b/Tests/InfirmaryCoverageTests.cs new file mode 100644 index 00000000..5bac43f2 --- /dev/null +++ b/Tests/InfirmaryCoverageTests.cs @@ -0,0 +1,272 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using FluentAssertions; +using UsurperRemake; +using UsurperRemake.Systems; +using Xunit; + +namespace UsurperReborn.Tests; + +/// +/// v1.1.11: the Team HQ Infirmary (+10% per level) raises what every healing potion restores, in and +/// out of combat, after every other modifier of the heal and before the cap to missing HP. The bonus is +/// the potion owner's: the character whose Healing count drops. There is no single choke point, so every +/// method in Scripts/ that decrements a Healing count must either call TeamHQBonus.ApplyPotionHeal, be +/// listed below with its reason, or carry an "hq-infirmary: out (reason)" comment on the decrement. +/// +[Collection("SharedGameSingletons")] +public class InfirmaryCoverageTests +{ + private static string RepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !(Directory.Exists(Path.Combine(dir.FullName, "Scripts")) && Directory.Exists(Path.Combine(dir.FullName, "Localization")))) + dir = dir.Parent; + dir.Should().NotBeNull("the test must run inside the repository"); + return dir!.FullName; + } + + // ".Healing--", "--.Healing", ".Healing -= ..." or ".Healing = Math.Max(0, .Healing - ...)". + // HerbHealing and ManaPotions never match: the name must be exactly Healing after the dot. + private static readonly Regex PotionDecrement = new(@"(?:\b([A-Za-z_]\w*)\.Healing\s*(?:--|-=|=\s*Math\.Max\(\s*0\s*,\s*\1\.Healing\s*-)|--\s*[A-Za-z_]\w*\.Healing\b)"); + + private static readonly Regex MethodSignature = new(@"^\s*(?:public|private|internal|protected)\b[^=;]*?(\w+)\s*(?:<[^>]*>)?\s*\("); + + // "File.Method" -> why a potion is used up there without a heal to boost. + private static readonly Dictionary ExcludedMethods = new() + { + ["MaintenanceSystem.ProcessHealingSpoilage"] = "surplus potions spoil; nothing is drunk", + ["TempleLocation.SacrificePotions"] = "potions are destroyed as an offering; any heal is divine and to full", + ["DungeonLocation.WoundedManEncounter"] = "the potion goes to a stranger; no Character's HP rises", + ["DungeonLocation.IssuePotionToTeammateStash"] = "moves potions into a teammate's stock; they are drunk later through TeammateHealWithPotion", + ["NPCCombatSimulator.DrinkHealingPotion"] = "NPC world-sim: only NPCs drink here, and an NPC's Infirmary is always 0", + }; + + private static IEnumerable ScriptFiles() => + Directory.EnumerateFiles(Path.Combine(RepoRoot(), "Scripts"), "*.cs", SearchOption.AllDirectories); + + private static (int start, int end, string name) MethodAround(string[] lines, int index) + { + int start = -1; string name = "?"; + for (int j = index; j >= 0; j--) + { + var m = MethodSignature.Match(lines[j]); + if (m.Success) { start = j; name = m.Groups[1].Value; break; } + } + int end = index + 1; + while (end < lines.Length && !MethodSignature.IsMatch(lines[end])) end++; + return (Math.Max(0, start), end, name); + } + + private static List<(string file, int line, string method, string text, string body)> Decrements() + { + var hits = new List<(string, int, string, string, string)>(); + foreach (var path in ScriptFiles()) + { + var lines = File.ReadAllLines(path); + for (int i = 0; i < lines.Length; i++) + { + string code = lines[i].TrimStart(); + if (code.StartsWith("//") || code.StartsWith("*")) continue; + if (!PotionDecrement.IsMatch(lines[i])) continue; + var (start, end, name) = MethodAround(lines, i); + string body = string.Join("\n", lines.Skip(start).Take(end - start)); + hits.Add((Path.GetFileNameWithoutExtension(path), i + 1, name, lines[i].Trim(), body)); + } + } + return hits; + } + + [Fact] + public void EveryHealingPotionDecrement_AppliesTheInfirmaryOrSaysWhyNot() + { + var hits = Decrements(); + hits.Count.Should().BeGreaterThan(20, "the scan must find the potion-drinking sites, or it proves nothing"); + + var problems = new List(); + foreach (var (file, line, method, text, body) in hits) + { + if (ExcludedMethods.ContainsKey($"{file}.{method}")) continue; + if (body.Contains("TeamHQBonus.ApplyPotionHeal")) continue; + int comment = text.IndexOf("//", StringComparison.Ordinal); + bool markedOut = comment >= 0 && Regex.IsMatch(text.Substring(comment), @"hq-infirmary: out \(\S[^)]*\)"); + if (!markedOut) + problems.Add($"{file}.cs:{line} in {method}: {text}"); + } + problems.Should().BeEmpty("each method that uses up a healing potion applies TeamHQBonus.ApplyPotionHeal or is marked out with a reason"); + } + + [Fact] + public void TheScan_SeesTheKnownSites_AndIgnoresHerbsAndManaPotions() + { + var hits = Decrements(); + var methods = hits.Select(h => $"{h.file}.{h.method}").ToHashSet(); + methods.Should().Contain(new[] + { + "CombatEngine.ExecuteHeal", "CombatEngine.ExecuteUseItem", "CombatEngine.AutoHealWithPotions", "CombatEngine.HandleHealAlly", + "CombatEngine.TeammateHealWithPotion", "CombatEngine.ProcessComputerPlayerAction", "BaseLocation.UseQuickPotion", + "HomeLocation.UseHealingPotion", "DungeonLocation.HealTeammate", "DungeonLocation.HealEntireParty", + "DungeonLocation.UseHealingPotion", "DungeonLocation.HealToFull", "DungeonLocation.UseFollowerPotion", + "WorldBossSystem.DrinkHealingPotions", + }); + hits.Should().NotContain(h => h.text.Contains("HerbHealing") || h.text.Contains("ManaPotions")); + PotionDecrement.IsMatch("player.HerbHealing--;").Should().BeFalse(); + PotionDecrement.IsMatch("player.Healing = Math.Max(0, player.Healing - 2);").Should().BeTrue(); + } + + [Fact] + public void TheExcludedMethods_StillExist() + { + var present = new HashSet(); + foreach (var path in ScriptFiles()) + { + string file = Path.GetFileNameWithoutExtension(path); + foreach (var l in File.ReadLines(path)) + { + var m = MethodSignature.Match(l); + if (m.Success) present.Add($"{file}.{m.Groups[1].Value}"); + } + } + ExcludedMethods.Keys.Where(k => !present.Contains(k)).Should().BeEmpty("a stale exclusion would hide a new method of the same name"); + } + + [Fact] + public void TheOldInlineInfirmaryReader_IsGone() + { + string src = File.ReadAllText(Path.Combine(RepoRoot(), "Scripts", "Systems", "CombatEngine.cs")); + src.Should().NotContain("TeamHQBonus.Infirmary(", "the inline reader truncated and sat before the difficulty multiplier"); + } + + private static string MethodBody(string file, string method) + { + var lines = File.ReadAllLines(Path.Combine(RepoRoot(), file)); + int start = Array.FindIndex(lines, l => { var m = MethodSignature.Match(l); return m.Success && m.Groups[1].Value == method; }); + start.Should().BeGreaterThanOrEqualTo(0, $"{method} must exist in {file}"); + int end = start + 1; + while (end < lines.Length && !MethodSignature.IsMatch(lines[end])) end++; + return string.Join("\n", lines.Skip(start).Take(end - start)); + } + + private static int Count(string text, string what) => Regex.Matches(text, Regex.Escape(what)).Count; + + // The heal sites and the per-potion estimates and displays that must match them. + [Theory] + [InlineData("Scripts/Systems/CombatEngine.cs", "ExecuteHeal", 3)] // quick, multi-potion, the F estimate + [InlineData("Scripts/Systems/CombatEngine.cs", "ExecuteUseItem", 1)] + [InlineData("Scripts/Systems/CombatEngine.cs", "AutoHealWithPotions", 2)] // heal and threshold + [InlineData("Scripts/Systems/CombatEngine.cs", "HandleHealAlly", 2)] // heal and estimate + [InlineData("Scripts/Systems/CombatEngine.cs", "TeammateHealWithPotion", 1)] + [InlineData("Scripts/Systems/CombatEngine.cs", "ProcessComputerPlayerAction", 1)] + [InlineData("Scripts/Locations/BaseLocation.cs", "UseQuickPotion", 1)] + [InlineData("Scripts/Locations/HomeLocation.cs", "UseHealingPotion", 1)] + [InlineData("Scripts/Locations/DungeonLocation.cs", "UsePotions", 2)] // Electron payload and text menu + [InlineData("Scripts/Locations/DungeonLocation.cs", "HealTeammate", 2)] + [InlineData("Scripts/Locations/DungeonLocation.cs", "HealEntireParty", 3)] + [InlineData("Scripts/Locations/DungeonLocation.cs", "UseHealingPotion", 1)] + [InlineData("Scripts/Locations/DungeonLocation.cs", "HealToFull", 1)] + [InlineData("Scripts/Locations/DungeonLocation.cs", "UseFollowerPotion", 1)] + [InlineData("Scripts/Systems/WorldBossSystem.cs", "DrinkHealingPotions", 1)] + public void EachSite_AppliesTheInfirmary(string file, string method, int calls) + { + Count(MethodBody(file, method), "TeamHQBonus.ApplyPotionHeal(").Should().Be(calls); + } + + [Fact] + public void TheCombatPotion_AppliesTheInfirmary_AfterTheDifficultyMultiplier() + { + string body = MethodBody("Scripts/Systems/CombatEngine.cs", "ExecuteHeal"); + int from = 0; + for (int n = 0; n < 2; n++) + { + int difficulty = body.IndexOf("DifficultySystem.ApplyHealingMultiplier(healAmount)", from, StringComparison.Ordinal); + int bonus = body.IndexOf("TeamHQBonus.ApplyPotionHeal(player, healAmount)", from, StringComparison.Ordinal); + int cap = body.IndexOf("Math.Min(healAmount, player.MaxHP - player.HP)", from, StringComparison.Ordinal); + difficulty.Should().BeGreaterThan(0); + bonus.Should().BeGreaterThan(difficulty, "the Infirmary is the last modifier"); + cap.Should().BeGreaterThan(bonus, "and comes before the cap to missing HP"); + from = cap + 1; + } + } + + [Fact] + public void TheTeammatePotion_UsesTheOwnersInfirmary() + { + string body = MethodBody("Scripts/Systems/CombatEngine.cs", "TeammateHealWithPotion"); + body.Should().Contain("potionOwner = owner;", "a potion from the player's belt carries the belt owner's bonus"); + body.Should().Contain("TeamHQBonus.ApplyPotionHeal(potionOwner, healAmount)"); + } + + [Fact] + public void TheHomePotion_PutsTheInfirmaryInsideTheFloor() + { + MethodBody("Scripts/Locations/HomeLocation.cs", "UseHealingPotion") + .Should().Contain("Math.Max(50, TeamHQBonus.ApplyPotionHeal(currentPlayer, currentPlayer.MaxHP / 4))"); + } + + // Behaviour. + private static readonly BindingFlags F = BindingFlags.NonPublic | BindingFlags.Instance; + + private static Character Drinker(int infirmary) => new Character + { + Name1 = "infirmary", Name2 = "Infirmary", Class = CharacterClass.Warrior, Level = 20, HP = 1, MaxHP = 100_000, + Healing = 5, CombatSpeed = CombatSpeed.Instant, + Team = "X", HQLevelsTeam = infirmary > 0 ? "X" : "", HQInfirmaryLevel = infirmary, + }; + + private static async Task QuickHeal(Character hero, int seed) + { + var engine = new CombatEngine(new TerminalEmulator(new MemoryStream(), new MemoryStream())); + typeof(CombatEngine).GetField("random", F)!.SetValue(engine, new Random(seed)); + typeof(CombatEngine).GetField("currentPlayer", F)!.SetValue(engine, hero); + long before = hero.HP; + await (Task)typeof(CombatEngine).GetMethod("ExecuteHeal", F)! + .Invoke(engine, new object[] { hero, new CombatResult(), true })!; + hero.Healing.Should().Be(4, "one potion drunk"); + return hero.HP - before; + } + + [Fact] + public async Task AQuickCombatPotion_Heals20PercentMore_WithInfirmary2() + { + for (int seed = 1; seed <= 10; seed++) + { + long plain = await QuickHeal(Drinker(0), seed); + long boosted = await QuickHeal(Drinker(2), seed); + plain.Should().BeGreaterThan(100); + boosted.Should().Be((long)Math.Round(plain * 1.2), "Infirmary 2 multiplies the final heal by 1.2"); + } + } + + [Fact] + public async Task LevelsReadForAnotherTeam_GiveNothing() + { + var stale = Drinker(2); + stale.HQLevelsTeam = "Y"; + (await QuickHeal(stale, 3)).Should().Be(await QuickHeal(Drinker(0), 3)); + } + + private static async Task DungeonPotion(Character hero) + { + var dungeon = new DungeonLocation(); + typeof(BaseLocation).GetField("terminal", F)!.SetValue(dungeon, new TerminalEmulator(new MemoryStream(), new MemoryStream())); + long before = hero.HP; + await (Task)typeof(DungeonLocation).GetMethod("UseHealingPotion", F, null, new[] { typeof(Character) }, null)! + .Invoke(dungeon, new object[] { hero })!; + hero.Healing.Should().Be(4, "one potion drunk"); + return hero.HP - before; + } + + [Fact] + public async Task ADungeonPotion_Heals20PercentMore_WithInfirmary2() + { + long plain = await DungeonPotion(Drinker(0)); + long boosted = await DungeonPotion(Drinker(2)); + plain.Should().Be(25_000, "a quarter of MaxHP"); + boosted.Should().Be(30_000); + } +} From aa2433bd383cf664283c95d3879e6649ff9494c7 Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 18:50:43 +0000 Subject: [PATCH 10/42] The Team HQ Training reaches every combat XP award, once The Training bonus was applied to the whole victory pot before fatigue, the early-game multiplier and the split, so NPC teammates shared in the leader's bonus; PvP wins, retreats' own share and every flat reward for winning a fight (floor bosses, secret bosses, bounties, gang wars, the Gauntlet, the tournament, Seth Able, the portal guardian, the duelist, the prison break, Malachar, a bounty paid on a kill, saving a god in the fight, Noctura's betrayal, the world boss) had none of it. It now goes on each player's own XP, after every other modifier and after any split, exactly once: the leader's share, each grouped player's own share with their own levels, and each flat combat reward. Non-combat XP, NPC and companion shares and the Rat King's dice game are left as they are. A test reads every file under Scripts and fails on any XP award that neither applies it nor says why not. Also: a Barracks test compared one seeded swing with dodging on unseeded dice; it now compares only swings that land. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Data/SecretBosses.cs | 7 +- Scripts/Locations/AnchorRoadLocation.cs | 13 +- Scripts/Locations/DungeonLocation.cs | 28 +- Scripts/Locations/InnLocation.cs | 1 + Scripts/Locations/PrisonWalkLocation.cs | 2 +- Scripts/Locations/SanctumLocation.cs | 3 +- Scripts/Systems/CombatEngine.cs | 33 +- Scripts/Systems/OldGodBossSystem.cs | 8 +- Scripts/Systems/QuestSystem.cs | 1 + Scripts/Systems/RareEncounters.cs | 2 +- Scripts/Systems/WorldBossSystem.cs | 6 +- Tests/BarracksCoverageTests.cs | 18 +- Tests/TrainingCoverageTests.cs | 392 ++++++++++++++++++++++++ 13 files changed, 458 insertions(+), 56 deletions(-) create mode 100644 Tests/TrainingCoverageTests.cs diff --git a/Scripts/Data/SecretBosses.cs b/Scripts/Data/SecretBosses.cs index e1993a32..7b5cb28c 100644 --- a/Scripts/Data/SecretBosses.cs +++ b/Scripts/Data/SecretBosses.cs @@ -501,12 +501,13 @@ public async Task HandleVictory(SecretBossType type, Character player, TerminalE } // Grant rewards - player.Experience += boss.RewardXP; + long rewardXP = TeamHQBonus.ApplyXP(player, boss.RewardXP); // v1.1.11: Team HQ Training + player.Experience += rewardXP; player.Gold += boss.RewardGold; terminal.WriteLine(""); - if (boss.RewardXP > 0) - terminal.WriteLine(Loc.Get("secretboss.reward_xp", boss.RewardXP), "cyan"); + if (rewardXP > 0) + terminal.WriteLine(Loc.Get("secretboss.reward_xp", rewardXP), "cyan"); if (boss.RewardGold > 0) terminal.WriteLine(Loc.Get("secretboss.reward_gold", boss.RewardGold), "yellow"); diff --git a/Scripts/Locations/AnchorRoadLocation.cs b/Scripts/Locations/AnchorRoadLocation.cs index 6435ad53..082580ac 100644 --- a/Scripts/Locations/AnchorRoadLocation.cs +++ b/Scripts/Locations/AnchorRoadLocation.cs @@ -483,7 +483,7 @@ private async Task StartBountyHunting() if (result.Outcome == CombatOutcome.Victory) { long bounty = target.Level * 100 + (long)target.Darkness; - long expGain = target.Level * 50; + long expGain = TeamHQBonus.ApplyXP(currentPlayer, target.Level * 50); // v1.1.11: Team HQ Training terminal.SetColor("bright_green"); terminal.WriteLine(""); @@ -755,11 +755,12 @@ private async Task StartGangWar() terminal.SetColor("bright_green"); WriteSectionHeader(Loc.Get("anchor_road.gang_war_victory"), "bright_green"); terminal.WriteLine(Loc.Get("ui.defeated_all_members", enemiesDefeated, targetTeam.TeamName)); + long wonXP = TeamHQBonus.ApplyXP(currentPlayer, totalXPReward); // v1.1.11: Team HQ Training terminal.WriteLine(Loc.Get("ui.gold_plundered", $"{totalGoldReward:N0}")); - terminal.WriteLine($"{Loc.Get("ui.experience")}: {totalXPReward:N0}"); + terminal.WriteLine($"{Loc.Get("ui.experience")}: {wonXP:N0}"); currentPlayer.Gold += totalGoldReward; - currentPlayer.Experience += totalXPReward; + currentPlayer.Experience += wonXP; // Handle turf transfer if (targetTeam.ControlsTurf) @@ -800,7 +801,7 @@ private async Task StartGangWar() { // Give partial rewards for enemies defeated before losing long partialGold = totalGoldReward / 2; - long partialXP = totalXPReward / 2; + long partialXP = TeamHQBonus.ApplyXP(currentPlayer, totalXPReward / 2); // v1.1.11: after the cut terminal.SetColor("yellow"); terminal.WriteLine(Loc.Get("anchor_road.partial_rewards", $"{partialGold:N0}", $"{partialXP:N0}")); currentPlayer.Gold += partialGold; @@ -1060,7 +1061,7 @@ private async Task StartGauntlet() // Wave rewards long waveGold = GameConfig.GauntletGoldPerWavePerLevel * currentPlayer.Level; - long waveXP = GameConfig.GauntletXPPerWave * wave * currentPlayer.Level; + long waveXP = TeamHQBonus.ApplyXP(currentPlayer, GameConfig.GauntletXPPerWave * wave * currentPlayer.Level); // v1.1.11: Team HQ Training totalGoldEarned += waveGold; totalXPEarned += waveXP; currentPlayer.Gold += waveGold; @@ -1116,7 +1117,7 @@ private async Task StartGauntlet() string tierAchievementId = UsurperRemake.Data.GauntletChampionData.GetTierAchievementId(earnedTier); long tierGold = (long)rewards.GoldMultiplierPerLevel * currentPlayer.Level; - long tierXP = (long)rewards.XpMultiplierPerLevel * currentPlayer.Level; + long tierXP = TeamHQBonus.ApplyXP(currentPlayer, (long)rewards.XpMultiplierPerLevel * currentPlayer.Level); // v1.1.11: Team HQ Training int tierFame = rewards.FameBonus; totalGoldEarned += tierGold; diff --git a/Scripts/Locations/DungeonLocation.cs b/Scripts/Locations/DungeonLocation.cs index 2c70ff5c..1138d6a9 100644 --- a/Scripts/Locations/DungeonLocation.cs +++ b/Scripts/Locations/DungeonLocation.cs @@ -5472,7 +5472,7 @@ private async Task FightRoomMonsters(DungeonRoom room, bool isAmbush = false) long bossGold = currentDungeonLevel * 500 + dungeonRandom.Next(1000); long bossExp = currentDungeonLevel * 300; - var (bossXPShare, bossGoldShare) = AwardDungeonReward(bossExp, bossGold, "Boss Defeated"); + var (bossXPShare, bossGoldShare) = AwardDungeonReward(bossExp, bossGold, "Boss Defeated", fromCombat: true); if (teammates.Count > 0) { @@ -5481,7 +5481,7 @@ private async Task FightRoomMonsters(DungeonRoom room, bool isAmbush = false) } else { - terminal.WriteLine(Loc.Get("dungeon.boss_bonus", bossGold, bossExp)); + terminal.WriteLine(Loc.Get("dungeon.boss_bonus", bossGold, bossXPShare)); // v1.1.11: what was paid, with Training } // Artifact drop chance for specific floor bosses @@ -7818,7 +7818,7 @@ private async Task MysteriousPortalEncounter() terminal.SetColor("green"); terminal.WriteLine(Loc.Get("dungeon.portal_guardian_crystal")); long bonusGold = (long)(Math.Pow(currentDungeonLevel, 1.5) * 36); - long bonusXp = (long)(Math.Pow(currentDungeonLevel, 1.5) * 45); + long bonusXp = TeamHQBonus.ApplyXP(currentPlayer, (long)(Math.Pow(currentDungeonLevel, 1.5) * 45)); // v1.1.11: Team HQ Training currentPlayer.Gold += bonusGold; currentPlayer.Experience += bonusXp; } @@ -7830,7 +7830,7 @@ private async Task MysteriousPortalEncounter() terminal.WriteLine(Loc.Get("dungeon.portal_study")); // Small XP for studying - about half a monster kill long xpGain = (long)(Math.Pow(currentDungeonLevel, 1.5) * 8); - currentPlayer.Experience += xpGain; + currentPlayer.Experience += xpGain; // hq-training: out (portal study, no fight) terminal.WriteLine(Loc.Get("dungeon.portal_learn_magic", xpGain)); } else @@ -7977,7 +7977,7 @@ private async Task ChallengingDuelistEncounter() // Rewards scale with rivalry intensity - roughly 2-4 monster kills based on rivalry int rivalryBonus = 1 + duelist.TimesEncountered / 3; long goldReward = (long)(Math.Pow(currentDungeonLevel, 1.5) * 24 * rivalryBonus); - long xpReward = (long)(Math.Pow(currentDungeonLevel, 1.5) * 15 * (1 + rivalryBonus * 0.5)); + long xpReward = TeamHQBonus.ApplyXP(currentPlayer, (long)(Math.Pow(currentDungeonLevel, 1.5) * 15 * (1 + rivalryBonus * 0.5))); // v1.1.11: Team HQ Training currentPlayer.Gold += goldReward; currentPlayer.Experience += xpReward; AlignmentSystem.Instance.ChangeAlignment(currentPlayer, 5, isGood: true, "dungeon.duelist_victory"); // v0.57.12: paired movement @@ -16720,8 +16720,9 @@ private async Task FightMalachar(Character player, UsurperRemake.Systems.Compani terminal.WriteLine(Loc.Get("quest.aldric_ghosts.bonus"), "bright_cyan"); // XP reward - player.Experience += 25000; - terminal.WriteLine(Loc.Get("quest.aldric_ghosts.xp_reward", "25,000"), "bright_green"); + long malacharXP = TeamHQBonus.ApplyXP(player, 25000); // v1.1.11: Team HQ Training + player.Experience += malacharXP; + terminal.WriteLine(Loc.Get("quest.aldric_ghosts.xp_reward", $"{malacharXP:N0}"), "bright_green"); } await terminal.PressAnyKey(); @@ -17834,7 +17835,7 @@ private string BuildRoomAnsi(DungeonRoom room, string leaderName) /// Companions are skipped (handled by CompanionSystem). /// Returns (leaderXP, leaderGold). /// - private (long leaderXP, long leaderGold) SplitPartyRewards(long totalXP, long totalGold, string source) + private (long leaderXP, long leaderGold) SplitPartyRewards(long totalXP, long totalGold, string source, bool fromCombat = false) { var player = GetCurrentPlayer(); if (player == null) return (totalXP, totalGold); @@ -17866,6 +17867,8 @@ private string BuildRoomAnsi(DungeonRoom room, string leaderName) // Real players: full XP with level gap penalty float groupXPMult = GroupSystem.GetGroupXPMultiplier(teammate.Level, highestLevel); long memberXP = (long)(totalXP * groupXPMult); + // v1.1.11: a combat reward carries this player's own Team HQ Training. + if (fromCombat) memberXP = TeamHQBonus.ApplyXP(teammate, memberXP); teammate.Experience += memberXP; teammate.Statistics.RecordGoldChange(teammate.Gold); @@ -17882,7 +17885,7 @@ private string BuildRoomAnsi(DungeonRoom room, string leaderName) { // NPC teammates (spouses, mercenaries): 75% XP long npcXP = (long)(totalXP * 0.75); - teammate.Experience += npcXP; + teammate.Experience += npcXP; // hq-training: out (NPC teammate share, never the leader's bonus) } } @@ -17896,8 +17899,9 @@ private string BuildRoomAnsi(DungeonRoom room, string leaderName) /// /// Award XP and gold to the leader, splitting among party if in a group. /// Returns actual (xp, gold) awarded to the leader. + /// v1.1.11: fromCombat (the floor boss only) adds each real player's Team HQ Training to their own XP. /// - private (long xp, long gold) AwardDungeonReward(long xp, long gold, string source) + private (long xp, long gold) AwardDungeonReward(long xp, long gold, string source, bool fromCombat = false) { var player = GetCurrentPlayer(); if (player == null) return (xp, gold); @@ -17908,13 +17912,15 @@ private string BuildRoomAnsi(DungeonRoom room, string leaderName) lock (teammates) { hasGroupedPlayers = teammates.Any(t => t.IsGroupedPlayer); } if (hasGroupedPlayers) { - var (leaderXP, leaderGold) = SplitPartyRewards(xp, gold, source); + var (leaderXP, leaderGold) = SplitPartyRewards(xp, gold, source, fromCombat); + if (fromCombat) leaderXP = TeamHQBonus.ApplyXP(player, leaderXP); player.Gold += leaderGold; player.Experience += leaderXP; return (leaderXP, leaderGold); } else { + if (fromCombat) xp = TeamHQBonus.ApplyXP(player, xp); player.Gold += gold; player.Experience += xp; return (xp, gold); diff --git a/Scripts/Locations/InnLocation.cs b/Scripts/Locations/InnLocation.cs index a856dd84..60cf7e01 100644 --- a/Scripts/Locations/InnLocation.cs +++ b/Scripts/Locations/InnLocation.cs @@ -1006,6 +1006,7 @@ private async Task FightSethAble() xpReward /= 2; goldReward /= 2; } + xpReward = TeamHQBonus.ApplyXP(currentPlayer, xpReward); // v1.1.11: Team HQ Training, after the cut currentPlayer.Experience += xpReward; currentPlayer.Gold += goldReward; diff --git a/Scripts/Locations/PrisonWalkLocation.cs b/Scripts/Locations/PrisonWalkLocation.cs index e6bf7560..d9bcc9fd 100644 --- a/Scripts/Locations/PrisonWalkLocation.cs +++ b/Scripts/Locations/PrisonWalkLocation.cs @@ -554,7 +554,7 @@ private async Task BattlePrisonGuards(Character player, List gu await terminal.WriteLineAsync(Loc.Get("prison_walk.damage_taken", (playerStartHP - player.HP).ToString())); // Award experience for defeating guards - long expGained = guards.Sum(g => g.Level * 50); + long expGained = TeamHQBonus.ApplyXP(player, guards.Sum(g => g.Level * 50)); // v1.1.11: Team HQ Training player.Experience += expGained; await terminal.WriteLineAsync(Loc.Get("prison_walk.xp_gained", expGained.ToString())); diff --git a/Scripts/Locations/SanctumLocation.cs b/Scripts/Locations/SanctumLocation.cs index c8225870..8fa4d94c 100644 --- a/Scripts/Locations/SanctumLocation.cs +++ b/Scripts/Locations/SanctumLocation.cs @@ -680,6 +680,7 @@ private async Task StartHonorTournament() // through ChangeAlignment so the v0.60.0 DR curve scales high-Chivalry gains down). long waveGold = GameConfig.GauntletGoldPerWavePerLevel * currentPlayer.Level; long waveXP = GameConfig.GauntletXPPerWave * (waveIdx + 4) * currentPlayer.Level; // wave-equiv +3 for honor-payout shape + waveXP = TeamHQBonus.ApplyXP(currentPlayer, waveXP); // v1.1.11: Team HQ Training int waveFame = (waveIdx + 1) * 5; // 5 / 10 / 15 -- bigger per-wave Fame than Gauntlet warmup because each fight is a champion int waveChivalry = 10 + waveIdx * 5; // 10 / 15 / 20 across the three waves @@ -794,7 +795,7 @@ private async Task StartHonorTournament() var tierRewards = UsurperRemake.Data.HonorTournamentData.GetTierRewards(earnedTier); long bonusGold = tierRewards.GoldMultiplierPerLevel * currentPlayer.Level; - long bonusXP = tierRewards.XpMultiplierPerLevel * currentPlayer.Level; + long bonusXP = TeamHQBonus.ApplyXP(currentPlayer, tierRewards.XpMultiplierPerLevel * currentPlayer.Level); // v1.1.11: Team HQ Training int bonusFame = tierRewards.FameBonus; currentPlayer.Gold += bonusGold; diff --git a/Scripts/Systems/CombatEngine.cs b/Scripts/Systems/CombatEngine.cs index e11f317d..2d284965 100644 --- a/Scripts/Systems/CombatEngine.cs +++ b/Scripts/Systems/CombatEngine.cs @@ -2135,7 +2135,7 @@ public async Task PlayerVsMonsters( var underlying = player.PetRoster?.FirstOrDefault(p => string.Equals(p.Name, teammate.Name, StringComparison.OrdinalIgnoreCase)); if (underlying != null) { - underlying.Experience += Math.Max(5, result.ExperienceGained / 20); // ~5% of combat XP, min 5 + underlying.Experience += Math.Max(5, result.ExperienceGained / 20); // ~5% of combat XP, min 5; hq-training: out (pet, not a player) // Pet level-up at simple thresholds (50 * Level XP per next level). while (underlying.Experience >= underlying.Level * 50) { @@ -7230,12 +7230,6 @@ private async Task HandleVictory(CombatResult result) expReward = (long)(expReward * guildMult); } - // Team HQ Training bonus: +5% XP per level - if (TeamHQBonus.Training(result.Player) > 0) - { - expReward += (long)(expReward * (TeamHQBonus.Training(result.Player) * 0.05)); - } - // Fatigue XP penalty — Exhausted tier only (single-player only) if (!UsurperRemake.BBS.DoorMode.IsOnlineMode && result.Player.Fatigue >= GameConfig.FatigueExhaustedThreshold) { @@ -20680,12 +20674,6 @@ private async Task HandleVictoryMultiMonster(CombatResult result, bool offerMonk adjustedExp = (long)(adjustedExp * guildMultMM); } - // Team HQ Training bonus: +5% XP per level — multi-monster path - if (TeamHQBonus.Training(result.Player) > 0) - { - adjustedExp += (long)(adjustedExp * (TeamHQBonus.Training(result.Player) * 0.05)); - } - // Fatigue XP penalty — Exhausted tier only (single-player only) if (!UsurperRemake.BBS.DoorMode.IsOnlineMode && result.Player.Fatigue >= GameConfig.FatigueExhaustedThreshold) { @@ -20709,6 +20697,8 @@ private async Task HandleVictoryMultiMonster(CombatResult result, bool offerMonk // Apply per-slot XP percentage distribution long totalXPPotMM = adjustedExp; long playerXPmm = (long)(totalXPPotMM * xpSharesMM[0] / 100.0); + // v1.1.11: Team HQ Training on the player's own share, after the split, so it stays out of the teammates' pot. + playerXPmm = TeamHQBonus.ApplyXP(result.Player, playerXPmm); // Apply rewards (player's percentage share) result.Player.Experience += playerXPmm; @@ -21272,12 +21262,6 @@ private async Task HandlePartialVictory(CombatResult result, bool offerMonkEncou adjustedExp = (long)(adjustedExp * guildMultPV); } - // Team HQ Training bonus: +5% XP per level — berserker/special path - if (TeamHQBonus.Training(result.Player) > 0) - { - adjustedExp += (long)(adjustedExp * (TeamHQBonus.Training(result.Player) * 0.05)); - } - // v0.64.1 early-game XP multiplier (berserker / special path). double earlyGameMultPV = GameConfig.GetEarlyGameXPMultiplier((int)result.Player.Level); if (earlyGameMultPV > 1.0) @@ -21292,6 +21276,8 @@ private async Task HandlePartialVictory(CombatResult result, bool offerMonkEncou // Apply per-slot XP percentage distribution long totalXPPotPV = adjustedExp; long playerXPpv = (long)(totalXPPotPV * xpSharesPV[0] / 100.0); + // v1.1.11: Team HQ Training on the player's own share, after the split, so it stays out of the teammates' pot. + playerXPpv = TeamHQBonus.ApplyXP(result.Player, playerXPpv); result.Player.Experience += playerXPpv; result.Player.Gold += adjustedGold; @@ -26262,6 +26248,8 @@ private async Task DeterminePvPOutcome(CombatResult result) // Apply difficulty modifier (per-character difficulty + server-wide SysOp multiplier) float xpMult = DifficultySystem.GetExperienceMultiplier(DifficultySystem.CurrentDifficulty) * GameConfig.XPMultiplier; xpReward = (long)(xpReward * xpMult); + // v1.1.11: Team HQ Training last. + xpReward = TeamHQBonus.ApplyXP(result.Player, xpReward); // Calculate gold reward - take some of opponent's gold + level-based bonus long opponentGold = result.Opponent?.Gold ?? 0; @@ -30509,10 +30497,6 @@ private async Task DistributeGroupRewards(CombatResult result, long totalBaseExp playerExp = (long)(playerExp * gpGuildMult); } - // Team HQ Training bonus: +5% XP per level - if (TeamHQBonus.Training(groupedPlayer) > 0) - playerExp += (long)(playerExp * (TeamHQBonus.Training(groupedPlayer) * 0.05)); - // v0.64.1 early-game XP multiplier (per-grouped-player path). // Each grouped player's level keys into the curve independently // so a Lv 5 follower benefits even when grouped with a Lv 50 leader. @@ -30525,6 +30509,9 @@ private async Task DistributeGroupRewards(CombatResult result, long totalBaseExp if (groupXPMult < 1.0f) playerExp = (long)(playerExp * groupXPMult); + // v1.1.11: Team HQ Training last, with the follower's own levels. + playerExp = TeamHQBonus.ApplyXP(groupedPlayer, playerExp); + // Session XP diminishing returns removed in v0.54.7. groupedPlayer.SessionCombatCount++; diff --git a/Scripts/Systems/OldGodBossSystem.cs b/Scripts/Systems/OldGodBossSystem.cs index 8647b254..99d8e6e2 100644 --- a/Scripts/Systems/OldGodBossSystem.cs +++ b/Scripts/Systems/OldGodBossSystem.cs @@ -1082,7 +1082,7 @@ private async Task ConvertToBossResult( { if (wasSaved) { - return await HandleBossSaved(combatResult.Player, boss, terminal); + return await HandleBossSaved(combatResult.Player, boss, terminal, inCombat: true); } else if (combatResult.Outcome == CombatOutcome.Victory) { @@ -1122,7 +1122,7 @@ private async Task ConvertToBossResult( /// Handle boss being saved /// private async Task HandleBossSaved( - Character player, OldGodBossData boss, TerminalEmulator terminal) + Character player, OldGodBossData boss, TerminalEmulator terminal, bool inCombat = false) { terminal.Clear(); terminal.WriteLine(""); @@ -1150,6 +1150,8 @@ private async Task HandleBossSaved( // Award experience long xpReward = boss.Level * 1000; + // v1.1.11: Team HQ Training only when the save ended a fight, not when spared in dialogue. + if (inCombat) xpReward = TeamHQBonus.ApplyXP(player, xpReward); player.Experience += xpReward; terminal.WriteLine(Loc.Get("old_god.saved_xp", $"{xpReward:N0}"), "cyan"); @@ -1426,7 +1428,7 @@ private async Task HandleNocturaBetrayal(Character player, TerminalEmulato } // Rewards - long xpReward = 300000; + long xpReward = TeamHQBonus.ApplyXP(player, 300000); // v1.1.11: Team HQ Training int goldReward = 100000; player.Experience += xpReward; player.Gold += goldReward; diff --git a/Scripts/Systems/QuestSystem.cs b/Scripts/Systems/QuestSystem.cs index 0af37d1e..91fd4595 100644 --- a/Scripts/Systems/QuestSystem.cs +++ b/Scripts/Systems/QuestSystem.cs @@ -1581,6 +1581,7 @@ public static long AutoCompleteBountyForNPC(Character player, string npcName) player.Gold += reward; DebugLogger.Instance.LogInfo("GOLD", $"BOUNTY REWARD: {player.DisplayName} +{reward:N0}g for bounty on {npcName} (gold now {player.Gold:N0})"); long xpReward = Math.Max(player.Level * 50, reward / 5); // XP scales with player level and bounty + xpReward = TeamHQBonus.ApplyXP(player, xpReward); // v1.1.11: Team HQ Training, the kill paid it player.Experience += xpReward; totalReward += reward; diff --git a/Scripts/Systems/RareEncounters.cs b/Scripts/Systems/RareEncounters.cs index 9551c9b7..04c32b46 100644 --- a/Scripts/Systems/RareEncounters.cs +++ b/Scripts/Systems/RareEncounters.cs @@ -2434,7 +2434,7 @@ private static async Task RatKingEncounter(TerminalEmulator terminal, Character terminal.SetColor("green"); terminal.WriteLine(Loc.Get("encounter.ratking.fight_slay")); player.Gold += level * 1200; // Increased from 400 for economic balance - player.Experience += level * 200; + player.Experience += level * 200; // hq-training: out (a dice roll, no fight) } } else diff --git a/Scripts/Systems/WorldBossSystem.cs b/Scripts/Systems/WorldBossSystem.cs index 1081d176..27fc226d 100644 --- a/Scripts/Systems/WorldBossSystem.cs +++ b/Scripts/Systems/WorldBossSystem.cs @@ -539,14 +539,16 @@ public async Task DeliverWorldBossRewards(Character player, SqlSaveBackend backe terminal.SetColor("bright_yellow"); terminal.WriteLine(GameConfig.ScreenReaderMode ? $" {Loc.Get("world_boss.rewards_delivered_header")}" : $" ═══ {Loc.Get("world_boss.rewards_delivered_header")} ═══"); } - player.Experience += r.Xp; + // v1.1.11: Team HQ Training at delivery, where the player and their levels are loaded (after the settlement cap). + long xp = TeamHQBonus.ApplyXP(player, r.Xp); + player.Experience += xp; player.Gold += r.Gold; player.Fame += r.Fame; terminal.SetColor("white"); terminal.WriteLine(r.Kind == "kill" ? $" {Loc.Get("world_boss.reward_kind_kill", r.BossName, r.Night, (int)(r.Score * 100), r.Mvp ? Loc.Get("world_boss.tier_mvp") : Loc.Get("world_boss.tier_contributor"))}" : $" {Loc.Get("world_boss.reward_kind_withdraw", r.BossName, r.Night)}"); - terminal.WriteLine($" {Loc.Get("world_boss.reward_xp", $"{r.Xp:N0}")} {Loc.Get("world_boss.reward_gold", $"{r.Gold:N0}")} {Loc.Get("world_boss.reward_fame", r.Fame)}"); + terminal.WriteLine($" {Loc.Get("world_boss.reward_xp", $"{xp:N0}")} {Loc.Get("world_boss.reward_gold", $"{r.Gold:N0}")} {Loc.Get("world_boss.reward_fame", r.Fame)}"); var boss = await backend.GetWorldBossById(r.BossId); var bossDef = boss != null ? WorldBossDatabase.GetBossById(boss.DefinitionId) : null; diff --git a/Tests/BarracksCoverageTests.cs b/Tests/BarracksCoverageTests.cs index 10d82ff4..0d5fb597 100644 --- a/Tests/BarracksCoverageTests.cs +++ b/Tests/BarracksCoverageTests.cs @@ -272,10 +272,18 @@ public async Task TheAntiTankMinimum_StillHolds_AfterTheBarracks() [Fact] public async Task LevelsReadForAnotherTeam_GiveNothing() { - var other = Hero(2); - other.HQLevelsTeam = "Y"; - long plain = await Swing(Hero(0), 7); - long stale = await Swing(other, 7); - stale.Should().Be(plain); + // Dodging uses StatEffectsSystem's own unseeded dice, so compare only seeds where both swings land. + int compared = 0; + for (int seed = 1; seed <= 200 && compared < 10; seed++) + { + var other = Hero(2); + other.HQLevelsTeam = "Y"; + long plain = await Swing(Hero(0), seed); + long stale = await Swing(other, seed); + if (plain <= 0 || stale <= 0) continue; + stale.Should().Be(plain, $"seed {seed}: levels read for another team give nothing"); + compared++; + } + compared.Should().BeGreaterThan(0); } } diff --git a/Tests/TrainingCoverageTests.cs b/Tests/TrainingCoverageTests.cs new file mode 100644 index 00000000..7f635acb --- /dev/null +++ b/Tests/TrainingCoverageTests.cs @@ -0,0 +1,392 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using UsurperRemake; +using UsurperRemake.Systems; +using Xunit; + +namespace UsurperReborn.Tests; + +/// +/// v1.1.11: the Team HQ Training bonus (+5% per level) reaches every XP award a player earns from +/// combat, once, after every other modifier and after any party split, so it lands on the player's own +/// share and never flows to NPC teammates. There is no single choke point, so every method in Scripts/ +/// that adds to a Character's Experience must either apply TeamHQBonus.ApplyXP, be listed below with its +/// reason (not combat, not a player, dead code, a transfer), or carry an "hq-training: out (reason)" +/// comment on the add. +/// +[Collection("SharedGameSingletons")] +public class TrainingCoverageTests +{ + private static string RepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !(Directory.Exists(Path.Combine(dir.FullName, "Scripts")) && Directory.Exists(Path.Combine(dir.FullName, "Localization")))) + dir = dir.Parent; + dir.Should().NotBeNull("the test must run inside the repository"); + return dir!.FullName; + } + + // ".Experience += ...". ExperienceGained, GodExperience and the like never match. + private static readonly Regex XPAward = new(@"\b[A-Za-z_]\w*\.Experience\s*\+="); + + private static readonly Regex MethodSignature = new(@"^\s*(?:public|private|internal|protected)\b[^=;]*?(\w+)\s*(?:<[^>]*>)?\s*\("); + + // "File.Method" -> why XP is added there without the Training bonus. + private static readonly Dictionary ExcludedMethods = new() + { + // Dead code. + ["CombatEngine.HandleVictory"] = "dead: only DetermineCombatOutcome calls it, and nothing calls that", + ["CombatEngine.AwardTeammateExperience"] = "dead: no callers", + ["CompanionSystem.AwardCompanionExperience"] = "dead: no callers", + // Not a player Character's XP. + ["CombatEngine.DistributeTeamSlotXP"] = "NPC teammates and companions: their slot of the un-boosted pot", + ["CompanionSystem.AwardSpecificCompanionXP"] = "companion XP", + ["GameEngine.ProcessNPCLeveling"] = "NPC world simulation", + ["PermadeathHelper.TryDistributeInheritance"] = "heir inheritance, not an award for a fight", + // Transfers. + ["LevelMasterLocation.HelpTeamMember"] = "XP gifted from the player to an ally", + // Not combat: events, quests, features, romance, rare encounters. + ["OldGodBossSystem.CompleteSaveQuest"] = "an artifact returned to a god, no fight", + ["SevenSealsSystem.CollectSeal"] = "seal collected", + ["PuzzleSystem.DisplayPuzzleSuccess"] = "puzzle solved", + ["QuestSystem.ApplyQuestReward"] = "quest turn-in", + ["MailSystem.ProcessBirthdayMail"] = "birthday gift", + ["AchievementSystem.TryUnlock"] = "achievement reward", + ["AlignmentSystem.CheckAlignmentEvent"] = "alignment event", + ["DialogueSystem.ApplyEffect"] = "dialogue effect", + ["NPCPetitionSystem.ExecuteDyingWish"] = "a dying wish granted", + ["NPCPetitionSystem.ExecuteRivalryReport"] = "a rivalry report", + ["DiscoverySystem.ApplyEffect"] = "dungeon discovery", + ["FeatureInteractionSystem.HandleLoreDiscovery"] = "room feature", + ["FeatureInteractionSystem.HandleSkillSuccess"] = "room feature", + ["FeatureInteractionSystem.HandleSkillFailure"] = "room feature", + ["FeatureInteractionSystem.ApplyMoralChoice"] = "room feature", + ["FeatureInteractionSystem.ApplyClassSpecificBonus"] = "room feature", + ["FeatureInteractionSystem.HandleMemoryTrigger"] = "room feature", + ["FeatureInteractionSystem.HandleOceanInsight"] = "room feature", + ["FeatureInteractionSystem.HandleStandardInteraction"] = "room feature", + ["DungeonLocation.InteractWithFeature"] = "feature XP shared to teammates", + ["DungeonLocation.FallenAdventurerEncounter"] = "fallen adventurer's notes", + ["DungeonLocation.EchoingVoicesEncounter"] = "echoing voices", + ["DungeonLocation.MysteriousShrine"] = "shrine", + ["DungeonLocation.RiddleEncounter"] = "riddle", + ["DungeonLocation.FullPuzzleEncounter"] = "puzzle", + ["DungeonLocation.MysteryEventEncounter"] = "time warp event", + ["DungeonLocation.RiddleGateEncounter"] = "riddle gate", + ["DungeonLocation.ShareEventRewardsWithGroup"] = "the group's share of the events above", + ["DarkAlleyLocation.ExecuteEvilDeed"] = "evil deed", + ["CastleLocation.AudienceRequestQuest"] = "royal audience quest advance", + ["WildernessLocation.ShrineEncounter"] = "shrine", + ["TempleLocation.PerformEnhancedDesecration"] = "desecration", + ["InnLocation.PlayDrinkingGame"] = "drinking game", + ["LoveStreetLocation.ShowIntimateEncounter"] = "romance", + ["LoveStreetLocation.ProcessDateActivity"] = "romance", + ["LoveCornerLocation.HandleKiss"] = "romance", + ["LoveCornerLocation.HandleDinner"] = "romance", + ["LoveCornerLocation.HandleHoldHands"] = "romance", + ["LoveCornerLocation.HandleIntimate"] = "romance", + ["HomeLocation.SpendQualityTime"] = "family time", + ["RareEncounters.TavernStranger"] = "rare encounter, no fight", + ["RareEncounters.WanderingMinstrelEncounter"] = "rare encounter, no fight", + ["RareEncounters.FairyCircleEncounter"] = "rare encounter, no fight", + ["RareEncounters.DamselInDistressEncounter"] = "rare encounter, no fight", + ["RareEncounters.UsurperGhostEncounter"] = "rare encounter, no fight", + ["RareEncounters.GamblingDemonsEncounter"] = "rare encounter, gambling", + ["RareEncounters.OldHermitEncounter"] = "rare encounter, no fight", + ["RareEncounters.MysteriousMerchantEncounter"] = "rare encounter, no fight", + ["RareEncounters.TimeWarpEncounter"] = "rare encounter, no fight", + ["RareEncounters.AncientLibraryEncounter"] = "rare encounter, no fight", + ["RareEncounters.WishingWellEncounter"] = "rare encounter, no fight", + ["RareEncounters.BoneOracleEncounter"] = "rare encounter, no fight", + ["RareEncounters.RestlessSpiritsEncounter"] = "rare encounter, no fight", + ["RareEncounters.CryptKeeperEncounter"] = "rare encounter, riddles", + ["RareEncounters.AlchemistLabEncounter"] = "rare encounter, no fight", + ["RareEncounters.AncientGolemEncounter"] = "rare encounter, no fight", + ["RareEncounters.TimeCapsuleEncounter"] = "rare encounter, no fight", + ["RareEncounters.LostCivilizationEncounter"] = "rare encounter, no fight", + ["RareEncounters.TorturedSoulsEncounter"] = "rare encounter, no fight", + ["RareEncounters.LavaBoatEncounter"] = "rare encounter, no fight", + ["RareEncounters.CosmicEntityEncounter"] = "rare encounter, no fight", + }; + + private static IEnumerable ScriptFiles() => + Directory.EnumerateFiles(Path.Combine(RepoRoot(), "Scripts"), "*.cs", SearchOption.AllDirectories); + + private static (int start, int end, string name) MethodAround(string[] lines, int index) + { + int start = -1; string name = "?"; + for (int j = index; j >= 0; j--) + { + var m = MethodSignature.Match(lines[j]); + if (m.Success) { start = j; name = m.Groups[1].Value; break; } + } + int end = index + 1; + while (end < lines.Length && !MethodSignature.IsMatch(lines[end])) end++; + return (Math.Max(0, start), end, name); + } + + private static List<(string file, int line, string method, string text, string body)> Awards() + { + var hits = new List<(string, int, string, string, string)>(); + foreach (var path in ScriptFiles()) + { + var lines = File.ReadAllLines(path); + for (int i = 0; i < lines.Length; i++) + { + string code = lines[i].TrimStart(); + if (code.StartsWith("//") || code.StartsWith("*")) continue; + if (!XPAward.IsMatch(lines[i])) continue; + var (start, end, name) = MethodAround(lines, i); + string body = string.Join("\n", lines.Skip(start).Take(end - start)); + hits.Add((Path.GetFileNameWithoutExtension(path), i + 1, name, lines[i].Trim(), body)); + } + } + return hits; + } + + [Fact] + public void EveryExperienceAward_AppliesTheTrainingOrSaysWhyNot() + { + var hits = Awards(); + hits.Count.Should().BeGreaterThan(80, "the scan must find the XP award sites, or it proves nothing"); + + var problems = new List(); + foreach (var (file, line, method, text, body) in hits) + { + if (ExcludedMethods.ContainsKey($"{file}.{method}")) continue; + if (body.Contains("TeamHQBonus.ApplyXP")) continue; + int comment = text.IndexOf("//", StringComparison.Ordinal); + bool markedOut = comment >= 0 && Regex.IsMatch(text.Substring(comment), @"hq-training: out \(\S[^)]*\)"); + if (!markedOut) + problems.Add($"{file}.cs:{line} in {method}: {text}"); + } + problems.Should().BeEmpty("each method that adds a player's XP applies TeamHQBonus.ApplyXP or is marked out with a reason"); + } + + [Fact] + public void TheScan_SeesTheKnownSites_AndIgnoresOtherExperienceFields() + { + var methods = Awards().Select(h => $"{h.file}.{h.method}").ToHashSet(); + methods.Should().Contain(new[] + { + "CombatEngine.HandleVictoryMultiMonster", "CombatEngine.HandlePartialVictory", "CombatEngine.DistributeGroupRewards", + "CombatEngine.DeterminePvPOutcome", "DungeonLocation.AwardDungeonReward", "DungeonLocation.SplitPartyRewards", + "SecretBosses.HandleVictory", "OldGodBossSystem.HandleBossDefeated", "OldGodBossSystem.HandleBossSaved", + "OldGodBossSystem.HandleNocturaBetrayal", "AnchorRoadLocation.StartBountyHunting", "AnchorRoadLocation.StartGangWar", + "AnchorRoadLocation.StartGauntlet", "SanctumLocation.StartHonorTournament", "InnLocation.FightSethAble", + "DungeonLocation.MysteriousPortalEncounter", "DungeonLocation.ChallengingDuelistEncounter", + "PrisonWalkLocation.BattlePrisonGuards", "DungeonLocation.FightMalachar", "QuestSystem.AutoCompleteBountyForNPC", + "WorldBossSystem.DeliverWorldBossRewards", + }); + XPAward.IsMatch("result.ExperienceGained += 5;").Should().BeFalse(); + XPAward.IsMatch("god.GodExperience += 5;").Should().BeFalse(); + XPAward.IsMatch("player.Experience += 5;").Should().BeTrue(); + } + + [Fact] + public void TheExcludedMethods_StillExist() + { + var present = new HashSet(); + foreach (var path in ScriptFiles()) + { + string file = Path.GetFileNameWithoutExtension(path); + foreach (var l in File.ReadLines(path)) + { + var m = MethodSignature.Match(l); + if (m.Success) present.Add($"{file}.{m.Groups[1].Value}"); + } + } + ExcludedMethods.Keys.Where(k => !present.Contains(k)).Should().BeEmpty("a stale exclusion would hide a new method of the same name"); + } + + [Fact] + public void TheOldInlineTrainingReader_IsGone() + { + string src = File.ReadAllText(Path.Combine(RepoRoot(), "Scripts", "Systems", "CombatEngine.cs")); + src.Should().NotContain("TeamHQBonus.Training(", "the inline reader truncated, sat before fatigue and the early-game multiplier, and went into the teammates' pot"); + } + + private static string MethodBody(string file, string method) + { + var lines = File.ReadAllLines(Path.Combine(RepoRoot(), file)); + int start = Array.FindIndex(lines, l => { var m = MethodSignature.Match(l); return m.Success && m.Groups[1].Value == method; }); + start.Should().BeGreaterThanOrEqualTo(0, $"{method} must exist in {file}"); + int end = start + 1; + while (end < lines.Length && !MethodSignature.IsMatch(lines[end])) end++; + return string.Join("\n", lines.Skip(start).Take(end - start)); + } + + private static int Count(string text, string what) => Regex.Matches(text, Regex.Escape(what)).Count; + + [Theory] + [InlineData("HandleVictoryMultiMonster", "earlyGameMultMM", "xpSharesMM[0]", "TeamHQBonus.ApplyXP(result.Player, playerXPmm)", "result.Player.Experience += playerXPmm", "DistributeTeamSlotXP(")] + [InlineData("HandlePartialVictory", "earlyGameMultPV", "xpSharesPV[0]", "TeamHQBonus.ApplyXP(result.Player, playerXPpv)", "result.Player.Experience += playerXPpv", "DistributeTeamSlotXP(")] + public void TheEngine_AppliesTheTraining_ToThePlayersShare_AfterTheSplit(string method, string lastModifier, string split, string bonus, string add, string pot) + { + string body = MethodBody("Scripts/Systems/CombatEngine.cs", method); + Count(body, "TeamHQBonus.ApplyXP(").Should().Be(1); + int m = body.IndexOf(lastModifier, StringComparison.Ordinal), s = body.IndexOf(split, StringComparison.Ordinal); + int b = body.IndexOf(bonus, StringComparison.Ordinal), a = body.IndexOf(add, StringComparison.Ordinal), p = body.IndexOf(pot, StringComparison.Ordinal); + m.Should().BeGreaterThan(0); + s.Should().BeGreaterThan(m); + b.Should().BeGreaterThan(s, "the bonus is the player's own, after the split"); + a.Should().BeGreaterThan(b); + p.Should().BeGreaterThan(a, "the teammates' pot is the un-boosted total"); + } + + [Fact] + public void GroupedFollowers_GetTheirOwnTraining_AfterTheGroupGap() + { + string body = MethodBody("Scripts/Systems/CombatEngine.cs", "DistributeGroupRewards"); + int gap = body.IndexOf("playerExp = (long)(playerExp * groupXPMult)", StringComparison.Ordinal); + int bonus = body.IndexOf("TeamHQBonus.ApplyXP(groupedPlayer, playerExp)", StringComparison.Ordinal); + gap.Should().BeGreaterThan(0); + bonus.Should().BeGreaterThan(gap); + bonus.Should().BeLessThan(body.IndexOf("groupedPlayer.Experience += playerExp", StringComparison.Ordinal)); + } + + [Fact] + public void TheOldGodSave_GetsTheTraining_OnlyWhenItEndedAFight() + { + MethodBody("Scripts/Systems/OldGodBossSystem.cs", "ConvertToBossResult").Should().Contain("inCombat: true"); + MethodBody("Scripts/Systems/OldGodBossSystem.cs", "HandleBossSaved").Should().Contain("if (inCombat) xpReward = TeamHQBonus.ApplyXP(player, xpReward);"); + string start = MethodBody("Scripts/Systems/OldGodBossSystem.cs", "StartBossEncounter"); + start.Should().Contain("return await HandleBossSaved(player, boss, terminal);", "the dialogue spare is not a fight"); + } + + [Fact] + public void OnlyTheFloorBoss_CallsTheDungeonRewardFromCombat() + { + string src = File.ReadAllText(Path.Combine(RepoRoot(), "Scripts", "Locations", "DungeonLocation.cs")); + var calls = Regex.Matches(src, @"AwardDungeonReward\(([^;]*)\);").Select(m => m.Groups[1].Value).Where(a => !a.StartsWith("long ")).ToList(); + calls.Count.Should().BeGreaterThanOrEqualTo(4); + calls.Where(a => a.Contains("fromCombat: true")).Should().ContainSingle().Which.Should().Contain("\"Boss Defeated\""); + } + + // Behaviour. + private sealed class ScriptedStream : Stream + { + private readonly byte[] _data; private int _pos; + public ScriptedStream(string script) { _data = Encoding.UTF8.GetBytes(script); } + public override int Read(byte[] buffer, int offset, int count) + { + if (_pos >= _data.Length) return 0; + int n = Math.Min(count, _data.Length - _pos); Array.Copy(_data, _pos, buffer, offset, n); _pos += n; return n; + } + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) => Task.FromResult(Read(buffer, offset, count)); + public override bool CanRead => true; public override bool CanSeek => false; public override bool CanWrite => false; + public override long Length => _data.Length; public override long Position { get => _pos; set => throw new NotSupportedException(); } + public override void Flush() { } + public override long Seek(long o, SeekOrigin s) => throw new NotSupportedException(); + public override void SetLength(long v) => throw new NotSupportedException(); + public override void Write(byte[] b, int o, int c) => throw new NotSupportedException(); + } + + private static readonly BindingFlags F = BindingFlags.NonPublic | BindingFlags.Instance; + + private static TerminalEmulator Term() => new(new ScriptedStream(string.Concat(Enumerable.Repeat("P\n", 40))), new MemoryStream()); + + private static Character Hero(int training) => new Character + { + Name1 = "training", Name2 = "Training", Class = CharacterClass.Warrior, Race = CharacterRace.Human, Level = 40, + HP = 5000, MaxHP = 5000, Experience = 0, Gold = 0, AutoLevelUp = false, CombatSpeed = CombatSpeed.Instant, MKills = 100, + Team = "X", HQLevelsTeam = training > 0 ? "X" : "", HQTrainingLevel = training, + }; + + private static NPC Mate() => new NPC + { + Name1 = "mate", Name2 = "Mate", Class = CharacterClass.Warrior, Race = CharacterRace.Human, Level = 40, + HP = 3000, MaxHP = 3000, Experience = 0, + }; + + private static async Task<(long player, long mate)> WinTogether(int training) + { + var hero = Hero(training); + var mate = Mate(); + var monster = new Monster { Name = "Training Dummy", Level = 40, HP = 0, MaxHP = 100, Experience = 200_000, Gold = 10 }; + var result = new CombatResult { Player = hero, Outcome = CombatOutcome.Victory }; + result.DefeatedMonsters.Add(monster); + result.Teammates = new List { mate }; + var engine = new CombatEngine(Term()); + typeof(CombatEngine).GetField("random", F)!.SetValue(engine, new Random(7)); + typeof(CombatEngine).GetField("currentPlayer", F)!.SetValue(engine, hero); + await (Task)typeof(CombatEngine).GetMethod("HandleVictoryMultiMonster", F)!.Invoke(engine, new object[] { result, false })!; + // Other awards on this screen (achievements, quests) depend on shared state; the fight's own XP is ExperienceGained. + hero.Experience.Should().BeGreaterThanOrEqualTo(result.ExperienceGained, "the reported XP was paid"); + return (result.ExperienceGained, mate.Experience); + } + + [Fact] + public async Task AVictory_GivesThePlayer10PercentMore_WithTraining2_AndTheTeammateTheSame() + { + var plain = await WinTogether(0); + var boosted = await WinTogether(2); + plain.player.Should().BeGreaterThan(10_000); + plain.mate.Should().BeGreaterThan(10_000, "the NPC teammate has a slot of the pot"); + boosted.player.Should().Be((long)Math.Round(plain.player * 1.10), "Training 2 multiplies the player's own share by 1.1"); + boosted.mate.Should().Be(plain.mate, "the leader's Training does not flow into the teammates' pot"); + } + + private static async Task WinPvP(int training) + { + var hero = Hero(training); + var foe = new NPC { Name1 = "foe", Name2 = "Foe", Class = CharacterClass.Warrior, Race = CharacterRace.Human, Level = 40, HP = 0, MaxHP = 1000, Gold = 0 }; + var result = new CombatResult { Player = hero, Opponent = foe, Outcome = CombatOutcome.Victory }; + var engine = new CombatEngine(Term()); + typeof(CombatEngine).GetField("random", F)!.SetValue(engine, new Random(7)); + typeof(CombatEngine).GetField("currentPlayer", F)!.SetValue(engine, hero); + await (Task)typeof(CombatEngine).GetMethod("DeterminePvPOutcome", F)!.Invoke(engine, new object[] { result })!; + result.Outcome.Should().Be(CombatOutcome.Victory); + result.ExperienceGained.Should().Be(hero.Experience); + return hero.Experience; + } + + [Fact] + public async Task APvPWin_GivesThePlayer10PercentMore_WithTraining2() + { + long plain = await WinPvP(0); + long boosted = await WinPvP(2); + plain.Should().BeGreaterThan(0); + boosted.Should().Be((long)Math.Round(plain * 1.10)); + } + + private static long DungeonReward(Character hero, long xp, bool fromCombat, List? party = null) + { + var dungeon = new DungeonLocation(); + typeof(BaseLocation).GetField("terminal", F)!.SetValue(dungeon, Term()); + typeof(BaseLocation).GetField("currentPlayer", F)!.SetValue(dungeon, hero); + if (party != null) dungeon.teammates.AddRange(party); + var r = ((long xp, long gold))typeof(DungeonLocation).GetMethod("AwardDungeonReward", F)! + .Invoke(dungeon, new object[] { xp, 0L, "Test", fromCombat })!; + r.xp.Should().Be(hero.Experience, "the returned XP is what the leader got"); + return hero.Experience; + } + + [Fact] + public void TheDungeonReward_OutsideCombat_GetsNoTraining() + { + DungeonReward(Hero(2), 10_000, fromCombat: false).Should().Be(10_000, "treasure and the floor-cleared bonus are not combat XP"); + DungeonReward(Hero(0), 10_000, fromCombat: true).Should().Be(10_000); + DungeonReward(Hero(2), 10_000, fromCombat: true).Should().Be(11_000, "the floor boss is"); + } + + [Fact] + public void TheFloorBossReward_InAGroup_GivesEachRealPlayerTheirOwnTraining_AndNPCsNone() + { + var leader = Hero(2); + var follower = Hero(4); follower.Name1 = "follower"; follower.Team = "Y"; follower.HQLevelsTeam = "Y"; + follower.RemoteTerminal = Term(); + var npc = Mate(); + DungeonReward(leader, 10_000, fromCombat: true, new List { follower, npc }).Should().Be(11_000); + follower.Experience.Should().Be(12_000, "the follower's own Training 4"); + npc.Experience.Should().Be(7_500, "an NPC teammate gets 75% of the plain total"); + } +} From cc460c0210c9d5bb2ad847031ba348401741d580 Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 19:08:14 +0000 Subject: [PATCH 11/42] Wilderness gold finds pay on the dungeon's scale A level-36 player found about 150 gold in a ruins treasure or a foraged valuable, where the matching dungeon finds pay 3,600 to 10,800 (player report: "low by a factor of 100"). The wilderness finds grew by 3 to 20 gold a level; the dungeon's grow by 100 to 300. They now pay what the matching dungeon find pays, at the level the wilderness fights use (the region's minimum or the player's, whichever is higher): a small find like the dungeon's small find, a medium find and a ruins treasure like a treasure room, a large find a notch above a chest. Wilderness trips stay capped at four a day and two revisits. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Locations/WildernessLocation.cs | 32 ++++++++++++++--- Tests/WildernessGoldTests.cs | 47 +++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 Tests/WildernessGoldTests.cs diff --git a/Scripts/Locations/WildernessLocation.cs b/Scripts/Locations/WildernessLocation.cs index dc275114..71a5feb0 100644 --- a/Scripts/Locations/WildernessLocation.cs +++ b/Scripts/Locations/WildernessLocation.cs @@ -461,9 +461,31 @@ private async Task ForagingEncounter(WildernessRegion region) await terminal.PressAnyKey(); } + /// + /// v1.1.11: the level wilderness finds pay at, the same one its fights use: the region's minimum + /// level or the player's, whichever is higher. + /// + private int RewardLevel(WildernessRegion region) => Math.Max(region.MinLevel, currentPlayer.Level); + + /// + /// v1.1.11: wilderness gold finds pay what the matching dungeon finds pay at the same level (player + /// report: "low by a factor of 100"; they grew by 3 to 20 gold a level while the dungeon's grow by + /// 100 to 300). small = the dungeon's small find, medium and ruins treasure = a treasure room, + /// large = a notch above a chest. Wilderness trips are capped per day. + /// + internal static long FindGold(string size, int level, Random rng) + { + level = Math.Max(1, level); + return size switch + { + "small" => level * 30L + rng.Next(level * 20), + "large" => level * 150L + rng.Next(level * 200), + _ => level * 100L + rng.Next(level * 200), // "medium" and "treasure" + }; + } + private void ApplyForagingResult(string effect, WildernessRegion region) { - int levelScale = Math.Max(1, region.MinLevel / 5); switch (effect) { @@ -556,19 +578,19 @@ private void ApplyForagingResult(string effect, WildernessRegion region) } break; case "gold_small": - long goldS = 20 * levelScale + Random.Shared.Next(20); + long goldS = FindGold("small", RewardLevel(region), Random.Shared); currentPlayer.Gold += goldS; terminal.SetColor("bright_yellow"); terminal.WriteLine(Loc.Get("wilderness.worth_gold", goldS)); break; case "gold_medium": - long goldM = 50 * levelScale + Random.Shared.Next(50); + long goldM = FindGold("medium", RewardLevel(region), Random.Shared); currentPlayer.Gold += goldM; terminal.SetColor("bright_yellow"); terminal.WriteLine(Loc.Get("wilderness.worth_gold", goldM)); break; case "gold_large": - long goldL = 100 * levelScale + Random.Shared.Next(100); + long goldL = FindGold("large", RewardLevel(region), Random.Shared); currentPlayer.Gold += goldL; terminal.SetColor("bright_yellow"); terminal.WriteLine(Loc.Get("wilderness.worth_gold", goldL)); @@ -605,7 +627,7 @@ private async Task RuinsEncounter(WildernessRegion region) int roll = Random.Shared.Next(100); if (roll < 60) { - long gold = 30 + (long)(currentPlayer.Level * 3) + Random.Shared.Next(50); + long gold = FindGold("treasure", RewardLevel(region), Random.Shared); currentPlayer.Gold += gold; terminal.SetColor("bright_yellow"); terminal.WriteLine(Loc.Get("wilderness.ruins_gold_found", gold)); diff --git a/Tests/WildernessGoldTests.cs b/Tests/WildernessGoldTests.cs new file mode 100644 index 00000000..5f517fac --- /dev/null +++ b/Tests/WildernessGoldTests.cs @@ -0,0 +1,47 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using FluentAssertions; +using Xunit; + +namespace UsurperReborn.Tests; + +/// +/// v1.1.11 (player report, level 36: "the gold rewards in the forest/mountain/swamp/beach seem to be +/// low by a factor of 100, 148 in the Stormbreak Coast"): wilderness gold finds grew by 3 to 20 gold a +/// level, while the matching dungeon finds grow by 100 to 300. They now pay on the dungeon's scale. +/// +public class WildernessGoldTests +{ + [Theory] + [InlineData("small", 36, 36 * 30, 36 * 30 + 36 * 20)] + [InlineData("medium", 36, 36 * 100, 36 * 100 + 36 * 200)] + [InlineData("treasure", 36, 36 * 100, 36 * 100 + 36 * 200)] + [InlineData("large", 36, 36 * 150, 36 * 150 + 36 * 200)] + public void AFind_PaysOnTheDungeonsScale(string size, int level, long min, long maxExclusive) + { + var rng = new Random(1); + for (int i = 0; i < 200; i++) + { + long gold = WildernessLocation.FindGold(size, level, rng); + gold.Should().BeGreaterThanOrEqualTo(min).And.BeLessThan(maxExclusive); + } + } + + [Fact] + public void AtLevel36_ARuinsTreasure_IsNoLongerAboutAHundred() + { + // The report's 148 was the ruins treasure: 30 + level x 3 + up to 50. + WildernessLocation.FindGold("treasure", 36, new Random(7)).Should().BeGreaterThan(3000); + } + + [Fact] + public void EveryWildernessGoldFind_UsesTheScale() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Scripts"))) dir = dir.Parent; + string src = File.ReadAllText(Path.Combine(dir!.FullName, "Scripts", "Locations", "WildernessLocation.cs")); + Regex.Matches(src, @"FindGold\(""(small|medium|large|treasure)"", RewardLevel\(region\)").Count.Should().Be(4); + src.Should().NotContain("levelScale", "the old region-level scale paid a few gold a level"); + } +} From 8f299e1ca1b6e07700134ea191d82994f7f700fd Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 19:16:16 +0000 Subject: [PATCH 12/42] Beating a WANTED target counts wherever it happens Only the street fights told the quest system an NPC had been beaten. A duel (the Dormitory, the Dark Alley and its pit, the Inn, the Arena), sparing an NPC who surrendered, and the Inn's challenge did not, so the bounty was never paid and "Defeat " stayed at 0/1 however many times the target was beaten (player report: four times). QuestSystem.RecordNPCDefeat pays any bounty on the NPC and records the objective. A duel won against an NPC or ended by sparing one calls it, and so does the Inn challenge. A player loaded from a save is not an NPC and is left alone. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Locations/InnLocation.cs | 6 +++ Scripts/Systems/CombatEngine.cs | 18 +++++++ Scripts/Systems/QuestSystem.cs | 14 +++++ Tests/NPCDefeatQuestTests.cs | 93 ++++++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+) create mode 100644 Tests/NPCDefeatQuestTests.cs diff --git a/Scripts/Locations/InnLocation.cs b/Scripts/Locations/InnLocation.cs index 60cf7e01..5cd15b74 100644 --- a/Scripts/Locations/InnLocation.cs +++ b/Scripts/Locations/InnLocation.cs @@ -1361,6 +1361,12 @@ private async Task ChallengeNPC(NPC npc) // XP already awarded by combat engine — no double reward currentPlayer.PKills++; + + // v1.1.11: a bounty or Defeat objective on this patron is met (player report: a WANTED target + // beaten four times stayed at 0/1) + long innBounty = QuestSystem.RecordNPCDefeat(currentPlayer, npc); + if (innBounty > 0) + terminal.WriteLine(Loc.Get("street.fight.bounty_collected", innBounty.ToString("N0")), "bright_yellow"); currentPlayer.InnDuelsToday++; // Count toward daily NPC duel limit (separate from Seth fights) // Update relationship negatively diff --git a/Scripts/Systems/CombatEngine.cs b/Scripts/Systems/CombatEngine.cs index 2d284965..fb759ef6 100644 --- a/Scripts/Systems/CombatEngine.cs +++ b/Scripts/Systems/CombatEngine.cs @@ -26132,6 +26132,21 @@ private async Task OfferNPCSurrenderAsync(Character attacker, Character de return false; } + /// + /// v1.1.11: a duel won against an NPC (killed or spared) pays its bounty and meets a Defeat objective, + /// as a street fight always did. A player loaded from a save is not an NPC and is left alone. + /// + private void ReportNPCDefeat(CombatResult result) + { + if (result.Opponent is not NPC npc || result.Player == null) return; + long bounty = QuestSystem.RecordNPCDefeat(result.Player, npc); + if (bounty > 0) + { + terminal.WriteLine(Loc.Get("street.fight.bounty_collected", bounty.ToString("N0")), "bright_yellow"); + result.GoldGained += bounty; + } + } + private async Task DeterminePvPOutcome(CombatResult result) { // v0.64.1 Slice 18: spared-NPC path. OfferNPCSurrenderAsync sets @@ -26187,6 +26202,8 @@ private async Task DeterminePvPOutcome(CombatResult result) // No XP/gold reward -- sparing isn't a kill. The alignment + // relationship swing is the reward. + // v1.1.11: but the NPC was beaten, so a bounty or Defeat objective on it is met + ReportNPCDefeat(result); return; } @@ -26272,6 +26289,7 @@ private async Task DeterminePvPOutcome(CombatResult result) result.Player.Gold += goldReward; result.ExperienceGained = xpReward; result.GoldGained = goldReward; + ReportNPCDefeat(result); // v1.1.11 // Track peak gold result.Player.Statistics?.RecordGoldChange(result.Player.Gold); diff --git a/Scripts/Systems/QuestSystem.cs b/Scripts/Systems/QuestSystem.cs index 91fd4595..533687cb 100644 --- a/Scripts/Systems/QuestSystem.cs +++ b/Scripts/Systems/QuestSystem.cs @@ -1515,6 +1515,20 @@ public static void OnDungeonFloorCleared(Character player, int floorNumber) } } + /// + /// v1.1.11: a player beat an NPC, by any route: a street fight, a duel, the pit, the Inn challenge, + /// or sparing one who surrendered. Pays any bounty on the NPC and records the Defeat objective. + /// Only the street fights did this, so a WANTED target beaten anywhere else stayed at 0/1 + /// (player report). Returns the bounty paid. + /// + public static long RecordNPCDefeat(Character player, NPC npc) + { + if (player == null || npc == null) return 0; + long bounty = AutoCompleteBountyForNPC(player, npc.Name ?? npc.Name2 ?? ""); + OnNPCDefeated(player, npc); + return bounty; + } + /// /// Update quest progress when player defeats an NPC (bounty system) /// Call this from StreetEncounterSystem and BaseLocation.ChallengeNPC when NPC is killed diff --git a/Tests/NPCDefeatQuestTests.cs b/Tests/NPCDefeatQuestTests.cs new file mode 100644 index 00000000..ec42b369 --- /dev/null +++ b/Tests/NPCDefeatQuestTests.cs @@ -0,0 +1,93 @@ +using System; +using System.IO; +using System.Reflection; +using System.Threading.Tasks; +using FluentAssertions; +using UsurperRemake.Systems; +using Xunit; + +namespace UsurperReborn.Tests; + +/// +/// v1.1.11 (player report: "Have a Wanted quest, found NPC, beat them 4 times but quest still counts +/// Defeat (NPC) 0/1"): only the street fights reported an NPC's defeat to the quest system. A duel (the +/// Dormitory, the Dark Alley and its pit, the Inn, the Arena), sparing an NPC who surrendered, and the +/// Inn's challenge did not, so the bounty was never paid and the objective never moved. +/// +[Collection("SharedGameSingletons")] +public class NPCDefeatQuestTests +{ + private static readonly BindingFlags F = BindingFlags.NonPublic | BindingFlags.Instance; + + private static (Character hunter, NPC target, Quest bounty) Wanted(string name) + { + var hunter = new Character { Name1 = "hunter_" + name, Name2 = "Hunter " + name, Level = 32, HP = 500, MaxHP = 500, Gold = 0 }; + var target = new NPC { ID = "npc_" + name, Name1 = name, Name2 = name, Level = 30, HP = 0, MaxHP = 400 }; + var bounty = new Quest + { + Title = "WANTED: " + name, Initiator = "The Crown", QuestTarget = QuestTarget.DefeatNPC, + TargetNPCName = target.Name, BountyGold = 5000, Occupier = hunter.Name2, Date = DateTime.Now, DaysToComplete = 30 + }; + bounty.Objectives.Add(new QuestObjective(QuestObjectiveType.DefeatNPC, "Defeat " + name, 1, target.Name, target.Name)); + QuestSystem.AddQuestToDatabase(bounty); + return (hunter, target, bounty); + } + + private static async Task Outcome(CombatResult result) + { + var engine = new CombatEngine(new TerminalEmulator(new MemoryStream(), new MemoryStream())); + await (Task)typeof(CombatEngine).GetMethod("DeterminePvPOutcome", F)!.Invoke(engine, new object[] { result })!; + } + + [Fact] + public async Task ADuelWonAgainstTheTarget_PaysTheBountyAndMeetsTheObjective() + { + var (hunter, target, bounty) = Wanted("Duel Mark"); + await Outcome(new CombatResult { Player = hunter, Opponent = target }); + bounty.Deleted.Should().BeTrue("the bounty is completed"); + hunter.Gold.Should().BeGreaterThanOrEqualTo(5000); + } + + [Fact] + public async Task SparingTheTargetAfterItSurrenders_StillCounts() + { + var (hunter, target, bounty) = Wanted("Spared Mark"); + target.HP = 1; + await Outcome(new CombatResult { Player = hunter, Opponent = target, Outcome = CombatOutcome.OpponentSpared }); + bounty.Deleted.Should().BeTrue("the target was beaten; sparing it is not a failure to defeat it"); + } + + [Fact] + public async Task AWinAgainstAPlayerLoadedFromASave_IsNotAnNPCDefeat() + { + var (hunter, _, bounty) = Wanted("Player Named Alike"); + var savedPlayer = new Character { Name1 = "Player Named Alike", Name2 = "Player Named Alike", Level = 30, HP = 0, MaxHP = 400 }; + await Outcome(new CombatResult { Player = hunter, Opponent = savedPlayer }); + bounty.Deleted.Should().BeFalse(); + bounty.Deleted = true; // leave the shared database clean + } + + [Fact] + public void TheHelper_MeetsTheObjectiveOfAClaimedQuestThatIsNotABounty() + { + var hunter = new Character { Name1 = "hunter_q", Name2 = "Hunter Q", Level = 32 }; + var target = new NPC { ID = "npc_q", Name1 = "Quest Mark", Name2 = "Quest Mark", Level = 30 }; + var quest = new Quest { Title = "Teach them a lesson", QuestTarget = QuestTarget.DefeatNPC, Occupier = hunter.Name2, Date = DateTime.Now, DaysToComplete = 30 }; + quest.Objectives.Add(new QuestObjective(QuestObjectiveType.DefeatNPC, "Defeat Quest Mark", 1, target.Name, target.Name)); + QuestSystem.AddQuestToDatabase(quest); + QuestSystem.RecordNPCDefeat(hunter, target); + quest.Objectives[0].IsComplete.Should().BeTrue(); + quest.Deleted = true; + } + + [Fact] + public void TheInnChallenge_ReportsTheDefeat() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Scripts"))) dir = dir.Parent; + string src = File.ReadAllText(Path.Combine(dir!.FullName, "Scripts", "Locations", "InnLocation.cs")); + int start = src.IndexOf("private async Task ChallengeNPC(NPC npc)", StringComparison.Ordinal); + int end = src.IndexOf("\n private ", start + 10, StringComparison.Ordinal); + src.Substring(start, end - start).Should().Contain("QuestSystem.RecordNPCDefeat(currentPlayer, npc)"); + } +} From 593830367b3a99787b9b17b4e987ce6a2ae13ce2 Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 19:38:17 +0000 Subject: [PATCH 13/42] Fixes from the first review of the 1.1.11 branch as a whole - Empty-team cleanup: it ran on an NPC roster that login restores clear and refill, and it could delete a team a player joined after its snapshot. It now skips an untrustworthy roster, removes a team only when it has stayed empty for 30 minutes, and checks online players again right before the delete. - The fight-start HQ refresh enumerated the live party list, which other sessions change; it enumerates a copy. - Two sessions beating the same target at once could both be paid the bounty; finding and claiming it now happen under one lock. - Sparing a target, or beating an Inn patron, met kill-only assassination contracts; only a kill does now. - Weapon enchant procs on a basic swing, Power Attack and the off-hand scale from the Armory-boosted hit again. - The world boss's telegraphed strikes and channels apply the Barracks. - An Old God's fight still paid the engine's 10-XP floor; the handler's single payout is the only one. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Core/GameConfig.cs | 2 + Scripts/Locations/InnLocation.cs | 2 +- Scripts/Systems/CombatEngine.cs | 14 +++-- Scripts/Systems/QuestSystem.cs | 21 +++++-- Scripts/Systems/WorldBossSystem.cs | 1 + Scripts/Systems/WorldSimService.cs | 46 +++++++++++---- Tests/EmptyTeamCleanupTests.cs | 60 ++++++++++++++++--- Tests/HuntRoundOneTests.cs | 93 ++++++++++++++++++++++++++++++ Tests/NPCDefeatQuestTests.cs | 26 ++++++++- 9 files changed, 231 insertions(+), 34 deletions(-) create mode 100644 Tests/HuntRoundOneTests.cs diff --git a/Scripts/Core/GameConfig.cs b/Scripts/Core/GameConfig.cs index 101512b9..43d4b4bf 100644 --- a/Scripts/Core/GameConfig.cs +++ b/Scripts/Core/GameConfig.cs @@ -1853,6 +1853,8 @@ public static bool IsSmallRace(CharacterRace race) // per-level amount, so a kill is worth what it was. public const int OldGodDefeatXPPerLevel = 6000; public const int OldGodDefeatGoldPerLevel = 1500; + // v1.1.11: a player team is removed only after it has been seen empty this long (WorldSimService.PruneEmptyTeams) + public const int EmptyTeamGraceMinutes = 30; // v1.1.10: regular dungeon monsters on the floors before an Old God's floor grow tougher, HP and // damage, by this much per floor: +5% five floors out, up to +25% on the floor before the god diff --git a/Scripts/Locations/InnLocation.cs b/Scripts/Locations/InnLocation.cs index 5cd15b74..7a4773a0 100644 --- a/Scripts/Locations/InnLocation.cs +++ b/Scripts/Locations/InnLocation.cs @@ -1364,7 +1364,7 @@ private async Task ChallengeNPC(NPC npc) // v1.1.11: a bounty or Defeat objective on this patron is met (player report: a WANTED target // beaten four times stayed at 0/1) - long innBounty = QuestSystem.RecordNPCDefeat(currentPlayer, npc); + long innBounty = QuestSystem.RecordNPCDefeat(currentPlayer, npc, killed: false); // a brawl: the patron lives if (innBounty > 0) terminal.WriteLine(Loc.Get("street.fight.bounty_collected", innBounty.ToString("N0")), "bright_yellow"); currentPlayer.InnDuelsToday++; // Count toward daily NPC duel limit (separate from Seth fights) diff --git a/Scripts/Systems/CombatEngine.cs b/Scripts/Systems/CombatEngine.cs index fb759ef6..c1568b61 100644 --- a/Scripts/Systems/CombatEngine.cs +++ b/Scripts/Systems/CombatEngine.cs @@ -836,7 +836,8 @@ public async Task PlayerVsMonsters( { TeamHQBonus.RefreshLevels(player); if (teammates != null) - foreach (var mate in teammates.Where(t => t is not NPC)) TeamHQBonus.RefreshLevels(mate); + // a copy: other sessions add and remove followers from the live list (Codex review) + foreach (var mate in teammates.ToList().Where(t => t is not NPC)) TeamHQBonus.RefreshLevels(mate); } // v0.65.6 Death's Door: once-per-combat burst rescue resets at combat start. @@ -13526,7 +13527,7 @@ private async Task ProcessPlayerActionMultiMonster(CombatAction action, Characte // All landed-swing on-hit effects (post-hit enchants + Shaman weapon-enchant // rider + Ancestral Guidance) run through the shared helper so the [P] Power // Attack fires the identical pipeline. - ApplyPlayerSwingOnHitEffects(player, target, damage, isOffHandAttack, result); + ApplyPlayerSwingOnHitEffects(player, target, TeamHQBonus.ApplyAttack(player, damage), isOffHandAttack, result); // v1.1.11: riders scale from the hit, Armory included } } break; @@ -13847,7 +13848,7 @@ private async Task ExecutePowerAttackMultiMonster(Character player, List OfferNPCSurrenderAsync(Character attacker, Character de private void ReportNPCDefeat(CombatResult result) { if (result.Opponent is not NPC npc || result.Player == null) return; - long bounty = QuestSystem.RecordNPCDefeat(result.Player, npc); + long bounty = QuestSystem.RecordNPCDefeat(result.Player, npc, killed: !npc.IsAlive); if (bounty > 0) { terminal.WriteLine(Loc.Get("street.fight.bounty_collected", bounty.ToString("N0")), "bright_yellow"); diff --git a/Scripts/Systems/QuestSystem.cs b/Scripts/Systems/QuestSystem.cs index 533687cb..3a2e9a23 100644 --- a/Scripts/Systems/QuestSystem.cs +++ b/Scripts/Systems/QuestSystem.cs @@ -1521,10 +1521,11 @@ public static void OnDungeonFloorCleared(Character player, int floorNumber) /// Only the street fights did this, so a WANTED target beaten anywhere else stayed at 0/1 /// (player report). Returns the bounty paid. /// - public static long RecordNPCDefeat(Character player, NPC npc) + public static long RecordNPCDefeat(Character player, NPC npc, bool killed) { if (player == null || npc == null) return 0; - long bounty = AutoCompleteBountyForNPC(player, npc.Name ?? npc.Name2 ?? ""); + // a target beaten but left alive does not meet an assassination contract (Codex review) + long bounty = AutoCompleteBountyForNPC(player, npc.Name ?? npc.Name2 ?? "", includeKillContracts: killed); OnNPCDefeated(player, npc); return bounty; } @@ -1572,18 +1573,28 @@ public static void OnNPCDefeated(Character player, NPC defeatedNPC) /// Gives immediate reward without needing to claim first /// Returns the total bounty reward collected (0 if no bounties matched) /// - public static long AutoCompleteBountyForNPC(Character player, string npcName) + // v1.1.11: two sessions beating the same target at once each found the bounty before either marked + // it done, and both were paid (Codex review). Finding and marking now happen under one lock. + private static readonly object _bountyPayoutLock = new(); + + public static long AutoCompleteBountyForNPC(Character player, string npcName, bool includeKillContracts = true) { if (string.IsNullOrEmpty(npcName)) return 0; long totalReward = 0; + List matchingBounties; + lock (_bountyPayoutLock) + { // Find ALL bounties targeting this NPC (claimed or unclaimed) - var matchingBounties = questDatabase.Where(q => + matchingBounties = questDatabase.Where(q => !q.Deleted && !string.IsNullOrEmpty(q.TargetNPCName) && - q.TargetNPCName.Equals(npcName, StringComparison.OrdinalIgnoreCase) + q.TargetNPCName.Equals(npcName, StringComparison.OrdinalIgnoreCase) && + (includeKillContracts || q.QuestTarget != QuestTarget.Assassin) ).ToList(); + foreach (var claimed in matchingBounties) claimed.Deleted = true; // claimed for this payout + } foreach (var bounty in matchingBounties) { diff --git a/Scripts/Systems/WorldBossSystem.cs b/Scripts/Systems/WorldBossSystem.cs index 27fc226d..c5b57af7 100644 --- a/Scripts/Systems/WorldBossSystem.cs +++ b/Scripts/Systems/WorldBossSystem.cs @@ -1436,6 +1436,7 @@ internal void ApplyLandedTelegraph(WorldBossTelegraphOutcome landed, WorldBossPl ? (answered ? GameConfig.WorldBossTelegraphUnansweredPercent / 2 : GameConfig.WorldBossTelegraphUnansweredPercent) : (answered ? GameConfig.WorldBossTelegraphAnsweredPercent : GameConfig.WorldBossTelegraphUnansweredPercent); long dmg = Math.Max(1, (long)(player.MaxHP * pct)); + dmg = Math.Max(1, TeamHQBonus.ApplyDefense(player, dmg)); // v1.1.11: Team HQ Barracks on a telegraphed hit too player.HP = Math.Max(0, player.HP - dmg); terminal.SetColor(answered ? "yellow" : "bright_red"); terminal.WriteLine(landed.Kind == "channel" diff --git a/Scripts/Systems/WorldSimService.cs b/Scripts/Systems/WorldSimService.cs index cc679578..2f13c165 100644 --- a/Scripts/Systems/WorldSimService.cs +++ b/Scripts/Systems/WorldSimService.cs @@ -1400,31 +1400,47 @@ private void LoadSettlementState() /// (living or dead) carries it, and no player online has it in hand, e.g. between joining and /// the save that records it. The team's upgrades and vault go with it. /// - internal int PruneEmptyTeams() + // v1.1.11: when each candidate team was first seen empty. A team is removed only when it is still + // empty at least EmptyTeamGraceMinutes later, so a transient view cannot delete it: an NPC roster + // being rebuilt, or a player who joined and whose save has not landed yet (Codex review). + private readonly Dictionary _teamEmptySince = new(StringComparer.Ordinal); + + internal int PruneEmptyTeams() => PruneEmptyTeams(DateTime.UtcNow); + + internal int PruneEmptyTeams(DateTime now) { int removed = 0; try { - var npcTeams = new HashSet(NPCSpawnSystem.Instance.ActiveNPCs - .Where(n => !string.IsNullOrEmpty(n.Team)).Select(n => n.Team!)); - var sessions = UsurperRemake.Server.MudServer.Instance?.ActiveSessions.Values; - var onlineTeams = new HashSet(sessions == null ? Enumerable.Empty() - : sessions.Select(s => s.Context?.Player?.Team).Where(t => !string.IsNullOrEmpty(t)).Select(t => t!)); - var gone = new List(); - foreach (var team in sqlBackend.GetTeamsWithoutPlayerMembers()) + // an NPC roster being rebuilt (login restores clear and refill it) is not evidence of anything + var spawner = NPCSpawnSystem.Instance; + var roster = spawner.ActiveNPCs.ToList(); + if (spawner.IsRebuilding || !spawner.IsCountPlausible(roster.Count)) return 0; + + var npcTeams = new HashSet(roster.Where(n => !string.IsNullOrEmpty(n.Team)).Select(n => n.Team!)); + var candidates = new HashSet(sqlBackend.GetTeamsWithoutPlayerMembers().Where(t => !npcTeams.Contains(t) && !IsTeamOnline(t))); + + foreach (var gone in _teamEmptySince.Keys.Where(k => !candidates.Contains(k)).ToList()) + _teamEmptySince.Remove(gone); // someone is back in it + + var deleted = new List(); + foreach (var team in candidates) { - if (npcTeams.Contains(team) || onlineTeams.Contains(team)) continue; + if (!_teamEmptySince.TryGetValue(team, out var since)) { _teamEmptySince[team] = now; continue; } + if (now - since < TimeSpan.FromMinutes(GameConfig.EmptyTeamGraceMinutes)) continue; + if (IsTeamOnline(team)) { _teamEmptySince.Remove(team); continue; } // checked again right before the delete if (!sqlBackend.DeleteEmptyTeam(team)) continue; - gone.Add(team); + _teamEmptySince.Remove(team); + deleted.Add(team); DebugLogger.Instance.LogInfo("WORLDSIM", $"Removed empty team '{team}'"); } - removed = gone.Count; + removed = deleted.Count; if (removed > 0) { // the protection list ignores case: keep a name another team or an NPC still has var kept = sqlBackend.GetPlayerTeams().GetAwaiter().GetResult().Select(t => t.TeamName) .Concat(npcTeams).ToHashSet(StringComparer.OrdinalIgnoreCase); - foreach (var team in gone) + foreach (var team in deleted) if (!kept.Contains(team)) WorldSimulator.UnregisterPlayerTeam(team); } } @@ -1435,6 +1451,12 @@ internal int PruneEmptyTeams() return removed; } + private static bool IsTeamOnline(string team) + { + var sessions = UsurperRemake.Server.MudServer.Instance?.ActiveSessions.Values; + return sessions != null && sessions.ToList().Any(sess => sess.Context?.Player?.Team == team); + } + /// /// Load all player team names from player_teams table so WorldSimulator /// can protect them from NPC AI dissolution and unauthorized modification. diff --git a/Tests/EmptyTeamCleanupTests.cs b/Tests/EmptyTeamCleanupTests.cs index 1fc8abf4..d3c5995e 100644 --- a/Tests/EmptyTeamCleanupTests.cs +++ b/Tests/EmptyTeamCleanupTests.cs @@ -93,20 +93,62 @@ public void RemovingAnEmptyTeam_TakesItsUpgradesAndVault_AndOnlyIfStillEmpty() Count("SELECT COUNT(*) FROM team_vault WHERE team_name = 'Nobody Left'").Should().Be(0); } + /// Runs the cleanup against a roster that is plausibly complete (NPCSpawnSystem.IsCountPlausible). + private static T WithRoster(Func run, params NPC[] members) + { + var spawner = NPCSpawnSystem.Instance; + var added = new System.Collections.Generic.List(members); + for (int i = added.Count; i < 60; i++) + added.Add(new NPC { ID = $"npc_team_filler_{i}", Name1 = $"Filler {i}", Name2 = $"Filler {i}", Level = 5, HP = 50, MaxHP = 50 }); + foreach (var n in added) spawner.ActiveNPCs.Add(n); + try { return run(); } + finally { foreach (var n in added) spawner.ActiveNPCs.Remove(n); } + } + [Fact] - public void TheWorldSave_RemovesEmptyTeams_ButNotOneAnNPCIsIn() + public void TheWorldSave_RemovesATeamOnlyAfterItHasStayedEmpty_AndNeverOneAnNPCIsIn() { Team("Nobody Left"); Team("NPC Held"); var npc = new NPC { ID = "npc_empty_team_test", Name1 = "Hold", Name2 = "Hold", Team = "NPC Held", Level = 5, HP = 50, MaxHP = 50 }; - NPCSpawnSystem.Instance.ActiveNPCs.Add(npc); - try - { - new WorldSimService(_db).PruneEmptyTeams().Should().Be(1); - Count("SELECT COUNT(*) FROM player_teams WHERE team_name = 'Nobody Left'").Should().Be(0); - Count("SELECT COUNT(*) FROM player_teams WHERE team_name = 'NPC Held'").Should().Be(1, "an NPC is still on the team"); - } - finally { NPCSpawnSystem.Instance.ActiveNPCs.Remove(npc); } + var service = new WorldSimService(_db); + var t0 = new DateTime(2026, 9, 23, 12, 0, 0, DateTimeKind.Utc); + + WithRoster(() => service.PruneEmptyTeams(t0), npc).Should().Be(0, "the first sighting only starts the clock"); + WithRoster(() => service.PruneEmptyTeams(t0.AddMinutes(GameConfig.EmptyTeamGraceMinutes - 1)), npc).Should().Be(0); + WithRoster(() => service.PruneEmptyTeams(t0.AddMinutes(GameConfig.EmptyTeamGraceMinutes)), npc).Should().Be(1); + Count("SELECT COUNT(*) FROM player_teams WHERE team_name = 'Nobody Left'").Should().Be(0); + Count("SELECT COUNT(*) FROM player_teams WHERE team_name = 'NPC Held'").Should().Be(1, "an NPC is still on the team"); + } + + [Fact] + public void ATeamSomeoneJoinsBetweenPasses_IsKept() + { + Team("Rejoined Later"); + var service = new WorldSimService(_db); + var t0 = new DateTime(2026, 9, 23, 12, 0, 0, DateTimeKind.Utc); + WithRoster(() => service.PruneEmptyTeams(t0)); + Player("tomas", "Rejoined Later"); // joined and saved during the grace period + WithRoster(() => service.PruneEmptyTeams(t0.AddMinutes(GameConfig.EmptyTeamGraceMinutes + 5))).Should().Be(0); + Player("tomas2", null); + Exec("UPDATE players SET player_data = '{\"player\":{\"level\":10}}' WHERE username = 'tomas';"); // left again + WithRoster(() => service.PruneEmptyTeams(t0.AddMinutes(GameConfig.EmptyTeamGraceMinutes + 10))).Should().Be(0, "the clock starts again"); + Count("SELECT COUNT(*) FROM player_teams WHERE team_name = 'Rejoined Later'").Should().Be(1); + } + + [Fact] + public void WhileTheNPCRosterIsBeingRebuilt_NothingIsRemoved() + { + Team("Nobody Left"); + var service = new WorldSimService(_db); + var t0 = new DateTime(2026, 9, 23, 12, 0, 0, DateTimeKind.Utc); + WithRoster(() => service.PruneEmptyTeams(t0)); + var spawner = NPCSpawnSystem.Instance; + spawner.IsRebuilding = true; + try { WithRoster(() => service.PruneEmptyTeams(t0.AddHours(1))).Should().Be(0); } + finally { spawner.IsRebuilding = false; } + service.PruneEmptyTeams(t0.AddHours(2)).Should().Be(0, "an implausibly small roster proves nothing either"); + Count("SELECT COUNT(*) FROM player_teams WHERE team_name = 'Nobody Left'").Should().Be(1); } [Fact] diff --git a/Tests/HuntRoundOneTests.cs b/Tests/HuntRoundOneTests.cs new file mode 100644 index 00000000..4805cebd --- /dev/null +++ b/Tests/HuntRoundOneTests.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using UsurperRemake.Systems; +using Xunit; + +namespace UsurperReborn.Tests; + +/// +/// v1.1.11: findings of the first review of the 1.1.11 branch as a whole (Codex). +/// +[Collection("SharedGameSingletons")] +public class HuntRoundOneTests +{ + private static readonly BindingFlags F = BindingFlags.NonPublic | BindingFlags.Instance; + + private sealed class ScriptedStream : Stream + { + private readonly byte[] _data; private int _pos; + public ScriptedStream(string script) { _data = Encoding.UTF8.GetBytes(script); } + public override int Read(byte[] buffer, int offset, int count) + { + if (_pos >= _data.Length) return 0; + int n = Math.Min(count, _data.Length - _pos); Array.Copy(_data, _pos, buffer, offset, n); _pos += n; return n; + } + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) => Task.FromResult(Read(buffer, offset, count)); + public override bool CanRead => true; public override bool CanSeek => false; public override bool CanWrite => false; + public override long Length => _data.Length; public override long Position { get => _pos; set => throw new NotSupportedException(); } + public override void Flush() { } + public override long Seek(long o, SeekOrigin s) => throw new NotSupportedException(); + public override void SetLength(long v) => throw new NotSupportedException(); + public override void Write(byte[] b, int o, int c) => throw new NotSupportedException(); + } + + private static string Source(string relative) + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Scripts"))) dir = dir.Parent; + return File.ReadAllText(Path.Combine(dir!.FullName, relative)); + } + + private static string MethodBody(string src, string signature) + { + int start = src.IndexOf(signature, StringComparison.Ordinal); + start.Should().BeGreaterThan(0, signature); + int end = src.IndexOf("\n private ", start + 10, StringComparison.Ordinal); + int endPublic = src.IndexOf("\n public ", start + 10, StringComparison.Ordinal); + if (endPublic > 0 && (end < 0 || endPublic < end)) end = endPublic; + return src.Substring(start, end - start); + } + + [Fact] + public async Task AnOldGodsFight_PaysNoEngineXP_NotEvenTheTenXPFloor() + { + var data = UsurperRemake.Data.OldGodsData.GetGodBossData(OldGodType.Maelketh); + var god = (Monster)typeof(OldGodBossSystem).GetMethod("CreateBossMonster", F)!.Invoke(OldGodBossSystem.Instance, new object[] { data })!; + god.HP = 0; + var hero = new Character { Name1 = "godslayer", Name2 = "Godslayer", Class = CharacterClass.Warrior, Level = 30, HP = 5000, MaxHP = 5000, AutoLevelUp = false, CombatSpeed = CombatSpeed.Instant, MKills = 100 }; + var result = new CombatResult { Player = hero, Outcome = CombatOutcome.Victory }; + result.DefeatedMonsters.Add(god); + var engine = new CombatEngine(new TerminalEmulator(new ScriptedStream(string.Concat(Enumerable.Repeat("P\n", 40))), new MemoryStream())); + typeof(CombatEngine).GetField("currentPlayer", F)!.SetValue(engine, hero); + await (Task)typeof(CombatEngine).GetMethod("HandleVictoryMultiMonster", F)!.Invoke(engine, new object[] { result, false })!; + result.ExperienceGained.Should().Be(0, "HandleBossDefeated pays the god's reward, once"); + } + + [Fact] + public void TheSwingRiders_ScaleFromTheArmoryBoostedHit() + { + var calls = Regex.Matches(Source("Scripts/Systems/CombatEngine.cs"), @"ApplyPlayerSwingOnHitEffects\(player, \w+, ([^,]+),"); + calls.Count.Should().Be(3); + foreach (Match c in calls) c.Groups[1].Value.Should().Be("TeamHQBonus.ApplyAttack(player"); + } + + [Fact] + public void AWorldBossTelegraph_AppliesTheBarracks() + { + MethodBody(Source("Scripts/Systems/WorldBossSystem.cs"), "ApplyLandedTelegraph(").Should().Contain("TeamHQBonus.ApplyDefense(player, dmg)"); + } + + [Fact] + public void TheFightStartRefresh_EnumeratesACopyOfTheParty() + { + Source("Scripts/Systems/CombatEngine.cs").Should().Contain("foreach (var mate in teammates.ToList().Where(t => t is not NPC)) TeamHQBonus.RefreshLevels(mate);"); + } +} diff --git a/Tests/NPCDefeatQuestTests.cs b/Tests/NPCDefeatQuestTests.cs index ec42b369..3cf1b0cf 100644 --- a/Tests/NPCDefeatQuestTests.cs +++ b/Tests/NPCDefeatQuestTests.cs @@ -75,7 +75,7 @@ public void TheHelper_MeetsTheObjectiveOfAClaimedQuestThatIsNotABounty() var quest = new Quest { Title = "Teach them a lesson", QuestTarget = QuestTarget.DefeatNPC, Occupier = hunter.Name2, Date = DateTime.Now, DaysToComplete = 30 }; quest.Objectives.Add(new QuestObjective(QuestObjectiveType.DefeatNPC, "Defeat Quest Mark", 1, target.Name, target.Name)); QuestSystem.AddQuestToDatabase(quest); - QuestSystem.RecordNPCDefeat(hunter, target); + QuestSystem.RecordNPCDefeat(hunter, target, killed: true); quest.Objectives[0].IsComplete.Should().BeTrue(); quest.Deleted = true; } @@ -88,6 +88,28 @@ public void TheInnChallenge_ReportsTheDefeat() string src = File.ReadAllText(Path.Combine(dir!.FullName, "Scripts", "Locations", "InnLocation.cs")); int start = src.IndexOf("private async Task ChallengeNPC(NPC npc)", StringComparison.Ordinal); int end = src.IndexOf("\n private ", start + 10, StringComparison.Ordinal); - src.Substring(start, end - start).Should().Contain("QuestSystem.RecordNPCDefeat(currentPlayer, npc)"); + src.Substring(start, end - start).Should().Contain("QuestSystem.RecordNPCDefeat(currentPlayer, npc, killed: false)"); + } + + [Fact] + public async Task SparingTheTarget_DoesNotMeetAnAssassinationContract() + { + var (hunter, target, bounty) = Wanted("Contract Mark"); + bounty.QuestTarget = QuestTarget.Assassin; + target.HP = 1; + await Outcome(new CombatResult { Player = hunter, Opponent = target, Outcome = CombatOutcome.OpponentSpared }); + bounty.Deleted.Should().BeFalse("the contract is for a kill; the target walked away"); + hunter.Gold.Should().Be(0); + bounty.Deleted = true; + } + + [Fact] + public void TwoSessionsBeatingTheTargetAtOnce_ArePaidOnce() + { + var (hunter, target, _) = Wanted("Race Mark"); + long paid = 0; + System.Threading.Tasks.Parallel.For(0, 16, _ => + System.Threading.Interlocked.Add(ref paid, QuestSystem.AutoCompleteBountyForNPC(new Character { Name2 = "Racer", Level = 30 }, target.Name))); + paid.Should().Be(5000); } } From 2a244a9b4c26641aa5672857fdf3b6aa2ae2b2ab Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 19:52:36 +0000 Subject: [PATCH 14/42] Fixes from the second review of the 1.1.11 branch as a whole - Joining a team and the empty-team cleanup's last check and delete now hold one gate (TeamMembershipGate), so a join between them can no longer lose the team, its upgrades and vault. - A character archived by permadeath, restorable for seven days, still counts as a member of its team. - A duel defender loaded from a save keeps its team, so it fights with its own team's HQ bonuses; an echo keeps none. - Sparing an assassination target no longer meets the contract's objective, so it cannot be turned in by hand either. - An Old God's fight pays the god's Level x 2000 XP and x 500 gold again, shared by grouped players and allies like any victory; zeroing it took their share away. The god-defeated handler adds the leader's Level x 4000 XP and x 1000 gold once, so the leader's total is what it was. - The ability off-hand and a teammate's swing pass the Armory-boosted hit to their enchantment riders like every other caller; the Shaman weapon enchant and a fixed poison value, which do not come from the hit, take the Armory themselves. - A test pins the empty-team grace period at 30 minutes or more. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Core/GameConfig.cs | 10 +++--- Scripts/Locations/TeamCornerLocation.cs | 23 +++++++++---- Scripts/Systems/CombatEngine.cs | 18 +++++----- Scripts/Systems/OldGodBossSystem.cs | 10 ++---- Scripts/Systems/PlayerCharacterLoader.cs | 3 ++ Scripts/Systems/QuestSystem.cs | 8 +++-- Scripts/Systems/SqlSaveBackend.cs | 9 +++-- Scripts/Systems/TeamMembershipGate.cs | 14 ++++++++ Scripts/Systems/WorldSimService.cs | 11 +++++-- Tests/EmptyTeamCleanupTests.cs | 38 +++++++++++++++++++++ Tests/HuntRoundOneTests.cs | 42 +++++++++++++++--------- Tests/OldGodPayoutTests.cs | 19 ++++++----- 12 files changed, 149 insertions(+), 56 deletions(-) create mode 100644 Scripts/Systems/TeamMembershipGate.cs diff --git a/Scripts/Core/GameConfig.cs b/Scripts/Core/GameConfig.cs index 43d4b4bf..c7cd8642 100644 --- a/Scripts/Core/GameConfig.cs +++ b/Scripts/Core/GameConfig.cs @@ -1848,11 +1848,11 @@ public static bool IsSmallRace(CharacterRace race) // 1.25 as written). Capped here until the gods are retuned against the dialogue; the softer // answers (down to 0.50) are not capped. public const double OldGodDialogueBossDamageCap = 1.10; - // v1.1.11: an Old God kill paid its reward three times (the fight's victory, the god-defeated - // handler, and the dungeon again). It is paid once now, by the handler, at three times the old - // per-level amount, so a kill is worth what it was. - public const int OldGodDefeatXPPerLevel = 6000; - public const int OldGodDefeatGoldPerLevel = 1500; + // v1.1.11: an Old God kill paid the god-defeated handler's reward twice (the handler, then the dungeon + // again). The fight's own victory pays the god's Level x 2000 XP / x 500 gold, shared by the party as any + // victory is; the handler pays the leader this, once, so the leader's total stays what it was. + public const int OldGodDefeatXPPerLevel = 4000; + public const int OldGodDefeatGoldPerLevel = 1000; // v1.1.11: a player team is removed only after it has been seen empty this long (WorldSimService.PruneEmptyTeams) public const int EmptyTeamGraceMinutes = 30; diff --git a/Scripts/Locations/TeamCornerLocation.cs b/Scripts/Locations/TeamCornerLocation.cs index 91a7dd92..db3a06c2 100644 --- a/Scripts/Locations/TeamCornerLocation.cs +++ b/Scripts/Locations/TeamCornerLocation.cs @@ -1051,14 +1051,25 @@ private async Task JoinTeam() terminal.SetColor("white"); string password = await terminal.ReadLineAsync(); - var (exists, pwCorrect) = await backend.VerifyPlayerTeam(teamName, password); + // v1.1.11: hold the gate from the check that the team exists until the membership is saved, + // so the empty-team cleanup cannot remove it in between (TeamMembershipGate) + bool exists, pwCorrect; + await TeamMembershipGate.Gate.WaitAsync(); + try + { + (exists, pwCorrect) = await backend.VerifyPlayerTeam(teamName, password); + if (exists && pwCorrect) + { + currentPlayer.Team = teamName; + currentPlayer.TeamPW = password; + currentPlayer.CTurf = false; + TeamHQBonus.RefreshLevels(currentPlayer); // v1.1.11: the team's upgrades count from joining + await PersistTeamMembershipChange(); + } + } + finally { TeamMembershipGate.Gate.Release(); } if (exists && pwCorrect) { - currentPlayer.Team = teamName; - currentPlayer.TeamPW = password; - currentPlayer.CTurf = false; - TeamHQBonus.RefreshLevels(currentPlayer); // v1.1.11: the team's upgrades count from joining - await PersistTeamMembershipChange(); WorldSimulator.RegisterPlayerTeam(teamName); await backend.UpdatePlayerTeamMemberCount(teamName); diff --git a/Scripts/Systems/CombatEngine.cs b/Scripts/Systems/CombatEngine.cs index c1568b61..fc388d7c 100644 --- a/Scripts/Systems/CombatEngine.cs +++ b/Scripts/Systems/CombatEngine.cs @@ -8633,9 +8633,11 @@ private void CheckElementalEnchantProcs(Character attacker, Monster target, long if (targetAlive && weapon.HasPoisonEnchant && random.NextDouble() < GameConfig.PoisonEnchantProcChance) { - int poisonValue = weapon.PoisonDamage > 0 ? weapon.PoisonDamage : (int)(damage * 0.10); - long poisonDamage = Math.Max(1, poisonValue); - target.HP = Math.Max(0, target.HP - poisonDamage); // hq-armory: out (post-hit rider) + // v1.1.11: a fixed PoisonDamage does not come from the (Armory-boosted) hit, so it takes the Armory itself + long poisonDamage = weapon.PoisonDamage > 0 + ? Math.Max(1, TeamHQBonus.ApplyAttack(attacker, weapon.PoisonDamage)) + : Math.Max(1, (int)(damage * 0.10)); + target.HP = Math.Max(0, target.HP - poisonDamage); // hq-armory: out (percentage rider of the boosted hit; the fixed value applies it above) terminal.SetColor("green"); // v0.60.10 (druidah report): attribute teammate procs by name (see frost note above). terminal.WriteLine(isPlayer @@ -12793,7 +12795,8 @@ private void ApplyPlayerSwingOnHitEffects(Character player, Monster target, long long shamanWeapPowMM = shamanWeaponMM?.WeaponPower ?? 0; long enchantDamage = (long)(shamanWeapPowMM * player.ShamanEnchantPower / 100.0); enchantDamage = Math.Max(1, enchantDamage); - target.HP -= (int)enchantDamage; // hq-armory: out (post-hit rider) + enchantDamage = TeamHQBonus.ApplyAttack(player, enchantDamage); // v1.1.11: from weapon power, not the hit, so it takes the Armory itself + target.HP -= (int)enchantDamage; result.TotalDamageDealt += enchantDamage; string enchantMsg = player.ShamanEnchantType switch @@ -14523,7 +14526,7 @@ private async Task ApplyAbilityEffectsMultiMonster(Character player, Monster tar // Off-hand follow-up only procs its enchants if it actually connected; an evaded // swing (Incorporeal/Phase) passes through for 0 damage and must not lifesteal. if (await ApplySingleMonsterDamage(offHandTarget, ohDamage, result, "off-hand strike", player)) - ApplyPostHitEnchantments(player, offHandTarget, ohDamage, result, weaponSlot: EquipmentSlot.OffHand); + ApplyPostHitEnchantments(player, offHandTarget, TeamHQBonus.ApplyAttack(player, ohDamage), result, weaponSlot: EquipmentSlot.OffHand); // v1.1.11: riders scale from the boosted hit, as at every other caller } } @@ -18362,7 +18365,7 @@ private async Task ProcessTeammateActionMultiMonster(Character teammate, List 0 ? boss.LocIntro()[0] : "", - // v1.1.11: the fight's victory pays nothing for the god; HandleBossDefeated pays the reward once - Experience = 0, - Gold = 0, + // the fight's victory pays this to the whole party; HandleBossDefeated adds the leader's reward + Experience = boss.Level * 2000, + Gold = boss.Level * 500, }; // Set special abilities from phase 1 abilities for display @@ -1380,10 +1380,6 @@ private async Task HandleNocturaBetrayal(Character player, TerminalEmulato // line for line (artifact scaling, base difficulty scale, the Strength and Defence // splits), which a later edit to one could silently leave behind in the other. var noctura = CreateBossMonster(betrayalData); - // v1.1.11: the betrayal keeps its reward as it was: the fight's victory pays this, and the - // flat reward below is paid on top (CreateBossMonster now pays nothing for a god) - noctura.Experience = betrayalData.Level * 2000; - noctura.Gold = betrayalData.Level * 500; // Run combat terminal.Clear(); diff --git a/Scripts/Systems/PlayerCharacterLoader.cs b/Scripts/Systems/PlayerCharacterLoader.cs index ad9f52ec..668871d1 100644 --- a/Scripts/Systems/PlayerCharacterLoader.cs +++ b/Scripts/Systems/PlayerCharacterLoader.cs @@ -16,6 +16,9 @@ public static Character CreateFromSaveData(PlayerData playerData, string display { Name1 = playerData.Name1, Name2 = playerData.Name2 ?? displayName, + // v1.1.11: a duel defender fights with its own team's HQ levels (CombatEngine.PlayerVsPlayer reads + // them by team); without the team it had none (review). An echo is only a copy and keeps none. + Team = isEcho ? "" : (playerData.Team ?? ""), Level = playerData.Level, HP = playerData.MaxHP, MaxHP = playerData.MaxHP, diff --git a/Scripts/Systems/QuestSystem.cs b/Scripts/Systems/QuestSystem.cs index 3a2e9a23..3079effc 100644 --- a/Scripts/Systems/QuestSystem.cs +++ b/Scripts/Systems/QuestSystem.cs @@ -1526,7 +1526,7 @@ public static long RecordNPCDefeat(Character player, NPC npc, bool killed) if (player == null || npc == null) return 0; // a target beaten but left alive does not meet an assassination contract (Codex review) long bounty = AutoCompleteBountyForNPC(player, npc.Name ?? npc.Name2 ?? "", includeKillContracts: killed); - OnNPCDefeated(player, npc); + OnNPCDefeated(player, npc, killed); return bounty; } @@ -1534,7 +1534,7 @@ public static long RecordNPCDefeat(Character player, NPC npc, bool killed) /// Update quest progress when player defeats an NPC (bounty system) /// Call this from StreetEncounterSystem and BaseLocation.ChallengeNPC when NPC is killed /// - public static void OnNPCDefeated(Character player, NPC defeatedNPC) + public static void OnNPCDefeated(Character player, NPC defeatedNPC, bool killed = true) { if (player == null || defeatedNPC == null) return; @@ -1545,6 +1545,10 @@ public static void OnNPCDefeated(Character player, NPC defeatedNPC) var playerQuests = GetPlayerQuests(player.Name2); foreach (var quest in playerQuests) { + // v1.1.11: a target beaten and left alive does not meet an assassination contract, not even + // for a manual turn-in (review) + if (!killed && quest.QuestTarget == QuestTarget.Assassin) continue; + // Check if this quest is a bounty targeting this specific NPC if (!string.IsNullOrEmpty(quest.TargetNPCName)) { diff --git a/Scripts/Systems/SqlSaveBackend.cs b/Scripts/Systems/SqlSaveBackend.cs index 62fee257..6b0ccafb 100644 --- a/Scripts/Systems/SqlSaveBackend.cs +++ b/Scripts/Systems/SqlSaveBackend.cs @@ -5560,10 +5560,13 @@ public List GetTeamsWithoutPlayerMembers() return teams; } } + // A character archived by permadeath can be restored within the window, so it is still a member (review). cmd.CommandText = @" SELECT t.team_name FROM player_teams t WHERE NOT EXISTS (SELECT 1 FROM players p - WHERE (CASE WHEN json_valid(p.player_data) THEN json_extract(p.player_data, '$.player.team') END) = t.team_name);"; + WHERE (CASE WHEN json_valid(p.player_data) THEN json_extract(p.player_data, '$.player.team') END) = t.team_name) + AND NOT EXISTS (SELECT 1 FROM deleted_characters d WHERE d.expires_at > datetime('now') + AND (CASE WHEN json_valid(d.player_data) THEN json_extract(d.player_data, '$.player.team') END) = t.team_name);"; using var reader = cmd.ExecuteReader(); while (reader.Read()) teams.Add(reader.GetString(0)); } @@ -5592,7 +5595,9 @@ public bool DeleteEmptyTeam(string teamName) DELETE FROM player_teams WHERE team_name = @team AND NOT EXISTS (SELECT 1 FROM players p WHERE NOT json_valid(p.player_data) - OR (CASE WHEN json_valid(p.player_data) THEN json_extract(p.player_data, '$.player.team') END) = @team);"; + OR (CASE WHEN json_valid(p.player_data) THEN json_extract(p.player_data, '$.player.team') END) = @team) + AND NOT EXISTS (SELECT 1 FROM deleted_characters d WHERE d.expires_at > datetime('now') + AND (CASE WHEN json_valid(d.player_data) THEN json_extract(d.player_data, '$.player.team') END) = @team);"; team.Parameters.AddWithValue("@team", teamName); if (team.ExecuteNonQuery() != 1) return false; } diff --git a/Scripts/Systems/TeamMembershipGate.cs b/Scripts/Systems/TeamMembershipGate.cs new file mode 100644 index 00000000..3c37ba7b --- /dev/null +++ b/Scripts/Systems/TeamMembershipGate.cs @@ -0,0 +1,14 @@ +using System.Threading; + +namespace UsurperRemake.Systems +{ + /// + /// v1.1.11: joining a team (checking it exists, taking the membership, saving it) and the world + /// save's removal of an empty team (the last check that nobody is in it, and the delete) must not + /// interleave: a join between the two lost the team, its upgrades and vault (review). Both hold this. + /// + public static class TeamMembershipGate + { + public static readonly SemaphoreSlim Gate = new(1, 1); + } +} diff --git a/Scripts/Systems/WorldSimService.cs b/Scripts/Systems/WorldSimService.cs index 2f13c165..1cb0a09f 100644 --- a/Scripts/Systems/WorldSimService.cs +++ b/Scripts/Systems/WorldSimService.cs @@ -1428,8 +1428,15 @@ internal int PruneEmptyTeams(DateTime now) { if (!_teamEmptySince.TryGetValue(team, out var since)) { _teamEmptySince[team] = now; continue; } if (now - since < TimeSpan.FromMinutes(GameConfig.EmptyTeamGraceMinutes)) continue; - if (IsTeamOnline(team)) { _teamEmptySince.Remove(team); continue; } // checked again right before the delete - if (!sqlBackend.DeleteEmptyTeam(team)) continue; + // the last check and the delete, under the gate a join holds from its check to its save + bool removedNow; + TeamMembershipGate.Gate.Wait(); + try + { + removedNow = !IsTeamOnline(team) && sqlBackend.DeleteEmptyTeam(team); + } + finally { TeamMembershipGate.Gate.Release(); } + if (!removedNow) { if (IsTeamOnline(team)) _teamEmptySince.Remove(team); continue; } _teamEmptySince.Remove(team); deleted.Add(team); DebugLogger.Instance.LogInfo("WORLDSIM", $"Removed empty team '{team}'"); diff --git a/Tests/EmptyTeamCleanupTests.cs b/Tests/EmptyTeamCleanupTests.cs index d3c5995e..2e3fc2f9 100644 --- a/Tests/EmptyTeamCleanupTests.cs +++ b/Tests/EmptyTeamCleanupTests.cs @@ -174,4 +174,42 @@ public void WhileASaveIsMalformed_NoTeamIsRemoved() _db.DeleteEmptyTeam("Nobody Left").Should().BeFalse(); Count("SELECT COUNT(*) FROM player_teams WHERE team_name = 'Nobody Left'").Should().Be(1); } + + [Fact] + public void TheGracePeriod_IsLongEnoughToCoverAJoinAndItsSave() + { + // A join is saved within seconds, and a roster rebuild within a tick; 30 minutes is the margin the + // cleanup relies on before it treats a team as abandoned (review: nothing pinned it). + GameConfig.EmptyTeamGraceMinutes.Should().BeGreaterThanOrEqualTo(30); + } + + [Fact] + public void ACharacterArchivedByPermadeath_StillKeepsItsTeam_UntilTheArchiveExpires() + { + Team("Fallen Hero"); + Team("Long Gone"); + Exec("INSERT INTO deleted_characters (username, display_name, player_data, expires_at) VALUES " + + "('hero', 'Hero', '{\"player\":{\"team\":\"Fallen Hero\"}}', datetime('now', '+6 days')), " + + "('ghost', 'Ghost', '{\"player\":{\"team\":\"Long Gone\"}}', datetime('now', '-1 days'));"); + _db.GetTeamsWithoutPlayerMembers().Should().BeEquivalentTo(new[] { "Long Gone" }, "a restore within the window brings the member back"); + _db.DeleteEmptyTeam("Fallen Hero").Should().BeFalse(); + _db.DeleteEmptyTeam("Long Gone").Should().BeTrue(); + } + + [Fact] + public void TheJoinAndTheCleanup_HoldTheSameGate() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Scripts"))) dir = dir.Parent; + string corner = File.ReadAllText(Path.Combine(dir!.FullName, "Scripts", "Locations", "TeamCornerLocation.cs")); + string sim = File.ReadAllText(Path.Combine(dir.FullName, "Scripts", "Systems", "WorldSimService.cs")); + int wait = corner.IndexOf("await TeamMembershipGate.Gate.WaitAsync();", StringComparison.Ordinal); + int verify = corner.IndexOf("await backend.VerifyPlayerTeam(teamName, password);", StringComparison.Ordinal); + int persist = corner.IndexOf("await PersistTeamMembershipChange();", verify, StringComparison.Ordinal); + int release = corner.IndexOf("TeamMembershipGate.Gate.Release();", verify, StringComparison.Ordinal); + wait.Should().BeGreaterThan(0); + verify.Should().BeGreaterThan(wait, "the check that the team exists is inside the gate"); + release.Should().BeGreaterThan(persist, "and so is the save of the membership"); + sim.Should().Contain("TeamMembershipGate.Gate.Wait();").And.Contain("removedNow = !IsTeamOnline(team) && sqlBackend.DeleteEmptyTeam(team);"); + } } diff --git a/Tests/HuntRoundOneTests.cs b/Tests/HuntRoundOneTests.cs index 4805cebd..98b0cf43 100644 --- a/Tests/HuntRoundOneTests.cs +++ b/Tests/HuntRoundOneTests.cs @@ -57,26 +57,38 @@ private static string MethodBody(string src, string signature) } [Fact] - public async Task AnOldGodsFight_PaysNoEngineXP_NotEvenTheTenXPFloor() + public void EveryLiveRiderCaller_PassesTheArmoryBoostedHit() { - var data = UsurperRemake.Data.OldGodsData.GetGodBossData(OldGodType.Maelketh); - var god = (Monster)typeof(OldGodBossSystem).GetMethod("CreateBossMonster", F)!.Invoke(OldGodBossSystem.Instance, new object[] { data })!; - god.HP = 0; - var hero = new Character { Name1 = "godslayer", Name2 = "Godslayer", Class = CharacterClass.Warrior, Level = 30, HP = 5000, MaxHP = 5000, AutoLevelUp = false, CombatSpeed = CombatSpeed.Instant, MKills = 100 }; - var result = new CombatResult { Player = hero, Outcome = CombatOutcome.Victory }; - result.DefeatedMonsters.Add(god); - var engine = new CombatEngine(new TerminalEmulator(new ScriptedStream(string.Concat(Enumerable.Repeat("P\n", 40))), new MemoryStream())); - typeof(CombatEngine).GetField("currentPlayer", F)!.SetValue(engine, hero); - await (Task)typeof(CombatEngine).GetMethod("HandleVictoryMultiMonster", F)!.Invoke(engine, new object[] { result, false })!; - result.ExperienceGained.Should().Be(0, "HandleBossDefeated pays the god's reward, once"); + string src = Source("Scripts/Systems/CombatEngine.cs"); + Regex.Matches(src, @"ApplyPlayerSwingOnHitEffects\(player, \w+, TeamHQBonus\.ApplyAttack\(player, ").Count.Should().Be(3); + src.Should().Contain("ApplyPostHitEnchantments(player, offHandTarget, TeamHQBonus.ApplyAttack(player, ohDamage)"); + src.Should().Contain("ApplyPostHitEnchantments(teammate, target, TeamHQBonus.ApplyAttack(teammate, damage)"); + // riders that do not come from the hit take the Armory themselves + src.Should().Contain("enchantDamage = TeamHQBonus.ApplyAttack(player, enchantDamage);"); + src.Should().Contain("Math.Max(1, TeamHQBonus.ApplyAttack(attacker, weapon.PoisonDamage))"); } [Fact] - public void TheSwingRiders_ScaleFromTheArmoryBoostedHit() + public void ADuelDefenderFromASave_KeepsItsTeam_AndAnEchoDoesNot() { - var calls = Regex.Matches(Source("Scripts/Systems/CombatEngine.cs"), @"ApplyPlayerSwingOnHitEffects\(player, \w+, ([^,]+),"); - calls.Count.Should().Be(3); - foreach (Match c in calls) c.Groups[1].Value.Should().Be("TeamHQBonus.ApplyAttack(player"); + var data = new PlayerData { Name1 = "defender", Name2 = "Defender", Level = 30, MaxHP = 500, Team = "Iron Wolves" }; + PlayerCharacterLoader.CreateFromSaveData(data, "Defender").Team.Should().Be("Iron Wolves"); + PlayerCharacterLoader.CreateFromSaveData(data, "Defender", isEcho: true).Team.Should().Be(""); + } + + [Fact] + public void SparingAnAssassinationTarget_DoesNotMeetItsObjective() + { + var hunter = new Character { Name1 = "assassin_h", Name2 = "Assassin H", Level = 32 }; + var target = new NPC { ID = "npc_contract_obj", Name1 = "Contract Obj", Name2 = "Contract Obj", Level = 30 }; + var contract = new Quest { Title = "Contract", QuestTarget = QuestTarget.Assassin, Occupier = hunter.Name2, TargetNPCName = target.Name, Date = DateTime.Now, DaysToComplete = 30 }; + contract.Objectives.Add(new QuestObjective(QuestObjectiveType.DefeatNPC, "Kill Contract Obj", 1, target.Name, target.Name)); + QuestSystem.AddQuestToDatabase(contract); + QuestSystem.RecordNPCDefeat(hunter, target, killed: false); + contract.Objectives[0].IsComplete.Should().BeFalse("a manual turn-in must not accept a target who walked away"); + contract.Deleted.Should().BeFalse(); + QuestSystem.RecordNPCDefeat(hunter, target, killed: true); + contract.Deleted.Should().BeTrue("a kill meets it"); } [Fact] diff --git a/Tests/OldGodPayoutTests.cs b/Tests/OldGodPayoutTests.cs index 1c8b166b..093fb35a 100644 --- a/Tests/OldGodPayoutTests.cs +++ b/Tests/OldGodPayoutTests.cs @@ -40,12 +40,14 @@ public override void Flush() { } private static readonly BindingFlags F = BindingFlags.NonPublic | BindingFlags.Instance; [Fact] - public void TheGodsFight_PaysNothingForTheGod() + public void TheGodsFight_PaysTheGodsCombatRewardToTheParty() { var data = UsurperRemake.Data.OldGodsData.GetGodBossData(OldGodType.Maelketh); var god = (Monster)typeof(OldGodBossSystem).GetMethod("CreateBossMonster", F)!.Invoke(OldGodBossSystem.Instance, new object[] { data })!; - god.Experience.Should().Be(0, "the victory used to pay it on top of the handler"); - god.Gold.Should().Be(0); + // the fight's victory shares this with grouped players and allies, as any victory does (review: + // zeroing it took their share away); the handler adds the leader's own reward once + god.Experience.Should().Be(data.Level * 2000); + god.Gold.Should().Be(data.Level * 500); } [Fact] @@ -63,10 +65,11 @@ public async Task TheDungeon_DoesNotPayTheGodsRewardAgain() } [Fact] - public void TheOnePayout_IsThreeTimesTheOldPerLevelAmount() + public void TheLeadersReward_PlusTheFight_IsWhatAKillPaidBefore() { - GameConfig.OldGodDefeatXPPerLevel.Should().Be(3 * 2000); - GameConfig.OldGodDefeatGoldPerLevel.Should().Be(3 * 500); + // before: the fight (Level x 2000) + the handler (x 2000) + the dungeon repeating it (x 2000) + (2000 + GameConfig.OldGodDefeatXPPerLevel).Should().Be(3 * 2000); + (500 + GameConfig.OldGodDefeatGoldPerLevel).Should().Be(3 * 500); } [Fact] @@ -78,9 +81,9 @@ public async Task TheHandler_PaysTheKillOnce_WithTheTeamsTrainingBonus() var term = new TerminalEmulator(new ScriptedStream(new string('\n', 40)), new MemoryStream()); var res = await (Task)typeof(OldGodBossSystem).GetMethod("HandleBossDefeated", F)! .Invoke(OldGodBossSystem.Instance, new object[] { player, data, term })!; - long expected = (long)Math.Round(data.Level * 6000 * 1.10); + long expected = (long)Math.Round(data.Level * 4000 * 1.10); player.Experience.Should().Be(expected); - player.Gold.Should().Be(data.Level * 1500); + player.Gold.Should().Be(data.Level * 1000); res.XPGained.Should().Be(expected, "reported for the screen, not paid again"); } } From 8f108d42137b945a295eb5ab2ad33a19dd062867 Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 20:03:30 +0000 Subject: [PATCH 15/42] Fixes from the third review of the 1.1.11 branch as a whole - The in-process gate between a team join and the empty-team cleanup did not reach a cleanup or a join running in another process. A successful join check now stamps the team's last_join_at in the database, and the cleanup's delete leaves a team alone for ten minutes after a join; the gate is gone. - The Dormitory wake-up brawl brings the guest back after the fight, but a won brawl counted as a kill and could meet an assassination contract. PlayerVsPlayer takes a lethal flag; that brawl passes false. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Core/GameConfig.cs | 2 ++ Scripts/Locations/DormitoryLocation.cs | 2 +- Scripts/Locations/TeamCornerLocation.cs | 25 ++++++++---------------- Scripts/Systems/CombatEngine.cs | 9 +++++++-- Scripts/Systems/SqlSaveBackend.cs | 26 ++++++++++++++++++++++--- Scripts/Systems/TeamMembershipGate.cs | 14 ------------- Scripts/Systems/WorldSimService.cs | 12 +++--------- Tests/EmptyTeamCleanupTests.cs | 25 ++++++++++++------------ Tests/NPCDefeatQuestTests.cs | 13 +++++++++++++ 9 files changed, 69 insertions(+), 59 deletions(-) delete mode 100644 Scripts/Systems/TeamMembershipGate.cs diff --git a/Scripts/Core/GameConfig.cs b/Scripts/Core/GameConfig.cs index c7cd8642..4e1a335e 100644 --- a/Scripts/Core/GameConfig.cs +++ b/Scripts/Core/GameConfig.cs @@ -1855,6 +1855,8 @@ public static bool IsSmallRace(CharacterRace race) public const int OldGodDefeatGoldPerLevel = 1000; // v1.1.11: a player team is removed only after it has been seen empty this long (WorldSimService.PruneEmptyTeams) public const int EmptyTeamGraceMinutes = 30; + // v1.1.11: after a join, the team is left alone this long so the joiner's save can land (every process) + public const int EmptyTeamJoinGraceMinutes = 10; // v1.1.10: regular dungeon monsters on the floors before an Old God's floor grow tougher, HP and // damage, by this much per floor: +5% five floors out, up to +25% on the floor before the god diff --git a/Scripts/Locations/DormitoryLocation.cs b/Scripts/Locations/DormitoryLocation.cs index 7670ba79..8ea49243 100644 --- a/Scripts/Locations/DormitoryLocation.cs +++ b/Scripts/Locations/DormitoryLocation.cs @@ -905,7 +905,7 @@ private async Task WakeGuests() terminal.WriteLine(Loc.Get("dormitory.wakes_furious", npc.Name2), "red"); await Task.Delay(1000); - var result = await combatEngine.PlayerVsPlayer(currentPlayer, npc); + var result = await combatEngine.PlayerVsPlayer(currentPlayer, npc, lethal: false); // v1.1.11: the guest is brought back below if (!currentPlayer.IsAlive) { terminal.WriteLine(Loc.Get("dormitory.knocked_out"), "red"); diff --git a/Scripts/Locations/TeamCornerLocation.cs b/Scripts/Locations/TeamCornerLocation.cs index db3a06c2..bac99ea2 100644 --- a/Scripts/Locations/TeamCornerLocation.cs +++ b/Scripts/Locations/TeamCornerLocation.cs @@ -1051,25 +1051,16 @@ private async Task JoinTeam() terminal.SetColor("white"); string password = await terminal.ReadLineAsync(); - // v1.1.11: hold the gate from the check that the team exists until the membership is saved, - // so the empty-team cleanup cannot remove it in between (TeamMembershipGate) - bool exists, pwCorrect; - await TeamMembershipGate.Gate.WaitAsync(); - try - { - (exists, pwCorrect) = await backend.VerifyPlayerTeam(teamName, password); - if (exists && pwCorrect) - { - currentPlayer.Team = teamName; - currentPlayer.TeamPW = password; - currentPlayer.CTurf = false; - TeamHQBonus.RefreshLevels(currentPlayer); // v1.1.11: the team's upgrades count from joining - await PersistTeamMembershipChange(); - } - } - finally { TeamMembershipGate.Gate.Release(); } + // v1.1.11: a successful check stamps the team's last join, so the empty-team cleanup leaves it + // alone until this membership is saved (SqlSaveBackend.VerifyPlayerTeam / DeleteEmptyTeam) + var (exists, pwCorrect) = await backend.VerifyPlayerTeam(teamName, password); if (exists && pwCorrect) { + currentPlayer.Team = teamName; + currentPlayer.TeamPW = password; + currentPlayer.CTurf = false; + TeamHQBonus.RefreshLevels(currentPlayer); // v1.1.11: the team's upgrades count from joining + await PersistTeamMembershipChange(); WorldSimulator.RegisterPlayerTeam(teamName); await backend.UpdatePlayerTeamMemberCount(teamName); diff --git a/Scripts/Systems/CombatEngine.cs b/Scripts/Systems/CombatEngine.cs index fc388d7c..554bf1b4 100644 --- a/Scripts/Systems/CombatEngine.cs +++ b/Scripts/Systems/CombatEngine.cs @@ -524,8 +524,13 @@ private void EndPvPCombat(Character attacker, Character defender) ScrubTransientCombatState(defender); } - public async Task PlayerVsPlayer(Character attacker, Character defender, bool allowSurrender = true) + // v1.1.11: false for a duel whose caller brings the loser back (the Dormitory wake-up brawl), so a + // beaten NPC is not reported as killed (review) + private bool _pvpLethal = true; + + public async Task PlayerVsPlayer(Character attacker, Character defender, bool allowSurrender = true, bool lethal = true) { + _pvpLethal = lethal; // Wizard godmode: save HP/Mana before combat to restore after bool isGodMode = UsurperRemake.Server.SessionContext.IsActive && (UsurperRemake.Server.SessionContext.Current?.WizardGodMode ?? false); @@ -26143,7 +26148,7 @@ private async Task OfferNPCSurrenderAsync(Character attacker, Character de private void ReportNPCDefeat(CombatResult result) { if (result.Opponent is not NPC npc || result.Player == null) return; - long bounty = QuestSystem.RecordNPCDefeat(result.Player, npc, killed: !npc.IsAlive); + long bounty = QuestSystem.RecordNPCDefeat(result.Player, npc, killed: _pvpLethal && !npc.IsAlive); if (bounty > 0) { terminal.WriteLine(Loc.Get("street.fight.bounty_collected", bounty.ToString("N0")), "bright_yellow"); diff --git a/Scripts/Systems/SqlSaveBackend.cs b/Scripts/Systems/SqlSaveBackend.cs index 6b0ccafb..99cdcfb2 100644 --- a/Scripts/Systems/SqlSaveBackend.cs +++ b/Scripts/Systems/SqlSaveBackend.cs @@ -476,7 +476,8 @@ CREATE TABLE IF NOT EXISTS player_teams ( created_by TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')), member_count INTEGER DEFAULT 1, - controls_turf INTEGER DEFAULT 0 + controls_turf INTEGER DEFAULT 0, + last_join_at TEXT ); CREATE TABLE IF NOT EXISTS trade_offers ( @@ -882,6 +883,16 @@ created_at TEXT DEFAULT (datetime('now')), } catch { /* Column already exists - expected */ } + // v1.1.11: when a player last joined the team; the empty-team cleanup leaves a team alone for a + // while after a join, in every process (a join and the cleanup can run in different processes) + try + { + using var migCmd = connection.CreateCommand(); + migCmd.CommandText = "ALTER TABLE player_teams ADD COLUMN last_join_at TEXT;"; + migCmd.ExecuteNonQuery(); + } + catch { /* Column already exists - expected */ } + // v0.60.5: add created_ip column for per-IP registration rate limiting try { @@ -5429,7 +5440,14 @@ public void ClearGoldTransfers(IEnumerable ids) var result = await Task.Run(() => cmd.ExecuteScalar()); if (result == null) return (false, false); var storedHash = result.ToString() ?? ""; - return (true, VerifyPassword(password, storedHash)); + if (!VerifyPassword(password, storedHash)) return (true, false); + // v1.1.11: stamp the join, so the empty-team cleanup (in this process or another) leaves the team + // alone until the membership is saved; no row means it was removed a moment ago + using var stamp = connection.CreateCommand(); + stamp.CommandText = "UPDATE player_teams SET last_join_at = datetime('now') WHERE team_name = @name;"; + stamp.Parameters.AddWithValue("@name", teamName); + if (stamp.ExecuteNonQuery() != 1) return (false, false); + return (true, true); } catch (Exception ex) { @@ -5597,8 +5615,10 @@ AND NOT EXISTS (SELECT 1 FROM players p WHERE NOT json_valid(p.player_data) OR (CASE WHEN json_valid(p.player_data) THEN json_extract(p.player_data, '$.player.team') END) = @team) AND NOT EXISTS (SELECT 1 FROM deleted_characters d WHERE d.expires_at > datetime('now') - AND (CASE WHEN json_valid(d.player_data) THEN json_extract(d.player_data, '$.player.team') END) = @team);"; + AND (CASE WHEN json_valid(d.player_data) THEN json_extract(d.player_data, '$.player.team') END) = @team) + AND (last_join_at IS NULL OR last_join_at < datetime('now', '-' || @joinGrace || ' minutes'));"; team.Parameters.AddWithValue("@team", teamName); + team.Parameters.AddWithValue("@joinGrace", GameConfig.EmptyTeamJoinGraceMinutes); if (team.ExecuteNonQuery() != 1) return false; } foreach (var table in new[] { "team_upgrades", "team_vault" }) diff --git a/Scripts/Systems/TeamMembershipGate.cs b/Scripts/Systems/TeamMembershipGate.cs deleted file mode 100644 index 3c37ba7b..00000000 --- a/Scripts/Systems/TeamMembershipGate.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Threading; - -namespace UsurperRemake.Systems -{ - /// - /// v1.1.11: joining a team (checking it exists, taking the membership, saving it) and the world - /// save's removal of an empty team (the last check that nobody is in it, and the delete) must not - /// interleave: a join between the two lost the team, its upgrades and vault (review). Both hold this. - /// - public static class TeamMembershipGate - { - public static readonly SemaphoreSlim Gate = new(1, 1); - } -} diff --git a/Scripts/Systems/WorldSimService.cs b/Scripts/Systems/WorldSimService.cs index 1cb0a09f..dc9685b5 100644 --- a/Scripts/Systems/WorldSimService.cs +++ b/Scripts/Systems/WorldSimService.cs @@ -1428,15 +1428,9 @@ internal int PruneEmptyTeams(DateTime now) { if (!_teamEmptySince.TryGetValue(team, out var since)) { _teamEmptySince[team] = now; continue; } if (now - since < TimeSpan.FromMinutes(GameConfig.EmptyTeamGraceMinutes)) continue; - // the last check and the delete, under the gate a join holds from its check to its save - bool removedNow; - TeamMembershipGate.Gate.Wait(); - try - { - removedNow = !IsTeamOnline(team) && sqlBackend.DeleteEmptyTeam(team); - } - finally { TeamMembershipGate.Gate.Release(); } - if (!removedNow) { if (IsTeamOnline(team)) _teamEmptySince.Remove(team); continue; } + // a join in any process stamps the team first, and the delete refuses a recent stamp + if (IsTeamOnline(team)) { _teamEmptySince.Remove(team); continue; } + if (!sqlBackend.DeleteEmptyTeam(team)) continue; _teamEmptySince.Remove(team); deleted.Add(team); DebugLogger.Instance.LogInfo("WORLDSIM", $"Removed empty team '{team}'"); diff --git a/Tests/EmptyTeamCleanupTests.cs b/Tests/EmptyTeamCleanupTests.cs index 2e3fc2f9..2ec27a3e 100644 --- a/Tests/EmptyTeamCleanupTests.cs +++ b/Tests/EmptyTeamCleanupTests.cs @@ -197,19 +197,18 @@ public void ACharacterArchivedByPermadeath_StillKeepsItsTeam_UntilTheArchiveExpi } [Fact] - public void TheJoinAndTheCleanup_HoldTheSameGate() + public async Task AJoin_StampsTheTeam_AndTheDeleteLeavesAFreshlyJoinedTeamAlone() { - var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Scripts"))) dir = dir.Parent; - string corner = File.ReadAllText(Path.Combine(dir!.FullName, "Scripts", "Locations", "TeamCornerLocation.cs")); - string sim = File.ReadAllText(Path.Combine(dir.FullName, "Scripts", "Systems", "WorldSimService.cs")); - int wait = corner.IndexOf("await TeamMembershipGate.Gate.WaitAsync();", StringComparison.Ordinal); - int verify = corner.IndexOf("await backend.VerifyPlayerTeam(teamName, password);", StringComparison.Ordinal); - int persist = corner.IndexOf("await PersistTeamMembershipChange();", verify, StringComparison.Ordinal); - int release = corner.IndexOf("TeamMembershipGate.Gate.Release();", verify, StringComparison.Ordinal); - wait.Should().BeGreaterThan(0); - verify.Should().BeGreaterThan(wait, "the check that the team exists is inside the gate"); - release.Should().BeGreaterThan(persist, "and so is the save of the membership"); - sim.Should().Contain("TeamMembershipGate.Gate.Wait();").And.Contain("removedNow = !IsTeamOnline(team) && sqlBackend.DeleteEmptyTeam(team);"); + // A join and the cleanup can run in different processes; the stamp is in the database (review). + Exec($"INSERT INTO player_teams (team_name, password_hash, created_by) VALUES ('Just Joined', '{SqlSaveBackend.HashTeamPassword("pw")}', 'founder');"); + (await _db.VerifyPlayerTeam("Just Joined", "wrong")).Should().Be((true, false)); + Count("SELECT COUNT(*) FROM player_teams WHERE team_name = 'Just Joined' AND last_join_at IS NOT NULL").Should().Be(0, "a failed check does not stamp"); + + (await _db.VerifyPlayerTeam("Just Joined", "pw")).Should().Be((true, true)); + _db.DeleteEmptyTeam("Just Joined").Should().BeFalse("the joiner's save has not landed yet"); + + Exec($"UPDATE player_teams SET last_join_at = datetime('now', '-{GameConfig.EmptyTeamJoinGraceMinutes + 1} minutes') WHERE team_name = 'Just Joined';"); + _db.DeleteEmptyTeam("Just Joined").Should().BeTrue("the join never became a membership"); + (await _db.VerifyPlayerTeam("Just Joined", "pw")).Should().Be((false, false)); } } diff --git a/Tests/NPCDefeatQuestTests.cs b/Tests/NPCDefeatQuestTests.cs index 3cf1b0cf..974bcfdb 100644 --- a/Tests/NPCDefeatQuestTests.cs +++ b/Tests/NPCDefeatQuestTests.cs @@ -112,4 +112,17 @@ public void TwoSessionsBeatingTheTargetAtOnce_ArePaidOnce() System.Threading.Interlocked.Add(ref paid, QuestSystem.AutoCompleteBountyForNPC(new Character { Name2 = "Racer", Level = 30 }, target.Name))); paid.Should().Be(5000); } + + [Fact] + public async Task ANonLethalDuel_WonAgainstAnAssassinationTarget_DoesNotMeetTheContract() + { + // The Dormitory wake-up brawl brings the guest back after the fight (review). + var (hunter, target, bounty) = Wanted("Sleeper Mark"); + bounty.QuestTarget = QuestTarget.Assassin; + var engine = new CombatEngine(new TerminalEmulator(new MemoryStream(), new MemoryStream())); + typeof(CombatEngine).GetField("_pvpLethal", F)!.SetValue(engine, false); + await (Task)typeof(CombatEngine).GetMethod("DeterminePvPOutcome", F)!.Invoke(engine, new object[] { new CombatResult { Player = hunter, Opponent = target } })!; + bounty.Deleted.Should().BeFalse(); + bounty.Deleted = true; + } } From f56ce3791d0db625ffb65ec4513118435fbbc8e2 Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 20:26:11 +0000 Subject: [PATCH 16/42] A same-name character no longer inherits a deleted one's quests and deity A player deleted a character and created a new one with the same name; the new one kept the old board quests and chosen deity. Deleting from character select or an alt slot, and both admin deletes, never ran the purge permadeath runs; the deity was cleared by the account key while it is stored by display name; the shared quest list was never cleared for a new character; and single-player restored every character's deity from every save. - A new character clears anything left under its name at creation: deity (any case), quests it claimed or was offered, the Crown's WANTED bounties on it, and relationships. Display names are unique. - Every online delete (character select, alt slot, both admin consoles, both permadeath paths) runs one purge first: the existing SQL and hook purge, plus queued bequests, pending gold transfers, undelivered world boss rewards, quests and WANTED bounties, the guild cache, NPC spouses and children. A test fails on any delete that skips it. - Single-player: the slot delete goes through the save system and clears the deity by name; loading a save restores only that character's deity. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Core/GameEngine.cs | 99 +++++----- Scripts/Server/MudServer.cs | 3 +- Scripts/Systems/CombatEngine.cs | 2 + Scripts/Systems/FileSaveBackend.cs | 26 +++ Scripts/Systems/GodSystem.cs | 16 ++ Scripts/Systems/GuildSystem.cs | 10 + Scripts/Systems/OnlineAdminConsole.cs | 5 + Scripts/Systems/PermadeathHelper.cs | 141 +++++++++----- Scripts/Systems/QuestSystem.cs | 13 ++ Scripts/Systems/SaveSystem.cs | 54 +++++- Scripts/Systems/SqlSaveBackend.cs | 6 + Scripts/Systems/SysOpConsoleManager.cs | 5 + Tests/CharacterRecreationTests.cs | 247 +++++++++++++++++++++++++ 13 files changed, 528 insertions(+), 99 deletions(-) create mode 100644 Tests/CharacterRecreationTests.cs diff --git a/Scripts/Core/GameEngine.cs b/Scripts/Core/GameEngine.cs index 7783918f..a9f85cb1 100644 --- a/Scripts/Core/GameEngine.cs +++ b/Scripts/Core/GameEngine.cs @@ -666,15 +666,16 @@ private async Task RunBBSDoorMode() var confirm = await terminal.GetInput(Loc.Get("engine.delete_confirm_prompt")); if (confirm == "DELETE") { - // Disown this character's children before recreating with the - // same name, so the new character doesn't inherit the old life's - // kids (and their daily gold) by name-match. - DisownDeletedCharacterChildren(mainSave.PlayerName); + // v1.1.11: run the same purge as permadeath before the delete (quests, god, + // WANTED bounty, children, guild, queued deliveries). Only the children were + // cleared before, so a same-name recreation kept the old quests and god. + await PermadeathHelper.PurgeDeletedCharacterAsync( + SaveSystem.Instance.Backend as SqlSaveBackend, accountName, mainSave.PlayerName); // Delete main character save only (not alt) var saves = SaveSystem.Instance.GetPlayerSaves(accountName); foreach (var save in saves) { - SaveSystem.Instance.DeleteSave(Path.GetFileNameWithoutExtension(save.FileName)); + SaveSystem.Instance.DeleteSave(Path.GetFileNameWithoutExtension(save.FileName), save.PlayerName); } // Ensure identity is main account UsurperRemake.BBS.DoorMode.SetOnlineUsername(accountName); @@ -843,11 +844,11 @@ private async Task RunBBSDoorMode() var confirmDel = await terminal.GetInput(Loc.Get("engine.delete_alt_confirm")); if (confirmDel == "DELETE") { - // Disown the alt's children so a future same-named recreate - // doesn't inherit them (and so they don't linger parented to a - // character that no longer exists). - DisownDeletedCharacterChildren(altSave.PlayerName); - SaveSystem.Instance.DeleteSave(altKey); + // v1.1.11: the same purge as permadeath before the delete; it also disowns + // the alt's children, which this path did on its own before. + await PermadeathHelper.PurgeDeletedCharacterAsync( + SaveSystem.Instance.Backend as SqlSaveBackend, altKey, altSave.PlayerName); + SaveSystem.Instance.DeleteSave(altKey, altSave.PlayerName); // Also clean up sleeping_players entry for the alt if (SaveSystem.Instance.Backend is SqlSaveBackend sqlDel) { @@ -2556,20 +2557,9 @@ private async Task ShowSaveSlotMenu(string playerName) if (confirm == "DELETE") { // Delete all saves for this player - foreach (var save in saves) - { - var filePath = System.IO.Path.Combine( - System.IO.Path.Combine(GetUserDataPath(), "saves"), - save.FileName); - try - { - System.IO.File.Delete(filePath); - } - catch (Exception ex) - { - DebugLogger.Instance.Log(DebugLogger.LogLevel.Debug, "ENGINE", $"Swallowed exception: {ex.Message}"); - } - } + // v1.1.11: through SaveSystem (was a raw File.Delete per file), so the god entry + // is cleared by the character's Name2 as well as the save key. + SaveSystem.Instance.DeleteSaves(playerName, saves); terminal.WriteLine(Loc.Get("engine.all_saves_deleted"), "green"); await Task.Delay(1500); return; @@ -2851,8 +2841,9 @@ private async Task LoadSaveByFileName(string fileName) // Restore story systems (companions, children, seals, etc.) // In online mode, only restore this player's god entry — other players' stale // snapshots would overwrite their current worship choices in the shared GodSystem. - string? godRestoreFilter = (UsurperRemake.BBS.DoorMode.IsOnlineMode && currentPlayer != null) - ? currentPlayer.Name2 : null; + // v1.1.11: single-player too. Every save carries the whole worship dictionary, so an old + // entry for a deleted same-name character was restored with any save. + string? godRestoreFilter = GodRestoreFilterFor(currentPlayer); SaveSystem.Instance.RestoreStorySystems(saveData.StorySystems, godRestoreFilter); // Migration: sync RelationshipSystem with RomanceTracker for saves affected by @@ -4713,8 +4704,7 @@ private async Task LoadExistingGame(string playerName) // Restore story systems (companions, children, seals, etc.) // In online mode, only restore this player's god entry — other players' stale // snapshots would overwrite their current worship choices in the shared GodSystem. - string? godFilter = (UsurperRemake.BBS.DoorMode.IsOnlineMode && currentPlayer != null) - ? currentPlayer.Name2 : null; + string? godFilter = GodRestoreFilterFor(currentPlayer); // v1.1.11: single-player too SaveSystem.Instance.RestoreStorySystems(saveData.StorySystems, godFilter); // In online mode, override royal court, children, and marriages with world_state @@ -4758,28 +4748,35 @@ private async Task LoadExistingGame(string playerName) } /// - /// Disown a deleted character's children so a same-named recreate does not - /// inherit them by name-match (children match parents on display name, and - /// the recreated character keeps the same name). Online only -- in - /// single-player CreateNewGame resets FamilySystem entirely. Player report: - /// deleted a character, recreated it, and a kid from the old life paid the - /// daily child gold bonus on the next day. Persist is gated on disowned > 0 - /// so a stale/empty FamilySystem can never write an empty children set back - /// to world_state. + /// v1.1.11: clear the state shared systems still hold under a new character's display name: the + /// god worship entry, quests claimed by or offered to it, the King's WANTED bounty on it, and its + /// relationship records. Safe because the name cannot belong to anyone else: online, + /// CharacterCreationSystem refuses a name another character holds (IsDisplayNameTaken, ignoring + /// case, excluding only the key being created); in single-player only the loading character's + /// god entry is restored. On an NG+ reroll it clears the previous life, as the NG+ god clear does. + /// Returns the number of quests and bounties removed. /// - private void DisownDeletedCharacterChildren(string? characterName) + public static int ClearLeftoversForNewCharacter(string? name2) { - if (!UsurperRemake.BBS.DoorMode.IsOnlineMode || string.IsNullOrEmpty(characterName)) - return; - int disowned = UsurperRemake.Systems.FamilySystem.Instance.DisownChildrenOf(characterName); - if (disowned > 0) - { - DebugLogger.Instance.LogInfo("DELETE", $"Disowned {disowned} child(ren) of deleted character '{characterName}'"); - if (UsurperRemake.Systems.OnlineStateManager.Instance != null) - _ = UsurperRemake.Systems.OnlineStateManager.Instance.SaveSharedChildrenNow(); - } + if (string.IsNullOrWhiteSpace(name2)) return 0; + try { UsurperRemake.GodSystemSingleton.Instance?.ClearPlayerGodAnyCase(name2); } + catch (Exception ex) { DebugLogger.Instance.LogWarning("CREATE", $"Leftover god clear failed for '{name2}': {ex.Message}"); } + int removed = QuestSystem.RemovePlayerQuests(name2) + QuestSystem.RemoveBountiesOnPlayer(name2); + try { RelationshipSystem.Instance?.ResetPlayerRelationships(name2); } + catch (Exception ex) { DebugLogger.Instance.LogWarning("CREATE", $"Leftover relationship clear failed for '{name2}': {ex.Message}"); } + if (removed > 0) + DebugLogger.Instance.LogInfo("CREATE", $"Removed {removed} leftover quest(s) and bounties under '{name2}'."); + return removed; } + /// + /// v1.1.11: the one name whose god worship entry a load restores from the save. Online always + /// filtered this way; single-player restored the whole dictionary. Never null, so a load + /// without a name restores no one's entry rather than everyone's. + /// + public static string GodRestoreFilterFor(Character? player) => + !string.IsNullOrEmpty(player?.Name2) ? player!.Name2 : (player?.Name1 ?? ""); + /// /// Create new game /// @@ -4962,6 +4959,16 @@ private async Task CreateNewGame(string playerName) currentPlayer = (Character)newCharacter; + // v1.1.11: backstop for every path, single-player included. Player report: a character + // deleted and recreated under the same name kept the old god and quest list. Clear what the + // shared systems still hold under this name before the first save captures it. + int leftoverQuests = ClearLeftoversForNewCharacter(currentPlayer.Name2); + if (leftoverQuests > 0 && UsurperRemake.BBS.DoorMode.IsOnlineMode && OnlineStateManager.IsActive) + { + try { await OnlineStateManager.Instance!.SaveSharedQuestsNow(); } + catch (Exception qx) { DebugLogger.Instance.LogWarning("CREATE", $"Leftover quest push failed: {qx.Message}"); } + } + // Apply SysOp's default color theme to new characters currentPlayer.ColorTheme = GameConfig.DefaultColorTheme; ColorTheme.Current = GameConfig.DefaultColorTheme; diff --git a/Scripts/Server/MudServer.cs b/Scripts/Server/MudServer.cs index ec27248f..8e8c2a70 100644 --- a/Scripts/Server/MudServer.cs +++ b/Scripts/Server/MudServer.cs @@ -189,7 +189,8 @@ public async Task RunAsync(CancellationToken cancellationToken) // god's believer count atomically. try { - GodSystemSingleton.Instance.SetPlayerGod(primaryKey, ""); + // v1.1.11: in any letter case; the worship dictionary is case-sensitive. + GodSystemSingleton.Instance.ClearPlayerGodAnyCase(primaryKey); if (!string.Equals(primaryKey, username, StringComparison.OrdinalIgnoreCase)) GodSystemSingleton.Instance.SetPlayerGod(username, ""); } diff --git a/Scripts/Systems/CombatEngine.cs b/Scripts/Systems/CombatEngine.cs index 554bf1b4..2ea02834 100644 --- a/Scripts/Systems/CombatEngine.cs +++ b/Scripts/Systems/CombatEngine.cs @@ -21429,6 +21429,8 @@ private async Task HandleExcessiveDeathsPermadeath(CombatResult result) if (SaveSystem.Instance?.Backend is SqlSaveBackend sqlBackend && !string.IsNullOrEmpty(username)) { + // v1.1.11: the same purge as every other delete, so a same-name character starts clean + await PermadeathHelper.PurgeDeletedCharacterAsync(sqlBackend, username, displayName, player); sqlBackend.DeleteGameData(username, bypassArchive: false); DebugLogger.Instance.LogWarning("DEATH_CAP", $"Permadeleted '{username}' (display='{displayName}', lv={finalLevel}, class={className}) for excessive deaths ({player.PlaythroughDeaths} total, killed by {killerName}). 7-day /restore window active."); diff --git a/Scripts/Systems/FileSaveBackend.cs b/Scripts/Systems/FileSaveBackend.cs index 411497b3..45c6b982 100644 --- a/Scripts/Systems/FileSaveBackend.cs +++ b/Scripts/Systems/FileSaveBackend.cs @@ -321,6 +321,32 @@ public bool DeleteGameData(string playerName) } } + /// + /// v1.1.11: delete one listed save file (autosave, backup, emergency dump) by its file name in + /// the active save directory, under the write lock. A name with a path in it is refused. + /// + public bool DeleteSaveFile(string? fileName) + { + if (string.IsNullOrWhiteSpace(fileName) || Path.GetFileName(fileName) != fileName) return false; + _writeLock.Wait(); + try + { + var filePath = Path.Combine(SaveDirectory, fileName); + if (!File.Exists(filePath)) return false; + File.Delete(filePath); + return true; + } + catch (Exception ex) + { + DebugLogger.Instance.LogWarning("SAVE", $"DeleteSaveFile failed for '{fileName}': {ex.Message}"); + return false; + } + finally + { + _writeLock.Release(); + } + } + /// /// v0.60.7: predicate for auxiliary / non-character JSON files that live /// alongside saves. Returns true for files that should be excluded from diff --git a/Scripts/Systems/GodSystem.cs b/Scripts/Systems/GodSystem.cs index 6fc5ce54..ca236dff 100644 --- a/Scripts/Systems/GodSystem.cs +++ b/Scripts/Systems/GodSystem.cs @@ -347,6 +347,22 @@ public void SetPlayerGod(string playerName, string godName) } } + /// + /// v1.1.11: clear the worship entry for a character name in any letter case (the dictionary is + /// case-sensitive, display names are unique ignoring case). Goes through SetPlayerGod so the + /// god's believer count drops. Returns the number of entries cleared. + /// + public int ClearPlayerGodAnyCase(string? playerName) + { + if (string.IsNullOrWhiteSpace(playerName)) return 0; + var keys = playerGods.Keys + .Where(k => string.Equals(k, playerName, StringComparison.OrdinalIgnoreCase)) + .ToList(); + foreach (var key in keys) + SetPlayerGod(key, ""); + return keys.Count; + } + /// /// Calculate sacrifice gold return (Pascal Sacrifice_Gold_Return function) /// diff --git a/Scripts/Systems/GuildSystem.cs b/Scripts/Systems/GuildSystem.cs index e3883302..0ae26d68 100644 --- a/Scripts/Systems/GuildSystem.cs +++ b/Scripts/Systems/GuildSystem.cs @@ -159,6 +159,16 @@ private void LoadMembershipCache() return membershipCache.TryGetValue(username, out var guild) ? guild : null; } + /// + /// v1.1.11: drop a deleted character from the membership cache. The guild_members row is + /// removed by SqlSaveBackend.PurgePlayerWorldState; without this the cache kept it until restart. + /// + public void ForgetMember(string? username) + { + if (!string.IsNullOrWhiteSpace(username)) + membershipCache.TryRemove(username, out _); + } + /// /// Get the display name of a player's guild, or null if not in a guild. /// diff --git a/Scripts/Systems/OnlineAdminConsole.cs b/Scripts/Systems/OnlineAdminConsole.cs index a903b9dc..f0e08a09 100644 --- a/Scripts/Systems/OnlineAdminConsole.cs +++ b/Scripts/Systems/OnlineAdminConsole.cs @@ -646,6 +646,11 @@ internal async Task DeletePlayer() return; } + // v1.1.11: the same purge as permadeath before the delete. display_name carries a + // married surname, so the character's Name2 is read from the save. + var deletedSave = await backend.ReadGameData(target.Username); + await PermadeathHelper.PurgeDeletedCharacterAsync(backend, target.Username, + deletedSave?.Player?.Name2 ?? target.DisplayName); backend.DeleteGameData(target.Username); terminal.SetColor("green"); terminal.WriteLine($"Player '{target.DisplayName}' has been permanently deleted."); diff --git a/Scripts/Systems/PermadeathHelper.cs b/Scripts/Systems/PermadeathHelper.cs index 01e04c92..7db64d4d 100644 --- a/Scripts/Systems/PermadeathHelper.cs +++ b/Scripts/Systems/PermadeathHelper.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading.Tasks; using UsurperRemake.UI; @@ -192,54 +193,24 @@ public static async Task ExecutePermadeath(global::Character player, TerminalEmu // character created on this account key. sqlBackend.RecordFallenLegacy(username, displayName, finalLevel, className, killerName, GameConfig.GetFallenLegacyGold(finalLevel)); - - // v0.60.5: purge shared world-state references (guild membership, - // bounties, trades, world-boss damage, etc.) BEFORE clearing the - // player_data so any joined queries in the purge hooks still - // resolve. Player report (Rage): "lost all 4 lives, made a new - // char, came back in my guild still, actually still worshiping - // the same god." Fixed by this purge + the in-memory hook. - sqlBackend.PurgePlayerWorldState(username, displayName); - - sqlBackend.DeleteGameData(username, bypassArchive: false); - DebugLogger.Instance.LogWarning("DEATH_CAP", - $"Permadeleted '{username}' (display='{displayName}', lv={finalLevel}, class={className}, killer={killerName}). 7-day /restore window active."); } - // v0.65.0 (eldruin/Eldruin report): permadeath must also clear the - // character's identity-linked state that lives OUTSIDE the players - // row, or a same-name recreation re-binds to it. SQL-side state is - // handled by PurgePlayerWorldState above; these three live in - // shared in-memory systems + their world_state mirrors. - try - { - // (1) Claimed quests: keyed by Occupier = display name in the - // shared questDatabase. Remove them and push the cleaned list - // to world_state["quests"] so it's durable (otherwise they - // re-surface for the next character named the same). - int removedQuests = QuestSystem.RemovePlayerQuests(displayName, username, player.Name1, player.Name2); - if (removedQuests > 0) - DebugLogger.Instance.LogInfo("DEATH_CAP", $"Removed {removedQuests} claimed quest(s) for permadied '{displayName}'."); - if (UsurperRemake.BBS.DoorMode.IsOnlineMode && OnlineStateManager.IsActive) - await OnlineStateManager.Instance!.SaveSharedQuestsNow(); - } - catch (Exception qex) { DebugLogger.Instance.LogWarning("DEATH_CAP", $"Quest purge failed: {qex.Message}"); } + // v0.60.5: purge shared world-state references (guild membership, + // bounties, trades, world-boss damage, etc.) BEFORE clearing the + // player_data so any joined queries in the purge hooks still + // resolve. Player report (Rage): "lost all 4 lives, made a new + // char, came back in my guild still, actually still worshiping + // the same god." Fixed by this purge + the in-memory hook. + // v1.1.11: the purge, the quest removal and the child disown now live in + // PurgeDeletedCharacterAsync, which every online delete path shares. + await PurgeDeletedCharacterAsync(SaveSystem.Instance?.Backend as SqlSaveBackend, username, displayName, player); - try + if (SaveSystem.Instance?.Backend is SqlSaveBackend sqlDelete && !string.IsNullOrEmpty(username)) { - // (2) Children: disown so the dead parent's kids don't grant a - // same-name recreation family bonuses / show as their children. - // Use the ID-first Character overload (v0.63.0 slice 4) for the - // same rename/name-collision robustness the NG+/delete paths use. - int disowned = UsurperRemake.Systems.FamilySystem.Instance?.DisownChildrenOf(player) ?? 0; - if (disowned > 0) - { - DebugLogger.Instance.LogInfo("DEATH_CAP", $"Disowned {disowned} child(ren) of permadied '{displayName}'."); - if (UsurperRemake.BBS.DoorMode.IsOnlineMode && OnlineStateManager.IsActive) - await OnlineStateManager.Instance!.SaveSharedChildrenNow(); - } + sqlDelete.DeleteGameData(username, bypassArchive: false); + DebugLogger.Instance.LogWarning("DEATH_CAP", + $"Permadeleted '{username}' (display='{displayName}', lv={finalLevel}, class={className}, killer={killerName}). 7-day /restore window active."); } - catch (Exception cex) { DebugLogger.Instance.LogWarning("DEATH_CAP", $"Child disown failed: {cex.Message}"); } if (UsurperRemake.BBS.DoorMode.IsOnlineMode) { @@ -430,5 +401,89 @@ private static async Task TryDistributeInheritance( $"TryDistributeInheritance failed: {ex.Message}"); } } + + /// + /// v1.1.11: the one purge for a character leaving the world, shared by permadeath, the + /// character-select deletes (N, D) and both admin deletes. Player report: a character deleted + /// and recreated under the same name kept the old quest list and god. Call it BEFORE + /// DeleteGameData, which still archives the row for the 7-day /restore; as with permadeath, + /// a restore does not bring back the world state cleared here. + /// + public static async Task PurgeDeletedCharacterAsync(SqlSaveBackend? backend, string? username, string? displayName, global::Character? player = null) + { + string name = !string.IsNullOrWhiteSpace(displayName) ? displayName! : (username ?? ""); + if (string.IsNullOrWhiteSpace(name)) return; + + // SQL rows keyed by the character key, then PermadeathPurgeHook (god worship, relationships). + if (backend != null && !string.IsNullOrWhiteSpace(username)) + backend.PurgePlayerWorldState(username!, name); + + try + { + // Claimed quests (Occupier / OfferedTo = display name) and the King's WANTED bounty on + // the character. Pushed even when nothing was removed, since world_state may still hold + // a stale copy that a same-name character would merge back on load. + int removed = QuestSystem.RemovePlayerQuests(name) + QuestSystem.RemoveBountiesOnPlayer(name); + if (removed > 0) + DebugLogger.Instance.LogInfo("DELETE", $"Removed {removed} quest(s) and bounties for deleted '{name}'."); + if (UsurperRemake.BBS.DoorMode.IsOnlineMode && OnlineStateManager.IsActive) + await OnlineStateManager.Instance!.SaveSharedQuestsNow(); + } + catch (Exception qex) { DebugLogger.Instance.LogWarning("DELETE", $"Quest purge failed for '{name}': {qex.Message}"); } + + try + { + // The guild_members row went with the SQL purge; the cache is keyed by the character key. + if (!string.IsNullOrWhiteSpace(username)) GuildSystem.Instance?.ForgetMember(username); + GuildSystem.Instance?.ForgetMember(name); + } + catch (Exception gex) { DebugLogger.Instance.LogWarning("DELETE", $"Guild cache clear failed for '{name}': {gex.Message}"); } + + try + { + int widowed = ClearNpcSpousesOf(name); + if (widowed > 0) + DebugLogger.Instance.LogInfo("DELETE", $"Cleared the marriage of {widowed} NPC(s) to deleted '{name}'."); + } + catch (Exception sex) { DebugLogger.Instance.LogWarning("DELETE", $"NPC spouse clear failed for '{name}': {sex.Message}"); } + + try + { + // Children match parents by name, so a same-name recreation would inherit them. The + // ID-first Character overload is used when the caller has the character (permadeath). + var family = FamilySystem.Instance; + int disowned = family == null ? 0 : (player != null ? family.DisownChildrenOf(player) : family.DisownChildrenOf(name)); + if (disowned > 0) + { + DebugLogger.Instance.LogInfo("DELETE", $"Disowned {disowned} child(ren) of deleted '{name}'."); + if (UsurperRemake.BBS.DoorMode.IsOnlineMode && OnlineStateManager.IsActive) + await OnlineStateManager.Instance!.SaveSharedChildrenNow(); + } + } + catch (Exception cex) { DebugLogger.Instance.LogWarning("DELETE", $"Child disown failed for '{name}': {cex.Message}"); } + } + + /// + /// v1.1.11: end the marriage of any NPC whose spouse was the deleted character, clearing the + /// same three flags a divorce clears on the NPC. An NPC married to another NPC (registry) is + /// left alone, in case that NPC shares the name. + /// + public static int ClearNpcSpousesOf(string? name) + { + if (string.IsNullOrWhiteSpace(name)) return 0; + var npcs = NPCSpawnSystem.Instance?.ActiveNPCs; + if (npcs == null) return 0; + int cleared = 0; + foreach (var npc in npcs.ToList()) + { + if (npc == null || !string.Equals(npc.SpouseName, name, StringComparison.OrdinalIgnoreCase)) continue; + if (!string.IsNullOrEmpty(npc.ID) && NPCMarriageRegistry.Instance.IsMarriedToNPC(npc.ID)) continue; + npc.Married = false; + npc.IsMarried = false; + npc.SpouseName = ""; + cleared++; + } + return cleared; + } } } diff --git a/Scripts/Systems/QuestSystem.cs b/Scripts/Systems/QuestSystem.cs index 3079effc..b6f45d23 100644 --- a/Scripts/Systems/QuestSystem.cs +++ b/Scripts/Systems/QuestSystem.cs @@ -1284,6 +1284,19 @@ bool Matches(string? field) => return questDatabase.RemoveAll(q => Matches(q.Occupier) || Matches(q.OfferedTo)); } + /// + /// v1.1.11: remove the King's WANTED bounties posted on a player (PostBountyOnPlayer), so a deleted + /// character's bounty does not hang over a new character given the same name. + /// + public static int RemoveBountiesOnPlayer(string? playerName) + { + if (string.IsNullOrWhiteSpace(playerName)) return 0; + return questDatabase.RemoveAll(q => + q.Initiator == KING_BOUNTY_INITIATOR && + !string.IsNullOrEmpty(q.TargetNPCName) && + q.TargetNPCName.Equals(playerName, StringComparison.OrdinalIgnoreCase)); + } + /// /// v0.65.0: drop any quest claimed by / offered to playerName whose Id is NOT /// in keepIds. Run AFTER MergeWorldQuests during load to close the diff --git a/Scripts/Systems/SaveSystem.cs b/Scripts/Systems/SaveSystem.cs index d601e316..b9fe997e 100644 --- a/Scripts/Systems/SaveSystem.cs +++ b/Scripts/Systems/SaveSystem.cs @@ -201,17 +201,44 @@ public bool SaveExists(string playerName) /// /// Delete a save file /// - public bool DeleteSave(string playerName) + // v1.1.11: displayName is the character's Name2 from the save. Worship is keyed by it, not by + // the save key, so clearing by the key alone left the god for a same-name recreation. + public bool DeleteSave(string playerName, string? displayName = null) { // Clear in-memory god worship mapping so it doesn't persist to a new character with the same name try { UsurperRemake.GodSystemSingleton.Instance?.SetPlayerGod(playerName, ""); + UsurperRemake.GodSystemSingleton.Instance?.ClearPlayerGodAnyCase(displayName); } catch (Exception ex) { DebugLogger.Instance.Log(DebugLogger.LogLevel.Debug, "SAVE", $"GodSystem not initialized: {ex.Message}"); } return backend.DeleteGameData(playerName); } + + /// + /// v1.1.11: delete every listed save of one character (the single-player slot delete, which + /// used to File.Delete each file itself). The primary save goes through DeleteSave so the god + /// entry is cleared by the Name2 each save names; the other files (autosaves, backups, + /// emergency dumps) are removed by name in the backend's save directory. + /// + public void DeleteSaves(string playerName, IEnumerable saves) + { + var list = saves?.ToList() ?? new List(); + var names = list.Select(s => s.PlayerName).Where(n => !string.IsNullOrWhiteSpace(n)) + .Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + DeleteSave(playerName, names.FirstOrDefault()); + foreach (var extra in names.Skip(1)) + { + try { UsurperRemake.GodSystemSingleton.Instance?.ClearPlayerGodAnyCase(extra); } + catch (Exception ex) { DebugLogger.Instance.Log(DebugLogger.LogLevel.Debug, "SAVE", $"GodSystem not initialized: {ex.Message}"); } + } + if (backend is FileSaveBackend fileBackend) + { + foreach (var save in list) + fileBackend.DeleteSaveFile(save.FileName); + } + } /// /// Get list of all save files @@ -2351,6 +2378,22 @@ private StorySystemsData SerializeStorySystems() /// Restore story systems state from save data /// Note: Restoration is best-effort - systems may not support all restore operations /// + /// + /// v1.1.11: the god worship part of RestoreStorySystems, split out so it can be tested. With a + /// filter, only that player's entry is restored (GameEngine.GodRestoreFilterFor). + /// + public static void RestorePlayerGods(GodSystem godSystem, Dictionary? playerGods, string? onlyRestoreGodForPlayer) + { + if (godSystem == null || playerGods == null) return; + foreach (var kvp in playerGods) + { + if (onlyRestoreGodForPlayer != null && + !kvp.Key.Equals(onlyRestoreGodForPlayer, StringComparison.OrdinalIgnoreCase)) + continue; + godSystem.SetPlayerGod(kvp.Key, kvp.Value); + } + } + public void RestoreStorySystems(StorySystemsData? data, string? onlyRestoreGodForPlayer = null) { if (data == null) return; @@ -2468,14 +2511,7 @@ public void RestoreStorySystems(StorySystemsData? data, string? onlyRestoreGodFo // in the snapshot may be stale and would overwrite their current worship choices. try { - var godSystem = UsurperRemake.GodSystemSingleton.Instance; - foreach (var kvp in data.PlayerGods) - { - if (onlyRestoreGodForPlayer != null && - !kvp.Key.Equals(onlyRestoreGodForPlayer, StringComparison.OrdinalIgnoreCase)) - continue; - godSystem.SetPlayerGod(kvp.Key, kvp.Value); - } + RestorePlayerGods(UsurperRemake.GodSystemSingleton.Instance, data.PlayerGods, onlyRestoreGodForPlayer); } catch (Exception ex) { DebugLogger.Instance.Log(DebugLogger.LogLevel.Debug, "LOAD", $"System not available: {ex.Message}"); } diff --git a/Scripts/Systems/SqlSaveBackend.cs b/Scripts/Systems/SqlSaveBackend.cs index 99cdcfb2..84fbd42a 100644 --- a/Scripts/Systems/SqlSaveBackend.cs +++ b/Scripts/Systems/SqlSaveBackend.cs @@ -1196,6 +1196,12 @@ public void PurgePlayerWorldState(string username, string? displayName = null) // and must not be deleted out from under them. ExecPurge(connection, tx, "pvp_log", "LOWER(winner) = LOWER(@u)", username); + // v1.1.11: queued deliveries keyed by the character key. A new character on the same + // key used to collect the deleted one's inheritance, bank wires and boss rewards. + ExecPurge(connection, tx, "pending_inheritance", "LOWER(player_username) = LOWER(@u)", username); + ExecPurge(connection, tx, "pending_gold_transfers", "LOWER(recipient_username) = LOWER(@u)", username); + ExecPurge(connection, tx, "world_boss_rewards", "LOWER(player_name) = LOWER(@u) AND COALESCE(delivered, 0) = 0", username); + tx.Commit(); DebugLogger.Instance.LogInfo("PERMADEATH", $"Purged shared world-state references for '{username}' (guild, bounties, trades, auctions, etc.)"); diff --git a/Scripts/Systems/SysOpConsoleManager.cs b/Scripts/Systems/SysOpConsoleManager.cs index 5728ca8e..947493d5 100644 --- a/Scripts/Systems/SysOpConsoleManager.cs +++ b/Scripts/Systems/SysOpConsoleManager.cs @@ -698,6 +698,11 @@ private async Task DeletePlayerOnline(SqlSaveBackend sqlBackend) var confirm2 = await terminal.GetInputAsync(" Type YES for final confirmation: "); if (confirm2 != "YES") { terminal.SetColor("gray"); terminal.WriteLine(" Cancelled."); await terminal.GetInputAsync(" Press Enter..."); return; } + // v1.1.11: the same purge as permadeath before the delete. display_name carries a + // married surname, so the character's Name2 is read from the save. + var deletedSave = await sqlBackend.ReadGameData(target.Username); + await PermadeathHelper.PurgeDeletedCharacterAsync(sqlBackend, target.Username, + deletedSave?.Player?.Name2 ?? target.DisplayName); sqlBackend.DeleteGameData(target.Username); terminal.SetColor("green"); terminal.WriteLine($" {target.DisplayName} has been permanently deleted."); diff --git a/Tests/CharacterRecreationTests.cs b/Tests/CharacterRecreationTests.cs new file mode 100644 index 00000000..1f675420 --- /dev/null +++ b/Tests/CharacterRecreationTests.cs @@ -0,0 +1,247 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using UsurperRemake; +using UsurperRemake.Systems; +using Xunit; + +namespace UsurperReborn.Tests; + +/// +/// v1.1.11: player report: "I deleted my old character and created a new one using the exact same +/// name. The new character retained the list of unfinished quests (from the quest board) and the +/// deity I had chosen for the previous character." Worship and quests are keyed by display name in +/// shared systems, and only permadeath purged them. Now every delete path runs the permadeath purge, +/// and character creation clears whatever is still held under the new name. +/// +[Collection("SharedGameSingletons")] +public class CharacterRecreationTests : IDisposable +{ + private const string God = "Solarius"; // a default pantheon god, so VerifyGodExists holds + private readonly string _path = Path.Combine(Path.GetTempPath(), $"usurper-recreate-{Guid.NewGuid():N}.db"); + private readonly List _added = new(); + private SqlSaveBackend? _db; + + public void Dispose() + { + // GetAllQuests is a copy; remove the seeds through the database's own removers. + QuestSystem.RemovePlayerQuests("Bob", "Alice"); + QuestSystem.RemoveBountiesOnPlayer("Bob"); + QuestSystem.RemoveBountiesOnPlayer("Aldric"); + foreach (var name in new[] { "Bob", "bob", "Alice" }) + GodSystemSingleton.Instance.SetPlayerGod(name, ""); + if (_db != null) + { + SqliteConnection.ClearAllPools(); + try { File.Delete(_path); } catch { } + } + } + + private Quest Add(Quest q) + { + QuestSystem.AddQuestToDatabase(q); + _added.Add(q); + return q; + } + + private static Quest Wanted(string target) => new Quest + { + Title = "WANTED: " + target, Initiator = "The Crown", QuestTarget = QuestTarget.DefeatNPC, + TargetNPCName = target, BountyGold = 5000, Date = DateTime.Now, DaysToComplete = 30 + }; + + private static bool InDatabase(Quest q) => QuestSystem.GetAllQuests(includeCompleted: true).Contains(q); + + [Fact] + public void ANewCharacterWithADeletedCharactersName_HasNoGodAndNoQuests() + { + var gods = GodSystemSingleton.Instance; + gods.SetPlayerGod("Bob", God); + gods.PlayerHasGod("Bob").Should().BeTrue("the seed must be a real worship entry, or the test proves nothing"); + var claimed = Add(new Quest { Title = "Old claim", Occupier = "Bob", Date = DateTime.Now, DaysToComplete = 7 }); + var offered = Add(new Quest { Title = "Old offer", OfferedTo = "bob", Date = DateTime.Now, DaysToComplete = 7 }); + var someoneElses = Add(new Quest { Title = "Alice's claim", Occupier = "Alice", Date = DateTime.Now, DaysToComplete = 7 }); + + GameEngine.ClearLeftoversForNewCharacter("Bob").Should().Be(2); + + gods.PlayerHasGod("Bob").Should().BeFalse("the deleted character's god must not carry over"); + gods.GetPlayerGod("Bob").Should().BeEmpty(); + InDatabase(claimed).Should().BeFalse(); + InDatabase(offered).Should().BeFalse("OfferedTo matches in any letter case"); + InDatabase(someoneElses).Should().BeTrue("another character's quest is untouched"); + } + + [Fact] + public void TheGodEntryIsClearedInAnyLetterCase() + { + var gods = GodSystemSingleton.Instance; + gods.SetPlayerGod("bob", God); + gods.SetPlayerGod("Alice", God); + + gods.ClearPlayerGodAnyCase("Bob").Should().Be(1); + + gods.PlayerHasGod("bob").Should().BeFalse(); + gods.PlayerHasGod("Alice").Should().BeTrue(); + } + + [Fact] + public async System.Threading.Tasks.Task AWantedBountyOnTheName_IsRemoved_AtCreationAndByThePurge() + { + var onBob = Add(Wanted("Bob")); + var onNpc = Add(Wanted("Aldric")); + GameEngine.ClearLeftoversForNewCharacter("Bob").Should().Be(1); + InDatabase(onBob).Should().BeFalse("the King's bounty on the old character must not hang over the new one"); + InDatabase(onNpc).Should().BeTrue(); + + var again = Add(Wanted("bob")); + await PermadeathHelper.PurgeDeletedCharacterAsync(null, "bob_account", "Bob"); + InDatabase(again).Should().BeFalse("the delete purge removes it too"); + InDatabase(onNpc).Should().BeTrue(); + } + + [Fact] + public async System.Threading.Tasks.Task ThePurge_RemovesQueuedDeliveriesForTheKey() + { + _db = new SqlSaveBackend(_path); + _db.QueueInheritance("bob", "Aldric", "{\"name\":\"sword\"}").Should().BeTrue(); + _db.QueueInheritance("carol", "Aldric", "{\"name\":\"shield\"}").Should().BeTrue(); + _db.QueueGoldTransfer("bob", "Alice", 500).Should().BeTrue(); + _db.QueueGoldTransfer("carol", "Alice", 700).Should().BeTrue(); + (await _db.InsertWorldBossReward(new WorldBossReward { BossId = 1, BossName = "Leviathan", PlayerName = "bob", Night = 1, Gold = 100 })).Should().BeTrue(); + (await _db.InsertWorldBossReward(new WorldBossReward { BossId = 1, BossName = "Leviathan", PlayerName = "bob", Night = 2, Gold = 200 })).Should().BeTrue(); + (await _db.InsertWorldBossReward(new WorldBossReward { BossId = 1, BossName = "Leviathan", PlayerName = "carol", Night = 1, Gold = 300 })).Should().BeTrue(); + var delivered = _db.GetUndeliveredWorldBossRewards("bob").First(r => r.Night == 2); + (await _db.MarkWorldBossRewardDelivered(delivered.Id)).Should().BeTrue(); + + _db.PurgePlayerWorldState("Bob", "Bob"); + + _db.GetPendingInheritance("bob").Should().BeEmpty(); + _db.GetPendingGoldTransfers("bob").Should().BeEmpty(); + _db.GetUndeliveredWorldBossRewards("bob").Should().BeEmpty(); + _db.GetPendingInheritance("carol").Should().ContainSingle("another character's queue is untouched"); + _db.GetPendingGoldTransfers("carol").Should().ContainSingle(); + _db.GetUndeliveredWorldBossRewards("carol").Should().ContainSingle(); + + using var conn = new SqliteConnection($"Data Source={_path}"); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM world_boss_rewards WHERE player_name = 'bob' AND delivered = 1;"; + Convert.ToInt64(cmd.ExecuteScalar()).Should().Be(1, "a delivered reward is history and stays"); + } + + [Fact] + public void TheGodRestoreOnLoad_KeepsOnlyTheLoadingPlayersEntry() + { + var gods = new GodSystem(); + var saved = new Dictionary { ["Bob"] = God, ["Alice"] = "Valorian" }; + + SaveSystem.RestorePlayerGods(gods, saved, GameEngine.GodRestoreFilterFor(new Character { Name1 = "alice", Name2 = "Alice" })); + + gods.GetPlayerGod("Alice").Should().Be("Valorian"); + gods.PlayerHasGod("Bob").Should().BeFalse("an old entry for another name in the save is not re-injected"); + + var none = new GodSystem(); + SaveSystem.RestorePlayerGods(none, saved, GameEngine.GodRestoreFilterFor(null)); + none.PlayerHasGod("Bob").Should().BeFalse("with no name, no one's entry is restored"); + none.PlayerHasGod("Alice").Should().BeFalse(); + } + + private static string Source(params string[] parts) + { + var dir = new DirectoryInfo(Directory.GetCurrentDirectory()); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "usurper-reloaded.csproj"))) + dir = dir.Parent; + dir.Should().NotBeNull("the test must be able to find the repo root"); + return File.ReadAllText(Path.Combine(new[] { dir!.FullName, "Scripts" }.Concat(parts).ToArray())); + } + + private static string Between(string source, string from, string to) + { + int a = source.IndexOf(from, StringComparison.Ordinal); + a.Should().BeGreaterThanOrEqualTo(0, $"'{from}' must be in the source"); + int b = source.IndexOf(to, a, StringComparison.Ordinal); + b.Should().BeGreaterThan(a, $"'{to}' must follow '{from}'"); + return source.Substring(a, b - a); + } + + [Fact] + public void EveryOnlineDeletePath_RunsThePurgeBeforeTheDelete() + { + var engine = Source("Core", "GameEngine.cs"); + var n = Between(engine, "Loc.Get(\"engine.delete_main_warning\")", "await CreateNewGame(accountName);"); + n.Should().Contain("PermadeathHelper.PurgeDeletedCharacterAsync("); + n.IndexOf("PurgeDeletedCharacterAsync", StringComparison.Ordinal).Should().BeLessThan(n.IndexOf("DeleteSave(", StringComparison.Ordinal)); + + var d = Between(engine, "Loc.Get(\"engine.delete_alt_warning\"", "Loc.Get(\"engine.delete_alt_done\""); + d.Should().Contain("PermadeathHelper.PurgeDeletedCharacterAsync("); + d.IndexOf("PurgeDeletedCharacterAsync", StringComparison.Ordinal).Should().BeLessThan(d.IndexOf("DeleteSave(", StringComparison.Ordinal)); + + foreach (var (file, marker) in new[] { ("OnlineAdminConsole.cs", "DeleteGameData(target.Username)"), ("SysOpConsoleManager.cs", "DeleteGameData(target.Username)") }) + { + var src = Source("Systems", file); + int del = src.IndexOf(marker, StringComparison.Ordinal); + del.Should().BeGreaterThan(0, file); + var before = src.Substring(Math.Max(0, del - 600), 600); + before.Should().Contain("PermadeathHelper.PurgeDeletedCharacterAsync(", $"{file} purges before it deletes"); + } + + Source("Systems", "PermadeathHelper.cs").Should().Contain("await PurgeDeletedCharacterAsync(", "permadeath shares the same purge"); + } + + [Fact] + public void SinglePlayer_SlotDeleteAndLoad_GoThroughTheNameAwarePaths() + { + var engine = Source("Core", "GameEngine.cs"); + var slotDelete = Between(engine, "Loc.Get(\"engine.delete_saves_confirm\"", "Loc.Get(\"engine.all_saves_deleted\")"); + slotDelete.Should().Contain("SaveSystem.Instance.DeleteSaves("); + slotDelete.Should().NotContain("File.Delete(", "the raw delete skipped the god clear"); + + engine.Should().NotContain("IsOnlineMode && currentPlayer != null)\n ? currentPlayer.Name2 : null"); + System.Text.RegularExpressions.Regex.Matches(engine, @"RestoreStorySystems\(saveData\.StorySystems, god\w*\)").Count.Should().Be(2); + System.Text.RegularExpressions.Regex.Matches(engine, @"string\? god\w* = GodRestoreFilterFor\(currentPlayer\)").Count.Should().Be(2); + } + + [Fact] + public void EveryCharacterDelete_RunsThePurgeFirst_OrSaysWhyNot() + { + // Each call that deletes a character's data must purge the world state keyed by it first, or a + // same-name character inherits it (player report: quests and deity carried over). + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Scripts"))) dir = dir.Parent; + var allowed = new System.Collections.Generic.Dictionary + { + ["SaveSystem.cs"] = "DeleteSave: the backend call itself; its online callers purge first (N, D)", + ["CastleLocation.cs"] = "rebellion coin flip: deferred for 1.1.12 (it also passes the wrong key)", + }; + var problems = new System.Collections.Generic.List(); + foreach (var file in Directory.EnumerateFiles(Path.Combine(dir!.FullName, "Scripts"), "*.cs", SearchOption.AllDirectories)) + { + string src = File.ReadAllText(file); + string name = Path.GetFileName(file); + foreach (System.Text.RegularExpressions.Match m in System.Text.RegularExpressions.Regex.Matches(src, @"\.DeleteGameData\(")) + { + if (allowed.ContainsKey(name)) continue; + int methodStart = Math.Max(src.LastIndexOf("\n private ", m.Index, StringComparison.Ordinal), Math.Max(src.LastIndexOf("\n public ", m.Index, StringComparison.Ordinal), src.LastIndexOf("\n public ", m.Index, StringComparison.Ordinal))); + string before = src.Substring(Math.Max(0, methodStart), m.Index - Math.Max(0, methodStart)); + if (!before.Contains("PurgeDeletedCharacterAsync(")) + problems.Add($"{name}:{src.Take(m.Index).Count(c => c == '\n') + 1}"); + } + } + problems.Should().BeEmpty(); + } + + [Fact] + public void CreatingANewGame_RunsTheBackstop() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Scripts"))) dir = dir.Parent; + string src = File.ReadAllText(Path.Combine(dir!.FullName, "Scripts", "Core", "GameEngine.cs")); + int start = src.IndexOf("async Task CreateNewGame(", StringComparison.Ordinal); + start.Should().BeGreaterThan(0); + int end = src.IndexOf("\n private ", start + 10, StringComparison.Ordinal); + src.Substring(start, end - start).Should().Contain("ClearLeftoversForNewCharacter("); + } +} From e7d86bfd90f2dff707e2440156f42da155b24e27 Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 20:39:31 +0000 Subject: [PATCH 17/42] Fixes from the fourth review of the 1.1.11 branch as a whole - The delete purge and the new-character clean-up pushed this process's whole quest list to the shared record; a delete in a fresh process, which has not loaded the shared quests, overwrote other players'. They now remove the character's quests from the shared record in place. - The guild cache was also forgotten by display name, which can be another account's key; only the account key is used now. - A character named after an NPC removed the Crown's bounty on that NPC. Only a bounty posted on a player (no title key) is removed, and never for a name an NPC carries. - The admin deletes purged before deleting and ignored a failed delete, leaving a living character purged; they now purge only after the delete succeeds, and say when it failed. - The delete guard ignores commented-out code (supervisor review). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Core/GameEngine.cs | 10 ++-- Scripts/Systems/OnlineAdminConsole.cs | 13 +++- Scripts/Systems/OnlineStateManager.cs | 22 +++++++ Scripts/Systems/PermadeathHelper.cs | 15 +++-- Scripts/Systems/QuestSystem.cs | 21 +++++-- Scripts/Systems/SysOpConsoleManager.cs | 12 +++- Tests/CharacterRecreationTests.cs | 83 +++++++++++++++++++++++--- 7 files changed, 149 insertions(+), 27 deletions(-) diff --git a/Scripts/Core/GameEngine.cs b/Scripts/Core/GameEngine.cs index a9f85cb1..c8b0839b 100644 --- a/Scripts/Core/GameEngine.cs +++ b/Scripts/Core/GameEngine.cs @@ -4962,11 +4962,13 @@ private async Task CreateNewGame(string playerName) // v1.1.11: backstop for every path, single-player included. Player report: a character // deleted and recreated under the same name kept the old god and quest list. Clear what the // shared systems still hold under this name before the first save captures it. - int leftoverQuests = ClearLeftoversForNewCharacter(currentPlayer.Name2); - if (leftoverQuests > 0 && UsurperRemake.BBS.DoorMode.IsOnlineMode && OnlineStateManager.IsActive) + ClearLeftoversForNewCharacter(currentPlayer.Name2); + if (UsurperRemake.BBS.DoorMode.IsOnlineMode && OnlineStateManager.IsActive) { - try { await OnlineStateManager.Instance!.SaveSharedQuestsNow(); } - catch (Exception qx) { DebugLogger.Instance.LogWarning("CREATE", $"Leftover quest push failed: {qx.Message}"); } + // the shared record too, edited in place (this process may not hold the other players' quests) + string newName = currentPlayer.Name2; + try { await OnlineStateManager.Instance!.RemoveSharedQuestsAsync(q => PermadeathHelper.QuestLeftByCharacter(q, newName)); } + catch (Exception qx) { DebugLogger.Instance.LogWarning("CREATE", $"Leftover quest removal failed: {qx.Message}"); } } // Apply SysOp's default color theme to new characters diff --git a/Scripts/Systems/OnlineAdminConsole.cs b/Scripts/Systems/OnlineAdminConsole.cs index f0e08a09..e1bfbda7 100644 --- a/Scripts/Systems/OnlineAdminConsole.cs +++ b/Scripts/Systems/OnlineAdminConsole.cs @@ -646,12 +646,19 @@ internal async Task DeletePlayer() return; } - // v1.1.11: the same purge as permadeath before the delete. display_name carries a - // married surname, so the character's Name2 is read from the save. + // v1.1.11: the same purge as permadeath, once the delete has succeeded (a failed delete must not + // leave a living character purged, review). display_name carries a married surname, so the + // character's Name2 is read from the save first. var deletedSave = await backend.ReadGameData(target.Username); + if (!backend.DeleteGameData(target.Username)) + { + terminal.SetColor("red"); + terminal.WriteLine($"Deleting '{target.DisplayName}' failed; nothing was changed."); + await ReadInput(Loc.Get("ui.press_enter")); + return; + } await PermadeathHelper.PurgeDeletedCharacterAsync(backend, target.Username, deletedSave?.Player?.Name2 ?? target.DisplayName); - backend.DeleteGameData(target.Username); terminal.SetColor("green"); terminal.WriteLine($"Player '{target.DisplayName}' has been permanently deleted."); DebugLogger.Instance.LogWarning("ADMIN", $"Player '{target.DisplayName}' deleted by {DoorMode.OnlineUsername}"); diff --git a/Scripts/Systems/OnlineStateManager.cs b/Scripts/Systems/OnlineStateManager.cs index 563b1730..077c2444 100644 --- a/Scripts/Systems/OnlineStateManager.cs +++ b/Scripts/Systems/OnlineStateManager.cs @@ -309,6 +309,28 @@ public async Task SaveSharedQuestsNow() } } + /// + /// v1.1.11: remove the matching quests from the shared record itself, leaving every other quest as + /// stored. A delete in a fresh process has not loaded the shared quests, so pushing its own list + /// (SaveSharedQuestsNow) would overwrite other players' quests (review). Returns the number removed. + /// + public async Task RemoveSharedQuestsAsync(Func matches) + { + try + { + var shared = await LoadSharedQuests(); + if (shared == null) return 0; + int removed = shared.RemoveAll(q => matches(q)); + if (removed > 0) await SaveSharedQuests(shared); + return removed; + } + catch (Exception ex) + { + DebugLogger.Instance.LogError("ONLINE", $"RemoveSharedQuestsAsync failed: {ex.Message}"); + return 0; + } + } + /// /// Save quest data to shared state. /// diff --git a/Scripts/Systems/PermadeathHelper.cs b/Scripts/Systems/PermadeathHelper.cs index 7db64d4d..7595e11e 100644 --- a/Scripts/Systems/PermadeathHelper.cs +++ b/Scripts/Systems/PermadeathHelper.cs @@ -424,18 +424,19 @@ public static async Task PurgeDeletedCharacterAsync(SqlSaveBackend? backend, str // the character. Pushed even when nothing was removed, since world_state may still hold // a stale copy that a same-name character would merge back on load. int removed = QuestSystem.RemovePlayerQuests(name) + QuestSystem.RemoveBountiesOnPlayer(name); + // the shared record is edited in place, not replaced by this process's list (review) + if (UsurperRemake.BBS.DoorMode.IsOnlineMode && OnlineStateManager.IsActive) + removed += await OnlineStateManager.Instance!.RemoveSharedQuestsAsync(q => QuestLeftByCharacter(q, name)); if (removed > 0) DebugLogger.Instance.LogInfo("DELETE", $"Removed {removed} quest(s) and bounties for deleted '{name}'."); - if (UsurperRemake.BBS.DoorMode.IsOnlineMode && OnlineStateManager.IsActive) - await OnlineStateManager.Instance!.SaveSharedQuestsNow(); } catch (Exception qex) { DebugLogger.Instance.LogWarning("DELETE", $"Quest purge failed for '{name}': {qex.Message}"); } try { - // The guild_members row went with the SQL purge; the cache is keyed by the character key. + // The guild_members row went with the SQL purge; the cache is keyed by the character key + // only (a display name can be another account's key, review) if (!string.IsNullOrWhiteSpace(username)) GuildSystem.Instance?.ForgetMember(username); - GuildSystem.Instance?.ForgetMember(name); } catch (Exception gex) { DebugLogger.Instance.LogWarning("DELETE", $"Guild cache clear failed for '{name}': {gex.Message}"); } @@ -463,6 +464,12 @@ public static async Task PurgeDeletedCharacterAsync(SqlSaveBackend? backend, str catch (Exception cex) { DebugLogger.Instance.LogWarning("DELETE", $"Child disown failed for '{name}': {cex.Message}"); } } + /// v1.1.11: a shared quest the character claimed, was offered, or a Crown bounty on it. + public static bool QuestLeftByCharacter(QuestData q, string name) => + string.Equals(q.Occupier, name, StringComparison.OrdinalIgnoreCase) || + string.Equals(q.OfferedTo, name, StringComparison.OrdinalIgnoreCase) || + (!QuestSystem.IsNPCName(name) && QuestSystem.IsBountyOnPlayer(q.Initiator, q.TitleKey, q.TargetNPCName, name)); + /// /// v1.1.11: end the marriage of any NPC whose spouse was the deleted character, clearing the /// same three flags a divorce clears on the NPC. An NPC married to another NPC (registry) is diff --git a/Scripts/Systems/QuestSystem.cs b/Scripts/Systems/QuestSystem.cs index b6f45d23..ae105643 100644 --- a/Scripts/Systems/QuestSystem.cs +++ b/Scripts/Systems/QuestSystem.cs @@ -1290,13 +1290,24 @@ bool Matches(string? field) => /// public static int RemoveBountiesOnPlayer(string? playerName) { - if (string.IsNullOrWhiteSpace(playerName)) return 0; - return questDatabase.RemoveAll(q => - q.Initiator == KING_BOUNTY_INITIATOR && - !string.IsNullOrEmpty(q.TargetNPCName) && - q.TargetNPCName.Equals(playerName, StringComparison.OrdinalIgnoreCase)); + if (string.IsNullOrWhiteSpace(playerName) || IsNPCName(playerName)) return 0; + return questDatabase.RemoveAll(q => IsBountyOnPlayer(q.Initiator, q.TitleKey, q.TargetNPCName, playerName)); } + /// + /// v1.1.11: a Crown bounty posted on a PLAYER (PostBountyOnPlayer), not one on an NPC of the same name. + /// NPC bounties always carry a TitleKey; player bounties never have (review: a character named after an + /// NPC removed a bounty on that NPC). + /// + public static bool IsBountyOnPlayer(string? initiator, string? titleKey, string? target, string playerName) => + initiator == KING_BOUNTY_INITIATOR && string.IsNullOrEmpty(titleKey) && + !string.IsNullOrEmpty(target) && target.Equals(playerName, StringComparison.OrdinalIgnoreCase); + + /// v1.1.11: whether an NPC in the world carries this name. + public static bool IsNPCName(string name) => + NPCSpawnSystem.Instance?.ActiveNPCs?.ToList().Any(n => n != null && + (string.Equals(n.Name2, name, StringComparison.OrdinalIgnoreCase) || string.Equals(n.Name, name, StringComparison.OrdinalIgnoreCase))) == true; + /// /// v0.65.0: drop any quest claimed by / offered to playerName whose Id is NOT /// in keepIds. Run AFTER MergeWorldQuests during load to close the diff --git a/Scripts/Systems/SysOpConsoleManager.cs b/Scripts/Systems/SysOpConsoleManager.cs index 947493d5..9708ce40 100644 --- a/Scripts/Systems/SysOpConsoleManager.cs +++ b/Scripts/Systems/SysOpConsoleManager.cs @@ -698,12 +698,18 @@ private async Task DeletePlayerOnline(SqlSaveBackend sqlBackend) var confirm2 = await terminal.GetInputAsync(" Type YES for final confirmation: "); if (confirm2 != "YES") { terminal.SetColor("gray"); terminal.WriteLine(" Cancelled."); await terminal.GetInputAsync(" Press Enter..."); return; } - // v1.1.11: the same purge as permadeath before the delete. display_name carries a - // married surname, so the character's Name2 is read from the save. + // v1.1.11: the same purge as permadeath, once the delete has succeeded (a failed delete must not + // leave a living character purged, review). The character's Name2 is read from the save first. var deletedSave = await sqlBackend.ReadGameData(target.Username); + if (!sqlBackend.DeleteGameData(target.Username)) + { + terminal.SetColor("red"); + terminal.WriteLine($" Deleting {target.DisplayName} failed; nothing was changed."); + await terminal.GetInputAsync(" Press Enter..."); + return; + } await PermadeathHelper.PurgeDeletedCharacterAsync(sqlBackend, target.Username, deletedSave?.Player?.Name2 ?? target.DisplayName); - sqlBackend.DeleteGameData(target.Username); terminal.SetColor("green"); terminal.WriteLine($" {target.DisplayName} has been permanently deleted."); DebugLogger.Instance.LogWarning("SYSOP", $"Deleted player '{target.DisplayName}'"); diff --git a/Tests/CharacterRecreationTests.cs b/Tests/CharacterRecreationTests.cs index 1f675420..28b11de0 100644 --- a/Tests/CharacterRecreationTests.cs +++ b/Tests/CharacterRecreationTests.cs @@ -179,13 +179,17 @@ public void EveryOnlineDeletePath_RunsThePurgeBeforeTheDelete() d.Should().Contain("PermadeathHelper.PurgeDeletedCharacterAsync("); d.IndexOf("PurgeDeletedCharacterAsync", StringComparison.Ordinal).Should().BeLessThan(d.IndexOf("DeleteSave(", StringComparison.Ordinal)); - foreach (var (file, marker) in new[] { ("OnlineAdminConsole.cs", "DeleteGameData(target.Username)"), ("SysOpConsoleManager.cs", "DeleteGameData(target.Username)") }) + foreach (var file in new[] { "OnlineAdminConsole.cs", "SysOpConsoleManager.cs" }) { - var src = Source("Systems", file); - int del = src.IndexOf(marker, StringComparison.Ordinal); - del.Should().BeGreaterThan(0, file); - var before = src.Substring(Math.Max(0, del - 600), 600); - before.Should().Contain("PermadeathHelper.PurgeDeletedCharacterAsync(", $"{file} purges before it deletes"); + // the admin deletes purge only once the delete has succeeded (review: a failed delete left a + // living character purged) + var src = CodeOnly(Source("Systems", file)); + int del = src.IndexOf("DeleteGameData(target.Username))", StringComparison.Ordinal); + del.Should().BeGreaterThan(0, $"{file} checks the delete's result"); + src.Substring(Math.Max(0, del - 40), 40).Should().Contain("if (!", file); + int purge = src.IndexOf("PermadeathHelper.PurgeDeletedCharacterAsync(", del, StringComparison.Ordinal); + purge.Should().BeGreaterThan(del, $"{file} purges after a successful delete"); + (purge - del).Should().BeLessThan(700, file); } Source("Systems", "PermadeathHelper.cs").Should().Contain("await PurgeDeletedCharacterAsync(", "permadeath shares the same purge"); @@ -219,14 +223,16 @@ public void EveryCharacterDelete_RunsThePurgeFirst_OrSaysWhyNot() var problems = new System.Collections.Generic.List(); foreach (var file in Directory.EnumerateFiles(Path.Combine(dir!.FullName, "Scripts"), "*.cs", SearchOption.AllDirectories)) { - string src = File.ReadAllText(file); + string src = CodeOnly(File.ReadAllText(file)); string name = Path.GetFileName(file); foreach (System.Text.RegularExpressions.Match m in System.Text.RegularExpressions.Regex.Matches(src, @"\.DeleteGameData\(")) { if (allowed.ContainsKey(name)) continue; int methodStart = Math.Max(src.LastIndexOf("\n private ", m.Index, StringComparison.Ordinal), Math.Max(src.LastIndexOf("\n public ", m.Index, StringComparison.Ordinal), src.LastIndexOf("\n public ", m.Index, StringComparison.Ordinal))); string before = src.Substring(Math.Max(0, methodStart), m.Index - Math.Max(0, methodStart)); - if (!before.Contains("PurgeDeletedCharacterAsync(")) + int methodEnd = src.IndexOf("\n }", m.Index, StringComparison.Ordinal); + string body = src.Substring(Math.Max(0, methodStart), (methodEnd < 0 ? src.Length : methodEnd) - Math.Max(0, methodStart)); + if (!body.Contains("PurgeDeletedCharacterAsync(")) problems.Add($"{name}:{src.Take(m.Index).Count(c => c == '\n') + 1}"); } } @@ -244,4 +250,65 @@ public void CreatingANewGame_RunsTheBackstop() int end = src.IndexOf("\n private ", start + 10, StringComparison.Ordinal); src.Substring(start, end - start).Should().Contain("ClearLeftoversForNewCharacter("); } + + /// The source with // comments removed (line count kept), so a commented-out call does not count. + private static string CodeOnly(string src) => + string.Join("\n", src.Split('\n').Select(line => + { + int c = line.IndexOf("//", StringComparison.Ordinal); + return c >= 0 ? line.Substring(0, c) : line; + })); + + [Fact] + public void TheGuard_DoesNotCountACommentedOutPurge() + { + // Supervisor: a purge commented out during debugging kept the guard green. + string src = " public void Delete()\n {\n // await PermadeathHelper.PurgeDeletedCharacterAsync(b, u, n);\n backend.DeleteGameData(u);\n }\n"; + CodeOnly(src).Should().NotContain("PurgeDeletedCharacterAsync("); + CodeOnly(" await PermadeathHelper.PurgeDeletedCharacterAsync(b, u, n); // purge first").Should().Contain("PurgeDeletedCharacterAsync("); + } + + [Fact] + public void ACharacterNamedAfterAnNPC_LeavesTheBountyOnThatNPC() + { + // Review: NPC bounties are the Crown's too; a new character named after the NPC removed it. + var npc = new NPC { ID = "npc_bounty_namesake", Name1 = "Vexwell", Name2 = "Vexwell", Level = 20 }; + var npcBounty = new Quest { Title = "WANTED: Vexwell", TitleKey = "quest.bounty.wanted", Initiator = "The Crown", TargetNPCName = "Vexwell", Date = DateTime.Now, DaysToComplete = 30 }; + QuestSystem.AddQuestToDatabase(npcBounty); + NPCSpawnSystem.Instance.ActiveNPCs.Add(npc); + try + { + QuestSystem.RemoveBountiesOnPlayer("Vexwell").Should().Be(0); + QuestSystem.GetAllQuests(includeCompleted: true).Should().Contain(npcBounty); + } + finally { NPCSpawnSystem.Instance.ActiveNPCs.Remove(npc); npcBounty.Deleted = true; } + + // a bounty on a player carries no TitleKey and is removed + QuestSystem.IsBountyOnPlayer("The Crown", "", "Dorn", "Dorn").Should().BeTrue(); + QuestSystem.IsBountyOnPlayer("The Crown", "quest.bounty.wanted", "Dorn", "Dorn").Should().BeFalse(); + } + + [Fact] + public void TheSharedQuestRecord_IsEditedInPlace_AndOnlyForTheCharacter() + { + PermadeathHelper.QuestLeftByCharacter(new QuestData { Occupier = "Bob" }, "Bob").Should().BeTrue(); + PermadeathHelper.QuestLeftByCharacter(new QuestData { OfferedTo = "bob" }, "Bob").Should().BeTrue(); + PermadeathHelper.QuestLeftByCharacter(new QuestData { Initiator = "The Crown", TargetNPCName = "Bob" }, "Bob").Should().BeTrue(); + PermadeathHelper.QuestLeftByCharacter(new QuestData { Occupier = "Alice" }, "Bob").Should().BeFalse("another player's quest stays"); + + // review: a delete in a fresh process pushed its own (unloaded) quest list over everyone's + var purge = CodeOnly(Source("Systems", "PermadeathHelper.cs")); + purge.Should().Contain("RemoveSharedQuestsAsync(").And.NotContain("SaveSharedQuestsNow("); + var engine = CodeOnly(Source("Core", "GameEngine.cs")); + int create = engine.IndexOf("ClearLeftoversForNewCharacter(currentPlayer.Name2);", StringComparison.Ordinal); + engine.Substring(create, 600).Should().Contain("RemoveSharedQuestsAsync(").And.NotContain("SaveSharedQuestsNow("); + } + + [Fact] + public void TheGuildCache_IsForgottenByTheAccountKeyOnly() + { + // review: a display name can be another account's key + var purge = CodeOnly(Source("Systems", "PermadeathHelper.cs")); + purge.Should().Contain("ForgetMember(username)").And.NotContain("ForgetMember(name)"); + } } From e3589f6e963ca52c9b61588c299c76bfd4b7fbbc Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 20:49:38 +0000 Subject: [PATCH 18/42] Fixes from the fifth review of the 1.1.11 branch as a whole - A bounty posted on a player now carries IsPlayerBounty, saved and loaded with the quest. A marked bounty is removed with its character even when an NPC shares the name; an unmarked legacy bounty counts as a player's only when the NPC roster is complete and no NPC has that name, so a legacy NPC bounty is never taken for one. - The castle siege's fight with the king applies the Barracks to the king's hits, as the guard phases already do. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Core/Quest.cs | 2 ++ Scripts/Locations/CastleLocation.cs | 1 + Scripts/Systems/OnlineStateManager.cs | 1 + Scripts/Systems/PermadeathHelper.cs | 2 +- Scripts/Systems/QuestSystem.cs | 23 ++++++++++---- Scripts/Systems/SaveDataStructures.cs | 1 + Scripts/Systems/SaveSystem.cs | 1 + Tests/CharacterRecreationTests.cs | 46 ++++++++++++++++++++++++--- Tests/HuntRoundOneTests.cs | 6 ++++ 9 files changed, 72 insertions(+), 11 deletions(-) diff --git a/Scripts/Core/Quest.cs b/Scripts/Core/Quest.cs index 0eba17a8..18e19896 100644 --- a/Scripts/Core/Quest.cs +++ b/Scripts/Core/Quest.cs @@ -55,6 +55,8 @@ public partial class Quest public string TitleKey { get; set; } = ""; public List TitleArgs { get; set; } = new(); public string CommentKey { get; set; } = ""; + /// v1.1.11: a Crown bounty posted on a player (PostBountyOnPlayer), not on an NPC. + public bool IsPlayerBounty { get; set; } public List CommentArgs { get; set; } = new(); public string InitiatorKey { get; set; } = ""; diff --git a/Scripts/Locations/CastleLocation.cs b/Scripts/Locations/CastleLocation.cs index 7235977b..73fda2a3 100644 --- a/Scripts/Locations/CastleLocation.cs +++ b/Scripts/Locations/CastleLocation.cs @@ -8886,6 +8886,7 @@ private async Task CastleSiegeMenu() // King attacks (accounts for weapon and armor power) long kingDamage = Math.Max(1, siegeKingStr + siegeKingWeapPow - currentPlayer.Defence - currentPlayer.ArmPow); kingDamage = (long)(kingDamage * (0.8 + random.NextDouble() * 0.4)); + kingDamage = TeamHQBonus.ApplyDefense(currentPlayer, kingDamage); // v1.1.11: Team HQ Barracks on the king's hits too playerHP -= kingDamage; terminal.SetColor("red"); diff --git a/Scripts/Systems/OnlineStateManager.cs b/Scripts/Systems/OnlineStateManager.cs index 077c2444..92381e88 100644 --- a/Scripts/Systems/OnlineStateManager.cs +++ b/Scripts/Systems/OnlineStateManager.cs @@ -1690,6 +1690,7 @@ private List SerializeCurrentQuests() // bounties (king bounties + Slice 20 NPC-issued quests) couldn't // track their target across a server restart. TargetNPCName = quest.TargetNPCName ?? "", + IsPlayerBounty = quest.IsPlayerBounty, // v0.62.x Phase 4 (Mercenary board): faction-issued freelance contract fields. IsMercContract = quest.IsMercContract, IssuingFaction = quest.IssuingFaction.HasValue ? (int)quest.IssuingFaction.Value : -1, diff --git a/Scripts/Systems/PermadeathHelper.cs b/Scripts/Systems/PermadeathHelper.cs index 7595e11e..d60d2115 100644 --- a/Scripts/Systems/PermadeathHelper.cs +++ b/Scripts/Systems/PermadeathHelper.cs @@ -468,7 +468,7 @@ public static async Task PurgeDeletedCharacterAsync(SqlSaveBackend? backend, str public static bool QuestLeftByCharacter(QuestData q, string name) => string.Equals(q.Occupier, name, StringComparison.OrdinalIgnoreCase) || string.Equals(q.OfferedTo, name, StringComparison.OrdinalIgnoreCase) || - (!QuestSystem.IsNPCName(name) && QuestSystem.IsBountyOnPlayer(q.Initiator, q.TitleKey, q.TargetNPCName, name)); + QuestSystem.IsBountyOnPlayer(q.Initiator, q.TitleKey, q.TargetNPCName, q.IsPlayerBounty, name); /// /// v1.1.11: end the marriage of any NPC whose spouse was the deleted character, clearing the diff --git a/Scripts/Systems/QuestSystem.cs b/Scripts/Systems/QuestSystem.cs index ae105643..71b10af9 100644 --- a/Scripts/Systems/QuestSystem.cs +++ b/Scripts/Systems/QuestSystem.cs @@ -1122,6 +1122,7 @@ public static void RestoreFromSaveData(List savedQuests) OfferedTo = questData.OfferedTo, Forced = questData.Forced, TargetNPCName = questData.TargetNPCName ?? "", + IsPlayerBounty = questData.IsPlayerBounty, Deleted = questData.Status == QuestStatus.Completed || questData.Status == QuestStatus.Failed || questData.Status == QuestStatus.Abandoned, IsAbandoned = questData.Status == QuestStatus.Abandoned, // v0.62.x Phase 4 (Mercenary board): restore faction-issued freelance contract fields. @@ -1213,6 +1214,7 @@ public static void MergeWorldQuests(List savedQuests) OfferedTo = questData.OfferedTo, Forced = questData.Forced, TargetNPCName = questData.TargetNPCName ?? "", + IsPlayerBounty = questData.IsPlayerBounty, Deleted = questData.Status == QuestStatus.Completed || questData.Status == QuestStatus.Failed || questData.Status == QuestStatus.Abandoned, IsAbandoned = questData.Status == QuestStatus.Abandoned, // v0.62.x Phase 4 (Mercenary board): restore faction-issued freelance contract fields. @@ -1290,8 +1292,8 @@ bool Matches(string? field) => /// public static int RemoveBountiesOnPlayer(string? playerName) { - if (string.IsNullOrWhiteSpace(playerName) || IsNPCName(playerName)) return 0; - return questDatabase.RemoveAll(q => IsBountyOnPlayer(q.Initiator, q.TitleKey, q.TargetNPCName, playerName)); + if (string.IsNullOrWhiteSpace(playerName)) return 0; + return questDatabase.RemoveAll(q => IsBountyOnPlayer(q.Initiator, q.TitleKey, q.TargetNPCName, q.IsPlayerBounty, playerName)); } /// @@ -1299,9 +1301,16 @@ public static int RemoveBountiesOnPlayer(string? playerName) /// NPC bounties always carry a TitleKey; player bounties never have (review: a character named after an /// NPC removed a bounty on that NPC). /// - public static bool IsBountyOnPlayer(string? initiator, string? titleKey, string? target, string playerName) => - initiator == KING_BOUNTY_INITIATOR && string.IsNullOrEmpty(titleKey) && - !string.IsNullOrEmpty(target) && target.Equals(playerName, StringComparison.OrdinalIgnoreCase); + public static bool IsBountyOnPlayer(string? initiator, string? titleKey, string? target, bool isPlayerBounty, string playerName) + { + if (initiator != KING_BOUNTY_INITIATOR || string.IsNullOrEmpty(target) || !target.Equals(playerName, StringComparison.OrdinalIgnoreCase)) + return false; + if (isPlayerBounty) return true; // marked since v1.1.11 + // A bounty from before the mark: NPC bounties then could lack a TitleKey too, so it counts as a + // player's only when the NPC roster is complete and no NPC carries the name (review). + var spawner = NPCSpawnSystem.Instance; + return string.IsNullOrEmpty(titleKey) && spawner != null && spawner.IsRosterTrustworthy && !IsNPCName(playerName); + } /// v1.1.11: whether an NPC in the world carries this name. public static bool IsNPCName(string name) => @@ -1370,6 +1379,7 @@ public static void MergePlayerQuests(string playerName, List savedQue OfferedTo = questData.OfferedTo, Forced = questData.Forced, TargetNPCName = questData.TargetNPCName ?? "", + IsPlayerBounty = questData.IsPlayerBounty, Deleted = questData.Status == QuestStatus.Completed || questData.Status == QuestStatus.Failed || questData.Status == QuestStatus.Abandoned, IsAbandoned = questData.Status == QuestStatus.Abandoned, // v0.62.x Phase 4 (Mercenary board): restore faction-issued freelance contract fields. @@ -2218,7 +2228,8 @@ public static void PostBountyOnPlayer(string playerName, string crime, long boun DaysToComplete = 30, // Long duration for player bounties BountyGold = bountyAmount, // Actual gold amount (not limited by byte Reward) RewardType = QuestRewardType.Money, - TargetNPCName = playerName + TargetNPCName = playerName, + IsPlayerBounty = true // v1.1.11 }; bounty.Objectives.Add(new QuestObjective( diff --git a/Scripts/Systems/SaveDataStructures.cs b/Scripts/Systems/SaveDataStructures.cs index bc244d07..a6415c26 100644 --- a/Scripts/Systems/SaveDataStructures.cs +++ b/Scripts/Systems/SaveDataStructures.cs @@ -1354,6 +1354,7 @@ public class QuestData public string OfferedTo { get; set; } = ""; public bool Forced { get; set; } public string TargetNPCName { get; set; } = ""; + public bool IsPlayerBounty { get; set; } // v1.1.11 public List Objectives { get; set; } = new(); public List Monsters { get; set; } = new(); // v0.62.x Phase 4 (Mercenary board): faction-issued freelance contract fields. diff --git a/Scripts/Systems/SaveSystem.cs b/Scripts/Systems/SaveSystem.cs index b9fe997e..d7dab3ba 100644 --- a/Scripts/Systems/SaveSystem.cs +++ b/Scripts/Systems/SaveSystem.cs @@ -1617,6 +1617,7 @@ private List SerializeQuestList(List quests) OfferedTo = quest.OfferedTo, Forced = quest.Forced, TargetNPCName = quest.TargetNPCName ?? "", + IsPlayerBounty = quest.IsPlayerBounty, Objectives = new List(), Monsters = new List(), // v0.62.x Phase 4 (Mercenary board): faction-issued freelance contract fields. diff --git a/Tests/CharacterRecreationTests.cs b/Tests/CharacterRecreationTests.cs index 28b11de0..fbc4b78d 100644 --- a/Tests/CharacterRecreationTests.cs +++ b/Tests/CharacterRecreationTests.cs @@ -50,7 +50,7 @@ private Quest Add(Quest q) private static Quest Wanted(string target) => new Quest { Title = "WANTED: " + target, Initiator = "The Crown", QuestTarget = QuestTarget.DefeatNPC, - TargetNPCName = target, BountyGold = 5000, Date = DateTime.Now, DaysToComplete = 30 + TargetNPCName = target, BountyGold = 5000, Date = DateTime.Now, DaysToComplete = 30, IsPlayerBounty = true }; private static bool InDatabase(Quest q) => QuestSystem.GetAllQuests(includeCompleted: true).Contains(q); @@ -284,8 +284,8 @@ public void ACharacterNamedAfterAnNPC_LeavesTheBountyOnThatNPC() finally { NPCSpawnSystem.Instance.ActiveNPCs.Remove(npc); npcBounty.Deleted = true; } // a bounty on a player carries no TitleKey and is removed - QuestSystem.IsBountyOnPlayer("The Crown", "", "Dorn", "Dorn").Should().BeTrue(); - QuestSystem.IsBountyOnPlayer("The Crown", "quest.bounty.wanted", "Dorn", "Dorn").Should().BeFalse(); + QuestSystem.IsBountyOnPlayer("The Crown", "", "Dorn", true, "Dorn").Should().BeTrue(); + QuestSystem.IsBountyOnPlayer("The Crown", "quest.bounty.wanted", "Dorn", false, "Dorn").Should().BeFalse(); } [Fact] @@ -293,7 +293,7 @@ public void TheSharedQuestRecord_IsEditedInPlace_AndOnlyForTheCharacter() { PermadeathHelper.QuestLeftByCharacter(new QuestData { Occupier = "Bob" }, "Bob").Should().BeTrue(); PermadeathHelper.QuestLeftByCharacter(new QuestData { OfferedTo = "bob" }, "Bob").Should().BeTrue(); - PermadeathHelper.QuestLeftByCharacter(new QuestData { Initiator = "The Crown", TargetNPCName = "Bob" }, "Bob").Should().BeTrue(); + PermadeathHelper.QuestLeftByCharacter(new QuestData { Initiator = "The Crown", TargetNPCName = "Bob", IsPlayerBounty = true }, "Bob").Should().BeTrue(); PermadeathHelper.QuestLeftByCharacter(new QuestData { Occupier = "Alice" }, "Bob").Should().BeFalse("another player's quest stays"); // review: a delete in a fresh process pushed its own (unloaded) quest list over everyone's @@ -311,4 +311,42 @@ public void TheGuildCache_IsForgottenByTheAccountKeyOnly() var purge = CodeOnly(Source("Systems", "PermadeathHelper.cs")); purge.Should().Contain("ForgetMember(username)").And.NotContain("ForgetMember(name)"); } + + [Fact] + public void AMarkedPlayerBounty_IsRemoved_EvenWhenAnNPCSharesTheName() + { + // Review: the NPC-name guard also kept a real bounty on a player named like an NPC. + var npc = new NPC { ID = "npc_bounty_twin", Name1 = "Corvin", Name2 = "Corvin", Level = 20 }; + var onPlayer = Wanted("Corvin"); + QuestSystem.AddQuestToDatabase(onPlayer); + NPCSpawnSystem.Instance.ActiveNPCs.Add(npc); + try { QuestSystem.RemoveBountiesOnPlayer("Corvin").Should().Be(1); } + finally { NPCSpawnSystem.Instance.ActiveNPCs.Remove(npc); } + InDatabase(onPlayer).Should().BeFalse(); + } + + [Fact] + public void AnUnmarkedLegacyBounty_IsLeftAlone_WhileTheRosterIsIncomplete() + { + // Before the mark, NPC bounties could lack a TitleKey too; with no complete roster to rule out an + // NPC of that name, it is kept (review). The test roster holds a handful of NPCs, so it is not complete. + QuestSystem.IsBountyOnPlayer("The Crown", "", "Legacy Mark", false, "Legacy Mark").Should().BeFalse(); + + var spawner = NPCSpawnSystem.Instance; + var fillers = new System.Collections.Generic.List(); + for (int i = 0; i < 60; i++) fillers.Add(new NPC { ID = $"npc_legacy_filler_{i}", Name1 = $"Filler {i}", Name2 = $"Filler {i}", Level = 5 }); + foreach (var f in fillers) spawner.ActiveNPCs.Add(f); + try { QuestSystem.IsBountyOnPlayer("The Crown", "", "Legacy Mark", false, "Legacy Mark").Should().BeTrue("a complete roster has no NPC of that name"); } + finally { foreach (var f in fillers) spawner.ActiveNPCs.Remove(f); } + } + + [Fact] + public void ThePlayerBountyMark_SurvivesSaveAndLoad() + { + var engine = CodeOnly(Source("Systems", "OnlineStateManager.cs")) + CodeOnly(Source("Systems", "SaveSystem.cs")); + System.Text.RegularExpressions.Regex.Matches(engine, @"IsPlayerBounty = quest\.IsPlayerBounty").Count.Should().Be(2, "both writers carry it"); + System.Text.RegularExpressions.Regex.Matches(CodeOnly(Source("Systems", "QuestSystem.cs")), @"IsPlayerBounty = questData\.IsPlayerBounty").Count.Should().Be(3, "all three readers carry it"); + var json = System.Text.Json.JsonSerializer.Serialize(new QuestData { IsPlayerBounty = true }); + System.Text.Json.JsonSerializer.Deserialize(json)!.IsPlayerBounty.Should().BeTrue(); + } } diff --git a/Tests/HuntRoundOneTests.cs b/Tests/HuntRoundOneTests.cs index 98b0cf43..4206e27f 100644 --- a/Tests/HuntRoundOneTests.cs +++ b/Tests/HuntRoundOneTests.cs @@ -102,4 +102,10 @@ public void TheFightStartRefresh_EnumeratesACopyOfTheParty() { Source("Scripts/Systems/CombatEngine.cs").Should().Contain("foreach (var mate in teammates.ToList().Where(t => t is not NPC)) TeamHQBonus.RefreshLevels(mate);"); } + + [Fact] + public void TheCastleSiegeKing_HitsThroughTheBarracks() + { + Source("Scripts/Locations/CastleLocation.cs").Should().Contain("kingDamage = TeamHQBonus.ApplyDefense(currentPlayer, kingDamage);"); + } } From 9edd82c62dcab71568121bfc9e0885b8051cf637 Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 20:57:43 +0000 Subject: [PATCH 19/42] Fixes from the sixth review of the 1.1.11 branch as a whole - A new charge on a player topped up any Crown bounty of that name, an NPC's included, and left a legacy player bounty unmarked; it now tops up only a bounty on the player and marks it. - Beating an NPC could pay, and meet the objective of, a bounty posted on a player of the same name (the player could collect their own); the NPC-defeat payout and objectives now skip player bounties. - The castle siege fights outside CombatEngine and never read the team's current HQ levels; it reads them when the siege starts. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Locations/CastleLocation.cs | 4 ++++ Scripts/Systems/QuestSystem.cs | 8 ++++--- Tests/CharacterRecreationTests.cs | 36 +++++++++++++++++++++++++++++ Tests/HuntRoundOneTests.cs | 6 +++++ 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/Scripts/Locations/CastleLocation.cs b/Scripts/Locations/CastleLocation.cs index 73fda2a3..2cc62fb8 100644 --- a/Scripts/Locations/CastleLocation.cs +++ b/Scripts/Locations/CastleLocation.cs @@ -8458,6 +8458,10 @@ private async Task CastleSiegeMenu() return; } + // v1.1.11: the team's current HQ levels; the siege fights outside CombatEngine, which reads them + // at a fight's start (review: a teammate's upgrade was not seen) + TeamHQBonus.RefreshLevels(currentPlayer, backend); + // Must be on a team if (string.IsNullOrEmpty(currentPlayer.Team)) { diff --git a/Scripts/Systems/QuestSystem.cs b/Scripts/Systems/QuestSystem.cs index 71b10af9..2a869b57 100644 --- a/Scripts/Systems/QuestSystem.cs +++ b/Scripts/Systems/QuestSystem.cs @@ -1582,6 +1582,7 @@ public static void OnNPCDefeated(Character player, NPC defeatedNPC, bool killed // v1.1.11: a target beaten and left alive does not meet an assassination contract, not even // for a manual turn-in (review) if (!killed && quest.QuestTarget == QuestTarget.Assassin) continue; + if (quest.IsPlayerBounty) continue; // v1.1.11: a bounty on a player, not on this NPC // Check if this quest is a bounty targeting this specific NPC if (!string.IsNullOrEmpty(quest.TargetNPCName)) @@ -1629,6 +1630,7 @@ public static long AutoCompleteBountyForNPC(Character player, string npcName, bo !q.Deleted && !string.IsNullOrEmpty(q.TargetNPCName) && q.TargetNPCName.Equals(npcName, StringComparison.OrdinalIgnoreCase) && + !q.IsPlayerBounty && // v1.1.11: beating an NPC never pays a bounty on a player of that name (review) (includeKillContracts || q.QuestTarget != QuestTarget.Assassin) ).ToList(); foreach (var claimed in matchingBounties) claimed.Deleted = true; // claimed for this payout @@ -2200,15 +2202,15 @@ public static void PostBountyOnPlayer(string playerName, string crime, long boun if (king == null) return; // Check if player already has an active bounty + // v1.1.11: only a bounty on this PLAYER is topped up; an NPC of the same name keeps its own (review) var existingBounty = questDatabase.FirstOrDefault(q => - q.Initiator == KING_BOUNTY_INITIATOR && - q.TargetNPCName == playerName && - !q.Deleted); + !q.Deleted && IsBountyOnPlayer(q.Initiator, q.TitleKey, q.TargetNPCName, q.IsPlayerBounty, playerName)); if (existingBounty != null) { // Increase existing bounty existingBounty.BountyGold += bountyAmount; + existingBounty.IsPlayerBounty = true; // a legacy one is marked from now on existingBounty.Comment += $" {Loc.Get("quest.bounty.additional_charge", crime)}"; NewsSystem.Instance?.Newsy(true, Loc.Get("quest.bounty_increased_news", playerName, existingBounty.BountyGold)); return; diff --git a/Tests/CharacterRecreationTests.cs b/Tests/CharacterRecreationTests.cs index fbc4b78d..1a12d0dd 100644 --- a/Tests/CharacterRecreationTests.cs +++ b/Tests/CharacterRecreationTests.cs @@ -349,4 +349,40 @@ public void ThePlayerBountyMark_SurvivesSaveAndLoad() var json = System.Text.Json.JsonSerializer.Serialize(new QuestData { IsPlayerBounty = true }); System.Text.Json.JsonSerializer.Deserialize(json)!.IsPlayerBounty.Should().BeTrue(); } + + [Fact] + public void BeatingAnNPC_NeverPaysABountyOnAPlayerOfTheSameName() + { + // Review: the player could even collect the bounty on themselves by beating their NPC namesake. + var onPlayer = Wanted("Mirrow"); + QuestSystem.AddQuestToDatabase(onPlayer); + var hunter = new Character { Name1 = "hunter_m", Name2 = "Hunter M", Level = 30 }; + var namesake = new NPC { ID = "npc_mirrow", Name1 = "Mirrow", Name2 = "Mirrow", Level = 20 }; + QuestSystem.RecordNPCDefeat(hunter, namesake, killed: true).Should().Be(0); + InDatabase(onPlayer).Should().BeTrue(); + onPlayer.Deleted = true; + } + + [Fact] + public void ANewChargeOnAPlayer_TopsUpTheirBounty_NotAnNPCsOfTheSameName() + { + var npcBounty = new Quest { Title = "WANTED: Sable", TitleKey = "quest.bounty.wanted", Initiator = "The Crown", TargetNPCName = "Sable", BountyGold = 1000, Date = DateTime.Now, DaysToComplete = 30 }; + QuestSystem.AddQuestToDatabase(npcBounty); + try + { + QuestSystem.PostBountyOnPlayer("Sable", "theft", 700); + npcBounty.BountyGold.Should().Be(1000, "the NPC's bounty is not the player's"); + var mine = QuestSystem.GetAllQuests(includeCompleted: true).Where(q => q.IsPlayerBounty && q.TargetNPCName == "Sable" && !q.Deleted).ToList(); + if (CastleLocation.GetCurrentKing() != null) mine.Should().ContainSingle(); // posting needs a king + // the test world may have no king, so the selector is also pinned in the source + var src = CodeOnly(Source("Systems", "QuestSystem.cs")); + int post = src.IndexOf("public static void PostBountyOnPlayer(", StringComparison.Ordinal); + src.Substring(post, 900).Should().Contain("IsBountyOnPlayer(q.Initiator, q.TitleKey, q.TargetNPCName, q.IsPlayerBounty, playerName)"); + } + finally + { + npcBounty.Deleted = true; + foreach (var q in QuestSystem.GetAllQuests(includeCompleted: true).Where(q => q.TargetNPCName == "Sable")) q.Deleted = true; + } + } } diff --git a/Tests/HuntRoundOneTests.cs b/Tests/HuntRoundOneTests.cs index 4206e27f..2cc2833a 100644 --- a/Tests/HuntRoundOneTests.cs +++ b/Tests/HuntRoundOneTests.cs @@ -108,4 +108,10 @@ public void TheCastleSiegeKing_HitsThroughTheBarracks() { Source("Scripts/Locations/CastleLocation.cs").Should().Contain("kingDamage = TeamHQBonus.ApplyDefense(currentPlayer, kingDamage);"); } + + [Fact] + public void TheCastleSiege_ReadsTheTeamsCurrentLevels() + { + MethodBody(Source("Scripts/Locations/CastleLocation.cs"), "private async Task CastleSiegeMenu(").Should().Contain("TeamHQBonus.RefreshLevels(currentPlayer, backend);"); + } } From 2d8911297ab0a0754d5dfc51232aa7a330472371 Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 21:05:15 +0000 Subject: [PATCH 20/42] A bounty the Crown posts on an NPC is not a player bounty The Castle's crime report and the royal decree post bounties through PostBountyOnPlayer, including on NPCs; marking every one as a player bounty made a paid bounty on an NPC uncollectable (review). It takes onPlayer: the crime report passes false, the royal decree passes false for a name an NPC carries, and a top-up follows the same distinction. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Locations/CastleLocation.cs | 5 +++-- Scripts/Systems/QuestSystem.cs | 13 ++++++++----- Tests/CharacterRecreationTests.cs | 12 ++++++++++++ 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/Scripts/Locations/CastleLocation.cs b/Scripts/Locations/CastleLocation.cs index 2cc62fb8..338f86c4 100644 --- a/Scripts/Locations/CastleLocation.cs +++ b/Scripts/Locations/CastleLocation.cs @@ -3521,7 +3521,8 @@ private async Task PlaceBounty() NewsSystem.Instance.Newsy(true, $"BOUNTY: {amount:N0} gold on {name} by order of {currentKing.GetTitle()} {currentKing.Name}!"); // Wire into QuestSystem so the bounty is trackable - QuestSystem.PostBountyOnPlayer(name, "Royal decree", amount); + // v1.1.11: the king names anyone; a name an NPC carries is taken as that NPC + QuestSystem.PostBountyOnPlayer(name, "Royal decree", amount, onPlayer: !QuestSystem.IsNPCName(name)); // Broadcast and persist if (DoorMode.IsOnlineMode) @@ -7184,7 +7185,7 @@ private async Task AudienceReportCrime() NewsSystem.Instance?.Newsy(true, $"A bounty has been placed on {target.Name} by royal decree!"); // Wire into QuestSystem so the bounty is trackable - QuestSystem.PostBountyOnPlayer(target.Name, "Criminal activity", (int)Math.Min(bountyCost, int.MaxValue)); + QuestSystem.PostBountyOnPlayer(target.Name, "Criminal activity", (int)Math.Min(bountyCost, int.MaxValue), onPlayer: false); // v1.1.11: an NPC // Small chivalry boost for reporting — v0.57.12: paired movement AlignmentSystem.Instance.ChangeAlignment(currentPlayer, 5, isGood: true, "castle.report_crime"); diff --git a/Scripts/Systems/QuestSystem.cs b/Scripts/Systems/QuestSystem.cs index 2a869b57..306b99f2 100644 --- a/Scripts/Systems/QuestSystem.cs +++ b/Scripts/Systems/QuestSystem.cs @@ -2196,21 +2196,24 @@ private static Quest CreateGenericBounty(string kingName) /// /// The King can post a bounty on the player if they commit crimes /// - public static void PostBountyOnPlayer(string playerName, string crime, long bountyAmount) + /// v1.1.11: false when the target is an NPC (a reported crime); only a bounty on a + /// player is marked IsPlayerBounty, which NPC defeats never pay (review). + public static void PostBountyOnPlayer(string playerName, string crime, long bountyAmount, bool onPlayer = true) { var king = CastleLocation.GetCurrentKing(); if (king == null) return; // Check if player already has an active bounty // v1.1.11: only a bounty on this PLAYER is topped up; an NPC of the same name keeps its own (review) - var existingBounty = questDatabase.FirstOrDefault(q => - !q.Deleted && IsBountyOnPlayer(q.Initiator, q.TitleKey, q.TargetNPCName, q.IsPlayerBounty, playerName)); + var existingBounty = questDatabase.FirstOrDefault(q => !q.Deleted && (onPlayer + ? IsBountyOnPlayer(q.Initiator, q.TitleKey, q.TargetNPCName, q.IsPlayerBounty, playerName) + : q.Initiator == KING_BOUNTY_INITIATOR && !q.IsPlayerBounty && string.Equals(q.TargetNPCName, playerName, StringComparison.OrdinalIgnoreCase))); if (existingBounty != null) { // Increase existing bounty existingBounty.BountyGold += bountyAmount; - existingBounty.IsPlayerBounty = true; // a legacy one is marked from now on + if (onPlayer) existingBounty.IsPlayerBounty = true; // a legacy one is marked from now on existingBounty.Comment += $" {Loc.Get("quest.bounty.additional_charge", crime)}"; NewsSystem.Instance?.Newsy(true, Loc.Get("quest.bounty_increased_news", playerName, existingBounty.BountyGold)); return; @@ -2231,7 +2234,7 @@ public static void PostBountyOnPlayer(string playerName, string crime, long boun BountyGold = bountyAmount, // Actual gold amount (not limited by byte Reward) RewardType = QuestRewardType.Money, TargetNPCName = playerName, - IsPlayerBounty = true // v1.1.11 + IsPlayerBounty = onPlayer // v1.1.11 }; bounty.Objectives.Add(new QuestObjective( diff --git a/Tests/CharacterRecreationTests.cs b/Tests/CharacterRecreationTests.cs index 1a12d0dd..7be30103 100644 --- a/Tests/CharacterRecreationTests.cs +++ b/Tests/CharacterRecreationTests.cs @@ -385,4 +385,16 @@ public void ANewChargeOnAPlayer_TopsUpTheirBounty_NotAnNPCsOfTheSameName() foreach (var q in QuestSystem.GetAllQuests(includeCompleted: true).Where(q => q.TargetNPCName == "Sable")) q.Deleted = true; } } + + [Fact] + public void ABountyPostedOnAnNPC_IsNotAPlayerBounty_SoBeatingTheNPCPaysIt() + { + // Review: the Castle's crime report posts a paid bounty on an NPC through PostBountyOnPlayer; marking it + // as a player bounty made it uncollectable. + var src = CodeOnly(Source("Locations", "CastleLocation.cs")); + src.Should().Contain("\"Criminal activity\", (int)Math.Min(bountyCost, int.MaxValue), onPlayer: false)"); + src.Should().Contain("\"Royal decree\", amount, onPlayer: !QuestSystem.IsNPCName(name))"); + var quest = CodeOnly(Source("Systems", "QuestSystem.cs")); + quest.Should().Contain("IsPlayerBounty = onPlayer"); + } } From 7e55c5929c725e55d3d59461d45410398c5858b6 Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 21:13:07 +0000 Subject: [PATCH 21/42] A royal decree is a bounty on a player only when a player has the name The decree took "no NPC has this name" as proof the target is a player, but during another session's NPC roster rebuild a real NPC can be missing for a moment, and its bounty was then marked as a player's and could not be collected (review). It is a player bounty only when a player has that display name and no NPC does; otherwise it is an NPC bounty, which beating the target can pay. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Locations/CastleLocation.cs | 7 +++++-- Tests/CharacterRecreationTests.cs | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Scripts/Locations/CastleLocation.cs b/Scripts/Locations/CastleLocation.cs index 338f86c4..aa8d8bf8 100644 --- a/Scripts/Locations/CastleLocation.cs +++ b/Scripts/Locations/CastleLocation.cs @@ -3521,8 +3521,11 @@ private async Task PlaceBounty() NewsSystem.Instance.Newsy(true, $"BOUNTY: {amount:N0} gold on {name} by order of {currentKing.GetTitle()} {currentKing.Name}!"); // Wire into QuestSystem so the bounty is trackable - // v1.1.11: the king names anyone; a name an NPC carries is taken as that NPC - QuestSystem.PostBountyOnPlayer(name, "Royal decree", amount, onPlayer: !QuestSystem.IsNPCName(name)); + // v1.1.11: the king names anyone. It is a bounty on a player only when a player has that name + // and no NPC does; an NPC briefly missing from a roster being rebuilt must not be taken for a + // player (review), and an NPC bounty is the one beating the target can pay. + bool onPlayer = SaveSystem.Instance.IsDisplayNameTaken(name, "") && !QuestSystem.IsNPCName(name); + QuestSystem.PostBountyOnPlayer(name, "Royal decree", amount, onPlayer: onPlayer); // Broadcast and persist if (DoorMode.IsOnlineMode) diff --git a/Tests/CharacterRecreationTests.cs b/Tests/CharacterRecreationTests.cs index 7be30103..bd988a35 100644 --- a/Tests/CharacterRecreationTests.cs +++ b/Tests/CharacterRecreationTests.cs @@ -393,7 +393,8 @@ public void ABountyPostedOnAnNPC_IsNotAPlayerBounty_SoBeatingTheNPCPaysIt() // as a player bounty made it uncollectable. var src = CodeOnly(Source("Locations", "CastleLocation.cs")); src.Should().Contain("\"Criminal activity\", (int)Math.Min(bountyCost, int.MaxValue), onPlayer: false)"); - src.Should().Contain("\"Royal decree\", amount, onPlayer: !QuestSystem.IsNPCName(name))"); + src.Should().Contain("bool onPlayer = SaveSystem.Instance.IsDisplayNameTaken(name, \"\") && !QuestSystem.IsNPCName(name);"); + src.Should().Contain("\"Royal decree\", amount, onPlayer: onPlayer)"); var quest = CodeOnly(Source("Systems", "QuestSystem.cs")); quest.Should().Contain("IsPlayerBounty = onPlayer"); } From aa7915daf7cb6324d8c4065e1aa86b273968d16a Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 21:19:13 +0000 Subject: [PATCH 22/42] A royal decree needs a complete NPC roster to count as a player bounty A player may share an NPC's name; with the NPC missing from a roster being rebuilt, a decree on that name was still marked as a player's (review). It is a player bounty only when the roster is complete, a player has the name and no NPC does; otherwise an NPC bounty. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Locations/CastleLocation.cs | 3 ++- Tests/CharacterRecreationTests.cs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Scripts/Locations/CastleLocation.cs b/Scripts/Locations/CastleLocation.cs index aa8d8bf8..75acb18f 100644 --- a/Scripts/Locations/CastleLocation.cs +++ b/Scripts/Locations/CastleLocation.cs @@ -3524,7 +3524,8 @@ private async Task PlaceBounty() // v1.1.11: the king names anyone. It is a bounty on a player only when a player has that name // and no NPC does; an NPC briefly missing from a roster being rebuilt must not be taken for a // player (review), and an NPC bounty is the one beating the target can pay. - bool onPlayer = SaveSystem.Instance.IsDisplayNameTaken(name, "") && !QuestSystem.IsNPCName(name); + bool onPlayer = SaveSystem.Instance.IsDisplayNameTaken(name, "") + && NPCSpawnSystem.Instance.IsRosterTrustworthy && !QuestSystem.IsNPCName(name); QuestSystem.PostBountyOnPlayer(name, "Royal decree", amount, onPlayer: onPlayer); // Broadcast and persist diff --git a/Tests/CharacterRecreationTests.cs b/Tests/CharacterRecreationTests.cs index bd988a35..7dbd22e7 100644 --- a/Tests/CharacterRecreationTests.cs +++ b/Tests/CharacterRecreationTests.cs @@ -393,7 +393,8 @@ public void ABountyPostedOnAnNPC_IsNotAPlayerBounty_SoBeatingTheNPCPaysIt() // as a player bounty made it uncollectable. var src = CodeOnly(Source("Locations", "CastleLocation.cs")); src.Should().Contain("\"Criminal activity\", (int)Math.Min(bountyCost, int.MaxValue), onPlayer: false)"); - src.Should().Contain("bool onPlayer = SaveSystem.Instance.IsDisplayNameTaken(name, \"\") && !QuestSystem.IsNPCName(name);"); + src.Should().Contain("bool onPlayer = SaveSystem.Instance.IsDisplayNameTaken(name, \"\")"); + src.Should().Contain("&& NPCSpawnSystem.Instance.IsRosterTrustworthy && !QuestSystem.IsNPCName(name);", "a roster being rebuilt cannot rule out an NPC"); src.Should().Contain("\"Royal decree\", amount, onPlayer: onPlayer)"); var quest = CodeOnly(Source("Systems", "QuestSystem.cs")); quest.Should().Contain("IsPlayerBounty = onPlayer"); From 36f3683b05754e4017f35fd5088e0fcf7a6bbe7e Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 21:26:17 +0000 Subject: [PATCH 23/42] World boss rewards read the team's current Training level before paying A queued world-boss reward applied the Training bonus with the levels last read; a teammate's upgrade since then was missed, and the row was then marked delivered for good (review). Delivery refreshes the levels first. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Systems/WorldBossSystem.cs | 2 ++ Tests/HuntRoundOneTests.cs | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/Scripts/Systems/WorldBossSystem.cs b/Scripts/Systems/WorldBossSystem.cs index c5b57af7..3d0ee649 100644 --- a/Scripts/Systems/WorldBossSystem.cs +++ b/Scripts/Systems/WorldBossSystem.cs @@ -528,6 +528,8 @@ public async Task DeliverWorldBossRewards(Character player, SqlSaveBackend backe string key = RowKey(player); var rows = backend.GetUndeliveredWorldBossRewards(key); if (rows.Count == 0) return; + // v1.1.11: the team's current Training level; a teammate may have upgraded since it was read (review) + TeamHQBonus.RefreshLevels(player, backend); bool headerShown = false; foreach (var r in rows) { diff --git a/Tests/HuntRoundOneTests.cs b/Tests/HuntRoundOneTests.cs index 2cc2833a..575eb619 100644 --- a/Tests/HuntRoundOneTests.cs +++ b/Tests/HuntRoundOneTests.cs @@ -114,4 +114,13 @@ public void TheCastleSiege_ReadsTheTeamsCurrentLevels() { MethodBody(Source("Scripts/Locations/CastleLocation.cs"), "private async Task CastleSiegeMenu(").Should().Contain("TeamHQBonus.RefreshLevels(currentPlayer, backend);"); } + + [Fact] + public void WorldBossRewardDelivery_ReadsTheTeamsCurrentLevels_BeforeTheTraining() + { + var body = MethodBody(Source("Scripts/Systems/WorldBossSystem.cs"), "public async Task DeliverWorldBossRewards("); + int refresh = body.IndexOf("TeamHQBonus.RefreshLevels(player, backend);", StringComparison.Ordinal); + refresh.Should().BeGreaterThan(0); + body.IndexOf("TeamHQBonus.ApplyXP(", StringComparison.Ordinal).Should().BeGreaterThan(refresh); + } } From 46c1d5cf19dee02a536ab62ccaf92d00d2248e49 Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 21:31:16 +0000 Subject: [PATCH 24/42] Team HQ levels older than a minute are read again before use The levels were read at fixed points (login, a fight's start, the status screen, an upgrade, the siege, a reward delivery), so any other use, such as a potion in town, could apply levels from before a teammate's upgrade (review, for queued world-boss rewards). Online, a character's levels are now read again whenever they are more than a minute old, which closes that for every use at once. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Core/Character.cs | 2 ++ Scripts/Systems/TeamHQBonus.cs | 22 ++++++++++++++++++++-- Tests/TeamHQBonusTests.cs | 24 ++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/Scripts/Core/Character.cs b/Scripts/Core/Character.cs index 465fca2b..0d5cc6b6 100644 --- a/Scripts/Core/Character.cs +++ b/Scripts/Core/Character.cs @@ -596,6 +596,8 @@ public void AddMaterial(string materialId, int count = 1) public int HQInfirmaryLevel { get; set; } // +10% healing per level /// v1.1.11: the team the HQ levels above were read for; they count only while it is still the character's team (TeamHQBonus). public string HQLevelsTeam { get; set; } = ""; + /// v1.1.11: when the HQ levels were read (run time only); TeamHQBonus reads them again when old. + public DateTime HQLevelsReadAt { get; set; } = DateTime.MinValue; // Herb pouch inventory (v0.48.5) public int HerbHealing { get; set; } // Healing Herbs (garden lv1) diff --git a/Scripts/Systems/TeamHQBonus.cs b/Scripts/Systems/TeamHQBonus.cs index 43c20283..67a38b09 100644 --- a/Scripts/Systems/TeamHQBonus.cs +++ b/Scripts/Systems/TeamHQBonus.cs @@ -24,7 +24,24 @@ public static class TeamHQBonus public const double TrainingPerLevel = 0.05; public const double InfirmaryPerLevel = 0.10; - private static bool Current(Character c) => !string.IsNullOrEmpty(c.Team) && c.Team == c.HQLevelsTeam; + /// + /// v1.1.11: levels older than this are read again before use, so a teammate's upgrade reaches a potion + /// in town, a queued reward or any other use outside the fixed refresh points (review). + /// + public static readonly TimeSpan MaxLevelAge = TimeSpan.FromMinutes(1); + + // online only: the database holds the team's upgrades; tests and single-player have none to read + internal static Func IsOnline = () => UsurperRemake.BBS.DoorMode.IsOnlineMode; + internal static Func Backend = () => SaveSystem.Instance?.Backend as SqlSaveBackend; + internal static Func Now = () => DateTime.UtcNow; + + private static bool Current(Character c) + { + if (string.IsNullOrEmpty(c.Team)) return false; + if (c is not NPC && IsOnline() && Now() - c.HQLevelsReadAt > MaxLevelAge) + RefreshLevels(c); + return c.Team == c.HQLevelsTeam; + } public static int Armory(Character c) => Current(c) ? c.HQArmoryLevel : 0; public static int Barracks(Character c) => Current(c) ? c.HQBarracksLevel : 0; @@ -49,7 +66,8 @@ public static class TeamHQBonus /// public static void RefreshLevels(Character c, SqlSaveBackend? backend = null) { - backend ??= SaveSystem.Instance?.Backend as SqlSaveBackend; + backend ??= Backend(); + c.HQLevelsReadAt = Now(); if (c is NPC || string.IsNullOrEmpty(c.Team) || backend == null) { c.HQArmoryLevel = c.HQBarracksLevel = c.HQTrainingLevel = c.HQInfirmaryLevel = 0; diff --git a/Tests/TeamHQBonusTests.cs b/Tests/TeamHQBonusTests.cs index 204097f8..93bfaba4 100644 --- a/Tests/TeamHQBonusTests.cs +++ b/Tests/TeamHQBonusTests.cs @@ -164,4 +164,28 @@ public async System.Threading.Tasks.Task AWarriorWithOnlyTheTeamsBonus_SeesItOnT term.StreamWriterInternal!.Flush(); System.Text.Encoding.UTF8.GetString(output.ToArray()).Should().NotContain(Loc.Get("base.hq_armory", 3, 15)); } + + [Fact] + public void OnlineLevelsOlderThanAMinute_AreReadAgain_BeforeUse() + { + // Review: a potion in town or a queued reward used the levels from login; a teammate's upgrade was missed. + var saved = (TeamHQBonus.IsOnline, TeamHQBonus.Backend, TeamHQBonus.Now); + var clock = new DateTime(2026, 9, 24, 12, 0, 0, DateTimeKind.Utc); + try + { + TeamHQBonus.IsOnline = () => true; + TeamHQBonus.Backend = () => _db; + TeamHQBonus.Now = () => clock; + var c = Member("Iron Wolves"); + TeamHQBonus.RefreshLevels(c, _db); + TeamHQBonus.Armory(c).Should().Be(3); + + Exec("UPDATE team_upgrades SET level = 7 WHERE team_name = 'Iron Wolves' AND upgrade_type = 'armory';"); + clock = clock.AddSeconds(30); + TeamHQBonus.Armory(c).Should().Be(3, "read less than a minute ago"); + clock = clock.AddSeconds(31); + TeamHQBonus.Armory(c).Should().Be(7, "a teammate's upgrade is seen once the levels are a minute old"); + } + finally { (TeamHQBonus.IsOnline, TeamHQBonus.Backend, TeamHQBonus.Now) = saved; } + } } From 8a434b626d3b4bb06585e50d90f5bbfc4b46bc64 Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 22:00:39 +0000 Subject: [PATCH 25/42] The Alchemist's Potion Mastery reaches every healing potion Potion Mastery (+50%) was applied only by the combat quick-heal. The item key, the automatic potions after a fight, potions given to allies, town, Home, the dungeon menus, a group follower, a duel defender and the world boss all missed it. PotionBonus.ApplyOwnerBonuses applies the potion owner's Potion Mastery and then the team's Infirmary at every potion and every potion estimate, and the quick-heal no longer applies it itself. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Locations/BaseLocation.cs | 2 +- Scripts/Locations/DungeonLocation.cs | 20 ++++++++--------- Scripts/Locations/HomeLocation.cs | 2 +- Scripts/Systems/CombatEngine.cs | 28 +++++++++++------------- Scripts/Systems/PotionBonus.cs | 20 +++++++++++++++++ Scripts/Systems/WorldBossSystem.cs | 2 +- Tests/InfirmaryCoverageTests.cs | 32 ++++++++++++++++++++++------ 7 files changed, 71 insertions(+), 35 deletions(-) create mode 100644 Scripts/Systems/PotionBonus.cs diff --git a/Scripts/Locations/BaseLocation.cs b/Scripts/Locations/BaseLocation.cs index 2da31cd1..5791c346 100644 --- a/Scripts/Locations/BaseLocation.cs +++ b/Scripts/Locations/BaseLocation.cs @@ -3987,7 +3987,7 @@ protected async Task UseQuickPotion() // Use one potion long healAmount = 30 + currentPlayer.Level * 5 + Random.Shared.Next(10, 30); - healAmount = TeamHQBonus.ApplyPotionHeal(currentPlayer, healAmount); // v1.1.11: Infirmary, before the cap + healAmount = PotionBonus.ApplyOwnerBonuses(currentPlayer, healAmount); // v1.1.11: Infirmary, before the cap healAmount = Math.Min(healAmount, currentPlayer.MaxHP - currentPlayer.HP); currentPlayer.HP += healAmount; currentPlayer.Healing--; diff --git a/Scripts/Locations/DungeonLocation.cs b/Scripts/Locations/DungeonLocation.cs index 1138d6a9..ef3c2081 100644 --- a/Scripts/Locations/DungeonLocation.cs +++ b/Scripts/Locations/DungeonLocation.cs @@ -11686,7 +11686,7 @@ private async Task UsePotions() potions = player is Player pp ? pp.Healing : 0, maxPotions = player is Player pp2 ? pp2.MaxPotions : 0, gold = player.Gold, - healAmount = TeamHQBonus.ApplyPotionHeal(player, player.MaxHP / 4), // v1.1.11: Infirmary + healAmount = PotionBonus.ApplyOwnerBonuses(player, player.MaxHP / 4), // v1.1.11: Infirmary potionCost = 50 + (player.Level * 10), teammates = memberData, }); @@ -11743,7 +11743,7 @@ private async Task UsePotions() // Calculate heal amount (potions heal 25% of max HP) long healAmount = player.MaxHP / 4; - healAmount = TeamHQBonus.ApplyPotionHeal(player, healAmount); // v1.1.11: Infirmary + healAmount = PotionBonus.ApplyOwnerBonuses(player, healAmount); // v1.1.11: Infirmary terminal.SetColor("white"); terminal.WriteLine(Loc.Get("dungeon.options")); @@ -12443,7 +12443,7 @@ private async Task HealTeammate(Character player, List companions) // Calculate potions needed long missingHP = target.MaxHP - target.HP; int healPerPotion = 30 + player.Level * 5 + 20; - healPerPotion = (int)TeamHQBonus.ApplyPotionHeal(player, healPerPotion); // v1.1.11: the giver's Infirmary + healPerPotion = (int)PotionBonus.ApplyOwnerBonuses(player, healPerPotion); // v1.1.11: the giver's Infirmary int potionsNeeded = (int)Math.Ceiling((double)missingHP / healPerPotion); potionsNeeded = Math.Min(potionsNeeded, (int)player.Healing); @@ -12508,7 +12508,7 @@ private async Task HealTeammate(Character player, List companions) { player.Healing--; int healAmount = 30 + player.Level * 5 + dungeonRandom.Next(10, 31); - healAmount = (int)TeamHQBonus.ApplyPotionHeal(player, healAmount); // v1.1.11: the giver's Infirmary + healAmount = (int)PotionBonus.ApplyOwnerBonuses(player, healAmount); // v1.1.11: the giver's Infirmary target.HP = Math.Min(target.MaxHP, target.HP + healAmount); } long totalHeal = target.HP - oldHP; @@ -12548,7 +12548,7 @@ private async Task HealTeammate(Character player, List companions) private async Task HealEntireParty(Character player, List companions) { int healPerPotion = 30 + player.Level * 5 + 20; - healPerPotion = (int)TeamHQBonus.ApplyPotionHeal(player, healPerPotion); // v1.1.11: the giver's Infirmary + healPerPotion = (int)PotionBonus.ApplyOwnerBonuses(player, healPerPotion); // v1.1.11: the giver's Infirmary int totalPotionsUsed = 0; long totalHealing = 0; @@ -12632,7 +12632,7 @@ private async Task HealEntireParty(Character player, List companions) player.Healing--; totalPotionsUsed++; int healAmount = 30 + player.Level * 5 + dungeonRandom.Next(10, 31); - healAmount = (int)TeamHQBonus.ApplyPotionHeal(player, healAmount); // v1.1.11: Infirmary + healAmount = (int)PotionBonus.ApplyOwnerBonuses(player, healAmount); // v1.1.11: Infirmary player.HP = Math.Min(player.MaxHP, player.HP + healAmount); } long healed = player.HP - oldHP; @@ -12652,7 +12652,7 @@ private async Task HealEntireParty(Character player, List companions) player.Healing--; totalPotionsUsed++; int healAmount = 30 + player.Level * 5 + dungeonRandom.Next(10, 31); - healAmount = (int)TeamHQBonus.ApplyPotionHeal(player, healAmount); // v1.1.11: the giver's Infirmary + healAmount = (int)PotionBonus.ApplyOwnerBonuses(player, healAmount); // v1.1.11: the giver's Infirmary companion.HP = Math.Min(companion.MaxHP, companion.HP + healAmount); } long healed = companion.HP - oldHP; @@ -12708,7 +12708,7 @@ private async Task UseHealingPotion(Character player) // Use one potion player.Healing--; long healAmount = player.MaxHP / 4; - healAmount = TeamHQBonus.ApplyPotionHeal(player, healAmount); // v1.1.11: Infirmary, before the cap + healAmount = PotionBonus.ApplyOwnerBonuses(player, healAmount); // v1.1.11: Infirmary, before the cap long oldHP = player.HP; player.HP = Math.Min(player.MaxHP, player.HP + healAmount); long actualHeal = player.HP - oldHP; @@ -12748,7 +12748,7 @@ private async Task HealToFull(Character player) } long healAmount = player.MaxHP / 4; - healAmount = TeamHQBonus.ApplyPotionHeal(player, healAmount); // v1.1.11: Infirmary, so potionsNeeded matches + healAmount = PotionBonus.ApplyOwnerBonuses(player, healAmount); // v1.1.11: Infirmary, so potionsNeeded matches int potionsNeeded = (int)Math.Ceiling((double)(player.MaxHP - player.HP) / healAmount); int potionsToUse = Math.Min(potionsNeeded, (int)player.Healing); @@ -18667,7 +18667,7 @@ private static async Task UseFollowerPotion(Character player, TerminalEmulator t { long oldHP = player.HP; int healAmount = 30 + player.Level * 5 + Random.Shared.Next(10, 31); - healAmount = (int)TeamHQBonus.ApplyPotionHeal(player, healAmount); // v1.1.11: the follower's own Infirmary + healAmount = (int)PotionBonus.ApplyOwnerBonuses(player, healAmount); // v1.1.11: the follower's own Infirmary player.HP = Math.Min(player.MaxHP, player.HP + healAmount); long actualHeal = player.HP - oldHP; player.Healing--; diff --git a/Scripts/Locations/HomeLocation.cs b/Scripts/Locations/HomeLocation.cs index 09ac73b7..4bfaa3ae 100644 --- a/Scripts/Locations/HomeLocation.cs +++ b/Scripts/Locations/HomeLocation.cs @@ -1355,7 +1355,7 @@ private async Task UseHealingPotion() // Use a potion currentPlayer.Healing--; // v1.1.11: the Infirmary goes inside the 50 HP floor - long healAmount = Math.Max(50, TeamHQBonus.ApplyPotionHeal(currentPlayer, currentPlayer.MaxHP / 4)); // Heal 25% or at least 50 HP + long healAmount = Math.Max(50, PotionBonus.ApplyOwnerBonuses(currentPlayer, currentPlayer.MaxHP / 4)); // Heal 25% or at least 50 HP long oldHP = currentPlayer.HP; currentPlayer.HP = Math.Min(currentPlayer.HP + healAmount, currentPlayer.MaxHP); long actualHeal = currentPlayer.HP - oldHP; diff --git a/Scripts/Systems/CombatEngine.cs b/Scripts/Systems/CombatEngine.cs index 2ea02834..1b1b5b2a 100644 --- a/Scripts/Systems/CombatEngine.cs +++ b/Scripts/Systems/CombatEngine.cs @@ -4166,11 +4166,9 @@ private async Task ExecuteHeal(Character player, CombatResult result, bool quick // Quick heal uses one potion player.Healing--; long healAmount = 30 + player.Level * 5 + random.Next(10, 30); - if (player.Class == CharacterClass.Alchemist) - healAmount = (long)(healAmount * (1.0 + GameConfig.AlchemistPotionMasteryBonus)); healAmount = DifficultySystem.ApplyHealingMultiplier(healAmount); - // v1.1.11: Team HQ Infirmary, the last modifier before the cap - healAmount = TeamHQBonus.ApplyPotionHeal(player, healAmount); + // v1.1.11: the owner's Potion Mastery and Team HQ Infirmary, the last modifiers before the cap + healAmount = PotionBonus.ApplyOwnerBonuses(player, healAmount); healAmount = Math.Min(healAmount, player.MaxHP - player.HP); player.HP += healAmount; player.Statistics?.RecordPotionUsed(healAmount); @@ -4187,7 +4185,7 @@ private async Task ExecuteHeal(Character player, CombatResult result, bool quick // Regular heal - ask how many potions to use for full control long missingHP = player.MaxHP - player.HP; long avgHealPerPotion = 50 + player.Level * 5; // Average heal: 30 + level*5 + avg(10-30) - avgHealPerPotion = TeamHQBonus.ApplyPotionHeal(player, avgHealPerPotion); // v1.1.11: Infirmary + avgHealPerPotion = PotionBonus.ApplyOwnerBonuses(player, avgHealPerPotion); // v1.1.11: Infirmary int potionsToFullHeal = (int)Math.Ceiling((double)missingHP / avgHealPerPotion); potionsToFullHeal = Math.Min(potionsToFullHeal, (int)player.Healing); @@ -4211,11 +4209,9 @@ private async Task ExecuteHeal(Character player, CombatResult result, bool quick { player.Healing--; long healAmount = 30 + player.Level * 5 + random.Next(10, 30); - if (player.Class == CharacterClass.Alchemist) - healAmount = (long)(healAmount * (1.0 + GameConfig.AlchemistPotionMasteryBonus)); healAmount = DifficultySystem.ApplyHealingMultiplier(healAmount); - // v1.1.11: Team HQ Infirmary, the last modifier before the cap - healAmount = TeamHQBonus.ApplyPotionHeal(player, healAmount); + // v1.1.11: the owner's Potion Mastery and Team HQ Infirmary, the last modifiers before the cap + healAmount = PotionBonus.ApplyOwnerBonuses(player, healAmount); healAmount = Math.Min(healAmount, player.MaxHP - player.HP); player.HP += healAmount; totalHeal += healAmount; @@ -8896,7 +8892,7 @@ private void AutoHealWithPotions(Character player) // Don't waste a potion if the deficit is trivial — only auto-heal // when missing at least half a potion's average healing value long avgPotionHeal = 30 + player.Level * 5 + 20; // midpoint of random(10,30) - avgPotionHeal = TeamHQBonus.ApplyPotionHeal(player, avgPotionHeal); // v1.1.11: Infirmary + avgPotionHeal = PotionBonus.ApplyOwnerBonuses(player, avgPotionHeal); // v1.1.11: Infirmary long hpDeficit = player.MaxHP - player.HP; if (hpDeficit < avgPotionHeal / 2) return; @@ -8911,7 +8907,7 @@ private void AutoHealWithPotions(Character player) break; long healAmount = 30 + player.Level * 5 + random.Next(10, 30); - healAmount = TeamHQBonus.ApplyPotionHeal(player, healAmount); // v1.1.11: Infirmary, before the cap + healAmount = PotionBonus.ApplyOwnerBonuses(player, healAmount); // v1.1.11: Infirmary, before the cap healAmount = Math.Min(healAmount, player.MaxHP - player.HP); player.HP += healAmount; totalHealed += healAmount; @@ -17904,7 +17900,7 @@ private void HandleSpecialSpellEffectOnMonster(Monster target, string effect, in // Calculate how much HP is missing long missingHP = targetAlly.MaxHP - targetAlly.HP; int healPerPotion = 30 + player.Level * 5 + 20; // Average heal per potion - healPerPotion = (int)TeamHQBonus.ApplyPotionHeal(player, healPerPotion); // v1.1.11: the giver's Infirmary + healPerPotion = (int)PotionBonus.ApplyOwnerBonuses(player, healPerPotion); // v1.1.11: the giver's Infirmary // Ask if player wants to fully heal or use 1 potion int potionsNeeded = (int)Math.Ceiling((double)missingHP / healPerPotion); @@ -17954,7 +17950,7 @@ private void HandleSpecialSpellEffectOnMonster(Monster target, string effect, in { player.Healing--; int healAmount = 30 + player.Level * 5 + random.Next(10, 30); - healAmount = (int)TeamHQBonus.ApplyPotionHeal(player, healAmount); // v1.1.11: the giver's Infirmary + healAmount = (int)PotionBonus.ApplyOwnerBonuses(player, healAmount); // v1.1.11: the giver's Infirmary targetAlly.HP = Math.Min(targetAlly.MaxHP, targetAlly.HP + healAmount); } @@ -18660,7 +18656,7 @@ private async Task TeammateHealWithPotion(Character teammate, Character ta // Potion heals a fixed amount plus some randomness (same formula as player potions) int healAmount = 30 + teammate.Level * 5 + random.Next(10, 30); - healAmount = (int)TeamHQBonus.ApplyPotionHeal(potionOwner, healAmount); // v1.1.11: the potion owner's Infirmary + healAmount = (int)PotionBonus.ApplyOwnerBonuses(potionOwner, healAmount); // v1.1.11: the potion owner's Infirmary long oldHP = target.HP; target.HP = Math.Min(target.MaxHP, target.HP + healAmount); long actualHeal = target.HP - oldHP; @@ -22488,7 +22484,7 @@ private async Task ExecuteUseItem(Character player, CombatResult result) long hpNeeded = player.MaxHP - player.HP; int healPerPotion = 30 + player.Level * 5 + random.Next(10, 30); - healPerPotion = (int)TeamHQBonus.ApplyPotionHeal(player, healPerPotion); // v1.1.11: Infirmary, so potionsNeeded shrinks too + healPerPotion = (int)PotionBonus.ApplyOwnerBonuses(player, healPerPotion); // v1.1.11: Infirmary, so potionsNeeded shrinks too int potionsNeeded = (int)Math.Ceiling((double)hpNeeded / healPerPotion); potionsNeeded = Math.Min(potionsNeeded, (int)player.Healing); long actualHealing = Math.Min((long)potionsNeeded * healPerPotion, hpNeeded); @@ -25861,7 +25857,7 @@ private async Task ProcessComputerPlayerAction(Character computer, Character opp { computer.Healing--; long heal = 30 + computer.Level * 5 + random.Next(10, 30); - heal = TeamHQBonus.ApplyPotionHeal(computer, heal); // v1.1.11: the defender's own Infirmary + heal = PotionBonus.ApplyOwnerBonuses(computer, heal); // v1.1.11: the defender's own Infirmary heal = Math.Min(heal, computer.MaxHP - computer.HP); computer.HP += heal; terminal.WriteLine(Loc.Get("combat.pvp_ai_heals", computer.DisplayName, heal), "green"); diff --git a/Scripts/Systems/PotionBonus.cs b/Scripts/Systems/PotionBonus.cs new file mode 100644 index 00000000..1c4084bb --- /dev/null +++ b/Scripts/Systems/PotionBonus.cs @@ -0,0 +1,20 @@ +using System; + +namespace UsurperRemake.Systems +{ + /// + /// v1.1.11: the bonuses of a healing potion's owner (the character whose potion it is), applied after every + /// other modifier of the heal and before the cap to missing HP: the Alchemist's Potion Mastery, then the + /// team's Infirmary. Potion Mastery was applied only by the combat quick-heal, so every other potion (the + /// item key, auto-heal, potions given to allies, town, Home, the dungeon, the world boss) missed it. + /// + public static class PotionBonus + { + public static long ApplyOwnerBonuses(Character owner, long heal) + { + if (owner.Class == CharacterClass.Alchemist) + heal = (long)Math.Round(heal * (1.0 + GameConfig.AlchemistPotionMasteryBonus)); + return TeamHQBonus.ApplyPotionHeal(owner, heal); + } + } +} diff --git a/Scripts/Systems/WorldBossSystem.cs b/Scripts/Systems/WorldBossSystem.cs index 3d0ee649..1d5b2926 100644 --- a/Scripts/Systems/WorldBossSystem.cs +++ b/Scripts/Systems/WorldBossSystem.cs @@ -1754,7 +1754,7 @@ private async Task DrinkHealingPotions(Character player, TerminalEmulator termin qty = Math.Min(qty, available); long perPotion = (long)(player.MaxHP * 0.3); - perPotion = TeamHQBonus.ApplyPotionHeal(player, perPotion); // v1.1.11: Infirmary, before the cap + perPotion = PotionBonus.ApplyOwnerBonuses(player, perPotion); // v1.1.11: Infirmary, before the cap long totalHealed = 0; int drank = 0; for (int i = 0; i < qty && player.Healing > 0 && player.HP < player.MaxHP; i++) diff --git a/Tests/InfirmaryCoverageTests.cs b/Tests/InfirmaryCoverageTests.cs index 5bac43f2..93c8684b 100644 --- a/Tests/InfirmaryCoverageTests.cs +++ b/Tests/InfirmaryCoverageTests.cs @@ -92,13 +92,13 @@ public void EveryHealingPotionDecrement_AppliesTheInfirmaryOrSaysWhyNot() foreach (var (file, line, method, text, body) in hits) { if (ExcludedMethods.ContainsKey($"{file}.{method}")) continue; - if (body.Contains("TeamHQBonus.ApplyPotionHeal")) continue; + if (body.Contains("PotionBonus.ApplyOwnerBonuses")) continue; int comment = text.IndexOf("//", StringComparison.Ordinal); bool markedOut = comment >= 0 && Regex.IsMatch(text.Substring(comment), @"hq-infirmary: out \(\S[^)]*\)"); if (!markedOut) problems.Add($"{file}.cs:{line} in {method}: {text}"); } - problems.Should().BeEmpty("each method that uses up a healing potion applies TeamHQBonus.ApplyPotionHeal or is marked out with a reason"); + problems.Should().BeEmpty("each method that uses up a healing potion applies PotionBonus.ApplyOwnerBonuses (Potion Mastery then the Infirmary) or is marked out with a reason"); } [Fact] @@ -173,7 +173,7 @@ private static string MethodBody(string file, string method) [InlineData("Scripts/Systems/WorldBossSystem.cs", "DrinkHealingPotions", 1)] public void EachSite_AppliesTheInfirmary(string file, string method, int calls) { - Count(MethodBody(file, method), "TeamHQBonus.ApplyPotionHeal(").Should().Be(calls); + Count(MethodBody(file, method), "PotionBonus.ApplyOwnerBonuses(").Should().Be(calls); } [Fact] @@ -184,7 +184,7 @@ public void TheCombatPotion_AppliesTheInfirmary_AfterTheDifficultyMultiplier() for (int n = 0; n < 2; n++) { int difficulty = body.IndexOf("DifficultySystem.ApplyHealingMultiplier(healAmount)", from, StringComparison.Ordinal); - int bonus = body.IndexOf("TeamHQBonus.ApplyPotionHeal(player, healAmount)", from, StringComparison.Ordinal); + int bonus = body.IndexOf("PotionBonus.ApplyOwnerBonuses(player, healAmount)", from, StringComparison.Ordinal); int cap = body.IndexOf("Math.Min(healAmount, player.MaxHP - player.HP)", from, StringComparison.Ordinal); difficulty.Should().BeGreaterThan(0); bonus.Should().BeGreaterThan(difficulty, "the Infirmary is the last modifier"); @@ -198,14 +198,14 @@ public void TheTeammatePotion_UsesTheOwnersInfirmary() { string body = MethodBody("Scripts/Systems/CombatEngine.cs", "TeammateHealWithPotion"); body.Should().Contain("potionOwner = owner;", "a potion from the player's belt carries the belt owner's bonus"); - body.Should().Contain("TeamHQBonus.ApplyPotionHeal(potionOwner, healAmount)"); + body.Should().Contain("PotionBonus.ApplyOwnerBonuses(potionOwner, healAmount)"); } [Fact] public void TheHomePotion_PutsTheInfirmaryInsideTheFloor() { MethodBody("Scripts/Locations/HomeLocation.cs", "UseHealingPotion") - .Should().Contain("Math.Max(50, TeamHQBonus.ApplyPotionHeal(currentPlayer, currentPlayer.MaxHP / 4))"); + .Should().Contain("Math.Max(50, PotionBonus.ApplyOwnerBonuses(currentPlayer, currentPlayer.MaxHP / 4))"); } // Behaviour. @@ -269,4 +269,24 @@ public async Task ADungeonPotion_Heals20PercentMore_WithInfirmary2() plain.Should().Be(25_000, "a quarter of MaxHP"); boosted.Should().Be(30_000); } + + [Fact] + public async Task AnAlchemistsDungeonPotion_HealsHalfAgainAsMuch() + { + // Potion Mastery was applied only by the combat quick-heal; every other potion missed it. + var alchemist = Drinker(0); + alchemist.Class = CharacterClass.Alchemist; + (await DungeonPotion(alchemist)).Should().Be(37_500, "a quarter of MaxHP, plus 50%"); + + var both = Drinker(2); + both.Class = CharacterClass.Alchemist; + (await DungeonPotion(both)).Should().Be(45_000, "Potion Mastery, then the Infirmary"); + } + + [Fact] + public void TheQuickHeal_NoLongerAppliesPotionMasteryItself() + { + // it goes through PotionBonus with every other potion; applying it inline as well would count it twice + Count(MethodBody("Scripts/Systems/CombatEngine.cs", "ExecuteHeal"), "AlchemistPotionMasteryBonus").Should().Be(0); + } } From 76e8f77d10483ed32c999069ab27a1976f130048 Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 22:08:26 +0000 Subject: [PATCH 26/42] A Crown bounty on a player is paid to whoever beats them in a duel A bounty the Crown posted on a player could never be collected. A duel won against that player (the arena, a sleeping player at the Inn or Dormitory, the Dark Alley, the throne) now pays it to the winner, lethal or not. It is claimed under the payout lock before it is paid, so it is paid once; the shared quest record is edited in place; and a player never collects the bounty on themselves. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Systems/CombatEngine.cs | 23 +++++++++++---- Scripts/Systems/QuestSystem.cs | 37 ++++++++++++++++++++++++ Tests/NPCDefeatQuestTests.cs | 50 +++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 5 deletions(-) diff --git a/Scripts/Systems/CombatEngine.cs b/Scripts/Systems/CombatEngine.cs index 1b1b5b2a..e86c8f66 100644 --- a/Scripts/Systems/CombatEngine.cs +++ b/Scripts/Systems/CombatEngine.cs @@ -26143,10 +26143,23 @@ private async Task OfferNPCSurrenderAsync(Character attacker, Character de /// v1.1.11: a duel won against an NPC (killed or spared) pays its bounty and meets a Defeat objective, /// as a street fight always did. A player loaded from a save is not an NPC and is left alone. /// - private void ReportNPCDefeat(CombatResult result) + private async Task ReportDuelDefeat(CombatResult result) { - if (result.Opponent is not NPC npc || result.Player == null) return; - long bounty = QuestSystem.RecordNPCDefeat(result.Player, npc, killed: _pvpLethal && !npc.IsAlive); + if (result.Player == null || result.Opponent == null) return; + long bounty; + if (result.Opponent is NPC npc) + bounty = QuestSystem.RecordNPCDefeat(result.Player, npc, killed: _pvpLethal && !npc.IsAlive); + else + { + // v1.1.11: a Crown bounty on a player is paid to the duel's winner, lethal or not (a duel won) + var paid = QuestSystem.CollectBountiesOnPlayer(result.Player, result.Opponent); + bounty = paid.Sum(QuestSystem.BountyReward); + if (paid.Count > 0 && UsurperRemake.BBS.DoorMode.IsOnlineMode && OnlineStateManager.IsActive) + { + var ids = paid.Select(q => q.Id).ToHashSet(); + await OnlineStateManager.Instance!.RemoveSharedQuestsAsync(q => ids.Contains(q.Id)); // edited in place + } + } if (bounty > 0) { terminal.WriteLine(Loc.Get("street.fight.bounty_collected", bounty.ToString("N0")), "bright_yellow"); @@ -26210,7 +26223,7 @@ private async Task DeterminePvPOutcome(CombatResult result) // No XP/gold reward -- sparing isn't a kill. The alignment + // relationship swing is the reward. // v1.1.11: but the NPC was beaten, so a bounty or Defeat objective on it is met - ReportNPCDefeat(result); + await ReportDuelDefeat(result); return; } @@ -26296,7 +26309,7 @@ private async Task DeterminePvPOutcome(CombatResult result) result.Player.Gold += goldReward; result.ExperienceGained = xpReward; result.GoldGained = goldReward; - ReportNPCDefeat(result); // v1.1.11 + await ReportDuelDefeat(result); // v1.1.11 // Track peak gold result.Player.Statistics?.RecordGoldChange(result.Player.Gold); diff --git a/Scripts/Systems/QuestSystem.cs b/Scripts/Systems/QuestSystem.cs index 306b99f2..60e707b3 100644 --- a/Scripts/Systems/QuestSystem.cs +++ b/Scripts/Systems/QuestSystem.cs @@ -1549,6 +1549,43 @@ public static void OnDungeonFloorCleared(Character player, int floorNumber) } } + /// + /// v1.1.11: a Crown bounty posted on a player is paid to whoever beats that player in a duel (the arena, + /// a sleeping player at the Inn or Dormitory, the Dark Alley, the throne). Nothing paid it before. The + /// bounty is claimed under the payout lock before it is paid, so it is paid once; a player never + /// collects the bounty on themselves. Returns the bounties paid, for the caller to show and persist. + /// + public static List CollectBountiesOnPlayer(Character winner, Character loser) + { + var names = new[] { loser.Name2, loser.DisplayName }.Where(n => !string.IsNullOrWhiteSpace(n)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + if (winner == null || names.Count == 0) return new List(); + if (names.Any(n => n.Equals(winner.Name2, StringComparison.OrdinalIgnoreCase) || n.Equals(winner.DisplayName, StringComparison.OrdinalIgnoreCase))) + return new List(); + + List claimed; + lock (_bountyPayoutLock) + { + claimed = questDatabase.Where(q => !q.Deleted && q.IsPlayerBounty && q.Initiator == KING_BOUNTY_INITIATOR && + !string.IsNullOrEmpty(q.TargetNPCName) && names.Any(n => q.TargetNPCName.Equals(n, StringComparison.OrdinalIgnoreCase))).ToList(); + foreach (var q in claimed) { q.Deleted = true; q.Occupier = winner.Name2; q.OccupiedDays = 1; } + } + + foreach (var bounty in claimed) + { + long reward = bounty.BountyGold > 0 ? bounty.BountyGold : bounty.Reward * 100L; + if (reward <= 0) reward = 500; + winner.Gold += reward; + long xpReward = TeamHQBonus.ApplyXP(winner, Math.Max(winner.Level * 50, reward / 5)); + winner.Experience += xpReward; + DebugLogger.Instance.LogInfo("GOLD", $"PLAYER BOUNTY: {winner.DisplayName} +{reward:N0}g for the bounty on {bounty.TargetNPCName}"); + StatisticsManager.Current?.RecordBountyComplete(); + NewsSystem.Instance?.Newsy(true, Loc.Get("quest.bounty_collected_news", winner.Name2, bounty.TargetNPCName, reward)); + } + return claimed; + } + + public static long BountyReward(Quest q) => q.BountyGold > 0 ? q.BountyGold : Math.Max(500, q.Reward * 100L); + /// /// v1.1.11: a player beat an NPC, by any route: a street fight, a duel, the pit, the Inn challenge, /// or sparing one who surrendered. Pays any bounty on the NPC and records the Defeat objective. diff --git a/Tests/NPCDefeatQuestTests.cs b/Tests/NPCDefeatQuestTests.cs index 974bcfdb..a21bc276 100644 --- a/Tests/NPCDefeatQuestTests.cs +++ b/Tests/NPCDefeatQuestTests.cs @@ -125,4 +125,54 @@ public async Task ANonLethalDuel_WonAgainstAnAssassinationTarget_DoesNotMeetTheC bounty.Deleted.Should().BeFalse(); bounty.Deleted = true; } + + private static Quest BountyOnPlayer(string name, long gold) + { + var q = new Quest + { + Title = "WANTED: " + name, Initiator = "The Crown", QuestTarget = QuestTarget.DefeatNPC, TargetNPCName = name, + BountyGold = gold, IsPlayerBounty = true, Date = DateTime.Now, DaysToComplete = 30 + }; + QuestSystem.AddQuestToDatabase(q); + return q; + } + + [Fact] + public async Task BeatingAPlayerWithABountyInADuel_PaysTheWinnerOnce() + { + // Jason's decision: a Crown bounty on a player is paid to whoever beats them in a duel. The + // Inn and Dormitory attacks on a sleeping player end in this same duel outcome. + var bounty = BountyOnPlayer("Wanted Rogue", 4000); + var winner = new Character { Name1 = "sheriff", Name2 = "Sheriff", Level = 30, Gold = 0, HP = 500, MaxHP = 500 }; + var rogue = new Character { Name1 = "wanted_rogue", Name2 = "Wanted Rogue", Level = 30, HP = 0, MaxHP = 400 }; + await Outcome(new CombatResult { Player = winner, Opponent = rogue }); + winner.Gold.Should().BeGreaterThanOrEqualTo(4000); + bounty.Deleted.Should().BeTrue(); + + long after = winner.Gold; + rogue.HP = 0; + await Outcome(new CombatResult { Player = winner, Opponent = rogue }); + (winner.Gold - after).Should().BeLessThan(4000, "the bounty is paid once"); + } + + [Fact] + public void APlayer_CannotCollectTheBountyOnThemselves() + { + var bounty = BountyOnPlayer("Self Collector", 3000); + var self = new Character { Name1 = "self_collector", Name2 = "Self Collector", Level = 30 }; + QuestSystem.CollectBountiesOnPlayer(self, self).Should().BeEmpty(); + bounty.Deleted.Should().BeFalse(); + bounty.Deleted = true; + } + + [Fact] + public async Task ADuelWonAgainstAnNPC_StillDoesNotPayABountyOnAPlayerOfThatName() + { + var bounty = BountyOnPlayer("Twin Name", 3000); + var winner = new Character { Name1 = "hunter_tn", Name2 = "Hunter TN", Level = 30, Gold = 0, HP = 500, MaxHP = 500 }; + var npcTwin = new NPC { ID = "npc_twin_name", Name1 = "Twin Name", Name2 = "Twin Name", Level = 30, HP = 0, MaxHP = 400 }; + await Outcome(new CombatResult { Player = winner, Opponent = npcTwin }); + bounty.Deleted.Should().BeFalse(); + bounty.Deleted = true; + } } From 5ce16cd00fe166244c3beb5ecfa3d41c4dd6b266 Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 22:31:06 +0000 Subject: [PATCH 27/42] Team and guild leadership passes on when the leader leaves A team's leader record (where NPC members' bequests go) and a guild's leader were never reassigned, so a leader who left, was deleted or died for good kept the title; five live teams list a founder who is no longer on them. Leadership now passes to the highest-level remaining player member (then the earliest to join a guild, then the username), skipping banned and emergency accounts: when the leader quits the team or leaves the guild, when the character is deleted or dies permanently (the shared purge), and in a world-save pass after the empty-team cleanup. That pass only takes teams whose leader is a known character no longer on the team, never one joined in the last ten minutes, and never an unmatched old key, which the admin's Fix Team Leaders screen handles. Every write is guarded on the old leader. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Locations/TeamCornerLocation.cs | 5 + Scripts/Systems/GuildSystem.cs | 123 +++++++++-- Scripts/Systems/PermadeathHelper.cs | 11 + Scripts/Systems/SqlSaveBackend.cs | 124 +++++++++++ Scripts/Systems/WorldSimService.cs | 23 +++ Tests/LeaderSuccessionTests.cs | 260 ++++++++++++++++++++++++ 6 files changed, 528 insertions(+), 18 deletions(-) create mode 100644 Tests/LeaderSuccessionTests.cs diff --git a/Scripts/Locations/TeamCornerLocation.cs b/Scripts/Locations/TeamCornerLocation.cs index bac99ea2..a5850e6a 100644 --- a/Scripts/Locations/TeamCornerLocation.cs +++ b/Scripts/Locations/TeamCornerLocation.cs @@ -1188,6 +1188,11 @@ private async Task QuitTeam() { await backend.UpdatePlayerTeamMemberCount(oldTeam); + // v1.1.11: a leader who quits passes the team to the highest-level player left in it + string myKey = GameEngine.InheritanceKey(currentPlayer); + if (string.Equals(await backend.GetTeamLeaderUsername(oldTeam), myKey, StringComparison.Ordinal)) + backend.TryPassTeamLeadership(oldTeam, myKey, myKey, requireOldLeaderGone: true, out _); + // If team is now empty (no players AND no NPCs), delete it var remainingPlayers = await backend.GetPlayerTeamMembers(oldTeam); var remainingNPCs = NPCSpawnSystem.Instance.ActiveNPCs diff --git a/Scripts/Systems/GuildSystem.cs b/Scripts/Systems/GuildSystem.cs index 0ae26d68..c4bb62b0 100644 --- a/Scripts/Systems/GuildSystem.cs +++ b/Scripts/Systems/GuildSystem.cs @@ -65,12 +65,15 @@ public class GuildSystem public static bool RankCanKick(string rank) => rank == "Leader"; public static bool RankCanPromote(string rank) => rank == "Leader"; - public GuildSystem(string databasePath) + public GuildSystem(string databasePath) : this(databasePath, register: true) { } + + /// v1.1.11: register false leaves Instance alone (tests on a temporary database). + internal GuildSystem(string databasePath, bool register) { connectionString = $"Data Source={databasePath}"; InitializeTables(); LoadMembershipCache(); - Instance = this; + if (register) Instance = this; } private void InitializeTables() @@ -169,6 +172,95 @@ public void ForgetMember(string? username) membershipCache.TryRemove(username, out _); } + /// + /// v1.1.11: passes a guild from oldLeader to the highest-level remaining member who has a character + /// and is neither banned nor an emergency account; ties go to the earliest joined_at, then the + /// username in ordinal order (SqlSaveBackend.PickSuccessor). Only if the guild is still oldLeader's. + /// A guild with no candidate is left as it is. Returns the new leader, or null. + /// + public string? PassLeadership(string guildName, string oldLeader) + { + try + { + using var conn = new SqliteConnection(connectionString); + conn.Open(); + var candidates = new List<(string, int, string?)>(); + using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = @" + SELECT gm.username, + CAST(CASE WHEN json_valid(p.player_data) THEN json_extract(p.player_data, '$.player.level') END AS INTEGER), + gm.joined_at + FROM guild_members gm + JOIN players p ON p.username = gm.username COLLATE NOCASE + WHERE gm.guild_name = @guild COLLATE NOCASE + AND p.is_banned = 0 AND p.username NOT LIKE 'emergency_%' + AND LOWER(gm.username) != LOWER(@old)"; + cmd.Parameters.AddWithValue("@guild", guildName); + cmd.Parameters.AddWithValue("@old", oldLeader); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + candidates.Add((reader.GetString(0), reader.IsDBNull(1) ? 0 : reader.GetInt32(1), reader.IsDBNull(2) ? null : reader.GetString(2))); + } + var successor = SqlSaveBackend.PickSuccessor(candidates); + if (successor == null) return null; + + using var tx = conn.BeginTransaction(); + using (var update = conn.CreateCommand()) + { + // v1.1.11: a leader changed meanwhile (a transfer) wins + update.Transaction = tx; + update.CommandText = "UPDATE guilds SET leader_username = @new WHERE name = @guild COLLATE NOCASE AND leader_username = @old COLLATE NOCASE"; + update.Parameters.AddWithValue("@new", successor.ToLowerInvariant()); + update.Parameters.AddWithValue("@guild", guildName); + update.Parameters.AddWithValue("@old", oldLeader); + if (update.ExecuteNonQuery() != 1) return null; + } + using (var rank = conn.CreateCommand()) + { + rank.Transaction = tx; + rank.CommandText = "UPDATE guild_members SET rank = 'Leader' WHERE username = @new COLLATE NOCASE AND guild_name = @guild COLLATE NOCASE"; + rank.Parameters.AddWithValue("@new", successor); + rank.Parameters.AddWithValue("@guild", guildName); + rank.ExecuteNonQuery(); + } + tx.Commit(); + DebugLogger.Instance?.LogInfo("GUILD", $"Guild '{guildName}' leadership passed from '{oldLeader}' to '{successor.ToLowerInvariant()}'"); + return successor.ToLowerInvariant(); + } + catch (Exception ex) + { + DebugLogger.Instance?.LogError("GUILD", $"Failed to pass the leadership of guild '{guildName}': {ex.Message}"); + return null; + } + } + + /// + /// v1.1.11: passes on every guild led by a deleted or permadead character, after the purge removed + /// its guild_members row. Leadership is not cached, so the membership cache needs nothing here. + /// + public int PassLeadershipOf(string username) + { + if (string.IsNullOrWhiteSpace(username)) return 0; + var guilds = new List<(string Name, string Leader)>(); + try + { + using var conn = new SqliteConnection(connectionString); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"SELECT g.name, g.leader_username FROM guilds g WHERE g.leader_username = @user COLLATE NOCASE + AND NOT EXISTS (SELECT 1 FROM guild_members gm WHERE gm.username = @user COLLATE NOCASE AND gm.guild_name = g.name COLLATE NOCASE)"; + cmd.Parameters.AddWithValue("@user", username); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) guilds.Add((reader.GetString(0), reader.GetString(1))); + } + catch (Exception ex) + { + DebugLogger.Instance?.LogError("GUILD", $"Failed to list the guilds led by '{username}': {ex.Message}"); + } + return guilds.Count(g => PassLeadership(g.Name, g.Leader) != null); + } + /// /// Get the display name of a player's guild, or null if not in a guild. /// @@ -341,23 +433,18 @@ INSERT OR IGNORE INTO guild_members (username, guild_name, rank) // If leader left, promote next member or disband if (isLeader) { - // Prefer promoting an Officer, otherwise oldest member - using var nextCmd = conn.CreateCommand(); - nextCmd.CommandText = @"SELECT username FROM guild_members WHERE guild_name = @guild - ORDER BY CASE rank WHEN 'Officer' THEN 0 ELSE 1 END, joined_at ASC LIMIT 1"; - nextCmd.Parameters.AddWithValue("@guild", guildName); - var nextMember = nextCmd.ExecuteScalar()?.ToString(); - - if (nextMember != null) + int remaining; + using (var countCmd = conn.CreateCommand()) + { + countCmd.CommandText = "SELECT COUNT(*) FROM guild_members WHERE guild_name = @guild"; + countCmd.Parameters.AddWithValue("@guild", guildName); + remaining = Convert.ToInt32(countCmd.ExecuteScalar()); + } + + if (remaining > 0) { - // Promote next member to leader - using var promoteCmd = conn.CreateCommand(); - promoteCmd.CommandText = @" - UPDATE guilds SET leader_username = @user WHERE name = @guild; - UPDATE guild_members SET rank = 'Leader' WHERE username = @user AND guild_name = @guild"; - promoteCmd.Parameters.AddWithValue("@user", nextMember); - promoteCmd.Parameters.AddWithValue("@guild", guildName); - promoteCmd.ExecuteNonQuery(); + // v1.1.11: the highest-level remaining player takes over (was: an Officer, else the oldest member) + PassLeadership(guildName, username); } else { diff --git a/Scripts/Systems/PermadeathHelper.cs b/Scripts/Systems/PermadeathHelper.cs index d60d2115..9cbbd156 100644 --- a/Scripts/Systems/PermadeathHelper.cs +++ b/Scripts/Systems/PermadeathHelper.cs @@ -440,6 +440,17 @@ public static async Task PurgeDeletedCharacterAsync(SqlSaveBackend? backend, str } catch (Exception gex) { DebugLogger.Instance.LogWarning("DELETE", $"Guild cache clear failed for '{name}': {gex.Message}"); } + try + { + // v1.1.11: a team or guild the character led passes to its highest-level remaining player + if (!string.IsNullOrWhiteSpace(username)) + { + backend?.PassTeamLeadershipOfDeleted(username!); + GuildSystem.Instance?.PassLeadershipOf(username!); + } + } + catch (Exception lex) { DebugLogger.Instance.LogWarning("DELETE", $"Leadership succession failed for '{name}': {lex.Message}"); } + try { int widowed = ClearNpcSpousesOf(name); diff --git a/Scripts/Systems/SqlSaveBackend.cs b/Scripts/Systems/SqlSaveBackend.cs index 84fbd42a..98dcf03e 100644 --- a/Scripts/Systems/SqlSaveBackend.cs +++ b/Scripts/Systems/SqlSaveBackend.cs @@ -5225,6 +5225,130 @@ UPDATE pending_inheritance } } + /// + /// v1.1.11: the successor to a team or guild leader: the highest level first; the same level goes + /// to the earliest joiner when a join time is recorded (guild_members.joined_at; teams record none, + /// players.created_at is the account's age and last_join_at is per team), then the username in + /// ordinal order. Null when there is no candidate. + /// + public static string? PickSuccessor(IEnumerable<(string Username, int Level, string? JoinedAt)> candidates) => + candidates.OrderByDescending(c => c.Level) + .ThenBy(c => c.JoinedAt == null ? 1 : 0) + .ThenBy(c => c.JoinedAt ?? "", StringComparer.Ordinal) + .ThenBy(c => c.Username, StringComparer.Ordinal) + .Select(c => c.Username).FirstOrDefault(); + + /// + /// v1.1.11: passes a team's leader key (created_by) from oldKey to the highest-level remaining + /// player member, never excludeKey, a banned player or an emergency account. Only if the team still + /// has oldKey; with requireOldLeaderGone, also only if oldKey's save no longer names the team. The + /// key is left alone when there is no successor. True when the team was updated. + /// + public bool TryPassTeamLeadership(string teamName, string oldKey, string? excludeKey, bool requireOldLeaderGone, out string? newKey) + { + newKey = null; + try + { + using var connection = OpenConnection(); + var candidates = new List<(string, int, string?)>(); + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = @" + SELECT p.username, CAST(json_extract(p.player_data, '$.player.level') AS INTEGER) + FROM players p + WHERE (CASE WHEN json_valid(p.player_data) THEN json_extract(p.player_data, '$.player.team') END) = @team + AND p.player_data != '{}' AND LENGTH(p.player_data) > 2 + AND p.is_banned = 0 AND p.username NOT LIKE 'emergency_%' + AND LOWER(p.username) != LOWER(@old);"; + cmd.Parameters.AddWithValue("@team", teamName); + cmd.Parameters.AddWithValue("@old", oldKey); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var key = reader.GetString(0); + if (excludeKey != null && string.Equals(key, excludeKey, StringComparison.OrdinalIgnoreCase)) continue; + candidates.Add((key, reader.IsDBNull(1) ? 0 : reader.GetInt32(1), null)); + } + } + var successor = PickSuccessor(candidates); + if (successor == null) return false; + using (var update = connection.CreateCommand()) + { + // v1.1.11: a leader key changed meanwhile (an admin fix, another pass) wins + update.CommandText = "UPDATE player_teams SET created_by = @new WHERE team_name = @team AND created_by = @old" + + (requireOldLeaderGone ? @" + AND NOT EXISTS (SELECT 1 FROM players l WHERE l.username = @old + AND (NOT json_valid(l.player_data) + OR (CASE WHEN json_valid(l.player_data) THEN json_extract(l.player_data, '$.player.team') END) = @team));" : ";"); + update.Parameters.AddWithValue("@new", successor.ToLowerInvariant()); + update.Parameters.AddWithValue("@team", teamName); + update.Parameters.AddWithValue("@old", oldKey); + if (update.ExecuteNonQuery() != 1) return false; + } + newKey = successor.ToLowerInvariant(); + DebugLogger.Instance.LogInfo("TEAM", $"Team '{teamName}' leadership passed from '{oldKey}' to '{newKey}'"); + return true; + } + catch (Exception ex) + { + DebugLogger.Instance.LogError("TEAM", $"Failed to pass the leadership of team '{teamName}': {ex.Message}"); + return false; + } + } + + /// v1.1.11: passes on every team led by a character that is being deleted (its row still names the team). + public int PassTeamLeadershipOfDeleted(string characterKey) + { + if (string.IsNullOrWhiteSpace(characterKey)) return 0; + var teams = new List<(string Team, string Key)>(); + try + { + using var connection = OpenConnection(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT team_name, created_by FROM player_teams WHERE LOWER(created_by) = LOWER(@key);"; + cmd.Parameters.AddWithValue("@key", characterKey); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) teams.Add((reader.GetString(0), reader.GetString(1))); + } + catch (Exception ex) + { + DebugLogger.Instance.LogError("TEAM", $"Failed to list the teams led by '{characterKey}': {ex.Message}"); + } + return teams.Count(t => TryPassTeamLeadership(t.Team, t.Key, characterKey, requireOldLeaderGone: false, out _)); + } + + /// + /// v1.1.11: teams whose leader key is a known character (a players row) whose valid save no longer + /// names the team, and that nobody joined within EmptyTeamJoinGraceMinutes (a joiner's save may not + /// have landed). A key that matches no character is the admin's Fix Team Leaders screen's to map, + /// so it is never listed here. + /// + public List<(string Team, string Leader)> GetTeamsLedByExMembers() + { + var teams = new List<(string, string)>(); + try + { + using var connection = OpenConnection(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" + SELECT t.team_name, t.created_by FROM player_teams t + JOIN players l ON l.username = t.created_by + -- a deleted character's row stays with '{}' (DeleteGameData), which names no team + WHERE json_valid(l.player_data) + AND COALESCE((CASE WHEN json_valid(l.player_data) THEN json_extract(l.player_data, '$.player.team') END), '') != t.team_name + AND (t.last_join_at IS NULL OR t.last_join_at < datetime('now', '-' || @joinGrace || ' minutes')) + ORDER BY t.team_name;"; + cmd.Parameters.AddWithValue("@joinGrace", GameConfig.EmptyTeamJoinGraceMinutes); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) teams.Add((reader.GetString(0), reader.GetString(1))); + } + catch (Exception ex) + { + DebugLogger.Instance.LogError("TEAM", $"Failed to list teams led by ex-members: {ex.Message}"); + } + return teams; + } + /// /// v0.61.5: Queue an item for delivery to a player on their next login. /// Used when a team NPC dies of old age — their belongings go to the team diff --git a/Scripts/Systems/WorldSimService.cs b/Scripts/Systems/WorldSimService.cs index dc9685b5..d4adf2dc 100644 --- a/Scripts/Systems/WorldSimService.cs +++ b/Scripts/Systems/WorldSimService.cs @@ -569,6 +569,9 @@ private async Task SaveWorldState() // v1.1.11: teams nobody is in any more PruneEmptyTeams(); + + // v1.1.11: then teams whose leader has left them + PassLeadershipOfDepartedLeaders(); } catch (Exception ex) { @@ -1452,6 +1455,26 @@ internal int PruneEmptyTeams(DateTime now) return removed; } + /// + /// v1.1.11: a team whose leader key is a character that is no longer on it passes to the + /// highest-level remaining player member (SqlSaveBackend.TryPassTeamLeadership). A key matching + /// no character is left to the admin's Fix Team Leaders screen. + /// + internal int PassLeadershipOfDepartedLeaders() + { + int passed = 0; + try + { + foreach (var (team, leader) in sqlBackend.GetTeamsLedByExMembers()) + if (sqlBackend.TryPassTeamLeadership(team, leader, leader, requireOldLeaderGone: true, out _)) passed++; + } + catch (Exception ex) + { + DebugLogger.Instance.LogError("TEAM", $"Failed to pass on the leadership of teams: {ex.Message}"); + } + return passed; + } + private static bool IsTeamOnline(string team) { var sessions = UsurperRemake.Server.MudServer.Instance?.ActiveSessions.Values; diff --git a/Tests/LeaderSuccessionTests.cs b/Tests/LeaderSuccessionTests.cs new file mode 100644 index 00000000..b523b495 --- /dev/null +++ b/Tests/LeaderSuccessionTests.cs @@ -0,0 +1,260 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using UsurperRemake.Systems; +using Xunit; + +namespace UsurperReborn.Tests; + +/// +/// v1.1.11: when a team's or a guild's leader leaves, is deleted or dies for good, leadership passes to +/// the highest-level remaining player member: the same level goes to the earliest joiner where a join +/// time is recorded (guilds), then the username in ordinal order. Banned players and emergency accounts +/// are never chosen. The world save also passes on teams whose leader is a known character no longer on +/// them, but never a team whose leader key matches no character (the admin's Fix Team Leaders screen). +/// +[Collection("SharedGameSingletons")] +public class LeaderSuccessionTests : IDisposable +{ + private readonly string _path = Path.Combine(Path.GetTempPath(), $"usurper-lst-{Guid.NewGuid():N}.db"); + private readonly SqlSaveBackend _db; + + public LeaderSuccessionTests() { _db = new SqlSaveBackend(_path); } + + public void Dispose() + { + SqliteConnection.ClearAllPools(); + try { File.Delete(_path); } catch { } + } + + private void Exec(string sql) + { + using var conn = new SqliteConnection($"Data Source={_path}"); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } + + private string? Scalar(string sql) + { + using var conn = new SqliteConnection($"Data Source={_path}"); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + return cmd.ExecuteScalar()?.ToString(); + } + + private void Player(string key, string? team, int level = 10, bool banned = false) => + Exec($"INSERT INTO players (username, display_name, player_data, is_banned) VALUES ('{key}', '{key}', " + + $"'{{\"player\":{{\"level\":{level}{(team != null ? $",\"team\":\"{team}\"" : "")}}}}}', {(banned ? 1 : 0)});"); + + private void Team(string name, string createdBy) => + Exec($"INSERT INTO player_teams (team_name, password_hash, created_by) VALUES ('{name}', 'x', '{createdBy}');"); + + private string? Leader(string team) => Scalar($"SELECT created_by FROM player_teams WHERE team_name = '{team}'"); + + private string? GuildLeader(string guild) => Scalar($"SELECT leader_username FROM guilds WHERE name = '{guild}'"); + + private void Guild(string name, string leader) => + Exec($"INSERT INTO guilds (name, display_name, leader_username) VALUES ('{name}', '{name}', '{leader}');"); + + private void GuildMember(string user, string guild, string joinedAt, string rank = "Member") => + Exec($"INSERT INTO guild_members (username, guild_name, rank, joined_at) VALUES ('{user}', '{guild}', '{rank}', '{joinedAt}');"); + + [Fact] + public void TheHighestLevelMember_TakesOver() + { + Player("founder", null, level: 50); + Player("bran", "Black Band", level: 20); + Player("tomas", "Black Band", level: 35); + Team("Black Band", "founder"); + + _db.TryPassTeamLeadership("Black Band", "founder", "founder", requireOldLeaderGone: true, out var next).Should().BeTrue(); + next.Should().Be("tomas"); + Leader("Black Band").Should().Be("tomas"); + } + + [Fact] + public void TheTieBreak_IsLevel_ThenEarliestJoin_ThenOrdinalUsername() + { + SqlSaveBackend.PickSuccessor(new[] { ("zed", 30, (string?)null), ("amy", 30, null), ("Zoe", 30, null) }) + .Should().Be("Zoe", "ordinal order puts upper case before lower case"); + SqlSaveBackend.PickSuccessor(new[] { ("amy", 30, (string?)"2026-05-01 00:00:00"), ("zed", 30, "2026-01-01 00:00:00") }) + .Should().Be("zed", "the earlier joiner wins a tie"); + SqlSaveBackend.PickSuccessor(new[] { ("amy", 30, (string?)null), ("zed", 30, "2026-01-01 00:00:00") }) + .Should().Be("zed", "a recorded join time comes before none"); + SqlSaveBackend.PickSuccessor(new[] { ("amy", 30, (string?)"2026-01-01 00:00:00"), ("zed", 31, "2026-05-01 00:00:00") }) + .Should().Be("zed", "level comes first"); + SqlSaveBackend.PickSuccessor(Array.Empty<(string, int, string?)>()).Should().BeNull(); + + // a team records no join time per member, so the same level goes by username + Player("founder", null); + Player("mira", "Tied", level: 30); + Player("bran", "Tied", level: 30); + Team("Tied", "founder"); + _db.TryPassTeamLeadership("Tied", "founder", "founder", requireOldLeaderGone: true, out _).Should().BeTrue(); + Leader("Tied").Should().Be("bran"); + } + + [Fact] + public void BannedAndEmergencyMembers_AreSkipped() + { + Player("founder", null); + Player("exiled", "Watch", level: 90, banned: true); + Player("emergency_1", "Watch", level: 80); + Player("tomas", "Watch", level: 5); + Team("Watch", "founder"); + _db.TryPassTeamLeadership("Watch", "founder", "founder", requireOldLeaderGone: true, out _).Should().BeTrue(); + Leader("Watch").Should().Be("tomas"); + + Player("founder2", null); + Player("exiled2", "Only Banned", level: 90, banned: true); + Team("Only Banned", "founder2"); + _db.TryPassTeamLeadership("Only Banned", "founder2", "founder2", requireOldLeaderGone: true, out _).Should().BeFalse(); + Leader("Only Banned").Should().Be("founder2", "with no successor the key stays"); + } + + [Fact] + public void TheWorldSave_PassesOnATeamWhoseKnownLeaderLeft() + { + Player("founder", "Other Team", level: 60); // a real account, now elsewhere + Player("bran", "Black Band", level: 20); + Team("Black Band", "founder"); + Player("stays", "Kept", level: 20); + Player("second", "Kept", level: 40); + Team("Kept", "stays"); + + Exec("INSERT INTO players (username, display_name, player_data) VALUES ('gone', 'gone', '{}');"); // deleted: DeleteGameData leaves '{}' + Player("tomas", "Gone Leader", level: 20); + Team("Gone Leader", "gone"); + + new WorldSimService(_db).PassLeadershipOfDepartedLeaders().Should().Be(2); + Leader("Black Band").Should().Be("bran"); + Leader("Gone Leader").Should().Be("tomas"); + Leader("Kept").Should().Be("stays", "a leader still on the team keeps it"); + } + + [Fact] + public void TheWorldSave_LeavesAnUnknownLeaderKeyAlone() + { + Player("bran", "Old Guard", level: 20); + Team("Old Guard", "mira vale"); // an old display-name key: the admin's Fix Team Leaders screen maps it + + new WorldSimService(_db).PassLeadershipOfDepartedLeaders().Should().Be(0); + Leader("Old Guard").Should().Be("mira vale"); + } + + [Fact] + public void TheWorldSave_SkipsATeamJoinedWithinTheGrace() + { + Player("founder", null, level: 60); + Player("bran", "Just Joined", level: 20); + Team("Just Joined", "founder"); + Exec("UPDATE player_teams SET last_join_at = datetime('now') WHERE team_name = 'Just Joined';"); + + var service = new WorldSimService(_db); + service.PassLeadershipOfDepartedLeaders().Should().Be(0, "the joiner may be the leader, whose save has not landed"); + Leader("Just Joined").Should().Be("founder"); + + Exec($"UPDATE player_teams SET last_join_at = datetime('now', '-{GameConfig.EmptyTeamJoinGraceMinutes + 1} minutes') WHERE team_name = 'Just Joined';"); + service.PassLeadershipOfDepartedLeaders().Should().Be(1); + Leader("Just Joined").Should().Be("bran"); + } + + [Fact] + public void AGuildLeaderWhoLeaves_IsSucceededByTheHighestLevelMember() + { + var guilds = new GuildSystem(_path, register: false); + Player("boss", null, level: 90); + Player("officer", null, level: 20); + Player("veteran", null, level: 40); + Player("newbie", null, level: 40); + Player("exiled", null, level: 99, banned: true); + Guild("ironhand", "boss"); + GuildMember("boss", "ironhand", "2026-01-01 00:00:00", "Leader"); + GuildMember("officer", "ironhand", "2026-01-02 00:00:00", "Officer"); + GuildMember("newbie", "ironhand", "2026-03-01 00:00:00"); + GuildMember("veteran", "ironhand", "2026-02-01 00:00:00"); + GuildMember("exiled", "ironhand", "2026-01-01 00:00:00"); + + Exec("DELETE FROM guild_members WHERE username = 'boss';"); // as the delete purge does + guilds.PassLeadershipOf("boss").Should().Be(1); + GuildLeader("ironhand").Should().Be("veteran", "level first, then the earlier joiner; the banned player is skipped"); + Scalar("SELECT rank FROM guild_members WHERE username = 'veteran'").Should().Be("Leader"); + + guilds.PassLeadership("ironhand", "someone else").Should().BeNull("the guild is no longer theirs"); + GuildLeader("ironhand").Should().Be("veteran"); + } + + [Fact] + public void AGuildWithNoMembersLeft_IsLeftAlone() + { + var guilds = new GuildSystem(_path, register: false); + Player("boss", null, level: 90); + Guild("empty hall", "boss"); + + guilds.PassLeadershipOf("boss").Should().Be(0); + GuildLeader("empty hall").Should().Be("boss"); + Scalar("SELECT COUNT(*) FROM guilds WHERE name = 'empty hall'").Should().Be("1", "succession never deletes a guild"); + } + + [Fact] + public async Task TheDeletePurge_PassesOnTheTeam_AndItsGuildToo() + { + Player("leader", "Black Band", level: 60); // the row is still there when the purge runs + Player("bran", "Black Band", level: 20); + Team("Black Band", "leader"); + + await PermadeathHelper.PurgeDeletedCharacterAsync(_db, "leader", "Leader"); + Leader("Black Band").Should().Be("bran"); + + CodeOnly(Source("Systems", "PermadeathHelper.cs")).Should().Contain("GuildSystem.Instance?.PassLeadershipOf(username!)"); + } + + [Fact] + public void TheQuitPath_AndTheLeaveCommand_PassOnLeadership() + { + string quit = CodeOnly(Source("Locations", "TeamCornerLocation.cs")); + int start = quit.IndexOf("private async Task QuitTeam()", StringComparison.Ordinal); + start.Should().BeGreaterThan(0); + int end = quit.IndexOf("\n private ", start + 10, StringComparison.Ordinal); + string body = quit.Substring(start, end - start); + body.Should().Contain("GameEngine.InheritanceKey(currentPlayer)"); + body.Should().Contain("TryPassTeamLeadership(oldTeam, myKey, myKey"); + body.IndexOf("TryPassTeamLeadership(", StringComparison.Ordinal) + .Should().BeGreaterThan(body.IndexOf("AutoSave(currentPlayer)", StringComparison.Ordinal), "after the save records the quit"); + + string guild = CodeOnly(Source("Systems", "GuildSystem.cs")); + int rm = guild.IndexOf("public string? RemoveMember(", StringComparison.Ordinal); + guild.IndexOf("PassLeadership(guildName, username)", rm, StringComparison.Ordinal).Should().BeGreaterThan(rm); + + string sim = CodeOnly(Source("Systems", "WorldSimService.cs")); + int prune = sim.IndexOf("PruneEmptyTeams();", StringComparison.Ordinal); + sim.IndexOf("PassLeadershipOfDepartedLeaders();", prune, StringComparison.Ordinal).Should().BeGreaterThan(prune, "after the empty-team cleanup"); + } + + [Fact] + public void CodeOnly_IgnoresACommentedOutCall() + { + CodeOnly(" // backend.TryPassTeamLeadership(oldTeam, myKey, myKey, true, out _);").Should().NotContain("TryPassTeamLeadership("); + } + + private static string Source(string folder, string file) + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Scripts"))) dir = dir.Parent; + return File.ReadAllText(Path.Combine(dir!.FullName, "Scripts", folder, file)); + } + + /// The source with // comments removed, as in CharacterRecreationTests. + private static string CodeOnly(string src) => + string.Join("\n", src.Split('\n').Select(line => + { + int c = line.IndexOf("//", StringComparison.Ordinal); + return c >= 0 ? line.Substring(0, c) : line; + })); +} From 707410d18359725a86fa9a41f23c1706a138f074 Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 22:31:32 +0000 Subject: [PATCH 28/42] A spell short of mana says how much it needs The quickbar showed a spell that lacked mana only as unavailable, so a new Magician took the staff to be the problem (player report). It now reads, for example, "Magic Missile (need 14 MP, have 6)". The staff and silence labels go through the translations too, in all five languages. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Localization/en.json | 3 ++ Localization/es.json | 3 ++ Localization/fr.json | 3 ++ Localization/hu.json | 3 ++ Localization/it.json | 3 ++ Scripts/Systems/CombatEngine.cs | 8 +++-- Tests/StatusAndQuickbarTests.cs | 57 +++++++++++++++++++++++++++++++++ 7 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 Tests/StatusAndQuickbarTests.cs diff --git a/Localization/en.json b/Localization/en.json index d587fb0f..e24eb3f3 100644 --- a/Localization/en.json +++ b/Localization/en.json @@ -1076,6 +1076,9 @@ "combat.speed_fast": "Fast", "combat.speed_normal": "Normal", "combat.menu_unavailable": "(unavailable)", + "combat.qb_need_mana": "{0} (need {1} MP, have {2})", + "combat.qb_need_weapon": "{0} (need {1})", + "combat.qb_silenced": "{0} (silenced)", "combat.choose_action": "Choose action: ", "combat.combat_menu": "Combat Menu", "combat.heal_potions_short": "Heal (Potions: {0}/{1})", diff --git a/Localization/es.json b/Localization/es.json index 6cb5de41..d9f7cf3d 100644 --- a/Localization/es.json +++ b/Localization/es.json @@ -1076,6 +1076,9 @@ "combat.speed_fast": "Rápida", "combat.speed_normal": "Normal", "combat.menu_unavailable": "(no disponible)", + "combat.qb_need_mana": "{0} (necesita {1} PM, tienes {2})", + "combat.qb_need_weapon": "{0} (requiere {1})", + "combat.qb_silenced": "{0} (silenciado)", "combat.choose_action": "Elige acción: ", "combat.combat_menu": "Menú de Combate", "combat.heal_potions_short": "Curar (Pociones: {0}/{1})", diff --git a/Localization/fr.json b/Localization/fr.json index 753f8fe4..122208a1 100644 --- a/Localization/fr.json +++ b/Localization/fr.json @@ -1076,6 +1076,9 @@ "combat.speed_fast": "Rapide", "combat.speed_normal": "Normal", "combat.menu_unavailable": "(indisponible)", + "combat.qb_need_mana": "{0} (il faut {1} PM, vous avez {2})", + "combat.qb_need_weapon": "{0} (nécessite {1})", + "combat.qb_silenced": "{0} (réduit au silence)", "combat.choose_action": "Choisissez une action : ", "combat.combat_menu": "Menu de Combat", "combat.heal_potions_short": "Soin (Potions : {0}/{1})", diff --git a/Localization/hu.json b/Localization/hu.json index 59a93c23..85179765 100644 --- a/Localization/hu.json +++ b/Localization/hu.json @@ -1076,6 +1076,9 @@ "combat.speed_fast": "Gyors", "combat.speed_normal": "Normál", "combat.menu_unavailable": "(nem elérhető)", + "combat.qb_need_mana": "{0} ({1} MP kell, van {2})", + "combat.qb_need_weapon": "{0} ({1} kell)", + "combat.qb_silenced": "{0} (elnémítva)", "combat.choose_action": "Válassz cselekvést: ", "combat.combat_menu": "Harci menü", "combat.heal_potions_short": "Gyógyítás (Főzetek: {0}/{1})", diff --git a/Localization/it.json b/Localization/it.json index b41058dd..c9a24392 100644 --- a/Localization/it.json +++ b/Localization/it.json @@ -1076,6 +1076,9 @@ "combat.speed_fast": "Veloce", "combat.speed_normal": "Normale", "combat.menu_unavailable": "(non disponibile)", + "combat.qb_need_mana": "{0} (servono {1} PM, ne hai {2})", + "combat.qb_need_weapon": "{0} (richiede {1})", + "combat.qb_silenced": "{0} (silenziato)", "combat.choose_action": "Scegli azione: ", "combat.combat_menu": "Menu Combattimento", "combat.heal_potions_short": "Cura (Pozioni: {0}/{1})", diff --git a/Scripts/Systems/CombatEngine.cs b/Scripts/Systems/CombatEngine.cs index e86c8f66..5a7db5df 100644 --- a/Scripts/Systems/CombatEngine.cs +++ b/Scripts/Systems/CombatEngine.cs @@ -28371,10 +28371,14 @@ private bool TryShowQuickbarInfo(Character player, string upperChoice) else if (!SpellSystem.HasRequiredSpellWeapon(player)) { var reqType = SpellSystem.GetSpellWeaponRequirement(player.Class); - displayName = $"{spell.Name} (Need {reqType})"; + displayName = Loc.Get("combat.qb_need_weapon", spell.Name, reqType); } else if (!player.CanCastSpells()) - displayName = $"{spell.Name} (SILENCED)"; + displayName = Loc.Get("combat.qb_silenced", spell.Name); + else if (player.Mana < manaCost) + // v1.1.11: say why it cannot be cast (player report: a new Magician read "unavailable" and + // suspected the staff) + displayName = Loc.Get("combat.qb_need_mana", spell.Name, manaCost, player.Mana); else displayName = $"{spell.Name} ({manaCost} MP)"; actions.Add(((i + 1).ToString(), slotId, displayName, canCast)); diff --git a/Tests/StatusAndQuickbarTests.cs b/Tests/StatusAndQuickbarTests.cs new file mode 100644 index 00000000..dade928b --- /dev/null +++ b/Tests/StatusAndQuickbarTests.cs @@ -0,0 +1,57 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using FluentAssertions; +using UsurperRemake.Systems; +using Xunit; + +namespace UsurperReborn.Tests; + +/// +/// v1.1.11: +/// a spell short of mana showed only "(unavailable)", so a new Magician suspected the staff (player report); +/// it now says how much mana is needed and how much there is. +/// +[Collection("SharedGameSingletons")] +public class StatusAndQuickbarTests +{ + private static readonly BindingFlags F = BindingFlags.NonPublic | BindingFlags.Instance; + + private static Character NewMagician(long mana) + { + var term = new TerminalEmulator(new MemoryStream(), new MemoryStream()); + var c = new Character { Name1 = "qb_mage", Name2 = "QB Mage", Class = CharacterClass.Magician, Race = CharacterRace.Elf, Level = 2, Intelligence = 20, Wisdom = 15, HP = 30, MaxHP = 30 }; + typeof(CharacterCreationSystem).GetMethod("GiveStartingWeapon", F)!.Invoke(new CharacterCreationSystem(term), new object[] { c }); + c.Spell = Enumerable.Range(0, GameConfig.MaxSpells).Select(_ => new System.Collections.Generic.List { false, false }).ToList(); + c.Spell[0][0] = true; + c.MaxMana = 100; c.Mana = mana; + GameEngine.AutoPopulateQuickbar(c); + return c; + } + + private static System.Collections.Generic.List<(string key, string slotId, string displayName, bool available)> Quickbar(Character c) + { + var engine = new CombatEngine(new TerminalEmulator(new MemoryStream(), new MemoryStream())); + return (System.Collections.Generic.List<(string, string, string, bool)>)typeof(CombatEngine).GetMethod("GetQuickbarActions", F)!.Invoke(engine, new object[] { c })!; + } + + [Fact] + public void ASpellShortOfMana_SaysHowMuchIsNeeded() + { + var mage = NewMagician(mana: 2); + var slot = Quickbar(mage).Single(a => a.slotId == "spell:1"); + slot.available.Should().BeFalse(); + var spell = SpellSystem.GetSpellInfo(CharacterClass.Magician, 1)!; + slot.displayName.Should().Be(Loc.Get("combat.qb_need_mana", spell.Name, SpellSystem.CalculateManaCost(spell, mage), 2)); + } + + [Fact] + public void ASpellWithEnoughMana_StillShowsItsCost() + { + var mage = NewMagician(mana: 100); + var slot = Quickbar(mage).Single(a => a.slotId == "spell:1"); + slot.available.Should().BeTrue("the new Magician's staff and mana are enough"); + slot.displayName.Should().EndWith(" MP)"); + } +} From 0dc40a9b33bcddc45fa45c5171712df46d601e3f Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 22:35:58 +0000 Subject: [PATCH 29/42] The death-cap permadeath deletes the dying character, not the account's main The permadeath for too many deaths keyed its purge and its delete on the account name, which is the main character's key; on an alt it would purge and archive the main character and leave the alt. It uses the character's own key, as the other permadeath path does. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Systems/CombatEngine.cs | 4 +++- Tests/CharacterRecreationTests.cs | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/Scripts/Systems/CombatEngine.cs b/Scripts/Systems/CombatEngine.cs index 5a7db5df..66f7420e 100644 --- a/Scripts/Systems/CombatEngine.cs +++ b/Scripts/Systems/CombatEngine.cs @@ -21417,7 +21417,9 @@ private async Task HandleExcessiveDeathsPermadeath(CombatResult result) try { var ctx = UsurperRemake.Server.SessionContext.Current; - string username = ctx?.Username ?? player.Name1 ?? player.Name2 ?? ""; + // v1.1.11: the character's key, as PermadeathHelper uses; the account name is the MAIN character's + // key, so on an alt the purge and the delete hit the main character (review) + string username = (!string.IsNullOrEmpty(ctx?.CharacterKey) ? ctx!.CharacterKey : ctx?.Username) ?? player.Name1 ?? player.Name2 ?? ""; string displayName = player.Name2 ?? player.Name1 ?? username; string killerName = result.Monster?.Name ?? "an unknown end"; int finalLevel = player.Level; diff --git a/Tests/CharacterRecreationTests.cs b/Tests/CharacterRecreationTests.cs index 7dbd22e7..739d76bd 100644 --- a/Tests/CharacterRecreationTests.cs +++ b/Tests/CharacterRecreationTests.cs @@ -399,4 +399,16 @@ public void ABountyPostedOnAnNPC_IsNotAPlayerBounty_SoBeatingTheNPCPaysIt() var quest = CodeOnly(Source("Systems", "QuestSystem.cs")); quest.Should().Contain("IsPlayerBounty = onPlayer"); } + + [Fact] + public void EveryPermadeath_DeletesTheCharactersOwnKey_NotTheAccounts() + { + // On an alt the account name is the MAIN character's key; the death-cap path deleted that one. + var engine = CodeOnly(Source("Systems", "CombatEngine.cs")); + int del = engine.IndexOf("sqlBackend.DeleteGameData(username, bypassArchive: false);", StringComparison.Ordinal); + del.Should().BeGreaterThan(0); + engine.Substring(Math.Max(0, del - 2500), 2500).Should().Contain("ctx!.CharacterKey : ctx?.Username"); + var helper = CodeOnly(Source("Systems", "PermadeathHelper.cs")); + helper.Should().Contain("ctx!.CharacterKey : ctx?.Username"); + } } From ca7912b1fd83618b57185f1e07b9d509d1081ebc Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 23:01:45 +0000 Subject: [PATCH 30/42] Deleting a character also ends its reign, its grudges, mail and auctions - A deleted king's reign ends through the existing abdication path (CastleLocation.EndPlayerReign), so the normal succession runs; an NPC king of the same name is left alone. - NPC grudges against the deleted name (hostile memories) are dropped and each NPC's impression rebuilt from what remains, in memory and in the shared NPC record, edited in place with a versioned write. - Mail to the display name and auction listings under it are purged with those under the account key. The old purge also deleted listings the character had bought, taking the seller's uncollected gold; it no longer does. A name an NPC may carry keeps its listings. - The rebellion coin flip deleted by the display name as a key, which deletes nothing when they differ; it uses the character key and runs the shared purge first, and the delete guard now covers it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/AI/MemorySystem.cs | 32 +++- Scripts/Locations/CastleLocation.cs | 42 +++-- Scripts/Systems/PermadeathHelper.cs | 90 +++++++++++ Scripts/Systems/SqlSaveBackend.cs | 42 ++++- Tests/CharacterRecreationTests.cs | 15 +- Tests/DeleteFollowUpTests.cs | 238 ++++++++++++++++++++++++++++ 6 files changed, 445 insertions(+), 14 deletions(-) create mode 100644 Tests/DeleteFollowUpTests.cs diff --git a/Scripts/AI/MemorySystem.cs b/Scripts/AI/MemorySystem.cs index b55fb196..8aa0fc1f 100644 --- a/Scripts/AI/MemorySystem.cs +++ b/Scripts/AI/MemorySystem.cs @@ -73,9 +73,12 @@ private void UpdateCharacterImpression(MemoryEvent memoryEvent) characterImpressions[characterId] = Math.Max(-1.0f, Math.Min(1.0f, characterImpressions[characterId])); } - private float CalculateImpressionChange(MemoryEvent memoryEvent) + private float CalculateImpressionChange(MemoryEvent memoryEvent) => ImpressionChangeFor(memoryEvent.Type); + + /// v1.1.11: how a memory of this type moves the NPC's impression of the character. + public static float ImpressionChangeFor(MemoryType type) { - return memoryEvent.Type switch + return type switch { MemoryType.Attacked => -0.8f, MemoryType.Betrayed => -0.9f, @@ -179,6 +182,31 @@ public bool HasMemoryOfEvent(MemoryType type, string? characterId = null, int ho (characterId == null || m.InvolvedCharacter == characterId)); } + /// + /// v1.1.11: a grudge or other negative memory about a character: a type that lowers the impression, + /// a family killing or a witnessed attack or theft, or any memory with a negative emotional impact. + /// + public static bool IsGrudge(MemoryType type, float emotionalImpact) => + emotionalImpact < 0f || ImpressionChangeFor(type) < 0f || + type is MemoryType.KilledMyParent or MemoryType.KilledMyFamily or MemoryType.WitnessedAttack or MemoryType.WitnessedTheft; + + /// + /// v1.1.11: drop the grudges naming a deleted character (any letter case) and rebuild the impression + /// of that name from the memories that remain, so a same-name character is not hated for the old one. + /// + public int ForgetGrudgesAgainst(string name) + { + if (string.IsNullOrWhiteSpace(name)) return 0; + int removed = memories.RemoveAll(m => string.Equals(m.InvolvedCharacter, name, StringComparison.OrdinalIgnoreCase) + && IsGrudge(m.Type, m.EmotionalImpact)); + if (removed == 0) return 0; + foreach (var key in characterImpressions.Keys.Where(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase)).ToList()) + characterImpressions.Remove(key); + foreach (var m in memories.Where(m => string.Equals(m.InvolvedCharacter, name, StringComparison.OrdinalIgnoreCase)).ToList()) + UpdateCharacterImpression(m); + return removed; + } + public void ForgetCharacter(string characterId) { memories.RemoveAll(m => m.InvolvedCharacter == characterId); diff --git a/Scripts/Locations/CastleLocation.cs b/Scripts/Locations/CastleLocation.cs index 75acb18f..0945d320 100644 --- a/Scripts/Locations/CastleLocation.cs +++ b/Scripts/Locations/CastleLocation.cs @@ -1874,11 +1874,14 @@ private async Task TriggerRebellion() var backend = SaveSystem.Instance.Backend as SqlSaveBackend; if (backend != null) { - string username = currentPlayer.Name2?.ToLowerInvariant() ?? currentPlayer.Name1?.ToLowerInvariant() ?? ""; + // v1.1.11: the character's own key, as permadeath gets it (Name2 deleted 0 rows when it + // differed from the key), and the shared purge before the delete. + string username = (!string.IsNullOrEmpty(sctx?.CharacterKey) ? sctx!.CharacterKey : sctx?.Username) ?? currentPlayer.Name1 ?? currentPlayer.Name2 ?? ""; if (!string.IsNullOrEmpty(username)) { try { + await PermadeathHelper.PurgeDeletedCharacterAsync(backend, username, currentPlayer.Name2 ?? currentPlayer.Name1, currentPlayer); backend.DeleteGameData(username); DebugLogger.Instance.LogInfo("REBELLION", $"Character '{kingName}' permanently deleted — coin was HEADS"); } @@ -7922,6 +7925,34 @@ public static void AbdicatePlayerThrone(Character player, string reason) { if (player == null || !player.King) return; + // Clear player state + player.King = false; + if (player.NobleTitle == "King" || player.NobleTitle == "Queen") + player.NobleTitle = null; + player.RoyalMercenaries?.Clear(); + player.RecalculateStats(); + + EndPlayerReign(player.DisplayName, reason); + } + + /// + /// v1.1.11: a deleted character who holds the throne abdicates through the same path, so the normal + /// NPC succession runs. The deleted character has no loaded Character, so the match is by the + /// king's name (Name2, or the married DisplayName) and only a player king is removed. + /// + public static bool AbdicateDeletedKing(string? name, string? displayName, string reason) + { + var king = GetCurrentKing(); + if (king == null || !king.IsActive || king.AI != CharacterAI.Human) return false; + bool named(string? n) => !string.IsNullOrWhiteSpace(n) && string.Equals(king.Name, n, StringComparison.OrdinalIgnoreCase); + if (!named(name) && !named(displayName)) return false; + EndPlayerReign(king.Name, reason); + return true; + } + + // v1.1.11: the throne side of AbdicatePlayerThrone, shared with AbdicateDeletedKing. + private static void EndPlayerReign(string kingDisplayName, string reason) + { var king = GetCurrentKing(); // Record monarch history @@ -7942,17 +7973,10 @@ public static void AbdicatePlayerThrone(Character player, string reason) king.IsActive = false; } - // Clear player state - player.King = false; - if (player.NobleTitle == "King" || player.NobleTitle == "Queen") - player.NobleTitle = null; - player.RoyalMercenaries?.Clear(); - player.RecalculateStats(); - currentKing = null; // News - NewsSystem.Instance?.Newsy(true, $"{player.DisplayName} has {reason}! The kingdom is in chaos!"); + NewsSystem.Instance?.Newsy(true, $"{kingDisplayName} has {reason}! The kingdom is in chaos!"); // Trigger NPC succession (uses only static fields + singletons) var npcs = NPCSpawnSystem.Instance?.ActiveNPCs; diff --git a/Scripts/Systems/PermadeathHelper.cs b/Scripts/Systems/PermadeathHelper.cs index 9cbbd156..8b2d78b4 100644 --- a/Scripts/Systems/PermadeathHelper.cs +++ b/Scripts/Systems/PermadeathHelper.cs @@ -451,6 +451,26 @@ public static async Task PurgeDeletedCharacterAsync(SqlSaveBackend? backend, str } catch (Exception lex) { DebugLogger.Instance.LogWarning("DELETE", $"Leadership succession failed for '{name}': {lex.Message}"); } + try + { + // v1.1.11: a deleted king abdicates through the normal path (history, NPC succession, persist) + string? shown = player?.DisplayName ?? (string.IsNullOrWhiteSpace(username) ? null : backend?.GetStoredDisplayName(username!)); + if (global::CastleLocation.AbdicateDeletedKing(name, shown, "left the throne and the realm")) + DebugLogger.Instance.LogInfo("DELETE", $"Deleted '{name}' held the throne; the reign has ended."); + } + catch (Exception tex) { DebugLogger.Instance.LogWarning("DELETE", $"Throne handover failed for '{name}': {tex.Message}"); } + + try + { + // v1.1.11: NPC grudges naming the character. In memory, and in the shared npcs record + // edited in place (a world-sim reload would otherwise bring them back). + int forgotten = ForgetNpcGrudgesAgainst(name); + if (backend != null) forgotten += await RemoveSharedNpcGrudgesAsync(backend, name); + if (forgotten > 0) + DebugLogger.Instance.LogInfo("DELETE", $"Dropped {forgotten} NPC grudge memory(ies) of deleted '{name}'."); + } + catch (Exception mex) { DebugLogger.Instance.LogWarning("DELETE", $"NPC grudge clear failed for '{name}': {mex.Message}"); } + try { int widowed = ClearNpcSpousesOf(name); @@ -481,6 +501,76 @@ public static bool QuestLeftByCharacter(QuestData q, string name) => string.Equals(q.OfferedTo, name, StringComparison.OrdinalIgnoreCase) || QuestSystem.IsBountyOnPlayer(q.Initiator, q.TitleKey, q.TargetNPCName, q.IsPlayerBounty, name); + /// v1.1.11: drop every live NPC's grudges against the name (MemorySystem.IsGrudge). + public static int ForgetNpcGrudgesAgainst(string? name) + { + if (string.IsNullOrWhiteSpace(name)) return 0; + var npcs = NPCSpawnSystem.Instance?.ActiveNPCs; + if (npcs == null) return 0; + int removed = 0; + foreach (var npc in npcs.ToList()) + { + if (npc == null) continue; + var brainMemory = npc.Brain?.Memory; + if (brainMemory != null) removed += brainMemory.ForgetGrudgesAgainst(name!); + if (npc.Memory != null && !ReferenceEquals(npc.Memory, brainMemory)) removed += npc.Memory.ForgetGrudgesAgainst(name!); + } + return removed; + } + + /// + /// v1.1.11: remove the grudges against the name from the npcs JSON, touching nothing else. + /// Returns the edited JSON, or null when nothing matched. + /// + public static string? RemoveGrudgesFromNpcJson(string json, string name, out int removed) + { + removed = 0; + if (string.IsNullOrWhiteSpace(json) || string.IsNullOrWhiteSpace(name)) return null; + if (System.Text.Json.Nodes.JsonNode.Parse(json) is not System.Text.Json.Nodes.JsonArray npcs) return null; + foreach (var npc in npcs) + { + if (npc?["memories"] is not System.Text.Json.Nodes.JsonArray memories) continue; + for (int i = memories.Count - 1; i >= 0; i--) + { + var m = memories[i]; + if (m == null || !string.Equals(StringOf(m["involvedCharacter"]), name, StringComparison.OrdinalIgnoreCase)) continue; + float impact = 0f; + try { impact = m["emotionalImpact"]?.GetValue() ?? 0f; } catch { } + bool typed = Enum.TryParse(StringOf(m["type"]), out var type); + if ((typed && MemorySystem.IsGrudge(type, impact)) || (!typed && impact < 0f)) + { + memories.RemoveAt(i); + removed++; + } + } + } + return removed > 0 ? npcs.ToJsonString() : null; + } + + private static string StringOf(System.Text.Json.Nodes.JsonNode? node) + { + try { return node?.GetValue() ?? ""; } catch { return ""; } + } + + /// + /// v1.1.11: the shared npcs record, edited in place under its version (as RemoveSharedQuestsAsync + /// does for quests), never replaced by this process's list. Retries if another writer got in first. + /// + public static async Task RemoveSharedNpcGrudgesAsync(SqlSaveBackend backend, string name) + { + for (int attempt = 0; attempt < 3; attempt++) + { + long version = backend.GetWorldStateVersion(OnlineStateManager.KEY_NPCS); + string? json = await backend.LoadWorldState(OnlineStateManager.KEY_NPCS); + if (string.IsNullOrEmpty(json)) return 0; + string? edited = RemoveGrudgesFromNpcJson(json, name, out int removed); + if (edited == null) return 0; + if (await backend.SaveWorldStateIfVersion(OnlineStateManager.KEY_NPCS, edited, version)) return removed; + } + DebugLogger.Instance.LogWarning("DELETE", $"Shared NPC grudges of '{name}' not cleared: the record kept changing."); + return 0; + } + /// /// v1.1.11: end the marriage of any NPC whose spouse was the deleted character, clearing the /// same three flags a divorce clears on the NPC. An NPC married to another NPC (registry) is diff --git a/Scripts/Systems/SqlSaveBackend.cs b/Scripts/Systems/SqlSaveBackend.cs index 98dcf03e..58482a0d 100644 --- a/Scripts/Systems/SqlSaveBackend.cs +++ b/Scripts/Systems/SqlSaveBackend.cs @@ -1181,7 +1181,11 @@ public void PurgePlayerWorldState(string username, string? displayName = null) ExecPurge(connection, tx, "messages", "LOWER(from_player) = LOWER(@u) OR LOWER(to_player) = LOWER(@u)", username); ExecPurge(connection, tx, "trade_offers", "LOWER(from_player) = LOWER(@u) OR LOWER(to_player) = LOWER(@u)", username); ExecPurge(connection, tx, "bounties", "LOWER(target_player) = LOWER(@u) OR LOWER(placed_by) = LOWER(@u) OR LOWER(claimed_by) = LOWER(@u)", username); - ExecPurge(connection, tx, "auction_listings", "LOWER(seller) = LOWER(@u) OR LOWER(buyer) = LOWER(@u)", username); + // v1.1.11: only the character's own listings. The buyer clause is gone: a sold row the seller + // has not collected holds that seller's gold, and the buyer already has the item. A seller + // equal to the key is skipped when it is another player's display name. + ExecPurge(connection, tx, "auction_listings", + "LOWER(seller) = LOWER(@u) AND NOT EXISTS (SELECT 1 FROM players p WHERE LOWER(p.display_name) = LOWER(@u) AND LOWER(p.username) != LOWER(@u))", username); ExecPurge(connection, tx, "world_boss_damage", "LOWER(player_name) = LOWER(@u)", username); // v0.65.0: pvp_log was deliberately excluded in v0.60.5 ("history @@ -1202,6 +1206,25 @@ public void PurgePlayerWorldState(string username, string? displayName = null) ExecPurge(connection, tx, "pending_gold_transfers", "LOWER(recipient_username) = LOWER(@u)", username); ExecPurge(connection, tx, "world_boss_rewards", "LOWER(player_name) = LOWER(@u) AND COALESCE(delivered, 0) = 0", username); + // v1.1.11: mail and auctions also key on the display name (mail to Name2, auction sellers + // are DisplayName.ToLower(), the married surname form comes from players.display_name). + // Only mail TO the character; a name that is another account's key is left alone. + if (!string.IsNullOrWhiteSpace(displayName)) + { + const string ownNames = "(SELECT LOWER(display_name) FROM players WHERE LOWER(username) = LOWER(@u) AND display_name IS NOT NULL)"; + ExecPurge(connection, tx, "messages", + $"to_player != '*' AND (LOWER(to_player) = LOWER(@d) OR LOWER(to_player) IN {ownNames}) " + + "AND NOT EXISTS (SELECT 1 FROM players p WHERE LOWER(p.username) = LOWER(messages.to_player) AND LOWER(p.username) != LOWER(@u))", + username, displayName); + // NPCs list under their own name in lowercase: a name an NPC may carry (one does, or the roster + // is not complete enough to rule it out) keeps its listings; a stale one is the safe side + var spawner = NPCSpawnSystem.Instance; + bool couldBeNpcName = spawner == null || !spawner.IsRosterTrustworthy || QuestSystem.IsNPCName(displayName); + if (!couldBeNpcName) + ExecPurge(connection, tx, "auction_listings", + $"LOWER(seller) = LOWER(@d) OR LOWER(seller) IN {ownNames}", username, displayName); + } + tx.Commit(); DebugLogger.Instance.LogInfo("PERMADEATH", $"Purged shared world-state references for '{username}' (guild, bounties, trades, auctions, etc.)"); @@ -1224,7 +1247,7 @@ public void PurgePlayerWorldState(string username, string? displayName = null) } } - private static void ExecPurge(SqliteConnection conn, SqliteTransaction tx, string table, string whereClause, string username) + private static void ExecPurge(SqliteConnection conn, SqliteTransaction tx, string table, string whereClause, string username, string? displayName = null) { try { @@ -1232,6 +1255,7 @@ private static void ExecPurge(SqliteConnection conn, SqliteTransaction tx, strin cmd.Transaction = tx; cmd.CommandText = $"DELETE FROM {table} WHERE {whereClause};"; cmd.Parameters.AddWithValue("@u", username); + if (displayName != null) cmd.Parameters.AddWithValue("@d", displayName); // v1.1.11: display-name key int rows = cmd.ExecuteNonQuery(); if (rows > 0) DebugLogger.Instance.LogInfo("PERMADEATH", $" {table}: removed {rows} row(s) for '{username}'"); @@ -5869,6 +5893,20 @@ public bool PlayerExists(string nameOrDisplay) /// Resolves a player name (username or display name) to their lowercase display name. /// Returns null if the player doesn't exist. /// + /// v1.1.11: the players.display_name of one key (the married surname form), or null. + public string? GetStoredDisplayName(string username) + { + try + { + using var connection = OpenConnection(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT display_name FROM players WHERE LOWER(username) = LOWER(@u) LIMIT 1;"; + cmd.Parameters.AddWithValue("@u", username); + return cmd.ExecuteScalar() as string; + } + catch { return null; } + } + public string? ResolvePlayerDisplayName(string nameOrDisplay) { try diff --git a/Tests/CharacterRecreationTests.cs b/Tests/CharacterRecreationTests.cs index 739d76bd..18792d5e 100644 --- a/Tests/CharacterRecreationTests.cs +++ b/Tests/CharacterRecreationTests.cs @@ -218,7 +218,6 @@ public void EveryCharacterDelete_RunsThePurgeFirst_OrSaysWhyNot() var allowed = new System.Collections.Generic.Dictionary { ["SaveSystem.cs"] = "DeleteSave: the backend call itself; its online callers purge first (N, D)", - ["CastleLocation.cs"] = "rebellion coin flip: deferred for 1.1.12 (it also passes the wrong key)", }; var problems = new System.Collections.Generic.List(); foreach (var file in Directory.EnumerateFiles(Path.Combine(dir!.FullName, "Scripts"), "*.cs", SearchOption.AllDirectories)) @@ -239,6 +238,20 @@ public void EveryCharacterDelete_RunsThePurgeFirst_OrSaysWhyNot() problems.Should().BeEmpty(); } + [Fact] + public void TheRebellionCoinFlip_DeletesTheCharactersKey_AfterThePurge() + { + // It passed Name2.ToLower() as the key, which deleted 0 rows when the two differed. + var src = CodeOnly(Source("Locations", "CastleLocation.cs")); + int del = src.IndexOf("backend.DeleteGameData(username);", StringComparison.Ordinal); + del.Should().BeGreaterThan(0); + string before = src.Substring(Math.Max(0, del - 1200), 1200); + before.Should().Contain("string username = (!string.IsNullOrEmpty(sctx?.CharacterKey) ? sctx!.CharacterKey : sctx?.Username)"); + before.Should().Contain("await PermadeathHelper.PurgeDeletedCharacterAsync(backend, username,"); + before.Should().NotContain("Name2?.ToLowerInvariant() ?? currentPlayer.Name1"); + before.IndexOf("PurgeDeletedCharacterAsync(", StringComparison.Ordinal).Should().BeGreaterThan(before.IndexOf("string username =", StringComparison.Ordinal)); + } + [Fact] public void CreatingANewGame_RunsTheBackstop() { diff --git a/Tests/DeleteFollowUpTests.cs b/Tests/DeleteFollowUpTests.cs new file mode 100644 index 00000000..cc67a96c --- /dev/null +++ b/Tests/DeleteFollowUpTests.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using UsurperRemake.Systems; +using Xunit; + +namespace UsurperReborn.Tests; + +/// +/// v1.1.11: the delete purge also ends a deleted king's reign (through the abdication path), drops NPC +/// grudges against the name, and removes mail and auction listings kept under the display name. +/// +[Collection("SharedGameSingletons")] +public class DeleteFollowUpTests : IDisposable +{ + private readonly string _path = Path.Combine(Path.GetTempPath(), $"usurper-delfu-{Guid.NewGuid():N}.db"); + private readonly SqlSaveBackend _db; + + public DeleteFollowUpTests() { _db = new SqlSaveBackend(_path); } + + public void Dispose() + { + SqliteConnection.ClearAllPools(); + try { File.Delete(_path); } catch { } + } + + private void Exec(string sql) + { + using var conn = new SqliteConnection($"Data Source={_path}"); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } + + private long Count(string sql) + { + using var conn = new SqliteConnection($"Data Source={_path}"); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + return Convert.ToInt64(cmd.ExecuteScalar()); + } + + private void Player(string username, string displayName) => + Exec($"INSERT INTO players (username, display_name, player_data) VALUES ('{username}', '{displayName}', '{{}}');"); + + /// Runs the body with the given king, then puts the world's throne back as it was. + private static async Task WithKing(King king, Func body) + { + var before = CastleLocation.GetCurrentKing(); + var history = CastleLocation.GetMonarchHistory().ToList(); + var flagged = NPCSpawnSystem.Instance.ActiveNPCs.Where(n => n.King).ToList(); + CastleLocation.SetKing(king); + try { await body(); } + finally + { + foreach (var n in NPCSpawnSystem.Instance.ActiveNPCs) n.King = flagged.Contains(n); + CastleLocation.SetKing(before); + CastleLocation.SetMonarchHistory(history); + } + } + + [Fact] + public async Task ADeletedKing_Abdicates_ThroughTheNormalPath() + { + await WithKing(King.CreateNewKing("Bob", CharacterAI.Human, CharacterSex.Male), async () => + { + await PermadeathHelper.PurgeDeletedCharacterAsync(null, "bob_account", "bob"); + + var now = CastleLocation.GetCurrentKing(); + (now == null || now.Name != "Bob").Should().BeTrue("the deleted character no longer reigns"); + var last = CastleLocation.GetMonarchHistory().Last(); + last.Name.Should().Be("Bob", "the abdication path records the reign"); + last.EndReason.Should().Be("left the throne and the realm"); + }); + } + + [Fact] + public async Task AMarriedKing_IsMatchedByTheStoredDisplayName() + { + Player("bob_account", "Bob Smith"); + await WithKing(King.CreateNewKing("Bob Smith", CharacterAI.Human, CharacterSex.Male), async () => + { + await PermadeathHelper.PurgeDeletedCharacterAsync(_db, "bob_account", "Bob"); + CastleLocation.GetMonarchHistory().Last().Name.Should().Be("Bob Smith"); + (CastleLocation.GetCurrentKing()?.Name).Should().NotBe("Bob Smith"); + }); + } + + [Fact] + public async Task AnNPCKingOfTheSameName_KeepsTheThrone() + { + var npcKing = King.CreateNewKing("Bob", CharacterAI.Computer, CharacterSex.Male); + await WithKing(npcKing, async () => + { + await PermadeathHelper.PurgeDeletedCharacterAsync(null, "bob_account", "Bob"); + CastleLocation.GetCurrentKing().Should().BeSameAs(npcKing); + npcKing.IsActive.Should().BeTrue(); + }); + await WithKing(King.CreateNewKing("Alice", CharacterAI.Human, CharacterSex.Female), async () => + { + CastleLocation.AbdicateDeletedKing("Bob", "Bob", "x").Should().BeFalse("another player's reign is untouched"); + await Task.CompletedTask; + }); + } + + private static MemoryEvent Mem(MemoryType type, string who, float impact = 0f) => + new MemoryEvent { Type = type, InvolvedCharacter = who, Description = type.ToString(), EmotionalImpact = impact, Importance = 0.9f }; + + [Fact] + public void GrudgeTypes_AreTheNegativeOnes() + { + MemorySystem.IsGrudge(MemoryType.Attacked, 0f).Should().BeTrue(); + MemorySystem.IsGrudge(MemoryType.Murdered, 0f).Should().BeTrue(); + MemorySystem.IsGrudge(MemoryType.KilledMyParent, 0f).Should().BeTrue(); + MemorySystem.IsGrudge(MemoryType.HeardGossip, -0.4f).Should().BeTrue("a negative memory of any type"); + MemorySystem.IsGrudge(MemoryType.Helped, 0.4f).Should().BeFalse(); + MemorySystem.IsGrudge(MemoryType.Traded, 0f).Should().BeFalse(); + } + + [Fact] + public async Task NpcGrudgesAgainstTheName_AreDropped_OthersKept() + { + var npc = new NPC { ID = "npc_grudge_test", Name1 = "Grudger", Name2 = "Grudger", Level = 10 }; + npc.Memory = new MemorySystem(); + npc.Memory.RecordEvent(Mem(MemoryType.Attacked, "Bob")); + npc.Memory.RecordEvent(Mem(MemoryType.Insulted, "bob")); + npc.Memory.RecordEvent(Mem(MemoryType.Helped, "Bob", 0.4f)); + npc.Memory.RecordEvent(Mem(MemoryType.Attacked, "Alice")); + NPCSpawnSystem.Instance.ActiveNPCs.Add(npc); + try + { + npc.Memory.GetCharacterImpression("Bob").Should().BeLessThan(0f, "the seed must be a real grudge"); + await PermadeathHelper.PurgeDeletedCharacterAsync(null, "bob_account", "Bob"); + + var left = npc.Memory.AllMemories; + left.Should().NotContain(m => m.InvolvedCharacter.Equals("Bob", StringComparison.OrdinalIgnoreCase) && m.Type != MemoryType.Helped); + left.Should().Contain(m => m.InvolvedCharacter == "Bob" && m.Type == MemoryType.Helped, "a kind memory is kept"); + left.Should().Contain(m => m.InvolvedCharacter == "Alice", "another character's grudge is kept"); + npc.Memory.GetCharacterImpression("Bob").Should().BeGreaterThan(0f, "the impression is rebuilt from what remains"); + npc.Memory.GetCharacterImpression("Alice").Should().BeLessThan(0f); + } + finally { NPCSpawnSystem.Instance.ActiveNPCs.Remove(npc); } + } + + [Fact] + public async Task TheSharedNpcRecord_LosesOnlyTheGrudges_AndKeepsEverythingElse() + { + string json = "[{\"name\":\"Grudger\",\"level\":10,\"unknownField\":{\"x\":1},\"memories\":[" + + "{\"type\":\"Attacked\",\"involvedCharacter\":\"Bob\",\"emotionalImpact\":-0.5}," + + "{\"type\":\"Helped\",\"involvedCharacter\":\"Bob\",\"emotionalImpact\":0.4}," + + "{\"type\":\"Betrayed\",\"involvedCharacter\":\"Alice\",\"emotionalImpact\":-0.9}]}," + + "{\"name\":\"Other\",\"memories\":[]}]"; + await _db.SaveWorldState(OnlineStateManager.KEY_NPCS, json); + long version = _db.GetWorldStateVersion(OnlineStateManager.KEY_NPCS); + + await PermadeathHelper.PurgeDeletedCharacterAsync(_db, "bob_account", "Bob"); + + var saved = System.Text.Json.Nodes.JsonNode.Parse((await _db.LoadWorldState(OnlineStateManager.KEY_NPCS))!)!.AsArray(); + var memories = saved[0]!["memories"]!.AsArray(); + memories.Select(m => (string)m!["type"]!).Should().BeEquivalentTo(new[] { "Helped", "Betrayed" }); + ((int)saved[0]!["unknownField"]!["x"]!).Should().Be(1, "the record is edited in place, not rebuilt"); + saved.Count.Should().Be(2); + _db.GetWorldStateVersion(OnlineStateManager.KEY_NPCS).Should().Be(version + 1, "a versioned write, so the world sim reloads it"); + + PermadeathHelper.RemoveGrudgesFromNpcJson(json, "Carol", out int none).Should().BeNull(); + none.Should().Be(0); + } + + [Fact] + public async Task MailAndAuctions_UnderTheDisplayName_ArePurged_OthersKept() + { + Player("bob_account", "Bob Smith"); + Player("alice", "Alice"); + await _db.SendMessage("System", "Bob", "mail", "to the display name"); + await _db.SendMessage("System", "Bob Smith", "mail", "to the married name"); + await _db.SendMessage("System", "bob_account", "mail", "to the key"); + await _db.SendMessage("System", "Alice", "mail", "to someone else"); + await _db.SendMessage("Bob", "Alice", "mail", "sent by Bob, in Alice's inbox"); + + int bobs = await _db.CreateAuctionListing("bob", "Sword", "{}", 100); + int bobsMarried = await _db.CreateAuctionListing("Bob Smith", "Shield", "{}", 100); + int alices = await _db.CreateAuctionListing("alice", "Helm", "{}", 100); + int alicesSoldToBob = await _db.CreateAuctionListing("alice", "Ring", "{}", 500); + (await _db.BuyAuctionListing(alicesSoldToBob, "bob")).Should().BeTrue(); + + WithCompleteRoster(() => _db.PurgePlayerWorldState("bob_account", "Bob")); + + Count("SELECT COUNT(*) FROM messages WHERE to_player IN ('Bob', 'Bob Smith', 'bob_account');").Should().Be(0); + Count("SELECT COUNT(*) FROM messages WHERE to_player = 'Alice';").Should().Be(2, "Alice's mail stays, even mail Bob sent"); + Count($"SELECT COUNT(*) FROM auction_listings WHERE id IN ({bobs}, {bobsMarried});").Should().Be(0); + Count($"SELECT COUNT(*) FROM auction_listings WHERE id = {alices};").Should().Be(1); + Count($"SELECT COUNT(*) FROM auction_listings WHERE id = {alicesSoldToBob} AND status = 'sold' AND COALESCE(gold_collected, 0) = 0;") + .Should().Be(1, "the seller's uncollected gold must not vanish with the buyer"); + } + + [Fact] + public async Task MailToANameThatIsAnotherAccountsKey_IsKept() + { + Player("bob", "Robert"); // another account whose KEY is "bob" + Player("bob_account", "Bob"); + await _db.SendMessage("System", "bob", "mail", "for the other account"); + _db.PurgePlayerWorldState("bob_account", "Bob"); + Count("SELECT COUNT(*) FROM messages WHERE to_player = 'bob';").Should().Be(1); + } + + /// Runs the purge with a roster complete enough to rule out an NPC of the name (NPCSpawnSystem.IsCountPlausible). + private static void WithCompleteRoster(Action run, params NPC[] members) + { + var spawner = NPCSpawnSystem.Instance; + var added = new System.Collections.Generic.List(members); + for (int i = added.Count; i < 60; i++) + added.Add(new NPC { ID = $"npc_auction_filler_{i}", Name1 = $"Filler {i}", Name2 = $"Filler {i}", Level = 5 }); + foreach (var n in added) spawner.ActiveNPCs.Add(n); + try { run(); } + finally { foreach (var n in added) spawner.ActiveNPCs.Remove(n); } + } + + [Fact] + public async Task AnNPCsListings_UnderTheSameName_AreKept() + { + // NPCs list auctions under their own name in lowercase; a player of that name being deleted must not + // take them, and neither may a purge that cannot rule out such an NPC (an incomplete roster). + Player("vesna_account", "Vesna"); + int npcListing = await _db.CreateAuctionListing("vesna", "Staff", "{}", 100); + var npc = new NPC { ID = "npc_vesna", Name1 = "Vesna", Name2 = "Vesna", Level = 20 }; + WithCompleteRoster(() => _db.PurgePlayerWorldState("vesna_account", "Vesna"), npc); + Count($"SELECT COUNT(*) FROM auction_listings WHERE id = {npcListing};").Should().Be(1, "an NPC carries the name"); + + _db.PurgePlayerWorldState("vesna_account", "Vesna"); // the test roster alone is not complete + Count($"SELECT COUNT(*) FROM auction_listings WHERE id = {npcListing};").Should().Be(1, "an incomplete roster cannot rule out the NPC"); + } +} From e211bd528a2587c428cd2ccb50884eaced8186dc Mon Sep 17 00:00:00 2001 From: binaryknight Date: Wed, 23 Sep 2026 23:32:18 +0000 Subject: [PATCH 31/42] Fixes from the twelfth review of the 1.1.11 branch - The auction purge takes a seller name only when no other player uses it (display name or save name, malformed saves skipped), and the account key pass keeps an NPC's listings as the display-name pass does. - A bounty is claimed once in the database (bounty_claims, INSERT OR IGNORE, also added as a migration) before it is paid, so two game processes holding the same bounty cannot both pay it. - Only a character loaded from a player's save (IsLoadedPlayer, run time only) collects a player bounty; a hired guard of the same name does not. - The world-save succession update rechecks the join grace itself, and guild succession reads and writes in one transaction, rolling back if the new leader has left. - Deleting a king from a fresh process loads the shared royal court first. - The shared NPC record edit that drops grudges also clears the marriage to the deleted character, so the world sim does not reload it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Core/Character.cs | 3 + Scripts/Locations/CastleLocation.cs | 45 ++++++-- Scripts/Systems/CombatEngine.cs | 6 +- Scripts/Systems/GuildSystem.cs | 7 +- Scripts/Systems/OnlineStateManager.cs | 1 + Scripts/Systems/PermadeathHelper.cs | 50 ++++++--- Scripts/Systems/PlayerCharacterLoader.cs | 1 + Scripts/Systems/QuestSystem.cs | 26 ++++- Scripts/Systems/SqlSaveBackend.cs | 75 +++++++++++-- Scripts/Systems/WorldSimService.cs | 3 +- Tests/DeleteFollowUpTests.cs | 128 ++++++++++++++++++++++- Tests/LeaderSuccessionTests.cs | 47 +++++++++ Tests/NPCDefeatQuestTests.cs | 117 ++++++++++++++++++++- Tests/TeamHQBonusTests.cs | 10 ++ 14 files changed, 478 insertions(+), 41 deletions(-) diff --git a/Scripts/Core/Character.cs b/Scripts/Core/Character.cs index 0d5cc6b6..c0a26556 100644 --- a/Scripts/Core/Character.cs +++ b/Scripts/Core/Character.cs @@ -598,6 +598,9 @@ public void AddMaterial(string materialId, int count = 1) public string HQLevelsTeam { get; set; } = ""; /// v1.1.11: when the HQ levels were read (run time only); TeamHQBonus reads them again when old. public DateTime HQLevelsReadAt { get; set; } = DateTime.MinValue; + /// v1.1.11: built from a real player's save (PlayerCharacterLoader), not a hired guard or an echo. Run time only. + [System.Text.Json.Serialization.JsonIgnore] + public bool IsLoadedPlayer { get; set; } // Herb pouch inventory (v0.48.5) public int HerbHealing { get; set; } // Healing Herbs (garden lv1) diff --git a/Scripts/Locations/CastleLocation.cs b/Scripts/Locations/CastleLocation.cs index 0945d320..e9b98329 100644 --- a/Scripts/Locations/CastleLocation.cs +++ b/Scripts/Locations/CastleLocation.cs @@ -6017,7 +6017,8 @@ private async Task ChallengeThrone() HP = 500, MaxHP = 500, AI = CharacterAI.Computer, - Class = CharacterClass.Warrior + Class = CharacterClass.Warrior, + IsLoadedPlayer = true // v1.1.11: stands in for the player king, so a bounty on them is paid }; } @@ -6051,7 +6052,8 @@ private async Task ChallengeThrone() HP = (long)((400 + kingLevel * 60) * GameConfig.KingDefenderHPBonus), MaxHP = (long)((400 + kingLevel * 60) * GameConfig.KingDefenderHPBonus), AI = CharacterAI.Computer, - Class = CharacterClass.Warrior + Class = CharacterClass.Warrior, + IsLoadedPlayer = currentKing.AI == CharacterAI.Human // v1.1.11: a stand-in for a player king }; terminal.SetColor("gray"); @@ -7943,15 +7945,44 @@ public static void AbdicatePlayerThrone(Character player, string reason) public static bool AbdicateDeletedKing(string? name, string? displayName, string reason) { var king = GetCurrentKing(); + if (!IsDeletedCharactersReign(king, name, displayName)) return false; + EndPlayerReign(king!.Name, reason); + return true; + } + + /// v1.1.11: set when this process has read the shared royal_court (the login, castle or world-sim loader). + public static bool RoyalCourtLoadedFromShared { get; set; } + + /// v1.1.11: an active player king named by the deleted character's Name2 or display name. + internal static bool IsDeletedCharactersReign(King? king, string? name, string? displayName) + { if (king == null || !king.IsActive || king.AI != CharacterAI.Human) return false; bool named(string? n) => !string.IsNullOrWhiteSpace(n) && string.Equals(king.Name, n, StringComparison.OrdinalIgnoreCase); - if (!named(name) && !named(displayName)) return false; - EndPlayerReign(king.Name, reason); + return named(name) || named(displayName); + } + + /// v1.1.11: online, a process that has not read the shared royal_court (a fresh door process at character select) reads it first. + internal static bool NeedsSharedCourtLoad(bool online, King? king, bool loadedFromShared) => + online && (king == null || !loadedFromShared); + + /// + /// v1.1.11: the delete path. Online, the authoritative royal_court is read first when this process + /// has not loaded it, and the ended reign is written back to it before returning. + /// + public static async Task AbdicateDeletedKingAsync(string? name, string? displayName, string reason) + { + var osm = UsurperRemake.BBS.DoorMode.IsOnlineMode ? OnlineStateManager.Instance : null; + if (NeedsSharedCourtLoad(osm != null, GetCurrentKing(), RoyalCourtLoadedFromShared)) + await osm!.LoadRoyalCourtFromWorldState(); + var king = GetCurrentKing(); + if (!IsDeletedCharactersReign(king, name, displayName)) return false; + EndPlayerReign(king!.Name, reason, persist: osm == null); + if (osm != null) await osm.SaveRoyalCourtToWorldState(); return true; } // v1.1.11: the throne side of AbdicatePlayerThrone, shared with AbdicateDeletedKing. - private static void EndPlayerReign(string kingDisplayName, string reason) + private static void EndPlayerReign(string kingDisplayName, string reason, bool persist = true) { var king = GetCurrentKing(); @@ -8000,8 +8031,8 @@ private static void EndPlayerReign(string kingDisplayName, string reason) } } - // Persist to world_state in online mode - if (UsurperRemake.BBS.DoorMode.IsOnlineMode) + // Persist to world_state in online mode (v1.1.11: the delete path awaits its own write) + if (persist && UsurperRemake.BBS.DoorMode.IsOnlineMode) { var osm = OnlineStateManager.Instance; if (osm != null) diff --git a/Scripts/Systems/CombatEngine.cs b/Scripts/Systems/CombatEngine.cs index 66f7420e..f3d382e3 100644 --- a/Scripts/Systems/CombatEngine.cs +++ b/Scripts/Systems/CombatEngine.cs @@ -26151,9 +26151,10 @@ private async Task ReportDuelDefeat(CombatResult result) long bounty; if (result.Opponent is NPC npc) bounty = QuestSystem.RecordNPCDefeat(result.Player, npc, killed: _pvpLethal && !npc.IsAlive); - else + else if (result.Opponent.IsLoadedPlayer) { - // v1.1.11: a Crown bounty on a player is paid to the duel's winner, lethal or not (a duel won) + // v1.1.11: a Crown bounty on a player is paid to the duel's winner, lethal or not (a duel won). + // Only a player loaded from a save; a hired guard or an echo may carry a player's name. var paid = QuestSystem.CollectBountiesOnPlayer(result.Player, result.Opponent); bounty = paid.Sum(QuestSystem.BountyReward); if (paid.Count > 0 && UsurperRemake.BBS.DoorMode.IsOnlineMode && OnlineStateManager.IsActive) @@ -26162,6 +26163,7 @@ private async Task ReportDuelDefeat(CombatResult result) await OnlineStateManager.Instance!.RemoveSharedQuestsAsync(q => ids.Contains(q.Id)); // edited in place } } + else bounty = 0; if (bounty > 0) { terminal.WriteLine(Loc.Get("street.fight.bounty_collected", bounty.ToString("N0")), "bright_yellow"); diff --git a/Scripts/Systems/GuildSystem.cs b/Scripts/Systems/GuildSystem.cs index c4bb62b0..487c14ed 100644 --- a/Scripts/Systems/GuildSystem.cs +++ b/Scripts/Systems/GuildSystem.cs @@ -184,9 +184,13 @@ public void ForgetMember(string? username) { using var conn = new SqliteConnection(connectionString); conn.Open(); + // v1.1.11: the candidates are read in the same (immediate) transaction as the update, so a + // member who leaves meanwhile cannot be appointed + using var tx = conn.BeginTransaction(deferred: false); var candidates = new List<(string, int, string?)>(); using (var cmd = conn.CreateCommand()) { + cmd.Transaction = tx; cmd.CommandText = @" SELECT gm.username, CAST(CASE WHEN json_valid(p.player_data) THEN json_extract(p.player_data, '$.player.level') END AS INTEGER), @@ -205,7 +209,6 @@ FROM guild_members gm var successor = SqlSaveBackend.PickSuccessor(candidates); if (successor == null) return null; - using var tx = conn.BeginTransaction(); using (var update = conn.CreateCommand()) { // v1.1.11: a leader changed meanwhile (a transfer) wins @@ -222,7 +225,7 @@ FROM guild_members gm rank.CommandText = "UPDATE guild_members SET rank = 'Leader' WHERE username = @new COLLATE NOCASE AND guild_name = @guild COLLATE NOCASE"; rank.Parameters.AddWithValue("@new", successor); rank.Parameters.AddWithValue("@guild", guildName); - rank.ExecuteNonQuery(); + if (rank.ExecuteNonQuery() != 1) { tx.Rollback(); return null; } // v1.1.11: never a guild with no Leader rank } tx.Commit(); DebugLogger.Instance?.LogInfo("GUILD", $"Guild '{guildName}' leadership passed from '{oldLeader}' to '{successor.ToLowerInvariant()}'"); diff --git a/Scripts/Systems/OnlineStateManager.cs b/Scripts/Systems/OnlineStateManager.cs index 92381e88..bbe3ce67 100644 --- a/Scripts/Systems/OnlineStateManager.cs +++ b/Scripts/Systems/OnlineStateManager.cs @@ -638,6 +638,7 @@ public async Task LoadRoyalCourtFromWorldState() if (string.IsNullOrEmpty(json)) return; var royalCourt = JsonSerializer.Deserialize(json, jsonOptions); + if (royalCourt != null) global::CastleLocation.RoyalCourtLoadedFromShared = true; // v1.1.11 if (royalCourt == null || string.IsNullOrEmpty(royalCourt.KingName)) return; var king = global::CastleLocation.GetCurrentKing(); diff --git a/Scripts/Systems/PermadeathHelper.cs b/Scripts/Systems/PermadeathHelper.cs index 8b2d78b4..a809defb 100644 --- a/Scripts/Systems/PermadeathHelper.cs +++ b/Scripts/Systems/PermadeathHelper.cs @@ -455,7 +455,7 @@ public static async Task PurgeDeletedCharacterAsync(SqlSaveBackend? backend, str { // v1.1.11: a deleted king abdicates through the normal path (history, NPC succession, persist) string? shown = player?.DisplayName ?? (string.IsNullOrWhiteSpace(username) ? null : backend?.GetStoredDisplayName(username!)); - if (global::CastleLocation.AbdicateDeletedKing(name, shown, "left the throne and the realm")) + if (await global::CastleLocation.AbdicateDeletedKingAsync(name, shown, "left the throne and the realm")) DebugLogger.Instance.LogInfo("DELETE", $"Deleted '{name}' held the throne; the reign has ended."); } catch (Exception tex) { DebugLogger.Instance.LogWarning("DELETE", $"Throne handover failed for '{name}': {tex.Message}"); } @@ -464,8 +464,10 @@ public static async Task PurgeDeletedCharacterAsync(SqlSaveBackend? backend, str { // v1.1.11: NPC grudges naming the character. In memory, and in the shared npcs record // edited in place (a world-sim reload would otherwise bring them back). + // v1.1.11: the same in-place edit also ends NPC marriages to the name, so this write + // cannot carry the old marriage back for a world-sim reload int forgotten = ForgetNpcGrudgesAgainst(name); - if (backend != null) forgotten += await RemoveSharedNpcGrudgesAsync(backend, name); + if (backend != null) forgotten += (await RemoveDeletedCharacterFromSharedNpcsAsync(backend, name)).Grudges; if (forgotten > 0) DebugLogger.Instance.LogInfo("DELETE", $"Dropped {forgotten} NPC grudge memory(ies) of deleted '{name}'."); } @@ -519,12 +521,14 @@ public static int ForgetNpcGrudgesAgainst(string? name) } /// - /// v1.1.11: remove the grudges against the name from the npcs JSON, touching nothing else. - /// Returns the edited JSON, or null when nothing matched. + /// v1.1.11: remove the grudges against the name from the npcs JSON, and end the marriage of an NPC + /// whose spouse was the name (married, isMarried, spouseName, as ClearNpcSpousesOf does), touching + /// nothing else. An NPC married to another NPC is left alone: the registry says so, or another + /// record in the JSON names this NPC as its spouse. Returns the edited JSON, or null when nothing matched. /// - public static string? RemoveGrudgesFromNpcJson(string json, string name, out int removed) + public static string? RemoveDeletedCharacterFromNpcJson(string json, string name, out int grudges, out int spouses) { - removed = 0; + grudges = 0; spouses = 0; if (string.IsNullOrWhiteSpace(json) || string.IsNullOrWhiteSpace(name)) return null; if (System.Text.Json.Nodes.JsonNode.Parse(json) is not System.Text.Json.Nodes.JsonArray npcs) return null; foreach (var npc in npcs) @@ -540,11 +544,27 @@ public static int ForgetNpcGrudgesAgainst(string? name) if ((typed && MemorySystem.IsGrudge(type, impact)) || (!typed && impact < 0f)) { memories.RemoveAt(i); - removed++; + grudges++; } } } - return removed > 0 ? npcs.ToJsonString() : null; + foreach (var npc in npcs) + { + if (npc is not System.Text.Json.Nodes.JsonObject rec) continue; + if (!string.Equals(StringOf(rec["spouseName"]), name, StringComparison.OrdinalIgnoreCase)) continue; + string id = StringOf(rec["characterID"]); + if (!string.IsNullOrEmpty(id) && NPCMarriageRegistry.Instance.IsMarriedToNPC(id)) continue; + string own = StringOf(rec["name"]); + bool npcSpouse = !string.IsNullOrEmpty(own) && npcs.Any(o => o != null && !ReferenceEquals(o, npc) + && string.Equals(StringOf(o["name"]), name, StringComparison.OrdinalIgnoreCase) + && string.Equals(StringOf(o["spouseName"]), own, StringComparison.OrdinalIgnoreCase)); + if (npcSpouse) continue; + rec["married"] = false; + rec["isMarried"] = false; + rec["spouseName"] = ""; + spouses++; + } + return grudges + spouses > 0 ? npcs.ToJsonString() : null; } private static string StringOf(System.Text.Json.Nodes.JsonNode? node) @@ -556,19 +576,19 @@ private static string StringOf(System.Text.Json.Nodes.JsonNode? node) /// v1.1.11: the shared npcs record, edited in place under its version (as RemoveSharedQuestsAsync /// does for quests), never replaced by this process's list. Retries if another writer got in first. /// - public static async Task RemoveSharedNpcGrudgesAsync(SqlSaveBackend backend, string name) + public static async Task<(int Grudges, int Spouses)> RemoveDeletedCharacterFromSharedNpcsAsync(SqlSaveBackend backend, string name) { for (int attempt = 0; attempt < 3; attempt++) { long version = backend.GetWorldStateVersion(OnlineStateManager.KEY_NPCS); string? json = await backend.LoadWorldState(OnlineStateManager.KEY_NPCS); - if (string.IsNullOrEmpty(json)) return 0; - string? edited = RemoveGrudgesFromNpcJson(json, name, out int removed); - if (edited == null) return 0; - if (await backend.SaveWorldStateIfVersion(OnlineStateManager.KEY_NPCS, edited, version)) return removed; + if (string.IsNullOrEmpty(json)) return (0, 0); + string? edited = RemoveDeletedCharacterFromNpcJson(json, name, out int grudges, out int spouses); + if (edited == null) return (0, 0); + if (await backend.SaveWorldStateIfVersion(OnlineStateManager.KEY_NPCS, edited, version)) return (grudges, spouses); } - DebugLogger.Instance.LogWarning("DELETE", $"Shared NPC grudges of '{name}' not cleared: the record kept changing."); - return 0; + DebugLogger.Instance.LogWarning("DELETE", $"Shared NPC grudges and marriages of '{name}' not cleared: the record kept changing."); + return (0, 0); } /// diff --git a/Scripts/Systems/PlayerCharacterLoader.cs b/Scripts/Systems/PlayerCharacterLoader.cs index 668871d1..c9e07162 100644 --- a/Scripts/Systems/PlayerCharacterLoader.cs +++ b/Scripts/Systems/PlayerCharacterLoader.cs @@ -51,6 +51,7 @@ public static Character CreateFromSaveData(PlayerData playerData, string display BonusArmPow = playerData.BonusArmPow, AI = CharacterAI.Computer, IsEcho = isEcho, + IsLoadedPlayer = !isEcho, // v1.1.11: a player's own save, so a bounty on them can be paid // Restore base stats for RecalculateStats BaseStrength = playerData.BaseStrength > 0 ? playerData.BaseStrength : playerData.Strength, BaseDexterity = playerData.BaseDexterity > 0 ? playerData.BaseDexterity : playerData.Dexterity, diff --git a/Scripts/Systems/QuestSystem.cs b/Scripts/Systems/QuestSystem.cs index 60e707b3..fb25b944 100644 --- a/Scripts/Systems/QuestSystem.cs +++ b/Scripts/Systems/QuestSystem.cs @@ -1559,6 +1559,7 @@ public static List CollectBountiesOnPlayer(Character winner, Character lo { var names = new[] { loser.Name2, loser.DisplayName }.Where(n => !string.IsNullOrWhiteSpace(n)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); if (winner == null || names.Count == 0) return new List(); + if (!loser.IsLoadedPlayer) return new List(); // v1.1.11: a hired guard or echo named like a player if (names.Any(n => n.Equals(winner.Name2, StringComparison.OrdinalIgnoreCase) || n.Equals(winner.DisplayName, StringComparison.OrdinalIgnoreCase))) return new List(); @@ -1567,7 +1568,12 @@ public static List CollectBountiesOnPlayer(Character winner, Character lo { claimed = questDatabase.Where(q => !q.Deleted && q.IsPlayerBounty && q.Initiator == KING_BOUNTY_INITIATOR && !string.IsNullOrEmpty(q.TargetNPCName) && names.Any(n => q.TargetNPCName.Equals(n, StringComparison.OrdinalIgnoreCase))).ToList(); - foreach (var q in claimed) { q.Deleted = true; q.Occupier = winner.Name2; q.OccupiedDays = 1; } + // v1.1.11: another process may hold the same bounty; only the one whose DB claim lands pays it. + // A bounty claimed elsewhere is marked Deleted here too, unpaid. + var found = claimed; + claimed = found.Where(q => ClaimAcrossProcesses(q, winner.Name2)).ToList(); + foreach (var q in found) q.Deleted = true; + foreach (var q in claimed) { q.Occupier = winner.Name2; q.OccupiedDays = 1; } } foreach (var bounty in claimed) @@ -1653,6 +1659,18 @@ public static void OnNPCDefeated(Character player, NPC defeatedNPC, bool killed // it done, and both were paid (Codex review). Finding and marking now happen under one lock. private static readonly object _bountyPayoutLock = new(); + /// + /// v1.1.11: the one-time claim in the shared database, taken before any payout. The in-process lock + /// covers one process; separate door and world-sim processes each hold their own copy of the bounty. + /// Without an SQL backend (offline, file saves) the lock is enough. A quest with no id cannot be + /// claimed by id and relies on the lock. + /// + internal static bool ClaimAcrossProcesses(Quest q, string claimer) + { + if (SaveSystem.Instance?.Backend is not SqlSaveBackend sql || string.IsNullOrEmpty(q.Id)) return true; + return sql.TryClaimBounty(q.Id, claimer); + } + public static long AutoCompleteBountyForNPC(Character player, string npcName, bool includeKillContracts = true) { if (string.IsNullOrEmpty(npcName)) return 0; @@ -1670,7 +1688,11 @@ public static long AutoCompleteBountyForNPC(Character player, string npcName, bo !q.IsPlayerBounty && // v1.1.11: beating an NPC never pays a bounty on a player of that name (review) (includeKillContracts || q.QuestTarget != QuestTarget.Assassin) ).ToList(); - foreach (var claimed in matchingBounties) claimed.Deleted = true; // claimed for this payout + // v1.1.11: another process may hold the same bounty; only the one whose DB claim lands pays it. + // A bounty claimed elsewhere is marked Deleted here too, unpaid. + var found = matchingBounties; + matchingBounties = found.Where(q => ClaimAcrossProcesses(q, player?.Name2 ?? "")).ToList(); + foreach (var claimed in found) claimed.Deleted = true; // claimed for this payout } foreach (var bounty in matchingBounties) diff --git a/Scripts/Systems/SqlSaveBackend.cs b/Scripts/Systems/SqlSaveBackend.cs index 58482a0d..96ea53cb 100644 --- a/Scripts/Systems/SqlSaveBackend.cs +++ b/Scripts/Systems/SqlSaveBackend.cs @@ -841,6 +841,13 @@ created_at TEXT DEFAULT (datetime('now')), UNIQUE(username, event) ); CREATE INDEX IF NOT EXISTS idx_onboarding_event ON onboarding_events(event, created_at DESC); + + -- v1.1.11: one row per bounty paid, so a bounty is paid once across processes + CREATE TABLE IF NOT EXISTS bounty_claims ( + quest_id TEXT PRIMARY KEY, + claimed_by TEXT, + claimed_at TEXT DEFAULT (datetime('now')) + ); "; cmd.ExecuteNonQuery(); } @@ -940,6 +947,15 @@ created_at TEXT DEFAULT (datetime('now')), } catch { /* Column already exists - expected */ } + // v1.1.11: the one-time bounty claim table, also on a database made by an older release + try + { + using var migCmd = connection.CreateCommand(); + migCmd.CommandText = "CREATE TABLE IF NOT EXISTS bounty_claims (quest_id TEXT PRIMARY KEY, claimed_by TEXT, claimed_at TEXT DEFAULT (datetime('now')));"; + migCmd.ExecuteNonQuery(); + } + catch (Exception ex) { DebugLogger.Instance.LogWarning("SQL", $"bounty_claims not ensured: {ex.Message}"); } + MigrateWorldBossTables(connection); // v1.1.4 DebugLogger.Instance.LogInfo("SQL", $"Database initialized at {databasePath}"); @@ -1183,9 +1199,10 @@ public void PurgePlayerWorldState(string username, string? displayName = null) ExecPurge(connection, tx, "bounties", "LOWER(target_player) = LOWER(@u) OR LOWER(placed_by) = LOWER(@u) OR LOWER(claimed_by) = LOWER(@u)", username); // v1.1.11: only the character's own listings. The buyer clause is gone: a sold row the seller // has not collected holds that seller's gold, and the buyer already has the item. A seller - // equal to the key is skipped when it is another player's display name. - ExecPurge(connection, tx, "auction_listings", - "LOWER(seller) = LOWER(@u) AND NOT EXISTS (SELECT 1 FROM players p WHERE LOWER(p.display_name) = LOWER(@u) AND LOWER(p.username) != LOWER(@u))", username); + // name another player or an NPC may carry now is left alone. + if (!CouldBeNpcName(username)) + ExecPurge(connection, tx, "auction_listings", + $"LOWER(seller) = LOWER(@u) AND {SellerNotOtherPlayer}", username); ExecPurge(connection, tx, "world_boss_damage", "LOWER(player_name) = LOWER(@u)", username); // v0.65.0: pvp_log was deliberately excluded in v0.60.5 ("history @@ -1218,11 +1235,9 @@ public void PurgePlayerWorldState(string username, string? displayName = null) username, displayName); // NPCs list under their own name in lowercase: a name an NPC may carry (one does, or the roster // is not complete enough to rule it out) keeps its listings; a stale one is the safe side - var spawner = NPCSpawnSystem.Instance; - bool couldBeNpcName = spawner == null || !spawner.IsRosterTrustworthy || QuestSystem.IsNPCName(displayName); - if (!couldBeNpcName) + if (!CouldBeNpcName(displayName)) ExecPurge(connection, tx, "auction_listings", - $"LOWER(seller) = LOWER(@d) OR LOWER(seller) IN {ownNames}", username, displayName); + $"(LOWER(seller) = LOWER(@d) OR LOWER(seller) IN {ownNames}) AND {SellerNotOtherPlayer}", username, displayName); } tx.Commit(); @@ -1247,6 +1262,44 @@ public void PurgePlayerWorldState(string username, string? displayName = null) } } + // v1.1.11: an auction seller name that another players row carries now, as its display name or + // its save's Name2, is that player's listing, never the deleted character's. + private const string SellerNotOtherPlayer = + "NOT EXISTS (SELECT 1 FROM players p WHERE LOWER(p.username) != LOWER(@u) AND (" + + "LOWER(p.display_name) = LOWER(auction_listings.seller) OR " + + "LOWER(CASE WHEN json_valid(p.player_data) THEN json_extract(p.player_data, '$.player.name2') END) = LOWER(auction_listings.seller)))"; + + // v1.1.11: a name an NPC may carry (one does, or the roster cannot rule it out) + private static bool CouldBeNpcName(string name) + { + var spawner = NPCSpawnSystem.Instance; + return spawner == null || !spawner.IsRosterTrustworthy || QuestSystem.IsNPCName(name); + } + + /// + /// v1.1.11: claims a bounty for payout, once across every process on this database. True only for the + /// first claim of the quest id; false when another process (or an earlier claim) already took it, or + /// the claim could not be written. + /// + public bool TryClaimBounty(string questId, string claimer) + { + if (string.IsNullOrWhiteSpace(questId)) return false; + try + { + using var connection = OpenConnection(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "INSERT OR IGNORE INTO bounty_claims (quest_id, claimed_by) VALUES (@q, @c);"; + cmd.Parameters.AddWithValue("@q", questId); + cmd.Parameters.AddWithValue("@c", claimer ?? ""); + return cmd.ExecuteNonQuery() == 1; + } + catch (Exception ex) + { + DebugLogger.Instance.LogError("SQL", $"TryClaimBounty failed for '{questId}': {ex.Message}"); + return false; + } + } + private static void ExecPurge(SqliteConnection conn, SqliteTransaction tx, string table, string whereClause, string username, string? displayName = null) { try @@ -5267,8 +5320,10 @@ UPDATE pending_inheritance /// player member, never excludeKey, a banned player or an emergency account. Only if the team still /// has oldKey; with requireOldLeaderGone, also only if oldKey's save no longer names the team. The /// key is left alone when there is no successor. True when the team was updated. + /// v1.1.11: with respectJoinGrace (the world-save pass), also only if nobody joined within + /// EmptyTeamJoinGraceMinutes, checked by the update itself: a returning leader's save may not have landed. /// - public bool TryPassTeamLeadership(string teamName, string oldKey, string? excludeKey, bool requireOldLeaderGone, out string? newKey) + public bool TryPassTeamLeadership(string teamName, string oldKey, string? excludeKey, bool requireOldLeaderGone, out string? newKey, bool respectJoinGrace = false) { newKey = null; try @@ -5303,7 +5358,9 @@ FROM players p (requireOldLeaderGone ? @" AND NOT EXISTS (SELECT 1 FROM players l WHERE l.username = @old AND (NOT json_valid(l.player_data) - OR (CASE WHEN json_valid(l.player_data) THEN json_extract(l.player_data, '$.player.team') END) = @team));" : ";"); + OR (CASE WHEN json_valid(l.player_data) THEN json_extract(l.player_data, '$.player.team') END) = @team))" : "") + + (respectJoinGrace ? " AND (last_join_at IS NULL OR last_join_at < datetime('now', '-' || @joinGrace || ' minutes'))" : "") + ";"; + if (respectJoinGrace) update.Parameters.AddWithValue("@joinGrace", GameConfig.EmptyTeamJoinGraceMinutes); update.Parameters.AddWithValue("@new", successor.ToLowerInvariant()); update.Parameters.AddWithValue("@team", teamName); update.Parameters.AddWithValue("@old", oldKey); diff --git a/Scripts/Systems/WorldSimService.cs b/Scripts/Systems/WorldSimService.cs index d4adf2dc..e6b7e524 100644 --- a/Scripts/Systems/WorldSimService.cs +++ b/Scripts/Systems/WorldSimService.cs @@ -594,6 +594,7 @@ private void LoadRoyalCourtFromWorldState() if (string.IsNullOrEmpty(json)) return; var royalCourt = JsonSerializer.Deserialize(json, jsonOptions); + if (royalCourt != null) CastleLocation.RoyalCourtLoadedFromShared = true; // v1.1.11 if (royalCourt == null || string.IsNullOrEmpty(royalCourt.KingName)) return; var king = CastleLocation.GetCurrentKing(); @@ -1466,7 +1467,7 @@ internal int PassLeadershipOfDepartedLeaders() try { foreach (var (team, leader) in sqlBackend.GetTeamsLedByExMembers()) - if (sqlBackend.TryPassTeamLeadership(team, leader, leader, requireOldLeaderGone: true, out _)) passed++; + if (sqlBackend.TryPassTeamLeadership(team, leader, leader, requireOldLeaderGone: true, out _, respectJoinGrace: true)) passed++; // v1.1.11: rechecks the join grace } catch (Exception ex) { diff --git a/Tests/DeleteFollowUpTests.cs b/Tests/DeleteFollowUpTests.cs index cc67a96c..60ea64aa 100644 --- a/Tests/DeleteFollowUpTests.cs +++ b/Tests/DeleteFollowUpTests.cs @@ -168,7 +168,7 @@ public async Task TheSharedNpcRecord_LosesOnlyTheGrudges_AndKeepsEverythingElse( saved.Count.Should().Be(2); _db.GetWorldStateVersion(OnlineStateManager.KEY_NPCS).Should().Be(version + 1, "a versioned write, so the world sim reloads it"); - PermadeathHelper.RemoveGrudgesFromNpcJson(json, "Carol", out int none).Should().BeNull(); + PermadeathHelper.RemoveDeletedCharacterFromNpcJson(json, "Carol", out int none, out int noSpouse).Should().BeNull(); none.Should().Be(0); } @@ -235,4 +235,130 @@ public async Task AnNPCsListings_UnderTheSameName_AreKept() _db.PurgePlayerWorldState("vesna_account", "Vesna"); // the test roster alone is not complete Count($"SELECT COUNT(*) FROM auction_listings WHERE id = {npcListing};").Should().Be(1, "an incomplete roster cannot rule out the NPC"); } + + // ─── v1.1.11: review round 12 ─── + + private void DropJsonIndexes() + { + using var conn = new SqliteConnection($"Data Source={_path}"); + conn.Open(); + var names = new List(); + using (var q = conn.CreateCommand()) + { + q.CommandText = "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'players' AND sql LIKE '%json%';"; + using var r = q.ExecuteReader(); + while (r.Read()) names.Add(r.GetString(0)); + } + foreach (var name in names) { using var d = conn.CreateCommand(); d.CommandText = $"DROP INDEX \"{name}\";"; d.ExecuteNonQuery(); } + } + + private void PlayerWithBlob(string username, string displayName, string blob) => + Exec($"INSERT INTO players (username, display_name, player_data) VALUES ('{username}', '{displayName}', '{blob}');"); + + [Fact] + public async Task ANewBobsListing_SurvivesDeletingOldBobSmith() + { + PlayerWithBlob("old_bob", "Bob Smith", "{\"player\":{\"name2\":\"Bob\"}}"); + PlayerWithBlob("new_bob", "Bob Jones", "{\"player\":{\"name2\":\"Bob\"}}"); // Name2 Bob, since married + DropJsonIndexes(); // a fresh database's expression indexes refuse a malformed blob; an upgraded one may lack them + PlayerWithBlob("broken", "Zed", "not json {"); // a malformed blob in the table + int newBobs = await _db.CreateAuctionListing("bob", "Axe", "{}", 100); + int oldMarried = await _db.CreateAuctionListing("bob smith", "Shield", "{}", 100); + + WithCompleteRoster(() => _db.PurgePlayerWorldState("old_bob", "Bob")); + + Count($"SELECT COUNT(*) FROM auction_listings WHERE id = {newBobs};").Should().Be(1, "another player carries the name Bob now"); + Count($"SELECT COUNT(*) FROM auction_listings WHERE id = {oldMarried};").Should().Be(0, "the deleted character's own listing goes, malformed blob or not"); + } + + [Fact] + public async Task AnNPCsListing_SurvivesDeletingAnAccountOfItsName() + { + // the username key pass gets the same NPC protection as the display-name pass + Player("vesna", "Someone Else"); + int npcListing = await _db.CreateAuctionListing("vesna", "Staff", "{}", 100); + var npc = new NPC { ID = "npc_vesna_key", Name1 = "Vesna", Name2 = "Vesna", Level = 20 }; + WithCompleteRoster(() => _db.PurgePlayerWorldState("vesna", "Someone Else"), npc); + Count($"SELECT COUNT(*) FROM auction_listings WHERE id = {npcListing};").Should().Be(1, "an NPC carries the name"); + + _db.PurgePlayerWorldState("vesna", "Someone Else"); + Count($"SELECT COUNT(*) FROM auction_listings WHERE id = {npcListing};").Should().Be(1, "an incomplete roster cannot rule out the NPC"); + + int own = await _db.CreateAuctionListing("zanthor", "Ring", "{}", 100); + Player("zanthor", "Zanthor"); + WithCompleteRoster(() => _db.PurgePlayerWorldState("zanthor", "Zanthor")); + Count($"SELECT COUNT(*) FROM auction_listings WHERE id = {own};").Should().Be(0, "a name no NPC carries is purged"); + } + + [Fact] + public async Task TheSharedNpcRecord_LosesTheSpouse_AndTheGrudges_InOneVersionedWrite() + { + string json = "[" + + "{\"name\":\"Wife\",\"characterID\":\"npc_json_wife\",\"married\":true,\"isMarried\":true,\"spouseName\":\"Bob\",\"memories\":[" + + "{\"type\":\"Attacked\",\"involvedCharacter\":\"Bob\",\"emotionalImpact\":-0.5}]}," + + "{\"name\":\"Ann\",\"characterID\":\"npc_json_ann\",\"married\":true,\"isMarried\":true,\"spouseName\":\"Bob\",\"memories\":[]}," + + "{\"name\":\"Bob\",\"characterID\":\"npc_json_bob\",\"married\":true,\"isMarried\":true,\"spouseName\":\"Ann\",\"memories\":[]}]"; + await _db.SaveWorldState(OnlineStateManager.KEY_NPCS, json); + long version = _db.GetWorldStateVersion(OnlineStateManager.KEY_NPCS); + + var live = new NPC { ID = "npc_live_wife", Name1 = "Live Wife", Name2 = "Live Wife", Level = 10, SpouseName = "Bob", Married = true, IsMarried = true }; + NPCSpawnSystem.Instance.ActiveNPCs.Add(live); + try + { + await PermadeathHelper.PurgeDeletedCharacterAsync(_db, "bob_account", "Bob"); + live.SpouseName.Should().BeEmpty("the in-memory clear still runs"); + live.IsMarried.Should().BeFalse(); + } + finally { NPCSpawnSystem.Instance.ActiveNPCs.Remove(live); } + + var saved = System.Text.Json.Nodes.JsonNode.Parse((await _db.LoadWorldState(OnlineStateManager.KEY_NPCS))!)!.AsArray(); + var wife = saved[0]!; + ((string)wife["spouseName"]!).Should().BeEmpty(); + ((bool)wife["married"]!).Should().BeFalse(); + ((bool)wife["isMarried"]!).Should().BeFalse(); + wife["memories"]!.AsArray().Should().BeEmpty("the grudge is gone too"); + ((string)saved[1]!["spouseName"]!).Should().Be("Bob", "Ann is married to the NPC Bob, who names her back"); + ((bool)saved[1]!["isMarried"]!).Should().BeTrue(); + _db.GetWorldStateVersion(OnlineStateManager.KEY_NPCS).Should().Be(version + 1, "one versioned write"); + + // a record with only a marriage to the name (no grudge) is still edited + string onlySpouse = "[{\"name\":\"W\",\"married\":true,\"isMarried\":true,\"spouseName\":\"Carl\"}]"; + PermadeathHelper.RemoveDeletedCharacterFromNpcJson(onlySpouse, "Carl", out int g, out int sp).Should().NotBeNull(); + g.Should().Be(0); + sp.Should().Be(1); + } + + [Fact] + public void AFreshProcess_ReadsTheSharedRoyalCourt_BeforeDecidingTheDeletedKingsReign() + { + var king = King.CreateNewKing("Bob", CharacterAI.Human, CharacterSex.Male); + CastleLocation.NeedsSharedCourtLoad(online: true, king: null, loadedFromShared: false).Should().BeTrue(); + CastleLocation.NeedsSharedCourtLoad(online: true, king: king, loadedFromShared: false).Should().BeTrue("a king not read from shared state may be stale"); + CastleLocation.NeedsSharedCourtLoad(online: true, king: king, loadedFromShared: true).Should().BeFalse(); + CastleLocation.NeedsSharedCourtLoad(online: false, king: null, loadedFromShared: false).Should().BeFalse("offline has no shared court"); + + CastleLocation.IsDeletedCharactersReign(king, "Bob", null).Should().BeTrue(); + CastleLocation.IsDeletedCharactersReign(king, "x", "bob").Should().BeTrue(); + CastleLocation.IsDeletedCharactersReign(king, "Alice", "Alice").Should().BeFalse(); + CastleLocation.IsDeletedCharactersReign(King.CreateNewKing("Bob", CharacterAI.Computer, CharacterSex.Male), "Bob", "Bob").Should().BeFalse(); + CastleLocation.IsDeletedCharactersReign(null, "Bob", "Bob").Should().BeFalse(); + + string castle = Source("Locations", "CastleLocation.cs"); + int start = castle.IndexOf("public static async Task AbdicateDeletedKingAsync(", StringComparison.Ordinal); + start.Should().BeGreaterThan(0); + string body = castle.Substring(start, castle.IndexOf("private static void EndPlayerReign(", start, StringComparison.Ordinal) - start); + body.IndexOf("await osm!.LoadRoyalCourtFromWorldState();", StringComparison.Ordinal) + .Should().BeGreaterThan(body.IndexOf("NeedsSharedCourtLoad(", StringComparison.Ordinal)) + .And.BeLessThan(body.IndexOf("IsDeletedCharactersReign(", StringComparison.Ordinal)); + body.Should().Contain("await osm.SaveRoyalCourtToWorldState();", "the ended reign is written to the shared royal_court"); + Source("Systems", "OnlineStateManager.cs").Should().Contain("CastleLocation.RoyalCourtLoadedFromShared = true;"); + Source("Systems", "PermadeathHelper.cs").Should().Contain("await global::CastleLocation.AbdicateDeletedKingAsync("); + } + + private static string Source(string folder, string file) + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Scripts"))) dir = dir.Parent; + return File.ReadAllText(Path.Combine(dir!.FullName, "Scripts", folder, file)); + } } diff --git a/Tests/LeaderSuccessionTests.cs b/Tests/LeaderSuccessionTests.cs index b523b495..79ab2d59 100644 --- a/Tests/LeaderSuccessionTests.cs +++ b/Tests/LeaderSuccessionTests.cs @@ -165,6 +165,53 @@ public void TheWorldSave_SkipsATeamJoinedWithinTheGrace() Leader("Just Joined").Should().Be("bran"); } + [Fact] + public void TheWorldSavesUpdate_RechecksTheJoinGrace() + { + // v1.1.11: the pass listed the team before the founder rejoined; the rejoin stamped last_join_at but + // the founder's save (naming the team) has not landed. The update itself must see the stamp. + Player("founder", null, level: 60); + Player("bran", "Rejoined", level: 20); + Team("Rejoined", "founder"); + Exec("UPDATE player_teams SET last_join_at = datetime('now') WHERE team_name = 'Rejoined';"); + + _db.TryPassTeamLeadership("Rejoined", "founder", "founder", requireOldLeaderGone: true, out var next, respectJoinGrace: true) + .Should().BeFalse(); + next.Should().BeNull(); + Leader("Rejoined").Should().Be("founder"); + + Exec($"UPDATE player_teams SET last_join_at = datetime('now', '-{GameConfig.EmptyTeamJoinGraceMinutes + 1} minutes') WHERE team_name = 'Rejoined';"); + _db.TryPassTeamLeadership("Rejoined", "founder", "founder", requireOldLeaderGone: true, out _, respectJoinGrace: true).Should().BeTrue(); + Leader("Rejoined").Should().Be("bran"); + + CodeOnly(Source("Systems", "WorldSimService.cs")).Should().Contain("respectJoinGrace: true"); + } + + [Fact] + public void AGuildSuccessor_WhoLeftBeforeTheSuccession_IsNotAppointed() + { + var guilds = new GuildSystem(_path, register: false); + Player("boss", null, level: 90); + Player("top", null, level: 80); + Player("next", null, level: 40); + Guild("oakhall", "boss"); + GuildMember("top", "oakhall", "2026-01-01 00:00:00"); + GuildMember("next", "oakhall", "2026-01-02 00:00:00"); + + Exec("DELETE FROM guild_members WHERE username = 'top';"); // the first choice left + guilds.PassLeadership("oakhall", "boss").Should().Be("next"); + GuildLeader("oakhall").Should().Be("next"); + Scalar("SELECT rank FROM guild_members WHERE username = 'next'").Should().Be("Leader"); + + // the candidates are read inside the transaction, and a rank update that changes no row rolls back + string src = CodeOnly(Source("Systems", "GuildSystem.cs")); + int start = src.IndexOf("public string? PassLeadership(", StringComparison.Ordinal); + string body = src.Substring(start, src.IndexOf("public int PassLeadershipOf(", start, StringComparison.Ordinal) - start); + body.IndexOf("BeginTransaction(", StringComparison.Ordinal).Should().BeLessThan(body.IndexOf("SELECT gm.username", StringComparison.Ordinal)); + body.Should().Contain("cmd.Transaction = tx;"); + body.Should().Contain("if (rank.ExecuteNonQuery() != 1) { tx.Rollback(); return null; }"); + } + [Fact] public void AGuildLeaderWhoLeaves_IsSucceededByTheHighestLevelMember() { diff --git a/Tests/NPCDefeatQuestTests.cs b/Tests/NPCDefeatQuestTests.cs index a21bc276..aaae2d33 100644 --- a/Tests/NPCDefeatQuestTests.cs +++ b/Tests/NPCDefeatQuestTests.cs @@ -3,6 +3,7 @@ using System.Reflection; using System.Threading.Tasks; using FluentAssertions; +using Microsoft.Data.Sqlite; using UsurperRemake.Systems; using Xunit; @@ -144,7 +145,7 @@ public async Task BeatingAPlayerWithABountyInADuel_PaysTheWinnerOnce() // Inn and Dormitory attacks on a sleeping player end in this same duel outcome. var bounty = BountyOnPlayer("Wanted Rogue", 4000); var winner = new Character { Name1 = "sheriff", Name2 = "Sheriff", Level = 30, Gold = 0, HP = 500, MaxHP = 500 }; - var rogue = new Character { Name1 = "wanted_rogue", Name2 = "Wanted Rogue", Level = 30, HP = 0, MaxHP = 400 }; + var rogue = new Character { Name1 = "wanted_rogue", Name2 = "Wanted Rogue", Level = 30, HP = 0, MaxHP = 400, IsLoadedPlayer = true }; await Outcome(new CombatResult { Player = winner, Opponent = rogue }); winner.Gold.Should().BeGreaterThanOrEqualTo(4000); bounty.Deleted.Should().BeTrue(); @@ -159,7 +160,7 @@ public async Task BeatingAPlayerWithABountyInADuel_PaysTheWinnerOnce() public void APlayer_CannotCollectTheBountyOnThemselves() { var bounty = BountyOnPlayer("Self Collector", 3000); - var self = new Character { Name1 = "self_collector", Name2 = "Self Collector", Level = 30 }; + var self = new Character { Name1 = "self_collector", Name2 = "Self Collector", Level = 30, IsLoadedPlayer = true }; QuestSystem.CollectBountiesOnPlayer(self, self).Should().BeEmpty(); bounty.Deleted.Should().BeFalse(); bounty.Deleted = true; @@ -175,4 +176,116 @@ public async Task ADuelWonAgainstAnNPC_StillDoesNotPayABountyOnAPlayerOfThatName bounty.Deleted.Should().BeFalse(); bounty.Deleted = true; } + + // ─── v1.1.11: a hired guard is not the player it is named like ─── + + [Fact] + public async Task AHiredGuardNamedLikeAPlayer_PaysNothing_TheLoadedPlayerDoes() + { + var bounty = BountyOnPlayer("Rookie Guard", 3000); + var winner = new Character { Name1 = "raider", Name2 = "Raider", Level = 30, Gold = 0, HP = 500, MaxHP = 500 }; + var guard = new Character { Name1 = "Rookie Guard", Name2 = "Rookie Guard", Level = 10, HP = 0, MaxHP = 100 }; // as HeadlessCombatResolver builds one + await Outcome(new CombatResult { Player = winner, Opponent = guard }); + winner.Gold.Should().BeLessThan(3000, "the guard is not the player with the bounty"); + bounty.Deleted.Should().BeFalse(); + + var player = PlayerCharacterLoader.CreateFromSaveData(new PlayerData { Name1 = "rookie_guard", Name2 = "Rookie Guard", Level = 20, MaxHP = 300 }, "Rookie Guard"); + player.IsLoadedPlayer.Should().BeTrue(); + player.HP = 0; + await Outcome(new CombatResult { Player = winner, Opponent = player }); + winner.Gold.Should().BeGreaterThanOrEqualTo(3000); + bounty.Deleted.Should().BeTrue(); + + PlayerCharacterLoader.CreateFromSaveData(new PlayerData { Name2 = "Echo" }, "Echo", isEcho: true).IsLoadedPlayer.Should().BeFalse(); + } + + // ─── v1.1.11: the one-time claim in the shared database ─── + + private static async Task WithSqlBackend(Func body) + { + string path = Path.Combine(Path.GetTempPath(), $"usurper-claim-{Guid.NewGuid():N}.db"); + var field = typeof(SaveSystem).GetField("instance", BindingFlags.NonPublic | BindingFlags.Static)!; + var before = field.GetValue(null); + var db = new SqlSaveBackend(path); + SaveSystem.InitializeWithBackend(db); + try { await body(db, path); } + finally + { + field.SetValue(null, before); + SqliteConnection.ClearAllPools(); + try { File.Delete(path); } catch { } + } + } + + [Fact] + public async Task TryClaimBounty_SucceedsOncePerQuestId() + { + await WithSqlBackend(async (db, path) => + { + db.TryClaimBounty("Qclaim1", "alice").Should().BeTrue(); + db.TryClaimBounty("Qclaim1", "bob").Should().BeFalse("another process took it"); + db.TryClaimBounty("Qclaim2", "bob").Should().BeTrue(); + db.TryClaimBounty("", "bob").Should().BeFalse(); + + using var conn = new SqliteConnection($"Data Source={path}"); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "INSERT OR IGNORE INTO bounty_claims (quest_id, claimed_by) VALUES ('Qclaim1', 'carol');"; + cmd.ExecuteNonQuery().Should().Be(0, "a second claim of the same id changes no row"); + cmd.CommandText = "SELECT claimed_by FROM bounty_claims WHERE quest_id = 'Qclaim1';"; + (cmd.ExecuteScalar() as string).Should().Be("alice"); + await Task.CompletedTask; + }); + } + + [Fact] + public async Task APlayerBounty_PaidInOneProcess_PaysNothingThroughAnotherProcesssCopy() + { + await WithSqlBackend(async (db, _) => + { + var bounty = BountyOnPlayer("Stale Rogue", 4000); + var winner = new Character { Name1 = "sheriff_a", Name2 = "Sheriff A", Level = 30, Gold = 0 }; + var rogue = new Character { Name1 = "stale_rogue", Name2 = "Stale Rogue", Level = 30, IsLoadedPlayer = true }; + QuestSystem.CollectBountiesOnPlayer(winner, rogue).Should().ContainSingle(); + winner.Gold.Should().BeGreaterThanOrEqualTo(4000); + + // the other process still holds its own copy of the same bounty (same id, another object) + var copy = BountyOnPlayer("Stale Rogue", 4000); + copy.Id = bounty.Id; + var other = new Character { Name1 = "sheriff_b", Name2 = "Sheriff B", Level = 30, Gold = 0 }; + QuestSystem.CollectBountiesOnPlayer(other, rogue).Should().BeEmpty(); + other.Gold.Should().Be(0); + copy.Deleted.Should().BeTrue("another process took it, so it is gone here too"); + await Task.CompletedTask; + }); + } + + [Fact] + public async Task AnNPCBounty_AnotherProcessClaimedFirst_PaysNothing() + { + await WithSqlBackend(async (db, _) => + { + var (hunter, target, bounty) = Wanted("Claimed Mark"); + db.TryClaimBounty(bounty.Id, "another process").Should().BeTrue(); + QuestSystem.AutoCompleteBountyForNPC(hunter, target.Name).Should().Be(0); + hunter.Gold.Should().Be(0); + bounty.Deleted.Should().BeTrue(); + + var (hunter2, target2, bounty2) = Wanted("Unclaimed Mark"); + QuestSystem.AutoCompleteBountyForNPC(hunter2, target2.Name).Should().Be(5000); + db.TryClaimBounty(bounty2.Id, "late").Should().BeFalse("the payout claimed it first"); + await Task.CompletedTask; + }); + } + + [Fact] + public void TheClaimTable_IsAlsoAMigration() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Scripts"))) dir = dir.Parent; + string src = File.ReadAllText(Path.Combine(dir!.FullName, "Scripts", "Systems", "SqlSaveBackend.cs")); + int migrations = src.IndexOf("MigrateWorldBossTables(connection);", StringComparison.Ordinal); + src.LastIndexOf("CREATE TABLE IF NOT EXISTS bounty_claims", migrations, StringComparison.Ordinal) + .Should().BeGreaterThan(src.IndexOf("ALTER TABLE player_teams ADD COLUMN last_join_at", StringComparison.Ordinal)); + } } diff --git a/Tests/TeamHQBonusTests.cs b/Tests/TeamHQBonusTests.cs index 93bfaba4..a4882ac4 100644 --- a/Tests/TeamHQBonusTests.cs +++ b/Tests/TeamHQBonusTests.cs @@ -116,6 +116,16 @@ public void TheLevels_AreNeverInTheSave() .Where(m => m.Name.StartsWith("HQ", StringComparison.Ordinal)).Should().BeEmpty(); } + [Fact] + public void RunTimeOnlyCharacterFields_AreNeverInTheSave() + { + // v1.1.11: IsLoadedPlayer says a Character was built from a player's save this run; a save carrying it + // would mark any character loaded from it, and the HQ bookkeeping is the team's, read from the database. + var names = typeof(PlayerData).GetMembers(BindingFlags.Public | BindingFlags.Instance).Select(m => m.Name).ToList(); + names.Should().NotContain(new[] { "IsLoadedPlayer", "HQLevelsTeam", "HQLevelsReadAt" }); + typeof(Character).GetProperty("IsLoadedPlayer").Should().NotBeNull("the check above must name a real member"); + } + // ─── the status screen ─── private sealed class ScriptedStream : Stream From b0cd8bbc856b2e56c54a36a3f70b6c4c02d31a03 Mon Sep 17 00:00:00 2001 From: binaryknight Date: Thu, 24 Sep 2026 00:13:32 +0000 Subject: [PATCH 32/42] Fixes from the thirteenth review of the 1.1.11 branch - A throne vacated by a deleted king is recorded as vacant in the shared royal court; both court loaders honour it and the world sim fills it through the usual NPC succession, instead of writing the king back. - Deleting a character reads the shared court's king without applying it, and loads it only when the deleted character is the king. - A player's marriage to an NPC ends with the player's deletion (it was taken for an NPC-NPC marriage), in memory, the shared record and the marriage registry. - Guild leave re-reads the leader inside the removal's transaction and passes leadership there; a team successor is rechecked for eligibility in the update itself. - The auction purge and its NPC guard share one alias list, married name included. - A save-loaded duel defender keeps its married surname, so a bounty on the married name is collectable. - A quest without an id is claimed by a stable key (a hash of initiator, target and title); bounty claims older than 90 days are pruned. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Core/GameConfig.cs | 2 + Scripts/Locations/CastleLocation.cs | 59 ++++++- Scripts/Systems/ChallengeSystem.cs | 8 + Scripts/Systems/GuildSystem.cs | 153 ++++++++++-------- Scripts/Systems/OnlineStateManager.cs | 29 +++- Scripts/Systems/PermadeathHelper.cs | 27 +++- Scripts/Systems/PlayerCharacterLoader.cs | 2 + Scripts/Systems/QuestSystem.cs | 18 ++- Scripts/Systems/SaveDataStructures.cs | 2 + Scripts/Systems/SqlSaveBackend.cs | 70 +++++++-- Scripts/Systems/WorldSimService.cs | 16 +- Tests/DeleteFollowUpTests.cs | 188 ++++++++++++++++++++++- Tests/LeaderSuccessionTests.cs | 85 +++++++++- Tests/NPCDefeatQuestTests.cs | 81 ++++++++++ 14 files changed, 639 insertions(+), 101 deletions(-) diff --git a/Scripts/Core/GameConfig.cs b/Scripts/Core/GameConfig.cs index 4e1a335e..7dcdf996 100644 --- a/Scripts/Core/GameConfig.cs +++ b/Scripts/Core/GameConfig.cs @@ -1857,6 +1857,8 @@ public static bool IsSmallRace(CharacterRace race) public const int EmptyTeamGraceMinutes = 30; // v1.1.11: after a join, the team is left alone this long so the joiner's save can land (every process) public const int EmptyTeamJoinGraceMinutes = 10; + // v1.1.11: bounty_claims rows are kept this long (a bounty lasts at most 30 days), then pruned by the world save + public const int BountyClaimRetentionDays = 90; // v1.1.10: regular dungeon monsters on the floors before an Old God's floor grow tougher, HP and // damage, by this much per floor: +5% five floors out, up to +25% on the floor before the god diff --git a/Scripts/Locations/CastleLocation.cs b/Scripts/Locations/CastleLocation.cs index e9b98329..8c2ad7ac 100644 --- a/Scripts/Locations/CastleLocation.cs +++ b/Scripts/Locations/CastleLocation.cs @@ -7969,15 +7969,62 @@ internal static bool NeedsSharedCourtLoad(bool online, King? king, bool loadedFr /// v1.1.11: the delete path. Online, the authoritative royal_court is read first when this process /// has not loaded it, and the ended reign is written back to it before returning. /// - public static async Task AbdicateDeletedKingAsync(string? name, string? displayName, string reason) + public static Task AbdicateDeletedKingAsync(string? name, string? displayName, string reason) { var osm = UsurperRemake.BBS.DoorMode.IsOnlineMode ? OnlineStateManager.Instance : null; - if (NeedsSharedCourtLoad(osm != null, GetCurrentKing(), RoyalCourtLoadedFromShared)) - await osm!.LoadRoyalCourtFromWorldState(); + if (osm == null) return AbdicateDeletedKingAsync(name, displayName, reason, false, null, null, null); + return AbdicateDeletedKingAsync(name, displayName, reason, true, + osm.ReadRoyalCourtFromWorldState, osm.LoadRoyalCourtFromWorldState, () => osm.SaveRoyalCourtToWorldState(throneVacated: true)); + } + + /// + /// v1.1.11: the delete path with the shared court calls passed in. A process that has not read the + /// shared court only reads the king's name from it; the court is applied only when that king is the + /// deleted character, so deleting anyone else leaves this process's court as it was. + /// + internal static async Task AbdicateDeletedKingAsync(string? name, string? displayName, string reason, bool online, + Func>? readShared, Func? loadShared, Func? saveShared) + { + if (NeedsSharedCourtLoad(online, GetCurrentKing(), RoyalCourtLoadedFromShared)) + { + var shared = readShared == null ? null : await readShared(); + if (shared != null) + { + if (!SharedCourtNamesDeletedCharacter(shared, name, displayName)) return false; + if (loadShared != null) await loadShared(); + } + } var king = GetCurrentKing(); if (!IsDeletedCharactersReign(king, name, displayName)) return false; - EndPlayerReign(king!.Name, reason, persist: osm == null); - if (osm != null) await osm.SaveRoyalCourtToWorldState(); + EndPlayerReign(king!.Name, reason, persist: !online); + if (online && saveShared != null) await saveShared(); + return true; + } + + /// v1.1.11: the stored court's king is the deleted character (a reigning player of that name). + internal static bool SharedCourtNamesDeletedCharacter(RoyalCourtSaveData? court, string? name, string? displayName) + { + if (court == null || court.ThroneVacant || string.IsNullOrEmpty(court.KingName)) return false; + var king = new King { Name = court.KingName, AI = (CharacterAI)court.KingAI, IsActive = true }; + return IsDeletedCharactersReign(king, name, displayName); + } + + /// + /// v1.1.11: a court loader's handling of an explicit vacancy (ThroneVacant): this process's king is + /// cleared too. True when the court is a vacancy, and the loader applies nothing else from it. + /// + public static bool ApplySharedThroneVacancy(RoyalCourtSaveData court) + { + if (court == null || !court.ThroneVacant) return false; + var king = currentKing; + if (king != null) + { + king.IsActive = false; + var npcs = NPCSpawnSystem.Instance?.ActiveNPCs; + if (npcs != null) + foreach (var npc in npcs.Where(n => n != null && n.King && n.Name == king.Name).ToList()) npc.King = false; + currentKing = null; + } return true; } @@ -8039,7 +8086,7 @@ private static void EndPlayerReign(string kingDisplayName, string reason, bool p { _ = Task.Run(async () => { - try { await osm.SaveRoyalCourtToWorldState(); } + try { await osm.SaveRoyalCourtToWorldState(throneVacated: true); } // v1.1.11: no NPC took it catch (Exception ex) { DebugLogger.Instance.LogError("CASTLE", $"Failed to persist abdication: {ex.Message}"); diff --git a/Scripts/Systems/ChallengeSystem.cs b/Scripts/Systems/ChallengeSystem.cs index 7b3abd78..cdbd6dde 100644 --- a/Scripts/Systems/ChallengeSystem.cs +++ b/Scripts/Systems/ChallengeSystem.cs @@ -574,6 +574,14 @@ private void CrownNewKing(NPC newKing, King oldKing) /// private string? _lastDesignatedHeir; + /// v1.1.11: the empty-throne succession, run at once when a loader has just cleared the king. + public void ClaimEmptyThroneIfVacant() + { + if (CastleLocation.GetCurrentKing() != null) return; + _pendingChallenge = null; + ClaimEmptyThrone(); + } + private void ClaimEmptyThrone() { // Save orphans from the previous king before the throne changes hands diff --git a/Scripts/Systems/GuildSystem.cs b/Scripts/Systems/GuildSystem.cs index 487c14ed..9507092d 100644 --- a/Scripts/Systems/GuildSystem.cs +++ b/Scripts/Systems/GuildSystem.cs @@ -187,49 +187,10 @@ public void ForgetMember(string? username) // v1.1.11: the candidates are read in the same (immediate) transaction as the update, so a // member who leaves meanwhile cannot be appointed using var tx = conn.BeginTransaction(deferred: false); - var candidates = new List<(string, int, string?)>(); - using (var cmd = conn.CreateCommand()) - { - cmd.Transaction = tx; - cmd.CommandText = @" - SELECT gm.username, - CAST(CASE WHEN json_valid(p.player_data) THEN json_extract(p.player_data, '$.player.level') END AS INTEGER), - gm.joined_at - FROM guild_members gm - JOIN players p ON p.username = gm.username COLLATE NOCASE - WHERE gm.guild_name = @guild COLLATE NOCASE - AND p.is_banned = 0 AND p.username NOT LIKE 'emergency_%' - AND LOWER(gm.username) != LOWER(@old)"; - cmd.Parameters.AddWithValue("@guild", guildName); - cmd.Parameters.AddWithValue("@old", oldLeader); - using var reader = cmd.ExecuteReader(); - while (reader.Read()) - candidates.Add((reader.GetString(0), reader.IsDBNull(1) ? 0 : reader.GetInt32(1), reader.IsDBNull(2) ? null : reader.GetString(2))); - } - var successor = SqlSaveBackend.PickSuccessor(candidates); - if (successor == null) return null; - - using (var update = conn.CreateCommand()) - { - // v1.1.11: a leader changed meanwhile (a transfer) wins - update.Transaction = tx; - update.CommandText = "UPDATE guilds SET leader_username = @new WHERE name = @guild COLLATE NOCASE AND leader_username = @old COLLATE NOCASE"; - update.Parameters.AddWithValue("@new", successor.ToLowerInvariant()); - update.Parameters.AddWithValue("@guild", guildName); - update.Parameters.AddWithValue("@old", oldLeader); - if (update.ExecuteNonQuery() != 1) return null; - } - using (var rank = conn.CreateCommand()) - { - rank.Transaction = tx; - rank.CommandText = "UPDATE guild_members SET rank = 'Leader' WHERE username = @new COLLATE NOCASE AND guild_name = @guild COLLATE NOCASE"; - rank.Parameters.AddWithValue("@new", successor); - rank.Parameters.AddWithValue("@guild", guildName); - if (rank.ExecuteNonQuery() != 1) { tx.Rollback(); return null; } // v1.1.11: never a guild with no Leader rank - } + var successor = PassLeadershipInTx(conn, tx, guildName, oldLeader); + if (successor == null) { tx.Rollback(); return null; } tx.Commit(); - DebugLogger.Instance?.LogInfo("GUILD", $"Guild '{guildName}' leadership passed from '{oldLeader}' to '{successor.ToLowerInvariant()}'"); - return successor.ToLowerInvariant(); + return successor; } catch (Exception ex) { @@ -238,6 +199,58 @@ FROM guild_members gm } } + /// + /// v1.1.11: PassLeadership's work inside the caller's transaction. On failure nothing it wrote is + /// kept (a savepoint), and the caller's own writes are left for it to commit. Returns the new leader, or null. + /// + private static string? PassLeadershipInTx(SqliteConnection conn, SqliteTransaction tx, string guildName, string oldLeader) + { + var candidates = new List<(string, int, string?)>(); + using (var cmd = conn.CreateCommand()) + { + cmd.Transaction = tx; + cmd.CommandText = @" + SELECT gm.username, + CAST(CASE WHEN json_valid(p.player_data) THEN json_extract(p.player_data, '$.player.level') END AS INTEGER), + gm.joined_at + FROM guild_members gm + JOIN players p ON p.username = gm.username COLLATE NOCASE + WHERE gm.guild_name = @guild COLLATE NOCASE + AND p.is_banned = 0 AND p.username NOT LIKE 'emergency_%' + AND LOWER(gm.username) != LOWER(@old)"; + cmd.Parameters.AddWithValue("@guild", guildName); + cmd.Parameters.AddWithValue("@old", oldLeader); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + candidates.Add((reader.GetString(0), reader.IsDBNull(1) ? 0 : reader.GetInt32(1), reader.IsDBNull(2) ? null : reader.GetString(2))); + } + var successor = SqlSaveBackend.PickSuccessor(candidates); + if (successor == null) return null; + + tx.Save("succession"); + using (var update = conn.CreateCommand()) + { + // v1.1.11: a leader changed meanwhile (a transfer) wins + update.Transaction = tx; + update.CommandText = "UPDATE guilds SET leader_username = @new WHERE name = @guild COLLATE NOCASE AND leader_username = @old COLLATE NOCASE"; + update.Parameters.AddWithValue("@new", successor.ToLowerInvariant()); + update.Parameters.AddWithValue("@guild", guildName); + update.Parameters.AddWithValue("@old", oldLeader); + if (update.ExecuteNonQuery() != 1) { tx.Rollback("succession"); return null; } + } + using (var rank = conn.CreateCommand()) + { + rank.Transaction = tx; + rank.CommandText = "UPDATE guild_members SET rank = 'Leader' WHERE username = @new COLLATE NOCASE AND guild_name = @guild COLLATE NOCASE"; + rank.Parameters.AddWithValue("@new", successor); + rank.Parameters.AddWithValue("@guild", guildName); + if (rank.ExecuteNonQuery() != 1) { tx.Rollback("succession"); return null; } // v1.1.11: never a guild with no Leader rank + } + tx.Release("succession"); + DebugLogger.Instance?.LogInfo("GUILD", $"Guild '{guildName}' leadership passed from '{oldLeader}' to '{successor.ToLowerInvariant()}'"); + return successor.ToLowerInvariant(); + } + /// /// v1.1.11: passes on every guild led by a deleted or permadead character, after the purge removed /// its guild_members row. Leadership is not cached, so the membership cache needs nothing here. @@ -399,6 +412,10 @@ INSERT OR IGNORE INTO guild_members (username, guild_name, rank) } } + /// v1.1.11: a guild whose leader_username names no member any more needs a successor. + internal static bool NeedsGuildSuccession(string? leader, bool leaderIsMember) => + !string.IsNullOrEmpty(leader) && !leaderIsMember; + /// /// Remove a member from their guild. /// @@ -413,56 +430,66 @@ INSERT OR IGNORE INTO guild_members (username, guild_name, rank) using var conn = new SqliteConnection(connectionString); conn.Open(); - // Check if they're the leader - bool isLeader; - using (var checkCmd = conn.CreateCommand()) - { - checkCmd.CommandText = "SELECT leader_username FROM guilds WHERE name = @guild"; - checkCmd.Parameters.AddWithValue("@guild", guildName); - var leader = checkCmd.ExecuteScalar()?.ToString(); - isLeader = string.Equals(leader, username, StringComparison.OrdinalIgnoreCase); - } - - // Remove member + // v1.1.11: the membership delete and the leadership decision share one immediate transaction, and + // the leader is read after the delete, so a leader leaving at the same time as their successor + // cannot leave the guild leaderless + using var tx = conn.BeginTransaction(deferred: false); using (var delCmd = conn.CreateCommand()) { + delCmd.Transaction = tx; delCmd.CommandText = "DELETE FROM guild_members WHERE username = @user COLLATE NOCASE"; delCmd.Parameters.AddWithValue("@user", username.ToLowerInvariant()); delCmd.ExecuteNonQuery(); } - membershipCache.TryRemove(username, out _); - - // If leader left, promote next member or disband - if (isLeader) + string? leader; + bool leaderIsMember; + int remaining; + using (var checkCmd = conn.CreateCommand()) { - int remaining; - using (var countCmd = conn.CreateCommand()) + checkCmd.Transaction = tx; + checkCmd.CommandText = @"SELECT g.leader_username, + EXISTS (SELECT 1 FROM guild_members gm WHERE gm.username = g.leader_username COLLATE NOCASE AND gm.guild_name = g.name COLLATE NOCASE), + (SELECT COUNT(*) FROM guild_members gm WHERE gm.guild_name = g.name COLLATE NOCASE) + FROM guilds g WHERE g.name = @guild COLLATE NOCASE"; + checkCmd.Parameters.AddWithValue("@guild", guildName); + using var reader = checkCmd.ExecuteReader(); + if (reader.Read()) { - countCmd.CommandText = "SELECT COUNT(*) FROM guild_members WHERE guild_name = @guild"; - countCmd.Parameters.AddWithValue("@guild", guildName); - remaining = Convert.ToInt32(countCmd.ExecuteScalar()); + leader = reader.IsDBNull(0) ? null : reader.GetString(0); + leaderIsMember = reader.GetInt64(1) != 0; + remaining = reader.GetInt32(2); } + else { leader = null; leaderIsMember = true; remaining = 1; } // no guild row: nothing to decide + } + // v1.1.11: a leader who is not a member (the one leaving, or one who left meanwhile) is succeeded + if (NeedsGuildSuccession(leader, leaderIsMember)) + { if (remaining > 0) { // v1.1.11: the highest-level remaining player takes over (was: an Officer, else the oldest member) - PassLeadership(guildName, username); + PassLeadershipInTx(conn, tx, guildName, leader!); } else { // No members left — disband (clean up bank items too) using var cleanupCmd = conn.CreateCommand(); + cleanupCmd.Transaction = tx; cleanupCmd.CommandText = "DELETE FROM guild_bank_items WHERE guild_name = @guild"; cleanupCmd.Parameters.AddWithValue("@guild", guildName); cleanupCmd.ExecuteNonQuery(); using var disbandCmd = conn.CreateCommand(); + disbandCmd.Transaction = tx; disbandCmd.CommandText = "DELETE FROM guilds WHERE name = @guild"; disbandCmd.Parameters.AddWithValue("@guild", guildName); disbandCmd.ExecuteNonQuery(); } } + tx.Commit(); + + membershipCache.TryRemove(username, out _); return null; } diff --git a/Scripts/Systems/OnlineStateManager.cs b/Scripts/Systems/OnlineStateManager.cs index bbe3ce67..6afadd68 100644 --- a/Scripts/Systems/OnlineStateManager.cs +++ b/Scripts/Systems/OnlineStateManager.cs @@ -443,15 +443,18 @@ public async Task SaveAllSharedState() /// The world_state 'royal_court' key is the single source of truth - /// the world sim reads this and maintains it between player sessions. /// - public async Task SaveRoyalCourtToWorldState() + public async Task SaveRoyalCourtToWorldState(bool throneVacated = false) { try { var king = global::CastleLocation.GetCurrentKing(); if (king == null) { + // v1.1.11: an unmarked empty court never replaces a marked vacancy the world sim has yet to act on + if (KeepsStoredVacancy(await ReadRoyalCourtFromWorldState(), throneVacated)) return; // Throne is vacant — save empty state so other sessions see it - var emptyData = new RoyalCourtSaveData { KingName = "", Treasury = 0, KingAI = 1 }; + // v1.1.11: a reign that just ended with no successor is marked, so the loaders clear their king + var emptyData = new RoyalCourtSaveData { KingName = "", Treasury = 0, KingAI = 1, ThroneVacant = throneVacated }; var emptyJson = System.Text.Json.JsonSerializer.Serialize(emptyData, new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); var backend = SaveSystem.Instance?.Backend as SqlSaveBackend; @@ -625,6 +628,25 @@ public async Task SaveEconomyToWorldState() } } + /// v1.1.11: an empty-court save that is not itself a vacancy leaves a stored vacancy in place. + internal static bool KeepsStoredVacancy(RoyalCourtSaveData? stored, bool throneVacated) => + !throneVacated && stored?.ThroneVacant == true; + + /// v1.1.11: the shared royal_court as stored, applied to nothing (null when absent or unreadable). + public async Task ReadRoyalCourtFromWorldState() + { + try + { + var json = await backend.LoadWorldState("royal_court"); + return string.IsNullOrEmpty(json) ? null : JsonSerializer.Deserialize(json, jsonOptions); + } + catch (Exception ex) + { + DebugLogger.Instance.LogError("ONLINE", $"Failed to read royal court: {ex.Message}"); + return null; + } + } + /// /// Load royal court state from world_state and apply to current king. /// Called by player sessions on login to get the authoritative king state @@ -639,7 +661,8 @@ public async Task LoadRoyalCourtFromWorldState() var royalCourt = JsonSerializer.Deserialize(json, jsonOptions); if (royalCourt != null) global::CastleLocation.RoyalCourtLoadedFromShared = true; // v1.1.11 - if (royalCourt == null || string.IsNullOrEmpty(royalCourt.KingName)) return; + if (royalCourt == null || global::CastleLocation.ApplySharedThroneVacancy(royalCourt)) return; // v1.1.11 + if (string.IsNullOrEmpty(royalCourt.KingName)) return; var king = global::CastleLocation.GetCurrentKing(); diff --git a/Scripts/Systems/PermadeathHelper.cs b/Scripts/Systems/PermadeathHelper.cs index a809defb..dce8b932 100644 --- a/Scripts/Systems/PermadeathHelper.cs +++ b/Scripts/Systems/PermadeathHelper.cs @@ -553,7 +553,9 @@ public static int ForgetNpcGrudgesAgainst(string? name) if (npc is not System.Text.Json.Nodes.JsonObject rec) continue; if (!string.Equals(StringOf(rec["spouseName"]), name, StringComparison.OrdinalIgnoreCase)) continue; string id = StringOf(rec["characterID"]); - if (!string.IsNullOrEmpty(id) && NPCMarriageRegistry.Instance.IsMarriedToNPC(id)) continue; + // v1.1.11: a registered marriage to another NPC is kept; one to a player (the deleted one) is not + if (RegisteredToAnotherNpc(id, pid => npcs.Any(o => o != null && !ReferenceEquals(o, npc) && StringOf(o["characterID"]) == pid) + || IsActiveNpcId(pid, null))) continue; string own = StringOf(rec["name"]); bool npcSpouse = !string.IsNullOrEmpty(own) && npcs.Any(o => o != null && !ReferenceEquals(o, npc) && string.Equals(StringOf(o["name"]), name, StringComparison.OrdinalIgnoreCase) @@ -562,6 +564,7 @@ public static int ForgetNpcGrudgesAgainst(string? name) rec["married"] = false; rec["isMarried"] = false; rec["spouseName"] = ""; + if (!string.IsNullOrEmpty(id)) NPCMarriageRegistry.Instance.EndMarriage(id); // v1.1.11: as a divorce does spouses++; } return grudges + spouses > 0 ? npcs.ToJsonString() : null; @@ -593,8 +596,8 @@ private static string StringOf(System.Text.Json.Nodes.JsonNode? node) /// /// v1.1.11: end the marriage of any NPC whose spouse was the deleted character, clearing the - /// same three flags a divorce clears on the NPC. An NPC married to another NPC (registry) is - /// left alone, in case that NPC shares the name. + /// same three flags a divorce clears on the NPC and its registry entry. An NPC the registry + /// marries to another NPC is left alone, in case that NPC shares the name. /// public static int ClearNpcSpousesOf(string? name) { @@ -605,13 +608,29 @@ public static int ClearNpcSpousesOf(string? name) foreach (var npc in npcs.ToList()) { if (npc == null || !string.Equals(npc.SpouseName, name, StringComparison.OrdinalIgnoreCase)) continue; - if (!string.IsNullOrEmpty(npc.ID) && NPCMarriageRegistry.Instance.IsMarriedToNPC(npc.ID)) continue; + if (RegisteredToAnotherNpc(npc.ID, pid => IsActiveNpcId(pid, npc))) continue; // v1.1.11: not a player-NPC marriage npc.Married = false; npc.IsMarried = false; npc.SpouseName = ""; + if (!string.IsNullOrEmpty(npc.ID)) NPCMarriageRegistry.Instance.EndMarriage(npc.ID); // v1.1.11: as a divorce does cleared++; } return cleared; } + + /// + /// v1.1.11: the registry marries this NPC to another NPC. The registry also holds player-NPC + /// marriages, so the partner must itself be an NPC; a player partner (the deleted character) or an + /// unknown id is the marriage to clear. + /// + internal static bool RegisteredToAnotherNpc(string? npcId, Func isNpcId) + { + if (string.IsNullOrEmpty(npcId)) return false; + var partner = NPCMarriageRegistry.Instance.GetSpouseId(npcId!); + return !string.IsNullOrEmpty(partner) && partner != npcId && isNpcId(partner!); + } + + private static bool IsActiveNpcId(string id, NPC? self) => + NPCSpawnSystem.Instance?.ActiveNPCs?.Any(n => n != null && !ReferenceEquals(n, self) && n.ID == id) == true; } } diff --git a/Scripts/Systems/PlayerCharacterLoader.cs b/Scripts/Systems/PlayerCharacterLoader.cs index c9e07162..d4178fe4 100644 --- a/Scripts/Systems/PlayerCharacterLoader.cs +++ b/Scripts/Systems/PlayerCharacterLoader.cs @@ -16,6 +16,8 @@ public static Character CreateFromSaveData(PlayerData playerData, string display { Name1 = playerData.Name1, Name2 = playerData.Name2 ?? displayName, + // v1.1.11: the married surname, so DisplayName ("Bob Smith") matches the live character's (bounties) + FamilySurname = playerData.FamilySurname ?? "", // v1.1.11: a duel defender fights with its own team's HQ levels (CombatEngine.PlayerVsPlayer reads // them by team); without the team it had none (review). An echo is only a copy and keeps none. Team = isEcho ? "" : (playerData.Team ?? ""), diff --git a/Scripts/Systems/QuestSystem.cs b/Scripts/Systems/QuestSystem.cs index fb25b944..e9345c7a 100644 --- a/Scripts/Systems/QuestSystem.cs +++ b/Scripts/Systems/QuestSystem.cs @@ -1663,12 +1663,24 @@ public static void OnNPCDefeated(Character player, NPC defeatedNPC, bool killed /// v1.1.11: the one-time claim in the shared database, taken before any payout. The in-process lock /// covers one process; separate door and world-sim processes each hold their own copy of the bounty. /// Without an SQL backend (offline, file saves) the lock is enough. A quest with no id cannot be - /// claimed by id and relies on the lock. + /// claimed by id and is claimed under a key built from its fields (BountyClaimKey). /// internal static bool ClaimAcrossProcesses(Quest q, string claimer) { - if (SaveSystem.Instance?.Backend is not SqlSaveBackend sql || string.IsNullOrEmpty(q.Id)) return true; - return sql.TryClaimBounty(q.Id, claimer); + if (SaveSystem.Instance?.Backend is not SqlSaveBackend sql) return true; + return sql.TryClaimBounty(BountyClaimKey(q), claimer); + } + + /// + /// v1.1.11: the key a bounty is claimed under: its id, or for a quest saved without one, "legacy:" and a + /// SHA-256 of its initiator, target and title, the same in every process that loaded the quest. + /// + internal static string BountyClaimKey(Quest q) + { + if (!string.IsNullOrEmpty(q.Id)) return q.Id; + string basis = (q.Initiator ?? "") + "|" + (q.TargetNPCName ?? "") + "|" + (q.Title ?? ""); + var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(basis)); + return "legacy:" + Convert.ToHexString(hash).ToLowerInvariant(); } public static long AutoCompleteBountyForNPC(Character player, string npcName, bool includeKillContracts = true) diff --git a/Scripts/Systems/SaveDataStructures.cs b/Scripts/Systems/SaveDataStructures.cs index a6415c26..50e98319 100644 --- a/Scripts/Systems/SaveDataStructures.cs +++ b/Scripts/Systems/SaveDataStructures.cs @@ -1851,6 +1851,8 @@ public class GriefMemorySaveData public class RoyalCourtSaveData { public string KingName { get; set; } = ""; + /// v1.1.11: the throne was deliberately left empty; every loader clears its king. Absent (false) changes nothing. + public bool ThroneVacant { get; set; } public long Treasury { get; set; } public long TaxRate { get; set; } public long TotalReign { get; set; } diff --git a/Scripts/Systems/SqlSaveBackend.cs b/Scripts/Systems/SqlSaveBackend.cs index 96ea53cb..9fbeecc4 100644 --- a/Scripts/Systems/SqlSaveBackend.cs +++ b/Scripts/Systems/SqlSaveBackend.cs @@ -1197,12 +1197,24 @@ public void PurgePlayerWorldState(string username, string? displayName = null) ExecPurge(connection, tx, "messages", "LOWER(from_player) = LOWER(@u) OR LOWER(to_player) = LOWER(@u)", username); ExecPurge(connection, tx, "trade_offers", "LOWER(from_player) = LOWER(@u) OR LOWER(to_player) = LOWER(@u)", username); ExecPurge(connection, tx, "bounties", "LOWER(target_player) = LOWER(@u) OR LOWER(placed_by) = LOWER(@u) OR LOWER(claimed_by) = LOWER(@u)", username); - // v1.1.11: only the character's own listings. The buyer clause is gone: a sold row the seller - // has not collected holds that seller's gold, and the buyer already has the item. A seller - // name another player or an NPC may carry now is left alone. - if (!CouldBeNpcName(username)) + // v1.1.11: only the character's own listings, under each name it may have listed as (the key, + // the display name, the stored married display name). The buyer clause is gone: a sold row the + // seller has not collected holds that seller's gold, and the buyer already has the item. A + // seller name another player or an NPC may carry now is left alone, alias by alias. + string? storedDisplayName = null; + using (var dn = connection.CreateCommand()) + { + dn.Transaction = tx; + dn.CommandText = "SELECT display_name FROM players WHERE LOWER(username) = LOWER(@u) AND display_name IS NOT NULL LIMIT 1;"; + dn.Parameters.AddWithValue("@u", username); + storedDisplayName = dn.ExecuteScalar() as string; + } + foreach (var alias in AuctionSellerAliases(username, displayName, storedDisplayName)) + { + if (CouldBeNpcName(alias)) continue; ExecPurge(connection, tx, "auction_listings", - $"LOWER(seller) = LOWER(@u) AND {SellerNotOtherPlayer}", username); + $"LOWER(seller) = LOWER(@d) AND {SellerNotOtherPlayer}", username, alias); + } ExecPurge(connection, tx, "world_boss_damage", "LOWER(player_name) = LOWER(@u)", username); // v0.65.0: pvp_log was deliberately excluded in v0.60.5 ("history @@ -1233,11 +1245,6 @@ public void PurgePlayerWorldState(string username, string? displayName = null) $"to_player != '*' AND (LOWER(to_player) = LOWER(@d) OR LOWER(to_player) IN {ownNames}) " + "AND NOT EXISTS (SELECT 1 FROM players p WHERE LOWER(p.username) = LOWER(messages.to_player) AND LOWER(p.username) != LOWER(@u))", username, displayName); - // NPCs list under their own name in lowercase: a name an NPC may carry (one does, or the roster - // is not complete enough to rule it out) keeps its listings; a stale one is the safe side - if (!CouldBeNpcName(displayName)) - ExecPurge(connection, tx, "auction_listings", - $"(LOWER(seller) = LOWER(@d) OR LOWER(seller) IN {ownNames}) AND {SellerNotOtherPlayer}", username, displayName); } tx.Commit(); @@ -1269,6 +1276,18 @@ public void PurgePlayerWorldState(string username, string? displayName = null) "LOWER(p.display_name) = LOWER(auction_listings.seller) OR " + "LOWER(CASE WHEN json_valid(p.player_data) THEN json_extract(p.player_data, '$.player.name2') END) = LOWER(auction_listings.seller)))"; + /// + /// v1.1.11: every seller name the delete purge removes listings under: the key, the display name + /// given, and the display name stored on the players row (the married form). Each is checked + /// against the NPC guard by the caller; one list, so the guard and the DELETE cannot differ. + /// + internal static List AuctionSellerAliases(string username, string? displayName, string? storedDisplayName) => + new[] { username, displayName, storedDisplayName } + .Where(n => !string.IsNullOrWhiteSpace(n)) + .Select(n => n!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + // v1.1.11: a name an NPC may carry (one does, or the roster cannot rule it out) private static bool CouldBeNpcName(string name) { @@ -1300,6 +1319,24 @@ public bool TryClaimBounty(string questId, string claimer) } } + /// v1.1.11: removes bounty claims older than the retention (GameConfig.BountyClaimRetentionDays). Returns the rows removed. + public int PruneOldBountyClaims(int daysToKeep = GameConfig.BountyClaimRetentionDays) + { + try + { + using var connection = OpenConnection(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "DELETE FROM bounty_claims WHERE claimed_at < datetime('now', @cutoff);"; + cmd.Parameters.AddWithValue("@cutoff", $"-{daysToKeep} days"); + return cmd.ExecuteNonQuery(); + } + catch (Exception ex) + { + DebugLogger.Instance.LogError("SQL", $"Failed to prune bounty_claims: {ex.Message}"); + return 0; + } + } + private static void ExecPurge(SqliteConnection conn, SqliteTransaction tx, string table, string whereClause, string username, string? displayName = null) { try @@ -5323,6 +5360,9 @@ UPDATE pending_inheritance /// v1.1.11: with respectJoinGrace (the world-save pass), also only if nobody joined within /// EmptyTeamJoinGraceMinutes, checked by the update itself: a returning leader's save may not have landed. /// + /// v1.1.11: tests only; runs between the successor's selection and the update. + internal static Action? BeforeTeamLeaderUpdateForTests; + public bool TryPassTeamLeadership(string teamName, string oldKey, string? excludeKey, bool requireOldLeaderGone, out string? newKey, bool respectJoinGrace = false) { newKey = null; @@ -5351,10 +5391,16 @@ FROM players p } var successor = PickSuccessor(candidates); if (successor == null) return false; + BeforeTeamLeaderUpdateForTests?.Invoke(successor); using (var update = connection.CreateCommand()) { - // v1.1.11: a leader key changed meanwhile (an admin fix, another pass) wins - update.CommandText = "UPDATE player_teams SET created_by = @new WHERE team_name = @team AND created_by = @old" + + // v1.1.11: a leader key changed meanwhile (an admin fix, another pass) wins, and the successor + // must still be eligible when the update runs (not banned since, still on the team) + update.CommandText = "UPDATE player_teams SET created_by = @new WHERE team_name = @team AND created_by = @old" + @" + AND EXISTS (SELECT 1 FROM players s WHERE LOWER(s.username) = LOWER(@new) + AND s.is_banned = 0 AND s.username NOT LIKE 'emergency_%' + AND s.player_data != '{}' AND LENGTH(s.player_data) > 2 + AND (CASE WHEN json_valid(s.player_data) THEN json_extract(s.player_data, '$.player.team') END) = @team)" + (requireOldLeaderGone ? @" AND NOT EXISTS (SELECT 1 FROM players l WHERE l.username = @old AND (NOT json_valid(l.player_data) diff --git a/Scripts/Systems/WorldSimService.cs b/Scripts/Systems/WorldSimService.cs index e6b7e524..acfc2aa8 100644 --- a/Scripts/Systems/WorldSimService.cs +++ b/Scripts/Systems/WorldSimService.cs @@ -567,6 +567,9 @@ private async Task SaveWorldState() // Clean up orphaned data from deleted players await sqlBackend.PruneOrphanedPlayerData(); + // v1.1.11: paid-bounty claims past any bounty's life + sqlBackend.PruneOldBountyClaims(); + // v1.1.11: teams nobody is in any more PruneEmptyTeams(); @@ -586,7 +589,7 @@ private async Task SaveWorldState() /// read/write this key. The world sim is the primary maintainer; player actions /// (throne challenges, tax changes) write updates to this key immediately. /// - private void LoadRoyalCourtFromWorldState() + internal void LoadRoyalCourtFromWorldState() { try { @@ -595,7 +598,14 @@ private void LoadRoyalCourtFromWorldState() var royalCourt = JsonSerializer.Deserialize(json, jsonOptions); if (royalCourt != null) CastleLocation.RoyalCourtLoadedFromShared = true; // v1.1.11 - if (royalCourt == null || string.IsNullOrEmpty(royalCourt.KingName)) return; + if (royalCourt == null) return; + if (CastleLocation.ApplySharedThroneVacancy(royalCourt)) + { + // v1.1.11: the NPC succession used whenever there is no king fills it now + ChallengeSystem.Instance.ClaimEmptyThroneIfVacant(); + return; + } + if (string.IsNullOrEmpty(royalCourt.KingName)) return; var king = CastleLocation.GetCurrentKing(); @@ -801,7 +811,7 @@ private void LoadRoyalCourtFromWorldState() /// Save current royal court state to world_state. /// This is the authoritative write - the world sim maintains this data. /// - private async Task SaveRoyalCourtToWorldState() + internal async Task SaveRoyalCourtToWorldState() { try { diff --git a/Tests/DeleteFollowUpTests.cs b/Tests/DeleteFollowUpTests.cs index 60ea64aa..0c3110d5 100644 --- a/Tests/DeleteFollowUpTests.cs +++ b/Tests/DeleteFollowUpTests.cs @@ -344,17 +344,195 @@ public void AFreshProcess_ReadsTheSharedRoyalCourt_BeforeDecidingTheDeletedKings CastleLocation.IsDeletedCharactersReign(null, "Bob", "Bob").Should().BeFalse(); string castle = Source("Locations", "CastleLocation.cs"); - int start = castle.IndexOf("public static async Task AbdicateDeletedKingAsync(", StringComparison.Ordinal); + int start = castle.IndexOf("internal static async Task AbdicateDeletedKingAsync(", StringComparison.Ordinal); start.Should().BeGreaterThan(0); - string body = castle.Substring(start, castle.IndexOf("private static void EndPlayerReign(", start, StringComparison.Ordinal) - start); - body.IndexOf("await osm!.LoadRoyalCourtFromWorldState();", StringComparison.Ordinal) - .Should().BeGreaterThan(body.IndexOf("NeedsSharedCourtLoad(", StringComparison.Ordinal)) + string body = castle.Substring(start, castle.IndexOf("internal static bool SharedCourtNamesDeletedCharacter(", start, StringComparison.Ordinal) - start); + int read = body.IndexOf("await readShared()", StringComparison.Ordinal); + read.Should().BeGreaterThan(body.IndexOf("NeedsSharedCourtLoad(", StringComparison.Ordinal)); + body.IndexOf("await loadShared()", StringComparison.Ordinal) + .Should().BeGreaterThan(body.IndexOf("SharedCourtNamesDeletedCharacter(", read, StringComparison.Ordinal), "the court is applied only on a match") .And.BeLessThan(body.IndexOf("IsDeletedCharactersReign(", StringComparison.Ordinal)); - body.Should().Contain("await osm.SaveRoyalCourtToWorldState();", "the ended reign is written to the shared royal_court"); + body.Should().Contain("await saveShared()", "the ended reign is written to the shared royal_court"); + castle.Should().Contain("osm.ReadRoyalCourtFromWorldState, osm.LoadRoyalCourtFromWorldState, () => osm.SaveRoyalCourtToWorldState(throneVacated: true)"); Source("Systems", "OnlineStateManager.cs").Should().Contain("CastleLocation.RoyalCourtLoadedFromShared = true;"); Source("Systems", "PermadeathHelper.cs").Should().Contain("await global::CastleLocation.AbdicateDeletedKingAsync("); } + // ─── v1.1.11: review round 13 ─── + + private static string CourtJson(RoyalCourtSaveData court) => + System.Text.Json.JsonSerializer.Serialize(court, new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); + + [Fact] + public async Task AVacantThrone_IsHonouredByTheWorldSim_WhichDoesNotWriteTheKingBack() + { + bool loadedBefore = CastleLocation.RoyalCourtLoadedFromShared; + try + { + var bob = King.CreateNewKing("Bob", CharacterAI.Human, CharacterSex.Male); + await WithKing(bob, async () => + { + // a fresh database with no court: absent changes nothing + var sim = new WorldSimService(_db); + var simVersion = typeof(WorldSimService).GetField("lastRoyalCourtVersion", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + sim.LoadRoyalCourtFromWorldState(); + CastleLocation.GetCurrentKing().Should().BeSameAs(bob); + + // control: the sim's court save does write its king when it holds the current version + await _db.SaveWorldState("royal_court", CourtJson(new RoyalCourtSaveData { KingName = "Bob", KingAI = (int)CharacterAI.Human })); + simVersion.SetValue(sim, _db.GetWorldStateVersion("royal_court")); + await _db.SaveWorldState("royal_court", CourtJson(new RoyalCourtSaveData { KingName = "Nobody", KingAI = 1 })); + simVersion.SetValue(sim, _db.GetWorldStateVersion("royal_court")); + await sim.SaveRoyalCourtToWorldState(); + (await _db.LoadWorldState("royal_court")).Should().Contain("\"Bob\"", "the save path is live"); + + // an empty name without the vacancy mark (an older write) is still ignored + await _db.SaveWorldState("royal_court", CourtJson(new RoyalCourtSaveData { KingName = "", KingAI = 1 })); + sim.LoadRoyalCourtFromWorldState(); + CastleLocation.GetCurrentKing().Should().BeSameAs(bob); + + // the deleting process ended the reign with no successor + await _db.SaveWorldState("royal_court", CourtJson(new RoyalCourtSaveData { KingName = "", KingAI = 1, ThroneVacant = true })); + sim.LoadRoyalCourtFromWorldState(); + (CastleLocation.GetCurrentKing()?.Name).Should().NotBe("Bob", "the world sim's copy of the deleted king is cleared"); + bob.IsActive.Should().BeFalse(); + + simVersion.SetValue(sim, _db.GetWorldStateVersion("royal_court")); // the sim holds the vacancy's version + await sim.SaveRoyalCourtToWorldState(); + var stored = System.Text.Json.JsonSerializer.Deserialize((await _db.LoadWorldState("royal_court"))!, + new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + stored.KingName.Should().NotBe("Bob", "the world sim's court save does not write the deleted king back"); + }); + } + finally { CastleLocation.RoyalCourtLoadedFromShared = loadedBefore; } + + string osm = Source("Systems", "OnlineStateManager.cs"); + osm.Should().Contain("global::CastleLocation.ApplySharedThroneVacancy(royalCourt)", "the login loader honours it too"); + osm.Should().Contain("ThroneVacant = throneVacated"); + osm.Should().Contain("if (KeepsStoredVacancy(await ReadRoyalCourtFromWorldState(), throneVacated)) return;"); + OnlineStateManager.KeepsStoredVacancy(new RoyalCourtSaveData { ThroneVacant = true }, throneVacated: false) + .Should().BeTrue("a later session's plain empty save does not hide the vacancy from the world sim"); + OnlineStateManager.KeepsStoredVacancy(new RoyalCourtSaveData { ThroneVacant = true }, throneVacated: true).Should().BeFalse(); + OnlineStateManager.KeepsStoredVacancy(new RoyalCourtSaveData { KingName = "Bob" }, throneVacated: false).Should().BeFalse(); + OnlineStateManager.KeepsStoredVacancy(null, throneVacated: false).Should().BeFalse(); + Source("Systems", "WorldSimService.cs").Should().Contain("ChallengeSystem.Instance.ClaimEmptyThroneIfVacant();"); + } + + [Fact] + public async Task DeletingANonKing_ReadsOnlyTheName_AndLeavesTheCourtUntouched() + { + bool loadedBefore = CastleLocation.RoyalCourtLoadedFromShared; + try + { + var alice = King.CreateNewKing("Alice", CharacterAI.Human, CharacterSex.Female); + alice.Guards.Clear(); // she dismissed her guards + var shared = new RoyalCourtSaveData + { + KingName = "Alice", KingAI = (int)CharacterAI.Human, + Guards = new List { new RoyalGuardSaveData { Name = "Old Guard", IsActive = true } } + }; + await WithKing(alice, async () => + { + CastleLocation.RoyalCourtLoadedFromShared = false; + bool applied = false, saved = false; + bool ended = await CastleLocation.AbdicateDeletedKingAsync("Bob", "Bob", "left", online: true, + () => Task.FromResult(shared), + () => { applied = true; return Task.CompletedTask; }, + () => { saved = true; return Task.CompletedTask; }); + ended.Should().BeFalse(); + applied.Should().BeFalse("the court is not applied for a non-king"); + saved.Should().BeFalse(); + CastleLocation.GetCurrentKing().Should().BeSameAs(alice); + alice.Guards.Should().BeEmpty("dismissed guards stay dismissed"); + CastleLocation.RoyalCourtLoadedFromShared.Should().BeFalse("a mere read does not count as a load"); + }); + + // the shared court names the deleted character: it is loaded, the reign ends, and it is written back + var bobCourt = new RoyalCourtSaveData { KingName = "Bob", KingAI = (int)CharacterAI.Human }; + await WithKing(alice, async () => + { + CastleLocation.RoyalCourtLoadedFromShared = false; + bool applied = false, saved = false; + bool ended = await CastleLocation.AbdicateDeletedKingAsync("Bob", "Bob", "left", online: true, + () => Task.FromResult(bobCourt), + () => { applied = true; CastleLocation.SetKing(King.CreateNewKing("Bob", CharacterAI.Human, CharacterSex.Male)); return Task.CompletedTask; }, + () => { saved = true; return Task.CompletedTask; }); + ended.Should().BeTrue(); + applied.Should().BeTrue(); + saved.Should().BeTrue(); + (CastleLocation.GetCurrentKing()?.Name).Should().NotBe("Bob"); + }); + } + finally { CastleLocation.RoyalCourtLoadedFromShared = loadedBefore; } + + CastleLocation.SharedCourtNamesDeletedCharacter(new RoyalCourtSaveData { KingName = "Bob", KingAI = (int)CharacterAI.Computer }, "Bob", "Bob") + .Should().BeFalse("an NPC king of the name"); + CastleLocation.SharedCourtNamesDeletedCharacter(new RoyalCourtSaveData { KingName = "", ThroneVacant = true }, "Bob", "Bob").Should().BeFalse(); + CastleLocation.SharedCourtNamesDeletedCharacter(new RoyalCourtSaveData { KingName = "Bob Smith", KingAI = (int)CharacterAI.Human }, "Bob", "Bob Smith").Should().BeTrue(); + } + + [Fact] + public void APlayerNpcMarriage_IsCleared_InMemoryAndInTheJson_AndLeavesTheRegistry() + { + var reg = NPCMarriageRegistry.Instance; + var wife = new NPC { ID = "npc_r13_wife", Name1 = "Wife", Name2 = "Wife", Level = 10, SpouseName = "Bob", Married = true, IsMarried = true }; + var ann = new NPC { ID = "npc_r13_ann", Name1 = "Ann", Name2 = "Ann", Level = 10, SpouseName = "Bob", Married = true, IsMarried = true }; + var npcBob = new NPC { ID = "npc_r13_bob", Name1 = "Bob", Name2 = "Bob", Level = 10, SpouseName = "Ann", Married = true, IsMarried = true }; + reg.RegisterMarriage("player_bob_id", wife.ID, "Bob", "Wife"); // the player Bob married Wife + reg.RegisterMarriage(ann.ID, npcBob.ID, "Ann", "Bob"); // Ann married the NPC Bob + NPCSpawnSystem.Instance.ActiveNPCs.AddRange(new[] { wife, ann, npcBob }); + try + { + PermadeathHelper.ClearNpcSpousesOf("Bob").Should().Be(1); + wife.SpouseName.Should().BeEmpty("a player-NPC marriage is in the registry too, and is cleared"); + wife.IsMarried.Should().BeFalse(); + reg.IsMarriedToNPC(wife.ID).Should().BeFalse("the registry entry goes as in a divorce"); + reg.IsMarriedToNPC("player_bob_id").Should().BeFalse(); + ann.SpouseName.Should().Be("Bob", "Ann's partner is an NPC"); + reg.GetSpouseId(ann.ID).Should().Be(npcBob.ID); + + // the shared JSON, with the registry holding the player-NPC marriage again + reg.RegisterMarriage("player_bob_id", wife.ID, "Bob", "Wife"); + string json = "[" + + "{\"name\":\"Wife\",\"characterID\":\"npc_r13_wife\",\"married\":true,\"isMarried\":true,\"spouseName\":\"Bob\"}," + + "{\"name\":\"Ann\",\"characterID\":\"npc_r13_ann\",\"married\":true,\"isMarried\":true,\"spouseName\":\"Bob\"}]"; + var edited = PermadeathHelper.RemoveDeletedCharacterFromNpcJson(json, "Bob", out _, out int spouses); + spouses.Should().Be(1); + var arr = System.Text.Json.Nodes.JsonNode.Parse(edited!)!.AsArray(); + ((string)arr[0]!["spouseName"]!).Should().BeEmpty(); + ((string)arr[1]!["spouseName"]!).Should().Be("Bob", "the registry marries Ann to an NPC"); + reg.IsMarriedToNPC(wife.ID).Should().BeFalse(); + } + finally + { + foreach (var n in new[] { wife, ann, npcBob }) NPCSpawnSystem.Instance.ActiveNPCs.Remove(n); + reg.EndMarriage(wife.ID); + reg.EndMarriage(ann.ID); + } + } + + [Fact] + public async Task TheMarriedNameAlias_IsCheckedAgainstTheNpcGuard_AliasByAlias() + { + // Ursula took the surname Ironheart; an NPC is called Ursula Ironheart + Player("ursula", "Ursula Ironheart"); + int own = await _db.CreateAuctionListing("ursula", "Ring", "{}", 100); + int npcs = await _db.CreateAuctionListing("ursula ironheart", "Axe", "{}", 100); + var npc = new NPC { ID = "npc_ursula_ironheart", Name1 = "Ursula Ironheart", Name2 = "Ursula Ironheart", Level = 20 }; + WithCompleteRoster(() => _db.PurgePlayerWorldState("ursula", "Ursula"), npc); + Count($"SELECT COUNT(*) FROM auction_listings WHERE id = {npcs};").Should().Be(1, "the stored display name is an NPC's name"); + Count($"SELECT COUNT(*) FROM auction_listings WHERE id = {own};").Should().Be(0, "the other aliases are still purged"); + + SqlSaveBackend.AuctionSellerAliases("ursula", "Ursula", "Ursula Ironheart") + .Should().Equal("ursula", "Ursula Ironheart"); + SqlSaveBackend.AuctionSellerAliases("bob_account", null, " ").Should().Equal("bob_account"); + + string src = Source("Systems", "SqlSaveBackend.cs"); + int start = src.IndexOf("public void PurgePlayerWorldState(", StringComparison.Ordinal); + string body = src.Substring(start, src.IndexOf("private const string SellerNotOtherPlayer", start, StringComparison.Ordinal) - start); + body.Split("\"auction_listings\"").Length.Should().Be(2, "one auction DELETE, fed by the alias list the guard checks"); + } + private static string Source(string folder, string file) { var dir = new DirectoryInfo(AppContext.BaseDirectory); diff --git a/Tests/LeaderSuccessionTests.cs b/Tests/LeaderSuccessionTests.cs index 79ab2d59..6264c938 100644 --- a/Tests/LeaderSuccessionTests.cs +++ b/Tests/LeaderSuccessionTests.cs @@ -209,7 +209,7 @@ public void AGuildSuccessor_WhoLeftBeforeTheSuccession_IsNotAppointed() string body = src.Substring(start, src.IndexOf("public int PassLeadershipOf(", start, StringComparison.Ordinal) - start); body.IndexOf("BeginTransaction(", StringComparison.Ordinal).Should().BeLessThan(body.IndexOf("SELECT gm.username", StringComparison.Ordinal)); body.Should().Contain("cmd.Transaction = tx;"); - body.Should().Contain("if (rank.ExecuteNonQuery() != 1) { tx.Rollback(); return null; }"); + body.Should().Contain("if (rank.ExecuteNonQuery() != 1) { tx.Rollback(\"succession\"); return null; }"); } [Fact] @@ -277,7 +277,7 @@ public void TheQuitPath_AndTheLeaveCommand_PassOnLeadership() string guild = CodeOnly(Source("Systems", "GuildSystem.cs")); int rm = guild.IndexOf("public string? RemoveMember(", StringComparison.Ordinal); - guild.IndexOf("PassLeadership(guildName, username)", rm, StringComparison.Ordinal).Should().BeGreaterThan(rm); + guild.IndexOf("PassLeadershipInTx(conn, tx, guildName, leader!)", rm, StringComparison.Ordinal).Should().BeGreaterThan(rm); string sim = CodeOnly(Source("Systems", "WorldSimService.cs")); int prune = sim.IndexOf("PruneEmptyTeams();", StringComparison.Ordinal); @@ -290,6 +290,87 @@ public void CodeOnly_IgnoresACommentedOutCall() CodeOnly(" // backend.TryPassTeamLeadership(oldTeam, myKey, myKey, true, out _);").Should().NotContain("TryPassTeamLeadership("); } + // ─── v1.1.11: review round 13 ─── + + [Fact] + public void ALeavingMember_WhoseGuildLeaderIsNoLongerAMember_TriggersSuccession() + { + // the leader left (and passed the guild to b) while b was leaving: b's delete finds a leader who is + // not a member and passes it on, in the same transaction + _ = new GuildSystem(_path, register: false); // creates the guild tables + Player("b", null, level: 50); + Player("c", null, level: 30); + Guild("racehall", "b"); + GuildMember("b", "racehall", "2026-01-01 00:00:00", "Leader"); + GuildMember("c", "racehall", "2026-01-02 00:00:00"); + var guilds = new GuildSystem(_path, register: false); // the membership cache is read at start + guilds.RemoveMember("b").Should().BeNull(); + GuildLeader("racehall").Should().Be("c"); + Scalar("SELECT rank FROM guild_members WHERE username = 'c'").Should().Be("Leader"); + + // a stale leader (not a member) is repaired when anyone leaves + Player("gone", null, level: 90); + Player("d", null, level: 20); + Player("e", null, level: 40); + Guild("stalehall", "gone"); + GuildMember("d", "stalehall", "2026-01-01 00:00:00"); + GuildMember("e", "stalehall", "2026-01-02 00:00:00"); + guilds = new GuildSystem(_path, register: false); + guilds.RemoveMember("d").Should().BeNull(); + GuildLeader("stalehall").Should().Be("e"); + + GuildSystem.NeedsGuildSuccession("a", leaderIsMember: false).Should().BeTrue(); + GuildSystem.NeedsGuildSuccession("a", leaderIsMember: true).Should().BeFalse(); + GuildSystem.NeedsGuildSuccession(null, leaderIsMember: false).Should().BeFalse(); + + // the interleaving itself is not deterministic: the order is checked in the source instead + string src = CodeOnly(Source("Systems", "GuildSystem.cs")); + int rm = src.IndexOf("public string? RemoveMember(", StringComparison.Ordinal); + string body = src.Substring(rm, src.IndexOf("public string? DepositGold(", rm, StringComparison.Ordinal) - rm); + int begin = body.IndexOf("BeginTransaction(deferred: false)", StringComparison.Ordinal); + int del = body.IndexOf("DELETE FROM guild_members", StringComparison.Ordinal); + int read = body.IndexOf("SELECT g.leader_username", StringComparison.Ordinal); + int pass = body.IndexOf("PassLeadershipInTx(conn, tx,", StringComparison.Ordinal); + int commit = body.IndexOf("tx.Commit();", StringComparison.Ordinal); + begin.Should().BeGreaterThan(0); + del.Should().BeGreaterThan(begin); + read.Should().BeGreaterThan(del, "the leader is read after the delete, in the transaction"); + pass.Should().BeGreaterThan(read); + commit.Should().BeGreaterThan(pass); + body.Should().NotContain("bool isLeader"); + } + + [Fact] + public void ATeamSuccessor_BannedBetweenSelectionAndUpdate_DoesNotGetTheTeam() + { + Player("founder", null, level: 50); + Player("tomas", "Ravens", level: 40); + Team("Ravens", "founder"); + SqlSaveBackend.BeforeTeamLeaderUpdateForTests = chosen => Exec($"UPDATE players SET is_banned = 1 WHERE username = '{chosen}';"); + try + { + _db.TryPassTeamLeadership("Ravens", "founder", "founder", requireOldLeaderGone: true, out var next).Should().BeFalse(); + next.Should().BeNull(); + Leader("Ravens").Should().Be("founder", "the banned successor is refused by the update itself"); + } + finally { SqlSaveBackend.BeforeTeamLeaderUpdateForTests = null; } + + // one who left the team in that window is refused too + Player("founder2", null, level: 50); + Player("mira", "Owls", level: 40); + Team("Owls", "founder2"); + SqlSaveBackend.BeforeTeamLeaderUpdateForTests = chosen => Exec($"UPDATE players SET player_data = '{{\"player\":{{\"level\":40}}}}' WHERE username = '{chosen}';"); + try { _db.TryPassTeamLeadership("Owls", "founder2", "founder2", requireOldLeaderGone: true, out _).Should().BeFalse(); } + finally { SqlSaveBackend.BeforeTeamLeaderUpdateForTests = null; } + Leader("Owls").Should().Be("founder2"); + + // with nothing in between it passes as before + _db.TryPassTeamLeadership("Ravens", "founder", "founder", requireOldLeaderGone: true, out _).Should().BeFalse("tomas is banned now"); + Player("bran", "Ravens", level: 10); + _db.TryPassTeamLeadership("Ravens", "founder", "founder", requireOldLeaderGone: true, out var after).Should().BeTrue(); + after.Should().Be("bran"); + } + private static string Source(string folder, string file) { var dir = new DirectoryInfo(AppContext.BaseDirectory); diff --git a/Tests/NPCDefeatQuestTests.cs b/Tests/NPCDefeatQuestTests.cs index aaae2d33..909db12b 100644 --- a/Tests/NPCDefeatQuestTests.cs +++ b/Tests/NPCDefeatQuestTests.cs @@ -288,4 +288,85 @@ public void TheClaimTable_IsAlsoAMigration() src.LastIndexOf("CREATE TABLE IF NOT EXISTS bounty_claims", migrations, StringComparison.Ordinal) .Should().BeGreaterThan(src.IndexOf("ALTER TABLE player_teams ADD COLUMN last_join_at", StringComparison.Ordinal)); } + + // ─── v1.1.11: review round 13 ─── + + [Fact] + public void ALegacyQuestWithNoId_GetsTheSameClaimKey_InEveryProcess() + { + Quest Legacy(string target) => new Quest { Id = "", Title = "WANTED: " + target, Initiator = "The Crown", TargetNPCName = target, Date = DateTime.Now }; + var a = Legacy("Grim"); + System.Threading.Thread.Sleep(5); + var b = Legacy("Grim"); // another process's copy, built on its own (its load time differs) + QuestSystem.BountyClaimKey(a).Should().Be(QuestSystem.BountyClaimKey(b)); + QuestSystem.BountyClaimKey(a).Should().StartWith("legacy:").And.HaveLength("legacy:".Length + 64, "a SHA-256 in hex"); + QuestSystem.BountyClaimKey(a).Should().NotBe(QuestSystem.BountyClaimKey(Legacy("Other"))); + QuestSystem.BountyClaimKey(new Quest { Id = "Q42" }).Should().Be("Q42"); + } + + [Fact] + public async Task ALegacyBountyWithNoId_IsPaidOnce_AcrossProcesses() + { + await WithSqlBackend(async (db, _) => + { + var bounty = BountyOnPlayer("Idless Rogue", 3000); + bounty.Id = ""; + var rogue = new Character { Name1 = "idless_rogue", Name2 = "Idless Rogue", Level = 30, IsLoadedPlayer = true }; + var winner = new Character { Name1 = "sheriff_c", Name2 = "Sheriff C", Level = 30, Gold = 0 }; + QuestSystem.CollectBountiesOnPlayer(winner, rogue).Should().ContainSingle(); + + var copy = BountyOnPlayer("Idless Rogue", 3000); // the other process's copy, also with no id + copy.Id = ""; + var other = new Character { Name1 = "sheriff_d", Name2 = "Sheriff D", Level = 30, Gold = 0 }; + QuestSystem.CollectBountiesOnPlayer(other, rogue).Should().BeEmpty("the legacy key was claimed already"); + other.Gold.Should().Be(0); + await Task.CompletedTask; + }); + } + + [Fact] + public async Task OldBountyClaims_ArePruned_AfterTheRetention() + { + GameConfig.BountyClaimRetentionDays.Should().Be(90); + await WithSqlBackend(async (db, path) => + { + db.TryClaimBounty("Qfresh", "alice").Should().BeTrue(); + using (var conn = new SqliteConnection($"Data Source={path}")) + { + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "INSERT INTO bounty_claims (quest_id, claimed_by, claimed_at) VALUES ('Qold', 'bob', datetime('now', '-100 days')), ('Qrecent', 'bob', datetime('now', '-60 days'));"; + cmd.ExecuteNonQuery(); + } + db.PruneOldBountyClaims().Should().Be(1); + db.TryClaimBounty("Qold", "carol").Should().BeTrue("the old claim is gone"); + db.TryClaimBounty("Qrecent", "carol").Should().BeFalse("a claim within the retention stays"); + db.TryClaimBounty("Qfresh", "carol").Should().BeFalse(); + await Task.CompletedTask; + }); + + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Scripts"))) dir = dir.Parent; + File.ReadAllText(Path.Combine(dir!.FullName, "Scripts", "Systems", "WorldSimService.cs")) + .Should().Contain("sqlBackend.PruneOldBountyClaims();", "the world save cycle prunes it"); + } + + [Fact] + public void AMarriedPlayer_LoadsWithTheSameDisplayName_AndTheirBountyIsPaid() + { + var bob = new Character { Name1 = "bob_acct", Name2 = "Bob", FamilySurname = "Smith", Level = 20, HP = 100, MaxHP = 100 }; + var serialize = typeof(SaveSystem).GetMethod("SerializePlayer", BindingFlags.NonPublic | BindingFlags.Instance)!; + var data = (PlayerData)serialize.Invoke(SaveSystem.Instance, new object[] { bob })!; + var back = System.Text.Json.JsonSerializer.Deserialize(System.Text.Json.JsonSerializer.Serialize(data))!; + var loaded = PlayerCharacterLoader.CreateFromSaveData(back, "Bob"); + loaded.DisplayName.Should().Be(bob.DisplayName).And.Be("Bob Smith"); + + var bounty = BountyOnPlayer("Bob Smith", 2500); + try + { + var winner = new Character { Name1 = "sheriff_e", Name2 = "Sheriff E", Level = 30, Gold = 0 }; + QuestSystem.CollectBountiesOnPlayer(winner, loaded).Should().ContainSingle("the loaded defender carries the married name"); + } + finally { bounty.Deleted = true; } + } } From 9e2ca4e093b876a46ec66acae6ae76929cd8a214 Mon Sep 17 00:00:00 2001 From: binaryknight Date: Thu, 24 Sep 2026 00:40:31 +0000 Subject: [PATCH 33/42] Fixes from the fourteenth review of the 1.1.11 branch - Deleting a king always reads the shared royal court and ends the reign only when the shared king is the deleted character, writing it back with a versioned write that re-reads and decides again on a conflict; a process holding an old court no longer overwrites the current king. - Removing quests from the shared record uses a versioned write with a re-read and retries, so concurrent removals and additions all persist. - The shared cleanup after a player-bounty payout matches by the claim key, so one quest without an id no longer takes the others with it. - Bounty claims are no longer pruned: the claim row is the only guard against a bounty restored from an old save being paid again. - A deleted character's bounties are removed under every name it went by, married name included; its claimed quests only under its Name2. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Core/GameConfig.cs | 2 - Scripts/Locations/CastleLocation.cs | 54 +++-- Scripts/Systems/CombatEngine.cs | 5 +- Scripts/Systems/OnlineStateManager.cs | 308 +++++++++++++++----------- Scripts/Systems/PermadeathHelper.cs | 29 ++- Scripts/Systems/QuestSystem.cs | 12 +- Scripts/Systems/SqlSaveBackend.cs | 21 +- Scripts/Systems/WorldSimService.cs | 3 - Tests/CharacterRecreationTests.cs | 87 ++++++++ Tests/DeleteFollowUpTests.cs | 106 +++++++-- Tests/NPCDefeatQuestTests.cs | 75 +++++-- 11 files changed, 492 insertions(+), 210 deletions(-) diff --git a/Scripts/Core/GameConfig.cs b/Scripts/Core/GameConfig.cs index 7dcdf996..4e1a335e 100644 --- a/Scripts/Core/GameConfig.cs +++ b/Scripts/Core/GameConfig.cs @@ -1857,8 +1857,6 @@ public static bool IsSmallRace(CharacterRace race) public const int EmptyTeamGraceMinutes = 30; // v1.1.11: after a join, the team is left alone this long so the joiner's save can land (every process) public const int EmptyTeamJoinGraceMinutes = 10; - // v1.1.11: bounty_claims rows are kept this long (a bounty lasts at most 30 days), then pruned by the world save - public const int BountyClaimRetentionDays = 90; // v1.1.10: regular dungeon monsters on the floors before an Old God's floor grow tougher, HP and // damage, by this much per floor: +5% five floors out, up to +25% on the floor before the god diff --git a/Scripts/Locations/CastleLocation.cs b/Scripts/Locations/CastleLocation.cs index 8c2ad7ac..d7c2a70a 100644 --- a/Scripts/Locations/CastleLocation.cs +++ b/Scripts/Locations/CastleLocation.cs @@ -7961,44 +7961,60 @@ internal static bool IsDeletedCharactersReign(King? king, string? name, string? return named(name) || named(displayName); } - /// v1.1.11: online, a process that has not read the shared royal_court (a fresh door process at character select) reads it first. - internal static bool NeedsSharedCourtLoad(bool online, King? king, bool loadedFromShared) => - online && (king == null || !loadedFromShared); - /// - /// v1.1.11: the delete path. Online, the authoritative royal_court is read first when this process - /// has not loaded it, and the ended reign is written back to it before returning. + /// v1.1.11: the delete path. Online, the authoritative royal_court is always read to decide, and the + /// ended reign is written back under the version read before returning. /// public static Task AbdicateDeletedKingAsync(string? name, string? displayName, string reason) { var osm = UsurperRemake.BBS.DoorMode.IsOnlineMode ? OnlineStateManager.Instance : null; if (osm == null) return AbdicateDeletedKingAsync(name, displayName, reason, false, null, null, null); return AbdicateDeletedKingAsync(name, displayName, reason, true, - osm.ReadRoyalCourtFromWorldState, osm.LoadRoyalCourtFromWorldState, () => osm.SaveRoyalCourtToWorldState(throneVacated: true)); + osm.ReadRoyalCourtWithVersionAsync, osm.LoadRoyalCourtFromWorldState, v => osm.SaveRoyalCourtIfVersionAsync(v, throneVacated: true)); } /// - /// v1.1.11: the delete path with the shared court calls passed in. A process that has not read the - /// shared court only reads the king's name from it; the court is applied only when that king is the - /// deleted character, so deleting anyone else leaves this process's court as it was. + /// v1.1.11: the delete path with the shared court calls passed in. The shared court decides, even in a + /// process that loaded it earlier (its copy may be stale): the reign ends only when the stored king is + /// the deleted character, and that court is loaded first. The write is guarded by the version read; on a + /// conflict the court is read and the decision made again, up to 3 times. /// internal static async Task AbdicateDeletedKingAsync(string? name, string? displayName, string reason, bool online, - Func>? readShared, Func? loadShared, Func? saveShared) + Func>? readShared, Func? loadShared, Func>? saveSharedIfVersion) { - if (NeedsSharedCourtLoad(online, GetCurrentKing(), RoyalCourtLoadedFromShared)) + if (!online || readShared == null) { - var shared = readShared == null ? null : await readShared(); + var local = GetCurrentKing(); + if (!IsDeletedCharactersReign(local, name, displayName)) return false; + EndPlayerReign(local!.Name, reason, persist: !online); + return true; + } + + bool changedLocally = false; + var history = GetMonarchHistory().ToList(); + for (int attempt = 0; attempt < 3; attempt++) + { + if (changedLocally) SetMonarchHistory(history.ToList()); // v1.1.11: a retry records the reign once + var (shared, version) = await readShared(); if (shared != null) { - if (!SharedCourtNamesDeletedCharacter(shared, name, displayName)) return false; + if (!SharedCourtNamesDeletedCharacter(shared, name, displayName)) + { + // v1.1.11: a lost race ended a reign locally that the shared court no longer holds + if (changedLocally && loadShared != null) await loadShared(); + return false; + } if (loadShared != null) await loadShared(); } + var king = GetCurrentKing(); + if (!IsDeletedCharactersReign(king, name, displayName)) return false; + EndPlayerReign(king!.Name, reason, persist: false); + changedLocally = true; + if (saveSharedIfVersion == null || await saveSharedIfVersion(version)) return true; } - var king = GetCurrentKing(); - if (!IsDeletedCharactersReign(king, name, displayName)) return false; - EndPlayerReign(king!.Name, reason, persist: !online); - if (online && saveShared != null) await saveShared(); - return true; + DebugLogger.Instance.LogWarning("CASTLE", $"The reign of deleted '{name}' was not ended in the shared court: it kept changing."); + if (loadShared != null) await loadShared(); + return false; } /// v1.1.11: the stored court's king is the deleted character (a reigning player of that name). diff --git a/Scripts/Systems/CombatEngine.cs b/Scripts/Systems/CombatEngine.cs index f3d382e3..263deeea 100644 --- a/Scripts/Systems/CombatEngine.cs +++ b/Scripts/Systems/CombatEngine.cs @@ -26159,8 +26159,9 @@ private async Task ReportDuelDefeat(CombatResult result) bounty = paid.Sum(QuestSystem.BountyReward); if (paid.Count > 0 && UsurperRemake.BBS.DoorMode.IsOnlineMode && OnlineStateManager.IsActive) { - var ids = paid.Select(q => q.Id).ToHashSet(); - await OnlineStateManager.Instance!.RemoveSharedQuestsAsync(q => ids.Contains(q.Id)); // edited in place + // v1.1.11: matched by claim key, so one id-less bounty does not take the other id-less quests with it + var keys = paid.Select(q => QuestSystem.BountyClaimKey(q)).ToHashSet(); + await OnlineStateManager.Instance!.RemoveSharedQuestsAsync(q => keys.Contains(QuestSystem.BountyClaimKey(q))); // edited in place } } else bounty = 0; diff --git a/Scripts/Systems/OnlineStateManager.cs b/Scripts/Systems/OnlineStateManager.cs index 6afadd68..0d47b972 100644 --- a/Scripts/Systems/OnlineStateManager.cs +++ b/Scripts/Systems/OnlineStateManager.cs @@ -314,15 +314,36 @@ public async Task SaveSharedQuestsNow() /// stored. A delete in a fresh process has not loaded the shared quests, so pushing its own list /// (SaveSharedQuestsNow) would overwrite other players' quests (review). Returns the number removed. /// - public async Task RemoveSharedQuestsAsync(Func matches) + public Task RemoveSharedQuestsAsync(Func matches) => RemoveSharedQuestsAsync(matches, null); + + /// + /// v1.1.11: the edit is written under the record's version, re-read and re-applied if another + /// process wrote first, so a concurrent removal or addition is kept. beforeWrite is a test hook. + /// + internal async Task RemoveSharedQuestsAsync(Func matches, Func? beforeWrite) { try { - var shared = await LoadSharedQuests(); - if (shared == null) return 0; - int removed = shared.RemoveAll(q => matches(q)); - if (removed > 0) await SaveSharedQuests(shared); - return removed; + if (backend is not SqlSaveBackend sql) + { + var shared = await LoadSharedQuests(); + if (shared == null) return 0; + int removed = shared.RemoveAll(q => matches(q)); + if (removed > 0) await SaveSharedQuests(shared); + return removed; + } + for (int attempt = 0; attempt < 5; attempt++) + { + long version = sql.GetWorldStateVersion(KEY_QUESTS); // read before the value, so any later write is a conflict + var shared = await LoadSharedQuests(); + if (shared == null) return 0; + int removed = shared.RemoveAll(q => matches(q)); + if (removed == 0) return 0; + if (beforeWrite != null) await beforeWrite(); + if (await sql.SaveWorldStateIfVersion(KEY_QUESTS, JsonSerializer.Serialize(shared, jsonOptions), version)) return removed; + } + DebugLogger.Instance.LogWarning("ONLINE", "RemoveSharedQuestsAsync gave up: the quest record kept changing."); + return 0; } catch (Exception ex) { @@ -453,129 +474,12 @@ public async Task SaveRoyalCourtToWorldState(bool throneVacated = false) // v1.1.11: an unmarked empty court never replaces a marked vacancy the world sim has yet to act on if (KeepsStoredVacancy(await ReadRoyalCourtFromWorldState(), throneVacated)) return; // Throne is vacant — save empty state so other sessions see it - // v1.1.11: a reign that just ended with no successor is marked, so the loaders clear their king - var emptyData = new RoyalCourtSaveData { KingName = "", Treasury = 0, KingAI = 1, ThroneVacant = throneVacated }; - var emptyJson = System.Text.Json.JsonSerializer.Serialize(emptyData, - new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); var backend = SaveSystem.Instance?.Backend as SqlSaveBackend; - if (backend != null) await backend.SaveWorldState("royal_court", emptyJson); + if (backend != null) await backend.SaveWorldState("royal_court", EmptyRoyalCourtJson(throneVacated)); return; } - var data = new RoyalCourtSaveData - { - KingName = king.Name, - Treasury = king.Treasury, - TaxRate = king.TaxRate, - TotalReign = king.TotalReign, - KingTaxPercent = king.KingTaxPercent, - CityTaxPercent = king.CityTaxPercent, - DesignatedHeir = king.DesignatedHeir ?? "", - KingAI = (int)king.AI, - KingSex = (int)king.Sex, - CoronationDate = king.CoronationDate.ToString("o"), - TaxAlignment = (int)king.TaxAlignment, - MonarchHistory = global::CastleLocation.GetMonarchHistory()?.Select(m => new MonarchRecordSaveData - { - Name = m.Name, - Title = m.Title, - DaysReigned = m.DaysReigned, - CoronationDate = m.CoronationDate.ToString("o"), - EndReason = m.EndReason - }).ToList() ?? new List(), - CourtMembers = king.CourtMembers?.Select(m => new CourtMemberSaveData - { - Name = m.Name, - Faction = (int)m.Faction, - Influence = m.Influence, - LoyaltyToKing = m.LoyaltyToKing, - Role = m.Role, - IsPlotting = m.IsPlotting - }).ToList() ?? new List(), - Heirs = king.Heirs?.Select(h => new RoyalHeirSaveData - { - Name = h.Name, - Age = h.Age, - ClaimStrength = h.ClaimStrength, - ParentName = h.ParentName, - Sex = (int)h.Sex, - IsDesignated = h.IsDesignated - }).ToList() ?? new List(), - Spouse = king.Spouse != null ? new RoyalSpouseSaveData - { - Name = king.Spouse.Name, - Sex = (int)king.Spouse.Sex, - OriginalFaction = (int)king.Spouse.OriginalFaction, - Dowry = king.Spouse.Dowry, - Happiness = king.Spouse.Happiness - } : null, - ActivePlots = king.ActivePlots?.Select(p => new CourtIntrigueSaveData - { - PlotType = p.PlotType, - Conspirators = p.Conspirators, - Target = p.Target, - Progress = p.Progress, - IsDiscovered = p.IsDiscovered - }).ToList() ?? new List(), - Guards = king.Guards?.Select(g => new RoyalGuardSaveData - { - Name = g.Name, - AI = (int)g.AI, - Sex = (int)g.Sex, - DailySalary = g.DailySalary, - Loyalty = g.Loyalty, - IsActive = g.IsActive - }).ToList() ?? new List(), - MonsterGuards = king.MonsterGuards?.Select(m => new MonsterGuardSaveData - { - Name = m.Name, - Level = m.Level, - HP = m.HP, - MaxHP = m.MaxHP, - Strength = m.Strength, - Defence = m.Defence, - WeapPow = m.WeapPow, - ArmPow = m.ArmPow, - MonsterType = m.MonsterType, - PurchaseCost = m.PurchaseCost, - DailyFeedingCost = m.DailyFeedingCost - }).ToList() ?? new List(), - - // Phase 2 — previously unserialized fields - Prisoners = king.Prisoners?.Select(kvp => new PrisonRecordSaveData - { - CharacterName = kvp.Value.CharacterName, - Crime = kvp.Value.Crime, - Sentence = kvp.Value.Sentence, - DaysServed = kvp.Value.DaysServed, - ImprisonmentDate = kvp.Value.ImprisonmentDate.ToString("o"), - BailAmount = kvp.Value.BailAmount - }).ToList() ?? new List(), - Orphans = king.Orphans?.Select(o => new RoyalOrphanSaveData - { - Name = o.Name, - Age = o.Age, - Sex = (int)o.Sex, - ArrivalDate = o.ArrivalDate.ToString("o"), - BackgroundStory = o.BackgroundStory, - Happiness = o.Happiness, - MotherName = o.MotherName, - FatherName = o.FatherName, - MotherID = o.MotherID, - FatherID = o.FatherID, - Race = (int)o.Race, - BirthDate = o.BirthDate.ToString("o"), - Soul = o.Soul, - IsRealOrphan = o.IsRealOrphan - }).ToList() ?? new List(), - MagicBudget = king.MagicBudget, - EstablishmentStatus = king.EstablishmentStatus ?? new Dictionary(), - LastProclamation = king.LastProclamation ?? "", - LastProclamationDate = king.LastProclamationDate != DateTime.MinValue - ? king.LastProclamationDate.ToString("o") : "" - }; - - var json = JsonSerializer.Serialize(data, jsonOptions); + var json = RoyalCourtJson(king); await backend.SaveWorldState("royal_court", json); DebugLogger.Instance.LogDebug("ONLINE", $"Royal court saved to world_state: {king.Name}"); } @@ -585,6 +489,160 @@ public async Task SaveRoyalCourtToWorldState(bool throneVacated = false) } } + /// v1.1.11: the stored form of an empty court; a reign that just ended with no successor is marked, so the loaders clear their king. + private static string EmptyRoyalCourtJson(bool throneVacated) + { + var emptyData = new RoyalCourtSaveData { KingName = "", Treasury = 0, KingAI = 1, ThroneVacant = throneVacated }; + return System.Text.Json.JsonSerializer.Serialize(emptyData, + new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); + } + + /// v1.1.11: the stored form of the court of this king (the body of SaveRoyalCourtToWorldState). + private string RoyalCourtJson(King king) + { + var data = new RoyalCourtSaveData + { + KingName = king.Name, + Treasury = king.Treasury, + TaxRate = king.TaxRate, + TotalReign = king.TotalReign, + KingTaxPercent = king.KingTaxPercent, + CityTaxPercent = king.CityTaxPercent, + DesignatedHeir = king.DesignatedHeir ?? "", + KingAI = (int)king.AI, + KingSex = (int)king.Sex, + CoronationDate = king.CoronationDate.ToString("o"), + TaxAlignment = (int)king.TaxAlignment, + MonarchHistory = global::CastleLocation.GetMonarchHistory()?.Select(m => new MonarchRecordSaveData + { + Name = m.Name, + Title = m.Title, + DaysReigned = m.DaysReigned, + CoronationDate = m.CoronationDate.ToString("o"), + EndReason = m.EndReason + }).ToList() ?? new List(), + CourtMembers = king.CourtMembers?.Select(m => new CourtMemberSaveData + { + Name = m.Name, + Faction = (int)m.Faction, + Influence = m.Influence, + LoyaltyToKing = m.LoyaltyToKing, + Role = m.Role, + IsPlotting = m.IsPlotting + }).ToList() ?? new List(), + Heirs = king.Heirs?.Select(h => new RoyalHeirSaveData + { + Name = h.Name, + Age = h.Age, + ClaimStrength = h.ClaimStrength, + ParentName = h.ParentName, + Sex = (int)h.Sex, + IsDesignated = h.IsDesignated + }).ToList() ?? new List(), + Spouse = king.Spouse != null ? new RoyalSpouseSaveData + { + Name = king.Spouse.Name, + Sex = (int)king.Spouse.Sex, + OriginalFaction = (int)king.Spouse.OriginalFaction, + Dowry = king.Spouse.Dowry, + Happiness = king.Spouse.Happiness + } : null, + ActivePlots = king.ActivePlots?.Select(p => new CourtIntrigueSaveData + { + PlotType = p.PlotType, + Conspirators = p.Conspirators, + Target = p.Target, + Progress = p.Progress, + IsDiscovered = p.IsDiscovered + }).ToList() ?? new List(), + Guards = king.Guards?.Select(g => new RoyalGuardSaveData + { + Name = g.Name, + AI = (int)g.AI, + Sex = (int)g.Sex, + DailySalary = g.DailySalary, + Loyalty = g.Loyalty, + IsActive = g.IsActive + }).ToList() ?? new List(), + MonsterGuards = king.MonsterGuards?.Select(m => new MonsterGuardSaveData + { + Name = m.Name, + Level = m.Level, + HP = m.HP, + MaxHP = m.MaxHP, + Strength = m.Strength, + Defence = m.Defence, + WeapPow = m.WeapPow, + ArmPow = m.ArmPow, + MonsterType = m.MonsterType, + PurchaseCost = m.PurchaseCost, + DailyFeedingCost = m.DailyFeedingCost + }).ToList() ?? new List(), + + // Phase 2: previously unserialized fields + Prisoners = king.Prisoners?.Select(kvp => new PrisonRecordSaveData + { + CharacterName = kvp.Value.CharacterName, + Crime = kvp.Value.Crime, + Sentence = kvp.Value.Sentence, + DaysServed = kvp.Value.DaysServed, + ImprisonmentDate = kvp.Value.ImprisonmentDate.ToString("o"), + BailAmount = kvp.Value.BailAmount + }).ToList() ?? new List(), + Orphans = king.Orphans?.Select(o => new RoyalOrphanSaveData + { + Name = o.Name, + Age = o.Age, + Sex = (int)o.Sex, + ArrivalDate = o.ArrivalDate.ToString("o"), + BackgroundStory = o.BackgroundStory, + Happiness = o.Happiness, + MotherName = o.MotherName, + FatherName = o.FatherName, + MotherID = o.MotherID, + FatherID = o.FatherID, + Race = (int)o.Race, + BirthDate = o.BirthDate.ToString("o"), + Soul = o.Soul, + IsRealOrphan = o.IsRealOrphan + }).ToList() ?? new List(), + MagicBudget = king.MagicBudget, + EstablishmentStatus = king.EstablishmentStatus ?? new Dictionary(), + LastProclamation = king.LastProclamation ?? "", + LastProclamationDate = king.LastProclamationDate != DateTime.MinValue + ? king.LastProclamationDate.ToString("o") : "" + }; + + return JsonSerializer.Serialize(data, jsonOptions); + } + + /// v1.1.11: the stored court and the version it was read at (the version read first, so a later write is a conflict). + internal async Task<(RoyalCourtSaveData? Court, long Version)> ReadRoyalCourtWithVersionAsync() + { + long version = (backend as SqlSaveBackend)?.GetWorldStateVersion("royal_court") ?? 0; + return (await ReadRoyalCourtFromWorldState(), version); + } + + /// + /// v1.1.11: this process's court, written only if the stored court is still at the version read. + /// False on a conflict, so the caller re-reads and decides again. Without SQL it is a plain save. + /// + internal async Task SaveRoyalCourtIfVersionAsync(long version, bool throneVacated) + { + try + { + if (backend is not SqlSaveBackend sql) { await SaveRoyalCourtToWorldState(throneVacated); return true; } + var king = global::CastleLocation.GetCurrentKing(); + string json = king == null ? EmptyRoyalCourtJson(throneVacated) : RoyalCourtJson(king); + return await sql.SaveWorldStateIfVersion("royal_court", json, version); + } + catch (Exception ex) + { + DebugLogger.Instance.LogError("ONLINE", $"Failed to save royal court under its version: {ex.Message}"); + return false; + } + } + /// /// Save economy/city control data to world_state for the dashboard. /// Called from player sessions (game server) which have access to the player, diff --git a/Scripts/Systems/PermadeathHelper.cs b/Scripts/Systems/PermadeathHelper.cs index dce8b932..80afcc18 100644 --- a/Scripts/Systems/PermadeathHelper.cs +++ b/Scripts/Systems/PermadeathHelper.cs @@ -418,15 +418,22 @@ public static async Task PurgeDeletedCharacterAsync(SqlSaveBackend? backend, str if (backend != null && !string.IsNullOrWhiteSpace(username)) backend.PurgePlayerWorldState(username!, name); + // v1.1.11: every name the character was known by; a royal-debt bounty names the married display name + string? shown = player?.DisplayName ?? (string.IsNullOrWhiteSpace(username) ? null : backend?.GetStoredDisplayName(username!)); + var aliases = CharacterAliases(name, player?.Name2, shown); + // quests record only the character's Name2 (Occupier / OfferedTo); the married display name is for + // bounties only, since another character's Name2 may equal it (review) + var questNames = CharacterAliases(name, player?.Name2); + try { // Claimed quests (Occupier / OfferedTo = display name) and the King's WANTED bounty on // the character. Pushed even when nothing was removed, since world_state may still hold // a stale copy that a same-name character would merge back on load. - int removed = QuestSystem.RemovePlayerQuests(name) + QuestSystem.RemoveBountiesOnPlayer(name); + int removed = QuestSystem.RemovePlayerQuests(questNames.ToArray()) + aliases.Sum(a => QuestSystem.RemoveBountiesOnPlayer(a)); // the shared record is edited in place, not replaced by this process's list (review) if (UsurperRemake.BBS.DoorMode.IsOnlineMode && OnlineStateManager.IsActive) - removed += await OnlineStateManager.Instance!.RemoveSharedQuestsAsync(q => QuestLeftByCharacter(q, name)); + removed += await OnlineStateManager.Instance!.RemoveSharedQuestsAsync(q => QuestLeftByCharacter(q, questNames, aliases)); if (removed > 0) DebugLogger.Instance.LogInfo("DELETE", $"Removed {removed} quest(s) and bounties for deleted '{name}'."); } @@ -454,7 +461,6 @@ public static async Task PurgeDeletedCharacterAsync(SqlSaveBackend? backend, str try { // v1.1.11: a deleted king abdicates through the normal path (history, NPC succession, persist) - string? shown = player?.DisplayName ?? (string.IsNullOrWhiteSpace(username) ? null : backend?.GetStoredDisplayName(username!)); if (await global::CastleLocation.AbdicateDeletedKingAsync(name, shown, "left the throne and the realm")) DebugLogger.Instance.LogInfo("DELETE", $"Deleted '{name}' held the throne; the reign has ended."); } @@ -503,6 +509,23 @@ public static bool QuestLeftByCharacter(QuestData q, string name) => string.Equals(q.OfferedTo, name, StringComparison.OrdinalIgnoreCase) || QuestSystem.IsBountyOnPlayer(q.Initiator, q.TitleKey, q.TargetNPCName, q.IsPlayerBounty, name); + /// + /// v1.1.11: as above for a character known by several names (a royal-debt bounty names the married + /// display name): a match on the name or any alias. + /// + public static bool QuestLeftByCharacter(QuestData q, IReadOnlyList questNames, IReadOnlyList bountyAliases) => + questNames.Any(n => QuestLeftByCharacter(q, n)) || + bountyAliases.Any(a => QuestSystem.IsBountyOnPlayer(q.Initiator, q.TitleKey, q.TargetNPCName, q.IsPlayerBounty, a)); + + /// v1.1.11: the distinct, non-blank names a character went by, the name first. + public static List CharacterAliases(params string?[] names) + { + var list = new List(); + foreach (var n in names) + if (!string.IsNullOrWhiteSpace(n) && !list.Any(x => string.Equals(x, n, StringComparison.OrdinalIgnoreCase))) list.Add(n!); + return list; + } + /// v1.1.11: drop every live NPC's grudges against the name (MemorySystem.IsGrudge). public static int ForgetNpcGrudgesAgainst(string? name) { diff --git a/Scripts/Systems/QuestSystem.cs b/Scripts/Systems/QuestSystem.cs index e9345c7a..d8196e9e 100644 --- a/Scripts/Systems/QuestSystem.cs +++ b/Scripts/Systems/QuestSystem.cs @@ -1675,10 +1675,16 @@ internal static bool ClaimAcrossProcesses(Quest q, string claimer) /// v1.1.11: the key a bounty is claimed under: its id, or for a quest saved without one, "legacy:" and a /// SHA-256 of its initiator, target and title, the same in every process that loaded the quest. /// - internal static string BountyClaimKey(Quest q) + internal static string BountyClaimKey(Quest q) => BountyClaimKey(q.Id, q.Initiator, q.TargetNPCName, q.Title); + + /// v1.1.11: the same key for a stored quest, so the shared record is matched as the claim was. + internal static string BountyClaimKey(QuestData q) => BountyClaimKey(q.Id, q.Initiator, q.TargetNPCName, q.Title); + + /// v1.1.11: the claim key from the fields both quest forms carry. + internal static string BountyClaimKey(string? id, string? initiator, string? targetName, string? title) { - if (!string.IsNullOrEmpty(q.Id)) return q.Id; - string basis = (q.Initiator ?? "") + "|" + (q.TargetNPCName ?? "") + "|" + (q.Title ?? ""); + if (!string.IsNullOrEmpty(id)) return id!; + string basis = (initiator ?? "") + "|" + (targetName ?? "") + "|" + (title ?? ""); var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(basis)); return "legacy:" + Convert.ToHexString(hash).ToLowerInvariant(); } diff --git a/Scripts/Systems/SqlSaveBackend.cs b/Scripts/Systems/SqlSaveBackend.cs index 9fbeecc4..f858fa0c 100644 --- a/Scripts/Systems/SqlSaveBackend.cs +++ b/Scripts/Systems/SqlSaveBackend.cs @@ -842,7 +842,8 @@ created_at TEXT DEFAULT (datetime('now')), ); CREATE INDEX IF NOT EXISTS idx_onboarding_event ON onboarding_events(event, created_at DESC); - -- v1.1.11: one row per bounty paid, so a bounty is paid once across processes + -- v1.1.11: one row per bounty paid, so a bounty is paid once across processes. + -- v1.1.11: never pruned (a stale save can bring an old bounty back); one small row per bounty paid. CREATE TABLE IF NOT EXISTS bounty_claims ( quest_id TEXT PRIMARY KEY, claimed_by TEXT, @@ -1319,24 +1320,6 @@ public bool TryClaimBounty(string questId, string claimer) } } - /// v1.1.11: removes bounty claims older than the retention (GameConfig.BountyClaimRetentionDays). Returns the rows removed. - public int PruneOldBountyClaims(int daysToKeep = GameConfig.BountyClaimRetentionDays) - { - try - { - using var connection = OpenConnection(); - using var cmd = connection.CreateCommand(); - cmd.CommandText = "DELETE FROM bounty_claims WHERE claimed_at < datetime('now', @cutoff);"; - cmd.Parameters.AddWithValue("@cutoff", $"-{daysToKeep} days"); - return cmd.ExecuteNonQuery(); - } - catch (Exception ex) - { - DebugLogger.Instance.LogError("SQL", $"Failed to prune bounty_claims: {ex.Message}"); - return 0; - } - } - private static void ExecPurge(SqliteConnection conn, SqliteTransaction tx, string table, string whereClause, string username, string? displayName = null) { try diff --git a/Scripts/Systems/WorldSimService.cs b/Scripts/Systems/WorldSimService.cs index acfc2aa8..7b74bc5f 100644 --- a/Scripts/Systems/WorldSimService.cs +++ b/Scripts/Systems/WorldSimService.cs @@ -567,9 +567,6 @@ private async Task SaveWorldState() // Clean up orphaned data from deleted players await sqlBackend.PruneOrphanedPlayerData(); - // v1.1.11: paid-bounty claims past any bounty's life - sqlBackend.PruneOldBountyClaims(); - // v1.1.11: teams nobody is in any more PruneEmptyTeams(); diff --git a/Tests/CharacterRecreationTests.cs b/Tests/CharacterRecreationTests.cs index 18792d5e..3fec3f5a 100644 --- a/Tests/CharacterRecreationTests.cs +++ b/Tests/CharacterRecreationTests.cs @@ -424,4 +424,91 @@ public void EveryPermadeath_DeletesTheCharactersOwnKey_NotTheAccounts() var helper = CodeOnly(Source("Systems", "PermadeathHelper.cs")); helper.Should().Contain("ctx!.CharacterKey : ctx?.Username"); } + + // ─── v1.1.11: review round 14 ─── + + private static OnlineStateManager NewOsm(SqlSaveBackend db) => + (OnlineStateManager)Activator.CreateInstance(typeof(OnlineStateManager), + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance, null, new object[] { db, "r14" }, null)!; + + [Fact] + public async System.Threading.Tasks.Task InterleavedSharedQuestRemovals_BothPersist_AndAnAdditionIsKept() + { + _db = new SqlSaveBackend(_path); + var osm = NewOsm(_db); + await osm.SaveSharedQuests(new List { new QuestData { Id = "A" }, new QuestData { Id = "B" } }); + + bool first = true; + int removed = await osm.RemoveSharedQuestsAsync(q => q.Id == "A", async () => + { + if (!first) return; + first = false; + // another process, between this read and this write: removes B, then adds C + (await osm.RemoveSharedQuestsAsync(q => q.Id == "B")).Should().Be(1); + var now = (await osm.LoadSharedQuests())!; + now.Add(new QuestData { Id = "C" }); + await osm.SaveSharedQuests(now); + }); + removed.Should().Be(1); + first.Should().BeFalse("the hook ran"); + (await osm.LoadSharedQuests())!.Select(q => q.Id).Should().BeEquivalentTo(new[] { "C" }, "neither removal undoes the other, and the addition stays"); + + var src = CodeOnly(Source("Systems", "OnlineStateManager.cs")); + src.Should().Contain("await sql.SaveWorldStateIfVersion(KEY_QUESTS, JsonSerializer.Serialize(shared, jsonOptions), version)"); + } + + [Fact] + public async System.Threading.Tasks.Task ARoyalDebtBountyOnTheMarriedName_IsRemoved_LocallyAndInTheSharedRecord() + { + _db = new SqlSaveBackend(_path); + using (var conn = new SqliteConnection($"Data Source={_path}")) + { + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "INSERT INTO players (username, display_name, player_data) VALUES ('bob_acct', 'Bob Smith', '{}');"; + cmd.ExecuteNonQuery(); + } + try + { + // locally, with the display name read from the players row + var onMarried = Add(Wanted("Bob Smith")); + var onOther = Add(Wanted("Carol Smith")); + await PermadeathHelper.PurgeDeletedCharacterAsync(_db, "bob_acct", "Bob"); + InDatabase(onMarried).Should().BeFalse("the royal-debt bounty names the married display name"); + InDatabase(onOther).Should().BeTrue(); + + // locally, with the display name from the character passed in + var again = Add(Wanted("Bob Smith")); + var bob = new Character { Name1 = "bob_acct2", Name2 = "Bob", FamilySurname = "Smith" }; + bob.DisplayName.Should().Be("Bob Smith"); + await PermadeathHelper.PurgeDeletedCharacterAsync(null, "bob_acct2", "Bob", bob); + InDatabase(again).Should().BeFalse(); + + // the shared record, edited with the purge's predicate + var osm = NewOsm(_db); + await osm.SaveSharedQuests(new List + { + new QuestData { Id = "R1", Initiator = "The Crown", TargetNPCName = "Bob Smith", IsPlayerBounty = true }, + new QuestData { Id = "R2", Initiator = "The Crown", TargetNPCName = "Bob", IsPlayerBounty = true }, + new QuestData { Id = "R3", Initiator = "The Crown", TargetNPCName = "Carol Smith", IsPlayerBounty = true }, + new QuestData { Id = "R4", Occupier = "Bob" }, + new QuestData { Id = "R5", Occupier = "Bob Smith" } // another character whose Name2 is "Bob Smith" + }); + var aliases = PermadeathHelper.CharacterAliases("Bob", null, _db.GetStoredDisplayName("bob_acct")); + aliases.Should().Equal("Bob", "Bob Smith"); + (await osm.RemoveSharedQuestsAsync(q => PermadeathHelper.QuestLeftByCharacter(q, new[] { "Bob" }, aliases))).Should().Be(3); + (await osm.LoadSharedQuests())!.Select(q => q.Id).Should().Equal(new[] { "R3", "R5" }, + "quests record Name2 only; the married name is for bounties, and another character may carry it as Name2"); + } + finally + { + QuestSystem.RemoveBountiesOnPlayer("Bob Smith"); + QuestSystem.RemoveBountiesOnPlayer("Carol Smith"); + } + + var purge = CodeOnly(Source("Systems", "PermadeathHelper.cs")); + purge.Should().Contain("aliases.Sum(a => QuestSystem.RemoveBountiesOnPlayer(a))"); + purge.Should().Contain("RemoveSharedQuestsAsync(q => QuestLeftByCharacter(q, questNames, aliases))"); + purge.Should().Contain("QuestSystem.RemovePlayerQuests(questNames.ToArray())"); + } } diff --git a/Tests/DeleteFollowUpTests.cs b/Tests/DeleteFollowUpTests.cs index 0c3110d5..0bd2a6da 100644 --- a/Tests/DeleteFollowUpTests.cs +++ b/Tests/DeleteFollowUpTests.cs @@ -332,10 +332,6 @@ public async Task TheSharedNpcRecord_LosesTheSpouse_AndTheGrudges_InOneVersioned public void AFreshProcess_ReadsTheSharedRoyalCourt_BeforeDecidingTheDeletedKingsReign() { var king = King.CreateNewKing("Bob", CharacterAI.Human, CharacterSex.Male); - CastleLocation.NeedsSharedCourtLoad(online: true, king: null, loadedFromShared: false).Should().BeTrue(); - CastleLocation.NeedsSharedCourtLoad(online: true, king: king, loadedFromShared: false).Should().BeTrue("a king not read from shared state may be stale"); - CastleLocation.NeedsSharedCourtLoad(online: true, king: king, loadedFromShared: true).Should().BeFalse(); - CastleLocation.NeedsSharedCourtLoad(online: false, king: null, loadedFromShared: false).Should().BeFalse("offline has no shared court"); CastleLocation.IsDeletedCharactersReign(king, "Bob", null).Should().BeTrue(); CastleLocation.IsDeletedCharactersReign(king, "x", "bob").Should().BeTrue(); @@ -348,12 +344,14 @@ public void AFreshProcess_ReadsTheSharedRoyalCourt_BeforeDecidingTheDeletedKings start.Should().BeGreaterThan(0); string body = castle.Substring(start, castle.IndexOf("internal static bool SharedCourtNamesDeletedCharacter(", start, StringComparison.Ordinal) - start); int read = body.IndexOf("await readShared()", StringComparison.Ordinal); - read.Should().BeGreaterThan(body.IndexOf("NeedsSharedCourtLoad(", StringComparison.Ordinal)); - body.IndexOf("await loadShared()", StringComparison.Ordinal) + read.Should().BeGreaterThan(0); + body.Should().NotContain("RoyalCourtLoadedFromShared", "the shared court is read even by a process that loaded it earlier"); + int loop = body.IndexOf("for (int attempt", StringComparison.Ordinal); + body.IndexOf("await loadShared()", loop, StringComparison.Ordinal) .Should().BeGreaterThan(body.IndexOf("SharedCourtNamesDeletedCharacter(", read, StringComparison.Ordinal), "the court is applied only on a match") - .And.BeLessThan(body.IndexOf("IsDeletedCharactersReign(", StringComparison.Ordinal)); - body.Should().Contain("await saveShared()", "the ended reign is written to the shared royal_court"); - castle.Should().Contain("osm.ReadRoyalCourtFromWorldState, osm.LoadRoyalCourtFromWorldState, () => osm.SaveRoyalCourtToWorldState(throneVacated: true)"); + .And.BeLessThan(body.IndexOf("IsDeletedCharactersReign(", loop, StringComparison.Ordinal)); + body.Should().Contain("await saveSharedIfVersion(version)", "the ended reign is written under the version read"); + castle.Should().Contain("osm.ReadRoyalCourtWithVersionAsync, osm.LoadRoyalCourtFromWorldState, v => osm.SaveRoyalCourtIfVersionAsync(v, throneVacated: true)"); Source("Systems", "OnlineStateManager.cs").Should().Contain("CastleLocation.RoyalCourtLoadedFromShared = true;"); Source("Systems", "PermadeathHelper.cs").Should().Contain("await global::CastleLocation.AbdicateDeletedKingAsync("); } @@ -436,9 +434,9 @@ await WithKing(alice, async () => CastleLocation.RoyalCourtLoadedFromShared = false; bool applied = false, saved = false; bool ended = await CastleLocation.AbdicateDeletedKingAsync("Bob", "Bob", "left", online: true, - () => Task.FromResult(shared), + () => Task.FromResult<(RoyalCourtSaveData?, long)>((shared, 1)), () => { applied = true; return Task.CompletedTask; }, - () => { saved = true; return Task.CompletedTask; }); + _ => { saved = true; return Task.FromResult(true); }); ended.Should().BeFalse(); applied.Should().BeFalse("the court is not applied for a non-king"); saved.Should().BeFalse(); @@ -454,9 +452,9 @@ await WithKing(alice, async () => CastleLocation.RoyalCourtLoadedFromShared = false; bool applied = false, saved = false; bool ended = await CastleLocation.AbdicateDeletedKingAsync("Bob", "Bob", "left", online: true, - () => Task.FromResult(bobCourt), + () => Task.FromResult<(RoyalCourtSaveData?, long)>((bobCourt, 1)), () => { applied = true; CastleLocation.SetKing(King.CreateNewKing("Bob", CharacterAI.Human, CharacterSex.Male)); return Task.CompletedTask; }, - () => { saved = true; return Task.CompletedTask; }); + _ => { saved = true; return Task.FromResult(true); }); ended.Should().BeTrue(); applied.Should().BeTrue(); saved.Should().BeTrue(); @@ -471,6 +469,88 @@ await WithKing(alice, async () => CastleLocation.SharedCourtNamesDeletedCharacter(new RoyalCourtSaveData { KingName = "Bob Smith", KingAI = (int)CharacterAI.Human }, "Bob", "Bob Smith").Should().BeTrue(); } + // ─── v1.1.11: review round 14 ─── + + private static OnlineStateManager NewOsm(SqlSaveBackend db) => + (OnlineStateManager)Activator.CreateInstance(typeof(OnlineStateManager), + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance, null, new object[] { db, "r14" }, null)!; + + private RoyalCourtSaveData StoredCourt() => + System.Text.Json.JsonSerializer.Deserialize(_db.LoadWorldState("royal_court").Result!, + new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + + [Fact] + public async Task AStaleCourt_DoesNotAbdicateAFormerKing_OverTheSharedNewKing() + { + bool loadedBefore = CastleLocation.RoyalCourtLoadedFromShared; + try + { + var osm = NewOsm(_db); + // this process loaded Bob as king; Alice took the throne in another process since + var bob = King.CreateNewKing("Bob", CharacterAI.Human, CharacterSex.Male); + await _db.SaveWorldState("royal_court", CourtJson(new RoyalCourtSaveData { KingName = "Alice", KingAI = (int)CharacterAI.Human, Treasury = 777 })); + long version = _db.GetWorldStateVersion("royal_court"); + await WithKing(bob, async () => + { + CastleLocation.RoyalCourtLoadedFromShared = true; + bool ended = await CastleLocation.AbdicateDeletedKingAsync("Bob", "Bob", "left", online: true, + osm.ReadRoyalCourtWithVersionAsync, osm.LoadRoyalCourtFromWorldState, v => osm.SaveRoyalCourtIfVersionAsync(v, throneVacated: true)); + ended.Should().BeFalse("the shared king is Alice"); + StoredCourt().KingName.Should().Be("Alice"); + StoredCourt().Treasury.Should().Be(777); + _db.GetWorldStateVersion("royal_court").Should().Be(version, "nothing was written"); + }); + } + finally { CastleLocation.RoyalCourtLoadedFromShared = loadedBefore; } + } + + [Fact] + public async Task AVersionConflict_ReadsTheCourtAgain_AndDecidesAgain() + { + bool loadedBefore = CastleLocation.RoyalCourtLoadedFromShared; + try + { + var osm = NewOsm(_db); + // Alice takes the throne between the read and the write: the write fails, the re-read sees Alice + await _db.SaveWorldState("royal_court", CourtJson(new RoyalCourtSaveData { KingName = "Bob", KingAI = (int)CharacterAI.Human })); + await WithKing(King.CreateNewKing("Bob", CharacterAI.Human, CharacterSex.Male), async () => + { + int saves = 0; + bool ended = await CastleLocation.AbdicateDeletedKingAsync("Bob", "Bob", "left", online: true, + osm.ReadRoyalCourtWithVersionAsync, osm.LoadRoyalCourtFromWorldState, async v => + { + if (saves++ == 0) + await _db.SaveWorldState("royal_court", CourtJson(new RoyalCourtSaveData { KingName = "Alice", KingAI = (int)CharacterAI.Human })); + return await osm.SaveRoyalCourtIfVersionAsync(v, throneVacated: true); + }); + ended.Should().BeFalse(); + saves.Should().Be(1); + StoredCourt().KingName.Should().Be("Alice", "the newer court is not overwritten"); + (CastleLocation.GetCurrentKing()?.Name).Should().Be("Alice", "this process takes the shared court back"); + }); + + // a concurrent write that keeps Bob (a treasury change): the retry ends the reign and is written + await _db.SaveWorldState("royal_court", CourtJson(new RoyalCourtSaveData { KingName = "Bob", KingAI = (int)CharacterAI.Human })); + await WithKing(King.CreateNewKing("Bob", CharacterAI.Human, CharacterSex.Male), async () => + { + int saves = 0; + int bobRecords = CastleLocation.GetMonarchHistory().Count(m => m.Name == "Bob"); + bool ended = await CastleLocation.AbdicateDeletedKingAsync("Bob", "Bob", "left", online: true, + osm.ReadRoyalCourtWithVersionAsync, osm.LoadRoyalCourtFromWorldState, async v => + { + if (saves++ == 0) + await _db.SaveWorldState("royal_court", CourtJson(new RoyalCourtSaveData { KingName = "Bob", KingAI = (int)CharacterAI.Human, Treasury = 5 })); + return await osm.SaveRoyalCourtIfVersionAsync(v, throneVacated: true); + }); + ended.Should().BeTrue(); + saves.Should().Be(2); + StoredCourt().KingName.Should().NotBe("Bob"); + CastleLocation.GetMonarchHistory().Count(m => m.Name == "Bob").Should().Be(bobRecords + 1, "the retry records the reign once"); + }); + } + finally { CastleLocation.RoyalCourtLoadedFromShared = loadedBefore; } + } + [Fact] public void APlayerNpcMarriage_IsCleared_InMemoryAndInTheJson_AndLeavesTheRegistry() { diff --git a/Tests/NPCDefeatQuestTests.cs b/Tests/NPCDefeatQuestTests.cs index 909db12b..22fefe6e 100644 --- a/Tests/NPCDefeatQuestTests.cs +++ b/Tests/NPCDefeatQuestTests.cs @@ -325,30 +325,15 @@ await WithSqlBackend(async (db, _) => } [Fact] - public async Task OldBountyClaims_ArePruned_AfterTheRetention() + public void BountyClaims_AreNeverPruned() { - GameConfig.BountyClaimRetentionDays.Should().Be(90); - await WithSqlBackend(async (db, path) => - { - db.TryClaimBounty("Qfresh", "alice").Should().BeTrue(); - using (var conn = new SqliteConnection($"Data Source={path}")) - { - conn.Open(); - using var cmd = conn.CreateCommand(); - cmd.CommandText = "INSERT INTO bounty_claims (quest_id, claimed_by, claimed_at) VALUES ('Qold', 'bob', datetime('now', '-100 days')), ('Qrecent', 'bob', datetime('now', '-60 days'));"; - cmd.ExecuteNonQuery(); - } - db.PruneOldBountyClaims().Should().Be(1); - db.TryClaimBounty("Qold", "carol").Should().BeTrue("the old claim is gone"); - db.TryClaimBounty("Qrecent", "carol").Should().BeFalse("a claim within the retention stays"); - db.TryClaimBounty("Qfresh", "carol").Should().BeFalse(); - await Task.CompletedTask; - }); - + // v1.1.11: a stale save can bring an old bounty back after any retention, so the claims stay var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Scripts"))) dir = dir.Parent; - File.ReadAllText(Path.Combine(dir!.FullName, "Scripts", "Systems", "WorldSimService.cs")) - .Should().Contain("sqlBackend.PruneOldBountyClaims();", "the world save cycle prunes it"); + string Src(string folder, string file) => File.ReadAllText(Path.Combine(dir!.FullName, "Scripts", folder, file)); + Src("Systems", "SqlSaveBackend.cs").Should().NotContain("PruneOldBountyClaims").And.NotContain("DELETE FROM bounty_claims"); + Src("Systems", "WorldSimService.cs").Should().NotContain("PruneOldBountyClaims"); + Src("Core", "GameConfig.cs").Should().NotContain("BountyClaimRetentionDays"); } [Fact] @@ -369,4 +354,52 @@ public void AMarriedPlayer_LoadsWithTheSameDisplayName_AndTheirBountyIsPaid() } finally { bounty.Deleted = true; } } + + // ─── v1.1.11: review round 14 ─── + + [Fact] + public async Task CollectingOneIdlessBounty_RemovesOnlyThatOne_FromTheSharedRecord() + { + await WithSqlBackend(async (db, _) => + { + var osm = (OnlineStateManager)Activator.CreateInstance(typeof(OnlineStateManager), + BindingFlags.NonPublic | BindingFlags.Instance, null, new object[] { db, "r14" }, null)!; + QuestData Stored(string target) => new QuestData + { + Id = "", Title = "WANTED: " + target, Initiator = "The Crown", TargetNPCName = target, IsPlayerBounty = true + }; + await osm.SaveSharedQuests(new System.Collections.Generic.List + { + Stored("Idless Bob R14"), Stored("Idless Alice R14"), new QuestData { Id = "", Title = "Fetch the ale", Initiator = "Innkeeper" } + }); + + var bounty = BountyOnPlayer("Idless Bob R14", 2000); + bounty.Id = ""; + var bob = new Character { Name1 = "idless_bob_r14", Name2 = "Idless Bob R14", Level = 30, IsLoadedPlayer = true }; + var winner = new Character { Name1 = "sheriff_r14", Name2 = "Sheriff R14", Level = 30, Gold = 0 }; + var paid = QuestSystem.CollectBountiesOnPlayer(winner, bob); + paid.Should().ContainSingle(); + + // the same match the duel path uses (CombatEngine.ReportDuelDefeat) + var keys = new System.Collections.Generic.HashSet(); + foreach (var q in paid) keys.Add(QuestSystem.BountyClaimKey(q)); + (await osm.RemoveSharedQuestsAsync(q => keys.Contains(QuestSystem.BountyClaimKey(q)))).Should().Be(1); + + var left = (await osm.LoadSharedQuests())!; + left.Should().HaveCount(2); + left.Should().NotContain(q => q.TargetNPCName == "Idless Bob R14"); + left.Should().Contain(q => q.TargetNPCName == "Idless Alice R14", "another id-less bounty stays"); + left.Should().Contain(q => q.Title == "Fetch the ale"); + }); + + QuestSystem.BountyClaimKey(new QuestData { Id = "", Initiator = "The Crown", TargetNPCName = "X", Title = "WANTED: X" }) + .Should().Be(QuestSystem.BountyClaimKey(new Quest { Id = "", Initiator = "The Crown", TargetNPCName = "X", Title = "WANTED: X" })); + QuestSystem.BountyClaimKey(new QuestData { Id = "Q7" }).Should().Be("Q7"); + + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Scripts"))) dir = dir.Parent; + File.ReadAllText(Path.Combine(dir!.FullName, "Scripts", "Systems", "CombatEngine.cs")) + .Should().Contain("RemoveSharedQuestsAsync(q => keys.Contains(QuestSystem.BountyClaimKey(q)))") + .And.NotContain("RemoveSharedQuestsAsync(q => ids.Contains(q.Id))"); + } } From 97f65d15536e6a171e92e1cc6d168cf1800e4922 Mon Sep 17 00:00:00 2001 From: binaryknight Date: Thu, 24 Sep 2026 01:04:48 +0000 Subject: [PATCH 34/42] Fixes from the fifteenth review; NPC grudge and marriage clean-up deferred - Deleting a character no longer edits NPC grudges or NPC marriages. Another process's NPC save or the world sim's marriage registry could restore them; both return in 1.1.12 with an owner-process design. - A player bounty keeps its gold across save and reload (QuestData BountyGold); rows with none still pay the 500 fallback. - The married-name purge skips an alias another living player uses, so their bounty stays. - A throne vacancy carries the monarch history and both loaders import it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/AI/MemorySystem.cs | 32 +---- Scripts/Locations/CastleLocation.cs | 26 ++++ Scripts/Systems/OnlineStateManager.cs | 5 +- Scripts/Systems/PermadeathHelper.cs | 155 +-------------------- Scripts/Systems/QuestSystem.cs | 3 + Scripts/Systems/SaveDataStructures.cs | 1 + Scripts/Systems/SaveSystem.cs | 1 + Scripts/Systems/SqlSaveBackend.cs | 31 ++++- Tests/CharacterRecreationTests.cs | 126 +++++++++++++++++ Tests/DeleteFollowUpTests.cs | 186 ++++++-------------------- Tests/NPCDefeatQuestTests.cs | 74 ++++++++++ 11 files changed, 312 insertions(+), 328 deletions(-) diff --git a/Scripts/AI/MemorySystem.cs b/Scripts/AI/MemorySystem.cs index 8aa0fc1f..b55fb196 100644 --- a/Scripts/AI/MemorySystem.cs +++ b/Scripts/AI/MemorySystem.cs @@ -73,12 +73,9 @@ private void UpdateCharacterImpression(MemoryEvent memoryEvent) characterImpressions[characterId] = Math.Max(-1.0f, Math.Min(1.0f, characterImpressions[characterId])); } - private float CalculateImpressionChange(MemoryEvent memoryEvent) => ImpressionChangeFor(memoryEvent.Type); - - /// v1.1.11: how a memory of this type moves the NPC's impression of the character. - public static float ImpressionChangeFor(MemoryType type) + private float CalculateImpressionChange(MemoryEvent memoryEvent) { - return type switch + return memoryEvent.Type switch { MemoryType.Attacked => -0.8f, MemoryType.Betrayed => -0.9f, @@ -182,31 +179,6 @@ public bool HasMemoryOfEvent(MemoryType type, string? characterId = null, int ho (characterId == null || m.InvolvedCharacter == characterId)); } - /// - /// v1.1.11: a grudge or other negative memory about a character: a type that lowers the impression, - /// a family killing or a witnessed attack or theft, or any memory with a negative emotional impact. - /// - public static bool IsGrudge(MemoryType type, float emotionalImpact) => - emotionalImpact < 0f || ImpressionChangeFor(type) < 0f || - type is MemoryType.KilledMyParent or MemoryType.KilledMyFamily or MemoryType.WitnessedAttack or MemoryType.WitnessedTheft; - - /// - /// v1.1.11: drop the grudges naming a deleted character (any letter case) and rebuild the impression - /// of that name from the memories that remain, so a same-name character is not hated for the old one. - /// - public int ForgetGrudgesAgainst(string name) - { - if (string.IsNullOrWhiteSpace(name)) return 0; - int removed = memories.RemoveAll(m => string.Equals(m.InvolvedCharacter, name, StringComparison.OrdinalIgnoreCase) - && IsGrudge(m.Type, m.EmotionalImpact)); - if (removed == 0) return 0; - foreach (var key in characterImpressions.Keys.Where(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase)).ToList()) - characterImpressions.Remove(key); - foreach (var m in memories.Where(m => string.Equals(m.InvolvedCharacter, name, StringComparison.OrdinalIgnoreCase)).ToList()) - UpdateCharacterImpression(m); - return removed; - } - public void ForgetCharacter(string characterId) { memories.RemoveAll(m => m.InvolvedCharacter == characterId); diff --git a/Scripts/Locations/CastleLocation.cs b/Scripts/Locations/CastleLocation.cs index d7c2a70a..835f1a05 100644 --- a/Scripts/Locations/CastleLocation.cs +++ b/Scripts/Locations/CastleLocation.cs @@ -7814,6 +7814,31 @@ public static void SetMonarchHistory(List history) monarchHistory = history ?? new List(); } + /// v1.1.11: the monarch history in its stored form (every royal_court payload carries it). + public static List MonarchHistorySaveData() => + monarchHistory?.Select(m => new MonarchRecordSaveData + { + Name = m.Name, + Title = m.Title, + DaysReigned = m.DaysReigned, + CoronationDate = m.CoronationDate.ToString("o"), + EndReason = m.EndReason + }).ToList() ?? new List(); + + /// v1.1.11: take a stored monarch history; an empty or missing one leaves this process's as is. + public static void ImportMonarchHistory(List? saved) + { + if (saved == null || saved.Count == 0) return; + SetMonarchHistory(saved.Select(m => new MonarchRecord + { + Name = m.Name, + Title = m.Title, + DaysReigned = m.DaysReigned, + CoronationDate = DateTime.TryParse(m.CoronationDate, null, System.Globalization.DateTimeStyles.RoundtripKind, out var cd) ? cd : DateTime.Now, + EndReason = m.EndReason + }).ToList()); + } + /// /// Notify a dethroned player via system message. /// Their King flag will sync from world_state on next login or castle entry. @@ -8032,6 +8057,7 @@ internal static bool SharedCourtNamesDeletedCharacter(RoyalCourtSaveData? court, public static bool ApplySharedThroneVacancy(RoyalCourtSaveData court) { if (court == null || !court.ThroneVacant) return false; + ImportMonarchHistory(court.MonarchHistory); // v1.1.11: the ended reign the vacancy carries var king = currentKing; if (king != null) { diff --git a/Scripts/Systems/OnlineStateManager.cs b/Scripts/Systems/OnlineStateManager.cs index 0d47b972..848300e6 100644 --- a/Scripts/Systems/OnlineStateManager.cs +++ b/Scripts/Systems/OnlineStateManager.cs @@ -492,7 +492,9 @@ public async Task SaveRoyalCourtToWorldState(bool throneVacated = false) /// v1.1.11: the stored form of an empty court; a reign that just ended with no successor is marked, so the loaders clear their king. private static string EmptyRoyalCourtJson(bool throneVacated) { - var emptyData = new RoyalCourtSaveData { KingName = "", Treasury = 0, KingAI = 1, ThroneVacant = throneVacated }; + // v1.1.11: the history goes too, with the reign the abdication just recorded + var emptyData = new RoyalCourtSaveData { KingName = "", Treasury = 0, KingAI = 1, ThroneVacant = throneVacated, + MonarchHistory = global::CastleLocation.MonarchHistorySaveData() }; return System.Text.Json.JsonSerializer.Serialize(emptyData, new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); } @@ -1773,6 +1775,7 @@ private List SerializeCurrentQuests() // track their target across a server restart. TargetNPCName = quest.TargetNPCName ?? "", IsPlayerBounty = quest.IsPlayerBounty, + BountyGold = quest.BountyGold, // v1.1.11 // v0.62.x Phase 4 (Mercenary board): faction-issued freelance contract fields. IsMercContract = quest.IsMercContract, IssuingFaction = quest.IssuingFaction.HasValue ? (int)quest.IssuingFaction.Value : -1, diff --git a/Scripts/Systems/PermadeathHelper.cs b/Scripts/Systems/PermadeathHelper.cs index 80afcc18..167b655a 100644 --- a/Scripts/Systems/PermadeathHelper.cs +++ b/Scripts/Systems/PermadeathHelper.cs @@ -424,6 +424,10 @@ public static async Task PurgeDeletedCharacterAsync(SqlSaveBackend? backend, str // quests record only the character's Name2 (Occupier / OfferedTo); the married display name is for // bounties only, since another character's Name2 may equal it (review) var questNames = CharacterAliases(name, player?.Name2); + // v1.1.11: an extra alias another player now uses (a living "Bob Smith") names their bounty, not this one's + if (backend != null && !string.IsNullOrWhiteSpace(username)) + aliases = aliases.Where(a => questNames.Any(n => string.Equals(n, a, StringComparison.OrdinalIgnoreCase)) + || !backend.IsNameUsedByAnotherPlayer(a, username!)).ToList(); try { @@ -466,27 +470,6 @@ public static async Task PurgeDeletedCharacterAsync(SqlSaveBackend? backend, str } catch (Exception tex) { DebugLogger.Instance.LogWarning("DELETE", $"Throne handover failed for '{name}': {tex.Message}"); } - try - { - // v1.1.11: NPC grudges naming the character. In memory, and in the shared npcs record - // edited in place (a world-sim reload would otherwise bring them back). - // v1.1.11: the same in-place edit also ends NPC marriages to the name, so this write - // cannot carry the old marriage back for a world-sim reload - int forgotten = ForgetNpcGrudgesAgainst(name); - if (backend != null) forgotten += (await RemoveDeletedCharacterFromSharedNpcsAsync(backend, name)).Grudges; - if (forgotten > 0) - DebugLogger.Instance.LogInfo("DELETE", $"Dropped {forgotten} NPC grudge memory(ies) of deleted '{name}'."); - } - catch (Exception mex) { DebugLogger.Instance.LogWarning("DELETE", $"NPC grudge clear failed for '{name}': {mex.Message}"); } - - try - { - int widowed = ClearNpcSpousesOf(name); - if (widowed > 0) - DebugLogger.Instance.LogInfo("DELETE", $"Cleared the marriage of {widowed} NPC(s) to deleted '{name}'."); - } - catch (Exception sex) { DebugLogger.Instance.LogWarning("DELETE", $"NPC spouse clear failed for '{name}': {sex.Message}"); } - try { // Children match parents by name, so a same-name recreation would inherit them. The @@ -525,135 +508,5 @@ public static List CharacterAliases(params string?[] names) if (!string.IsNullOrWhiteSpace(n) && !list.Any(x => string.Equals(x, n, StringComparison.OrdinalIgnoreCase))) list.Add(n!); return list; } - - /// v1.1.11: drop every live NPC's grudges against the name (MemorySystem.IsGrudge). - public static int ForgetNpcGrudgesAgainst(string? name) - { - if (string.IsNullOrWhiteSpace(name)) return 0; - var npcs = NPCSpawnSystem.Instance?.ActiveNPCs; - if (npcs == null) return 0; - int removed = 0; - foreach (var npc in npcs.ToList()) - { - if (npc == null) continue; - var brainMemory = npc.Brain?.Memory; - if (brainMemory != null) removed += brainMemory.ForgetGrudgesAgainst(name!); - if (npc.Memory != null && !ReferenceEquals(npc.Memory, brainMemory)) removed += npc.Memory.ForgetGrudgesAgainst(name!); - } - return removed; - } - - /// - /// v1.1.11: remove the grudges against the name from the npcs JSON, and end the marriage of an NPC - /// whose spouse was the name (married, isMarried, spouseName, as ClearNpcSpousesOf does), touching - /// nothing else. An NPC married to another NPC is left alone: the registry says so, or another - /// record in the JSON names this NPC as its spouse. Returns the edited JSON, or null when nothing matched. - /// - public static string? RemoveDeletedCharacterFromNpcJson(string json, string name, out int grudges, out int spouses) - { - grudges = 0; spouses = 0; - if (string.IsNullOrWhiteSpace(json) || string.IsNullOrWhiteSpace(name)) return null; - if (System.Text.Json.Nodes.JsonNode.Parse(json) is not System.Text.Json.Nodes.JsonArray npcs) return null; - foreach (var npc in npcs) - { - if (npc?["memories"] is not System.Text.Json.Nodes.JsonArray memories) continue; - for (int i = memories.Count - 1; i >= 0; i--) - { - var m = memories[i]; - if (m == null || !string.Equals(StringOf(m["involvedCharacter"]), name, StringComparison.OrdinalIgnoreCase)) continue; - float impact = 0f; - try { impact = m["emotionalImpact"]?.GetValue() ?? 0f; } catch { } - bool typed = Enum.TryParse(StringOf(m["type"]), out var type); - if ((typed && MemorySystem.IsGrudge(type, impact)) || (!typed && impact < 0f)) - { - memories.RemoveAt(i); - grudges++; - } - } - } - foreach (var npc in npcs) - { - if (npc is not System.Text.Json.Nodes.JsonObject rec) continue; - if (!string.Equals(StringOf(rec["spouseName"]), name, StringComparison.OrdinalIgnoreCase)) continue; - string id = StringOf(rec["characterID"]); - // v1.1.11: a registered marriage to another NPC is kept; one to a player (the deleted one) is not - if (RegisteredToAnotherNpc(id, pid => npcs.Any(o => o != null && !ReferenceEquals(o, npc) && StringOf(o["characterID"]) == pid) - || IsActiveNpcId(pid, null))) continue; - string own = StringOf(rec["name"]); - bool npcSpouse = !string.IsNullOrEmpty(own) && npcs.Any(o => o != null && !ReferenceEquals(o, npc) - && string.Equals(StringOf(o["name"]), name, StringComparison.OrdinalIgnoreCase) - && string.Equals(StringOf(o["spouseName"]), own, StringComparison.OrdinalIgnoreCase)); - if (npcSpouse) continue; - rec["married"] = false; - rec["isMarried"] = false; - rec["spouseName"] = ""; - if (!string.IsNullOrEmpty(id)) NPCMarriageRegistry.Instance.EndMarriage(id); // v1.1.11: as a divorce does - spouses++; - } - return grudges + spouses > 0 ? npcs.ToJsonString() : null; - } - - private static string StringOf(System.Text.Json.Nodes.JsonNode? node) - { - try { return node?.GetValue() ?? ""; } catch { return ""; } - } - - /// - /// v1.1.11: the shared npcs record, edited in place under its version (as RemoveSharedQuestsAsync - /// does for quests), never replaced by this process's list. Retries if another writer got in first. - /// - public static async Task<(int Grudges, int Spouses)> RemoveDeletedCharacterFromSharedNpcsAsync(SqlSaveBackend backend, string name) - { - for (int attempt = 0; attempt < 3; attempt++) - { - long version = backend.GetWorldStateVersion(OnlineStateManager.KEY_NPCS); - string? json = await backend.LoadWorldState(OnlineStateManager.KEY_NPCS); - if (string.IsNullOrEmpty(json)) return (0, 0); - string? edited = RemoveDeletedCharacterFromNpcJson(json, name, out int grudges, out int spouses); - if (edited == null) return (0, 0); - if (await backend.SaveWorldStateIfVersion(OnlineStateManager.KEY_NPCS, edited, version)) return (grudges, spouses); - } - DebugLogger.Instance.LogWarning("DELETE", $"Shared NPC grudges and marriages of '{name}' not cleared: the record kept changing."); - return (0, 0); - } - - /// - /// v1.1.11: end the marriage of any NPC whose spouse was the deleted character, clearing the - /// same three flags a divorce clears on the NPC and its registry entry. An NPC the registry - /// marries to another NPC is left alone, in case that NPC shares the name. - /// - public static int ClearNpcSpousesOf(string? name) - { - if (string.IsNullOrWhiteSpace(name)) return 0; - var npcs = NPCSpawnSystem.Instance?.ActiveNPCs; - if (npcs == null) return 0; - int cleared = 0; - foreach (var npc in npcs.ToList()) - { - if (npc == null || !string.Equals(npc.SpouseName, name, StringComparison.OrdinalIgnoreCase)) continue; - if (RegisteredToAnotherNpc(npc.ID, pid => IsActiveNpcId(pid, npc))) continue; // v1.1.11: not a player-NPC marriage - npc.Married = false; - npc.IsMarried = false; - npc.SpouseName = ""; - if (!string.IsNullOrEmpty(npc.ID)) NPCMarriageRegistry.Instance.EndMarriage(npc.ID); // v1.1.11: as a divorce does - cleared++; - } - return cleared; - } - - /// - /// v1.1.11: the registry marries this NPC to another NPC. The registry also holds player-NPC - /// marriages, so the partner must itself be an NPC; a player partner (the deleted character) or an - /// unknown id is the marriage to clear. - /// - internal static bool RegisteredToAnotherNpc(string? npcId, Func isNpcId) - { - if (string.IsNullOrEmpty(npcId)) return false; - var partner = NPCMarriageRegistry.Instance.GetSpouseId(npcId!); - return !string.IsNullOrEmpty(partner) && partner != npcId && isNpcId(partner!); - } - - private static bool IsActiveNpcId(string id, NPC? self) => - NPCSpawnSystem.Instance?.ActiveNPCs?.Any(n => n != null && !ReferenceEquals(n, self) && n.ID == id) == true; } } diff --git a/Scripts/Systems/QuestSystem.cs b/Scripts/Systems/QuestSystem.cs index d8196e9e..37094f29 100644 --- a/Scripts/Systems/QuestSystem.cs +++ b/Scripts/Systems/QuestSystem.cs @@ -1123,6 +1123,7 @@ public static void RestoreFromSaveData(List savedQuests) Forced = questData.Forced, TargetNPCName = questData.TargetNPCName ?? "", IsPlayerBounty = questData.IsPlayerBounty, + BountyGold = questData.BountyGold, // v1.1.11 Deleted = questData.Status == QuestStatus.Completed || questData.Status == QuestStatus.Failed || questData.Status == QuestStatus.Abandoned, IsAbandoned = questData.Status == QuestStatus.Abandoned, // v0.62.x Phase 4 (Mercenary board): restore faction-issued freelance contract fields. @@ -1215,6 +1216,7 @@ public static void MergeWorldQuests(List savedQuests) Forced = questData.Forced, TargetNPCName = questData.TargetNPCName ?? "", IsPlayerBounty = questData.IsPlayerBounty, + BountyGold = questData.BountyGold, // v1.1.11 Deleted = questData.Status == QuestStatus.Completed || questData.Status == QuestStatus.Failed || questData.Status == QuestStatus.Abandoned, IsAbandoned = questData.Status == QuestStatus.Abandoned, // v0.62.x Phase 4 (Mercenary board): restore faction-issued freelance contract fields. @@ -1380,6 +1382,7 @@ public static void MergePlayerQuests(string playerName, List savedQue Forced = questData.Forced, TargetNPCName = questData.TargetNPCName ?? "", IsPlayerBounty = questData.IsPlayerBounty, + BountyGold = questData.BountyGold, // v1.1.11 Deleted = questData.Status == QuestStatus.Completed || questData.Status == QuestStatus.Failed || questData.Status == QuestStatus.Abandoned, IsAbandoned = questData.Status == QuestStatus.Abandoned, // v0.62.x Phase 4 (Mercenary board): restore faction-issued freelance contract fields. diff --git a/Scripts/Systems/SaveDataStructures.cs b/Scripts/Systems/SaveDataStructures.cs index 50e98319..894ce776 100644 --- a/Scripts/Systems/SaveDataStructures.cs +++ b/Scripts/Systems/SaveDataStructures.cs @@ -1355,6 +1355,7 @@ public class QuestData public bool Forced { get; set; } public string TargetNPCName { get; set; } = ""; public bool IsPlayerBounty { get; set; } // v1.1.11 + public long BountyGold { get; set; } // v1.1.11: a player bounty's posted gold (0 on legacy rows) public List Objectives { get; set; } = new(); public List Monsters { get; set; } = new(); // v0.62.x Phase 4 (Mercenary board): faction-issued freelance contract fields. diff --git a/Scripts/Systems/SaveSystem.cs b/Scripts/Systems/SaveSystem.cs index d7dab3ba..058f0cac 100644 --- a/Scripts/Systems/SaveSystem.cs +++ b/Scripts/Systems/SaveSystem.cs @@ -1618,6 +1618,7 @@ private List SerializeQuestList(List quests) Forced = quest.Forced, TargetNPCName = quest.TargetNPCName ?? "", IsPlayerBounty = quest.IsPlayerBounty, + BountyGold = quest.BountyGold, // v1.1.11 Objectives = new List(), Monsters = new List(), // v0.62.x Phase 4 (Mercenary board): faction-issued freelance contract fields. diff --git a/Scripts/Systems/SqlSaveBackend.cs b/Scripts/Systems/SqlSaveBackend.cs index f858fa0c..035e8603 100644 --- a/Scripts/Systems/SqlSaveBackend.cs +++ b/Scripts/Systems/SqlSaveBackend.cs @@ -1270,7 +1270,7 @@ public void PurgePlayerWorldState(string username, string? displayName = null) } } - // v1.1.11: an auction seller name that another players row carries now, as its display name or + // v1.1.11: an auction seller name that another player's row carries now, as its display name or // its save's Name2, is that player's listing, never the deleted character's. private const string SellerNotOtherPlayer = "NOT EXISTS (SELECT 1 FROM players p WHERE LOWER(p.username) != LOWER(@u) AND (" + @@ -5976,9 +5976,30 @@ public bool PlayerExists(string nameOrDisplay) } /// - /// Resolves a player name (username or display name) to their lowercase display name. - /// Returns null if the player doesn't exist. + /// v1.1.11: another player's row (a different key) uses the name, as its display name or its save's + /// Name2. On a failed read the name counts as used, so the delete purge keeps that bounty. /// + public bool IsNameUsedByAnotherPlayer(string name, string username) + { + if (string.IsNullOrWhiteSpace(name)) return false; + try + { + using var connection = OpenConnection(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT EXISTS (SELECT 1 FROM players WHERE LOWER(username) != LOWER(@u) AND (" + + "LOWER(display_name) = LOWER(@n) OR " + + "LOWER(CASE WHEN json_valid(player_data) THEN json_extract(player_data, '$.player.name2') END) = LOWER(@n)));"; + cmd.Parameters.AddWithValue("@u", username ?? ""); + cmd.Parameters.AddWithValue("@n", name); + return Convert.ToInt64(cmd.ExecuteScalar()) != 0; + } + catch (Exception ex) + { + DebugLogger.Instance.LogWarning("SQL", $"IsNameUsedByAnotherPlayer('{name}') failed: {ex.Message}"); + return true; + } + } + /// v1.1.11: the players.display_name of one key (the married surname form), or null. public string? GetStoredDisplayName(string username) { @@ -5993,6 +6014,10 @@ public bool PlayerExists(string nameOrDisplay) catch { return null; } } + /// + /// Resolves a player name (username or display name) to their lowercase display name. + /// Returns null if the player doesn't exist. + /// public string? ResolvePlayerDisplayName(string nameOrDisplay) { try diff --git a/Tests/CharacterRecreationTests.cs b/Tests/CharacterRecreationTests.cs index 3fec3f5a..852f30ef 100644 --- a/Tests/CharacterRecreationTests.cs +++ b/Tests/CharacterRecreationTests.cs @@ -511,4 +511,130 @@ await osm.SaveSharedQuests(new List purge.Should().Contain("RemoveSharedQuestsAsync(q => QuestLeftByCharacter(q, questNames, aliases))"); purge.Should().Contain("QuestSystem.RemovePlayerQuests(questNames.ToArray())"); } + + // ─── v1.1.11: review round 15 ─── + + private void Players(params (string User, string Display, string Data)[] rows) + { + using var conn = new SqliteConnection($"Data Source={_path}"); + conn.Open(); + // a fresh database's json expression indexes refuse a malformed blob; an upgraded one may lack them + var indexes = new List(); + using (var q = conn.CreateCommand()) + { + q.CommandText = "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'players' AND sql LIKE '%json%';"; + using var reader = q.ExecuteReader(); + while (reader.Read()) indexes.Add(reader.GetString(0)); + } + foreach (var name in indexes) { using var d = conn.CreateCommand(); d.CommandText = $"DROP INDEX \"{name}\";"; d.ExecuteNonQuery(); } + foreach (var r in rows) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "INSERT INTO players (username, display_name, player_data) VALUES (@u, @d, @p);"; + cmd.Parameters.AddWithValue("@u", r.User); + cmd.Parameters.AddWithValue("@d", r.Display); + cmd.Parameters.AddWithValue("@p", r.Data); + cmd.ExecuteNonQuery(); + } + } + + /// Runs the body as an online process whose OnlineStateManager is on this database. + private async System.Threading.Tasks.Task AsOnlineProcess(Func body) + { + var online = typeof(UsurperRemake.BBS.DoorMode).GetField("_onlineMode", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!; + var fallback = typeof(OnlineStateManager).GetField("_fallbackInstance", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!; + var (wasOnline, wasOsm) = (online.GetValue(null), fallback.GetValue(null)); + var osm = NewOsm(_db!); + online.SetValue(null, true); + fallback.SetValue(null, osm); + try { await body(osm); } + finally + { + online.SetValue(null, wasOnline); + fallback.SetValue(null, wasOsm); + } + } + + [Fact] + public void AnotherPlayersUseOfAName_IsFoundByDisplayNameOrName2_AndABadSaveIsSkipped() + { + _db = new SqlSaveBackend(_path); + Players(("bob_acct", "Bob Smith", "{}"), + ("bsmith", "Robert", "{\"player\":{\"name2\":\"Bob Smith\"}}"), + ("carol", "Carol Jones", "not json")); + _db.IsNameUsedByAnotherPlayer("bob smith", "bob_acct").Should().BeTrue("bsmith's save carries it as Name2"); + _db.IsNameUsedByAnotherPlayer("Carol Jones", "bob_acct").Should().BeTrue("carol's display name, next to an unreadable save"); + _db.IsNameUsedByAnotherPlayer("Carol Jones", "carol").Should().BeFalse("the character's own row does not count"); + _db.IsNameUsedByAnotherPlayer("Alice", "bob_acct").Should().BeFalse(); + _db.IsNameUsedByAnotherPlayer("Bob Smith", "BSMITH").Should().BeTrue("bob_acct's display name"); + CodeOnly(Source("Systems", "SqlSaveBackend.cs")) + .Should().Contain("LOWER(CASE WHEN json_valid(player_data) THEN json_extract(player_data, '$.player.name2') END) = LOWER(@n)"); + } + + [Fact] + public async System.Threading.Tasks.Task DeletingMarriedBob_KeepsTheBountyOnALivingBobSmith_LocallyAndShared() + { + _db = new SqlSaveBackend(_path); + Players(("bob_acct", "Bob Smith", "{\"player\":{\"name2\":\"Bob\"}}"), + ("bsmith", "Bob Smith Jr", "{\"player\":{\"name2\":\"Bob Smith\"}}")); + var bob = new Character { Name1 = "bob_acct", Name2 = "Bob", FamilySurname = "Smith" }; + bob.DisplayName.Should().Be("Bob Smith"); + try + { + await AsOnlineProcess(async osm => + { + var onLiving = Add(Wanted("Bob Smith")); + var onBob = Add(Wanted("Bob")); + await osm.SaveSharedQuests(new List + { + new QuestData { Id = "L1", Initiator = "The Crown", TargetNPCName = "Bob Smith", IsPlayerBounty = true }, + new QuestData { Id = "L2", Initiator = "The Crown", TargetNPCName = "Bob", IsPlayerBounty = true } + }); + + await PermadeathHelper.PurgeDeletedCharacterAsync(_db, "bob_acct", "Bob", bob); + + InDatabase(onLiving).Should().BeTrue("the living Bob Smith's bounty is theirs"); + InDatabase(onBob).Should().BeFalse(); + (await osm.LoadSharedQuests())!.Select(q => q.Id).Should().Equal(new[] { "L1" }); + }); + } + finally + { + QuestSystem.RemoveBountiesOnPlayer("Bob Smith"); + } + } + + [Fact] + public async System.Threading.Tasks.Task ThePurge_KeepsAQuestClaimedUnderTheMarriedName_AndRemovesItsBounty() + { + // quests record Name2, so a quest held by "Bob Smith" is another character's; the bounty is Bob's + _db = new SqlSaveBackend(_path); + var bob = new Character { Name1 = "bob_acct", Name2 = "Bob", FamilySurname = "Smith" }; + bob.DisplayName.Should().Be("Bob Smith"); + var claim = new Quest { Id = "q_claim_bob_smith", Title = "Held by Bob Smith", Occupier = "Bob Smith", Date = DateTime.Now, DaysToComplete = 7 }; + try + { + await AsOnlineProcess(async osm => + { + Add(claim); + var bounty = Add(Wanted("Bob Smith")); + await osm.SaveSharedQuests(new List + { + new QuestData { Id = "S1", Occupier = "Bob Smith" }, + new QuestData { Id = "S2", Initiator = "The Crown", TargetNPCName = "Bob Smith", IsPlayerBounty = true } + }); + + await PermadeathHelper.PurgeDeletedCharacterAsync(_db, "bob_acct", "Bob", bob); + + InDatabase(claim).Should().BeTrue("the purge's quest names exclude the married display name"); + InDatabase(bounty).Should().BeFalse(); + (await osm.LoadSharedQuests())!.Select(q => q.Id).Should().Equal(new[] { "S1" }); + }); + } + finally + { + QuestSystem.RemovePlayerQuests("Bob Smith"); + QuestSystem.RemoveBountiesOnPlayer("Bob Smith"); + } + } } diff --git a/Tests/DeleteFollowUpTests.cs b/Tests/DeleteFollowUpTests.cs index 0bd2a6da..3937c85d 100644 --- a/Tests/DeleteFollowUpTests.cs +++ b/Tests/DeleteFollowUpTests.cs @@ -11,8 +11,8 @@ namespace UsurperReborn.Tests; /// -/// v1.1.11: the delete purge also ends a deleted king's reign (through the abdication path), drops NPC -/// grudges against the name, and removes mail and auction listings kept under the display name. +/// v1.1.11: the delete purge also ends a deleted king's reign (through the abdication path) and removes +/// mail and auction listings kept under the display name. /// [Collection("SharedGameSingletons")] public class DeleteFollowUpTests : IDisposable @@ -109,69 +109,6 @@ await WithKing(King.CreateNewKing("Alice", CharacterAI.Human, CharacterSex.Femal }); } - private static MemoryEvent Mem(MemoryType type, string who, float impact = 0f) => - new MemoryEvent { Type = type, InvolvedCharacter = who, Description = type.ToString(), EmotionalImpact = impact, Importance = 0.9f }; - - [Fact] - public void GrudgeTypes_AreTheNegativeOnes() - { - MemorySystem.IsGrudge(MemoryType.Attacked, 0f).Should().BeTrue(); - MemorySystem.IsGrudge(MemoryType.Murdered, 0f).Should().BeTrue(); - MemorySystem.IsGrudge(MemoryType.KilledMyParent, 0f).Should().BeTrue(); - MemorySystem.IsGrudge(MemoryType.HeardGossip, -0.4f).Should().BeTrue("a negative memory of any type"); - MemorySystem.IsGrudge(MemoryType.Helped, 0.4f).Should().BeFalse(); - MemorySystem.IsGrudge(MemoryType.Traded, 0f).Should().BeFalse(); - } - - [Fact] - public async Task NpcGrudgesAgainstTheName_AreDropped_OthersKept() - { - var npc = new NPC { ID = "npc_grudge_test", Name1 = "Grudger", Name2 = "Grudger", Level = 10 }; - npc.Memory = new MemorySystem(); - npc.Memory.RecordEvent(Mem(MemoryType.Attacked, "Bob")); - npc.Memory.RecordEvent(Mem(MemoryType.Insulted, "bob")); - npc.Memory.RecordEvent(Mem(MemoryType.Helped, "Bob", 0.4f)); - npc.Memory.RecordEvent(Mem(MemoryType.Attacked, "Alice")); - NPCSpawnSystem.Instance.ActiveNPCs.Add(npc); - try - { - npc.Memory.GetCharacterImpression("Bob").Should().BeLessThan(0f, "the seed must be a real grudge"); - await PermadeathHelper.PurgeDeletedCharacterAsync(null, "bob_account", "Bob"); - - var left = npc.Memory.AllMemories; - left.Should().NotContain(m => m.InvolvedCharacter.Equals("Bob", StringComparison.OrdinalIgnoreCase) && m.Type != MemoryType.Helped); - left.Should().Contain(m => m.InvolvedCharacter == "Bob" && m.Type == MemoryType.Helped, "a kind memory is kept"); - left.Should().Contain(m => m.InvolvedCharacter == "Alice", "another character's grudge is kept"); - npc.Memory.GetCharacterImpression("Bob").Should().BeGreaterThan(0f, "the impression is rebuilt from what remains"); - npc.Memory.GetCharacterImpression("Alice").Should().BeLessThan(0f); - } - finally { NPCSpawnSystem.Instance.ActiveNPCs.Remove(npc); } - } - - [Fact] - public async Task TheSharedNpcRecord_LosesOnlyTheGrudges_AndKeepsEverythingElse() - { - string json = "[{\"name\":\"Grudger\",\"level\":10,\"unknownField\":{\"x\":1},\"memories\":[" + - "{\"type\":\"Attacked\",\"involvedCharacter\":\"Bob\",\"emotionalImpact\":-0.5}," + - "{\"type\":\"Helped\",\"involvedCharacter\":\"Bob\",\"emotionalImpact\":0.4}," + - "{\"type\":\"Betrayed\",\"involvedCharacter\":\"Alice\",\"emotionalImpact\":-0.9}]}," + - "{\"name\":\"Other\",\"memories\":[]}]"; - await _db.SaveWorldState(OnlineStateManager.KEY_NPCS, json); - long version = _db.GetWorldStateVersion(OnlineStateManager.KEY_NPCS); - - await PermadeathHelper.PurgeDeletedCharacterAsync(_db, "bob_account", "Bob"); - - var saved = System.Text.Json.Nodes.JsonNode.Parse((await _db.LoadWorldState(OnlineStateManager.KEY_NPCS))!)!.AsArray(); - var memories = saved[0]!["memories"]!.AsArray(); - memories.Select(m => (string)m!["type"]!).Should().BeEquivalentTo(new[] { "Helped", "Betrayed" }); - ((int)saved[0]!["unknownField"]!["x"]!).Should().Be(1, "the record is edited in place, not rebuilt"); - saved.Count.Should().Be(2); - _db.GetWorldStateVersion(OnlineStateManager.KEY_NPCS).Should().Be(version + 1, "a versioned write, so the world sim reloads it"); - - PermadeathHelper.RemoveDeletedCharacterFromNpcJson(json, "Carol", out int none, out int noSpouse).Should().BeNull(); - none.Should().Be(0); - } - [Fact] public async Task MailAndAuctions_UnderTheDisplayName_ArePurged_OthersKept() { @@ -290,44 +227,6 @@ public async Task AnNPCsListing_SurvivesDeletingAnAccountOfItsName() Count($"SELECT COUNT(*) FROM auction_listings WHERE id = {own};").Should().Be(0, "a name no NPC carries is purged"); } - [Fact] - public async Task TheSharedNpcRecord_LosesTheSpouse_AndTheGrudges_InOneVersionedWrite() - { - string json = "[" + - "{\"name\":\"Wife\",\"characterID\":\"npc_json_wife\",\"married\":true,\"isMarried\":true,\"spouseName\":\"Bob\",\"memories\":[" + - "{\"type\":\"Attacked\",\"involvedCharacter\":\"Bob\",\"emotionalImpact\":-0.5}]}," + - "{\"name\":\"Ann\",\"characterID\":\"npc_json_ann\",\"married\":true,\"isMarried\":true,\"spouseName\":\"Bob\",\"memories\":[]}," + - "{\"name\":\"Bob\",\"characterID\":\"npc_json_bob\",\"married\":true,\"isMarried\":true,\"spouseName\":\"Ann\",\"memories\":[]}]"; - await _db.SaveWorldState(OnlineStateManager.KEY_NPCS, json); - long version = _db.GetWorldStateVersion(OnlineStateManager.KEY_NPCS); - - var live = new NPC { ID = "npc_live_wife", Name1 = "Live Wife", Name2 = "Live Wife", Level = 10, SpouseName = "Bob", Married = true, IsMarried = true }; - NPCSpawnSystem.Instance.ActiveNPCs.Add(live); - try - { - await PermadeathHelper.PurgeDeletedCharacterAsync(_db, "bob_account", "Bob"); - live.SpouseName.Should().BeEmpty("the in-memory clear still runs"); - live.IsMarried.Should().BeFalse(); - } - finally { NPCSpawnSystem.Instance.ActiveNPCs.Remove(live); } - - var saved = System.Text.Json.Nodes.JsonNode.Parse((await _db.LoadWorldState(OnlineStateManager.KEY_NPCS))!)!.AsArray(); - var wife = saved[0]!; - ((string)wife["spouseName"]!).Should().BeEmpty(); - ((bool)wife["married"]!).Should().BeFalse(); - ((bool)wife["isMarried"]!).Should().BeFalse(); - wife["memories"]!.AsArray().Should().BeEmpty("the grudge is gone too"); - ((string)saved[1]!["spouseName"]!).Should().Be("Bob", "Ann is married to the NPC Bob, who names her back"); - ((bool)saved[1]!["isMarried"]!).Should().BeTrue(); - _db.GetWorldStateVersion(OnlineStateManager.KEY_NPCS).Should().Be(version + 1, "one versioned write"); - - // a record with only a marriage to the name (no grudge) is still edited - string onlySpouse = "[{\"name\":\"W\",\"married\":true,\"isMarried\":true,\"spouseName\":\"Carl\"}]"; - PermadeathHelper.RemoveDeletedCharacterFromNpcJson(onlySpouse, "Carl", out int g, out int sp).Should().NotBeNull(); - g.Should().Be(0); - sp.Should().Be(1); - } - [Fact] public void AFreshProcess_ReadsTheSharedRoyalCourt_BeforeDecidingTheDeletedKingsReign() { @@ -551,46 +450,6 @@ await WithKing(King.CreateNewKing("Bob", CharacterAI.Human, CharacterSex.Male), finally { CastleLocation.RoyalCourtLoadedFromShared = loadedBefore; } } - [Fact] - public void APlayerNpcMarriage_IsCleared_InMemoryAndInTheJson_AndLeavesTheRegistry() - { - var reg = NPCMarriageRegistry.Instance; - var wife = new NPC { ID = "npc_r13_wife", Name1 = "Wife", Name2 = "Wife", Level = 10, SpouseName = "Bob", Married = true, IsMarried = true }; - var ann = new NPC { ID = "npc_r13_ann", Name1 = "Ann", Name2 = "Ann", Level = 10, SpouseName = "Bob", Married = true, IsMarried = true }; - var npcBob = new NPC { ID = "npc_r13_bob", Name1 = "Bob", Name2 = "Bob", Level = 10, SpouseName = "Ann", Married = true, IsMarried = true }; - reg.RegisterMarriage("player_bob_id", wife.ID, "Bob", "Wife"); // the player Bob married Wife - reg.RegisterMarriage(ann.ID, npcBob.ID, "Ann", "Bob"); // Ann married the NPC Bob - NPCSpawnSystem.Instance.ActiveNPCs.AddRange(new[] { wife, ann, npcBob }); - try - { - PermadeathHelper.ClearNpcSpousesOf("Bob").Should().Be(1); - wife.SpouseName.Should().BeEmpty("a player-NPC marriage is in the registry too, and is cleared"); - wife.IsMarried.Should().BeFalse(); - reg.IsMarriedToNPC(wife.ID).Should().BeFalse("the registry entry goes as in a divorce"); - reg.IsMarriedToNPC("player_bob_id").Should().BeFalse(); - ann.SpouseName.Should().Be("Bob", "Ann's partner is an NPC"); - reg.GetSpouseId(ann.ID).Should().Be(npcBob.ID); - - // the shared JSON, with the registry holding the player-NPC marriage again - reg.RegisterMarriage("player_bob_id", wife.ID, "Bob", "Wife"); - string json = "[" + - "{\"name\":\"Wife\",\"characterID\":\"npc_r13_wife\",\"married\":true,\"isMarried\":true,\"spouseName\":\"Bob\"}," + - "{\"name\":\"Ann\",\"characterID\":\"npc_r13_ann\",\"married\":true,\"isMarried\":true,\"spouseName\":\"Bob\"}]"; - var edited = PermadeathHelper.RemoveDeletedCharacterFromNpcJson(json, "Bob", out _, out int spouses); - spouses.Should().Be(1); - var arr = System.Text.Json.Nodes.JsonNode.Parse(edited!)!.AsArray(); - ((string)arr[0]!["spouseName"]!).Should().BeEmpty(); - ((string)arr[1]!["spouseName"]!).Should().Be("Bob", "the registry marries Ann to an NPC"); - reg.IsMarriedToNPC(wife.ID).Should().BeFalse(); - } - finally - { - foreach (var n in new[] { wife, ann, npcBob }) NPCSpawnSystem.Instance.ActiveNPCs.Remove(n); - reg.EndMarriage(wife.ID); - reg.EndMarriage(ann.ID); - } - } - [Fact] public async Task TheMarriedNameAlias_IsCheckedAgainstTheNpcGuard_AliasByAlias() { @@ -613,6 +472,47 @@ public async Task TheMarriedNameAlias_IsCheckedAgainstTheNpcGuard_AliasByAlias() body.Split("\"auction_listings\"").Length.Should().Be(2, "one auction DELETE, fed by the alias list the guard checks"); } + [Fact] + public async Task AVacancy_CarriesTheMonarchHistory_AndBothLoadersImportIt() + { + bool loadedBefore = CastleLocation.RoyalCourtLoadedFromShared; + var osm = (OnlineStateManager)Activator.CreateInstance(typeof(OnlineStateManager), + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance, null, new object[] { _db, "r15" }, null)!; + try + { + await WithKing(null!, async () => + { + // this process ended Bob's reign with no successor; the history records it + CastleLocation.SetMonarchHistory(new List + { + new MonarchRecord { Name = "Old Queen", Title = "Queen", DaysReigned = 40, EndReason = "Abdicated" }, + new MonarchRecord { Name = "Bob", Title = "King", DaysReigned = 12, EndReason = "left the throne and the realm" } + }); + (await osm.SaveRoyalCourtIfVersionAsync(_db.GetWorldStateVersion("royal_court"), throneVacated: true)).Should().BeTrue(); + var stored = (await osm.ReadRoyalCourtFromWorldState())!; + stored.ThroneVacant.Should().BeTrue(); + stored.MonarchHistory.Select(m => m.Name).Should().Equal("Old Queen", "Bob"); + + // the login loader + CastleLocation.SetMonarchHistory(new List()); + await osm.LoadRoyalCourtFromWorldState(); + CastleLocation.GetMonarchHistory().Select(m => m.Name).Should().Equal("Old Queen", "Bob"); + CastleLocation.GetMonarchHistory()[1].DaysReigned.Should().Be(12); + + // the world sim's loader, which then fills the throne + CastleLocation.SetMonarchHistory(new List()); + new WorldSimService(_db).LoadRoyalCourtFromWorldState(); + CastleLocation.GetMonarchHistory().Select(m => m.Name).Should().StartWith(new[] { "Old Queen", "Bob" }); + + // an older vacancy with no history leaves this process's history alone + CastleLocation.SetMonarchHistory(new List { new MonarchRecord { Name = "Kept" } }); + CastleLocation.ApplySharedThroneVacancy(new RoyalCourtSaveData { ThroneVacant = true }).Should().BeTrue(); + CastleLocation.GetMonarchHistory().Select(m => m.Name).Should().Equal("Kept"); + }); + } + finally { CastleLocation.RoyalCourtLoadedFromShared = loadedBefore; } + } + private static string Source(string folder, string file) { var dir = new DirectoryInfo(AppContext.BaseDirectory); diff --git a/Tests/NPCDefeatQuestTests.cs b/Tests/NPCDefeatQuestTests.cs index 22fefe6e..03c85903 100644 --- a/Tests/NPCDefeatQuestTests.cs +++ b/Tests/NPCDefeatQuestTests.cs @@ -402,4 +402,78 @@ await osm.SaveSharedQuests(new System.Collections.Generic.List .Should().Contain("RemoveSharedQuestsAsync(q => keys.Contains(QuestSystem.BountyClaimKey(q)))") .And.NotContain("RemoveSharedQuestsAsync(q => ids.Contains(q.Id))"); } + + // ─── v1.1.11: a player bounty's gold survives a save and reload ─── + + /// Runs the body, then puts the shared quest list back as it was (the readers replace parts of it). + private static void KeepingTheQuestList(Action body) + { + var before = QuestSystem.GetAllQuests(includeCompleted: true); + try { body(); } + finally + { + QuestSystem.RestoreFromSaveData(new System.Collections.Generic.List()); + foreach (var q in before) QuestSystem.AddQuestToDatabase(q); + } + } + + private static Quest ByTarget(string target) => + System.Linq.Enumerable.Single(QuestSystem.GetAllQuests(includeCompleted: true), q => q.TargetNPCName == target && !q.Deleted); + + private static System.Collections.Generic.List ThroughJson(System.Collections.Generic.List list) => + System.Text.Json.JsonSerializer.Deserialize>(System.Text.Json.JsonSerializer.Serialize(list))!; + + [Fact] + public async Task APlayerBountysGold_SurvivesBothWriters_AndAllThreeReaders() + { + await WithSqlBackend(async (db, path) => + { + var osm = (OnlineStateManager)Activator.CreateInstance(typeof(OnlineStateManager), F, null, new object[] { db, "r15" }, null)!; + KeepingTheQuestList(() => + { + var posted = BountyOnPlayer("Gold Rogue", 100_000); + posted.Occupier = "Gold Hunter"; + + var saveWriter = typeof(SaveSystem).GetMethod("SerializeQuestList", F)!; + var fromSave = ThroughJson((System.Collections.Generic.List)saveWriter.Invoke(SaveSystem.Instance, new object[] { new System.Collections.Generic.List { posted } })!); + var fromOnline = ThroughJson((System.Collections.Generic.List)typeof(OnlineStateManager).GetMethod("SerializeCurrentQuests", F)!.Invoke(osm, null)!); + fromSave.Should().ContainSingle().Which.BountyGold.Should().Be(100_000); + System.Linq.Enumerable.Single(fromOnline, d => d.Id == posted.Id).BountyGold.Should().Be(100_000); + + QuestSystem.RestoreFromSaveData(fromSave); + ByTarget("Gold Rogue").BountyGold.Should().Be(100_000, "RestoreFromSaveData"); + QuestSystem.MergePlayerQuests("Gold Hunter", fromSave); + ByTarget("Gold Rogue").BountyGold.Should().Be(100_000, "MergePlayerQuests"); + QuestSystem.RestoreFromSaveData(new System.Collections.Generic.List()); + fromSave[0].Occupier = ""; + QuestSystem.MergeWorldQuests(fromSave); + ByTarget("Gold Rogue").BountyGold.Should().Be(100_000, "MergeWorldQuests"); + + // the reloaded bounty pays what was posted, not the 500 fallback + var winner = new Character { Name1 = "gold_sheriff", Name2 = "Gold Sheriff", Level = 30, Gold = 0 }; + var rogue = new Character { Name1 = "gold_rogue", Name2 = "Gold Rogue", Level = 30, IsLoadedPlayer = true }; + QuestSystem.CollectBountiesOnPlayer(winner, rogue).Should().ContainSingle(); + winner.Gold.Should().Be(100_000); + }); + await Task.CompletedTask; + }); + } + + [Fact] + public void ALegacyPlayerBounty_WithNoStoredGold_PaysThe500Fallback() + { + KeepingTheQuestList(() => + { + // a row written before BountyGold was stored + var legacy = System.Text.Json.JsonSerializer.Deserialize>( + "[{\"Id\":\"legacy_bounty\",\"Initiator\":\"The Crown\",\"TargetNPCName\":\"Legacy Rogue\",\"IsPlayerBounty\":true,\"QuestTarget\":" + + (int)QuestTarget.DefeatNPC + ",\"DaysToComplete\":30,\"StartTime\":\"" + DateTime.Now.ToString("o") + "\"}]")!; + legacy[0].BountyGold.Should().Be(0); + QuestSystem.RestoreFromSaveData(legacy); + var winner = new Character { Name1 = "legacy_sheriff", Name2 = "Legacy Sheriff", Level = 30, Gold = 0 }; + var rogue = new Character { Name1 = "legacy_rogue", Name2 = "Legacy Rogue", Level = 30, IsLoadedPlayer = true }; + QuestSystem.CollectBountiesOnPlayer(winner, rogue).Should().ContainSingle(); + winner.Gold.Should().Be(500); + }); + } } From 49a2669f1f5719cc40f80915bc7eea90ea31399f Mon Sep 17 00:00:00 2001 From: binaryknight Date: Thu, 24 Sep 2026 01:14:58 +0000 Subject: [PATCH 35/42] Fixes from the sixteenth review of the 1.1.11 branch - Deleting a player whose married name is another living player's name no longer ends that player's reign. - Beating a married player collects a bounty under their married name only when no other character uses that name. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Systems/PermadeathHelper.cs | 4 +++- Scripts/Systems/QuestSystem.cs | 4 ++++ Scripts/Systems/SqlSaveBackend.cs | 25 +++++++++++++++++++ Tests/DeleteFollowUpTests.cs | 13 ++++++++++ Tests/NPCDefeatQuestTests.cs | 37 +++++++++++++++++++++++++++++ 5 files changed, 82 insertions(+), 1 deletion(-) diff --git a/Scripts/Systems/PermadeathHelper.cs b/Scripts/Systems/PermadeathHelper.cs index 167b655a..105fdb0b 100644 --- a/Scripts/Systems/PermadeathHelper.cs +++ b/Scripts/Systems/PermadeathHelper.cs @@ -465,7 +465,9 @@ public static async Task PurgeDeletedCharacterAsync(SqlSaveBackend? backend, str try { // v1.1.11: a deleted king abdicates through the normal path (history, NPC succession, persist) - if (await global::CastleLocation.AbdicateDeletedKingAsync(name, shown, "left the throne and the realm")) + // v1.1.11: a married name another living player now uses names their reign, not this one's + string? kingAlias = shown != null && aliases.Contains(shown, StringComparer.OrdinalIgnoreCase) ? shown : null; + if (await global::CastleLocation.AbdicateDeletedKingAsync(name, kingAlias, "left the throne and the realm")) DebugLogger.Instance.LogInfo("DELETE", $"Deleted '{name}' held the throne; the reign has ended."); } catch (Exception tex) { DebugLogger.Instance.LogWarning("DELETE", $"Throne handover failed for '{name}': {tex.Message}"); } diff --git a/Scripts/Systems/QuestSystem.cs b/Scripts/Systems/QuestSystem.cs index 37094f29..ec6a49eb 100644 --- a/Scripts/Systems/QuestSystem.cs +++ b/Scripts/Systems/QuestSystem.cs @@ -1563,6 +1563,10 @@ public static List CollectBountiesOnPlayer(Character winner, Character lo var names = new[] { loser.Name2, loser.DisplayName }.Where(n => !string.IsNullOrWhiteSpace(n)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); if (winner == null || names.Count == 0) return new List(); if (!loser.IsLoadedPlayer) return new List(); // v1.1.11: a hired guard or echo named like a player + // v1.1.11: a married display name another player also uses ("Bob Smith") names their bounty, not the loser's + if (SaveSystem.Instance?.Backend is SqlSaveBackend sqlNames) + names = names.Where(n => n.Equals(loser.Name2, StringComparison.OrdinalIgnoreCase) || !sqlNames.IsNameUsedByAnotherCharacter(n, loser.Name2)).ToList(); + if (names.Count == 0) return new List(); if (names.Any(n => n.Equals(winner.Name2, StringComparison.OrdinalIgnoreCase) || n.Equals(winner.DisplayName, StringComparison.OrdinalIgnoreCase))) return new List(); diff --git a/Scripts/Systems/SqlSaveBackend.cs b/Scripts/Systems/SqlSaveBackend.cs index 035e8603..d7a1f64b 100644 --- a/Scripts/Systems/SqlSaveBackend.cs +++ b/Scripts/Systems/SqlSaveBackend.cs @@ -6000,6 +6000,31 @@ public bool IsNameUsedByAnotherPlayer(string name, string username) } } + /// + /// v1.1.11: a player row of another character (its save Name2 is not ownName2) uses the name, as display + /// name or Name2. A failed read counts as used, so a bounty under a shared name is not paid. + /// + public bool IsNameUsedByAnotherCharacter(string name, string ownName2) + { + if (string.IsNullOrWhiteSpace(name)) return false; + try + { + using var connection = OpenConnection(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT EXISTS (SELECT 1 FROM players p, " + + "(SELECT LOWER(CASE WHEN json_valid(player_data) THEN json_extract(player_data, '$.player.name2') END) AS n2, rowid AS rid FROM players) j " + + "WHERE j.rid = p.rowid AND COALESCE(j.n2, '') != LOWER(@own) AND (LOWER(p.display_name) = LOWER(@n) OR j.n2 = LOWER(@n)));"; + cmd.Parameters.AddWithValue("@n", name); + cmd.Parameters.AddWithValue("@own", ownName2 ?? ""); + return Convert.ToInt64(cmd.ExecuteScalar()) != 0; + } + catch (Exception ex) + { + DebugLogger.Instance.LogWarning("SQL", $"IsNameUsedByAnotherCharacter('{name}') failed: {ex.Message}"); + return true; + } + } + /// v1.1.11: the players.display_name of one key (the married surname form), or null. public string? GetStoredDisplayName(string username) { diff --git a/Tests/DeleteFollowUpTests.cs b/Tests/DeleteFollowUpTests.cs index 3937c85d..3f1909f5 100644 --- a/Tests/DeleteFollowUpTests.cs +++ b/Tests/DeleteFollowUpTests.cs @@ -109,6 +109,19 @@ await WithKing(King.CreateNewKing("Alice", CharacterAI.Human, CharacterSex.Femal }); } + [Fact] + public async Task DeletingMarriedBob_LeavesALivingBobSmith_OnTheThrone() + { + // v1.1.11: the deleted Bob's married name is also a living player's name; that reign is theirs + Player("bob_account", "Bob Smith"); + Exec("INSERT INTO players (username, display_name, player_data) VALUES ('bsmith', 'Bob Smith Jr', '{\"player\":{\"name2\":\"Bob Smith\"}}');"); + await WithKing(King.CreateNewKing("Bob Smith", CharacterAI.Human, CharacterSex.Male), async () => + { + await PermadeathHelper.PurgeDeletedCharacterAsync(_db, "bob_account", "Bob"); + (CastleLocation.GetCurrentKing()?.Name).Should().Be("Bob Smith", "the living Bob Smith still reigns"); + }); + } + [Fact] public async Task MailAndAuctions_UnderTheDisplayName_ArePurged_OthersKept() { diff --git a/Tests/NPCDefeatQuestTests.cs b/Tests/NPCDefeatQuestTests.cs index 03c85903..0bcd823c 100644 --- a/Tests/NPCDefeatQuestTests.cs +++ b/Tests/NPCDefeatQuestTests.cs @@ -355,6 +355,43 @@ public void AMarriedPlayer_LoadsWithTheSameDisplayName_AndTheirBountyIsPaid() finally { bounty.Deleted = true; } } + [Fact] + public async Task BeatingMarriedBob_DoesNotCollectTheBountyOnALivingBobSmith() + { + // v1.1.11: "Bob Smith" is also another player's Name2, so the bounty names them, not the married Bob + await WithSqlBackend(async (db, path) => + { + void Row(string user, string display, string name2) + { + using var conn = new SqliteConnection($"Data Source={path}"); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "INSERT INTO players (username, display_name, player_data) VALUES (@u, @d, @j);"; + cmd.Parameters.AddWithValue("@u", user); + cmd.Parameters.AddWithValue("@d", display); + cmd.Parameters.AddWithValue("@j", "{\"player\":{\"name2\":\"" + name2 + "\"}}"); + cmd.ExecuteNonQuery(); + } + Row("bob_ns", "Bob NS Smith", "Bob NS"); // the married Bob's own row + var bob = new Character { Name1 = "bob_ns", Name2 = "Bob NS", FamilySurname = "Smith", Level = 20, IsLoadedPlayer = true }; + var winner = new Character { Name1 = "sheriff_ns", Name2 = "Sheriff NS", Level = 30, Gold = 0 }; + var bounty = BountyOnPlayer("Bob NS Smith", 3000); + try + { + Row("bsmith_ns", "Robert NS", "Bob NS Smith"); + QuestSystem.CollectBountiesOnPlayer(winner, bob).Should().BeEmpty("the bounty names the living Bob NS Smith"); + bounty.Deleted.Should().BeFalse(); + winner.Gold.Should().Be(0); + + db.IsNameUsedByAnotherCharacter("Bob NS Smith", "Bob NS").Should().BeTrue(); + db.IsNameUsedByAnotherCharacter("Bob NS", "Bob NS").Should().BeFalse("the married Bob's own row is not another character"); + QuestSystem.CollectBountiesOnPlayer(winner, new Character { Name1 = "bob_ns", Name2 = "Bob NS Smith", Level = 20, IsLoadedPlayer = true }) + .Should().ContainSingle("beating the living Bob NS Smith, whose Name2 it is, collects it"); + } + finally { bounty.Deleted = true; } + }); + } + // ─── v1.1.11: review round 14 ─── [Fact] From 044f6bdc5cd767d96c6946ddc5a13e781b6cbc36 Mon Sep 17 00:00:00 2001 From: binaryknight Date: Thu, 24 Sep 2026 01:25:29 +0000 Subject: [PATCH 36/42] Fixes from the seventeenth review of the 1.1.11 branch - A bounty whose claim could not be written (a busy database) stays open and unpaid instead of being dropped. - A winner's married display name no longer blocks collecting the bounty on the player whose name it is; self-collection is judged by Name2. - Winning the throne from an NPC king is not a kill, so it does not complete an Assassin contract on them. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Locations/CastleLocation.cs | 3 +- Scripts/Systems/QuestSystem.cs | 20 ++++++--- Scripts/Systems/SqlSaveBackend.cs | 7 ++- Tests/NPCDefeatQuestTests.cs | 68 +++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 10 deletions(-) diff --git a/Scripts/Locations/CastleLocation.cs b/Scripts/Locations/CastleLocation.cs index 835f1a05..5f352dbe 100644 --- a/Scripts/Locations/CastleLocation.cs +++ b/Scripts/Locations/CastleLocation.cs @@ -5966,7 +5966,8 @@ private async Task ChallengeThrone() terminal.WriteLine(""); var combatEngine = new CombatEngine(terminal); - var kingResult = await combatEngine.PlayerVsPlayer(currentPlayer, kingCharacter, allowSurrender: false); // v0.64.1: throne challenge treats non-Victory as defeat + // v1.1.11: not lethal; the NPC king's HP is put back below, so a win is not a kill (an Assassin contract stays) + var kingResult = await combatEngine.PlayerVsPlayer(currentPlayer, kingCharacter, allowSurrender: false, lethal: false); // v0.64.1: throne challenge treats non-Victory as defeat // Restore original NPC stats after combat kingCharacter.Defence = origDef; diff --git a/Scripts/Systems/QuestSystem.cs b/Scripts/Systems/QuestSystem.cs index ec6a49eb..e86ac056 100644 --- a/Scripts/Systems/QuestSystem.cs +++ b/Scripts/Systems/QuestSystem.cs @@ -1567,8 +1567,11 @@ public static List CollectBountiesOnPlayer(Character winner, Character lo if (SaveSystem.Instance?.Backend is SqlSaveBackend sqlNames) names = names.Where(n => n.Equals(loser.Name2, StringComparison.OrdinalIgnoreCase) || !sqlNames.IsNameUsedByAnotherCharacter(n, loser.Name2)).ToList(); if (names.Count == 0) return new List(); - if (names.Any(n => n.Equals(winner.Name2, StringComparison.OrdinalIgnoreCase) || n.Equals(winner.DisplayName, StringComparison.OrdinalIgnoreCase))) + // v1.1.11: never the winner's own bounty; the winner is known by Name2, a married display name is cosmetic + if (ReferenceEquals(winner, loser) || string.Equals(winner.Name2, loser.Name2, StringComparison.OrdinalIgnoreCase)) return new List(); + names = names.Where(n => !n.Equals(winner.Name2, StringComparison.OrdinalIgnoreCase)).ToList(); + if (names.Count == 0) return new List(); List claimed; lock (_bountyPayoutLock) @@ -1578,8 +1581,9 @@ public static List CollectBountiesOnPlayer(Character winner, Character lo // v1.1.11: another process may hold the same bounty; only the one whose DB claim lands pays it. // A bounty claimed elsewhere is marked Deleted here too, unpaid. var found = claimed; - claimed = found.Where(q => ClaimAcrossProcesses(q, winner.Name2)).ToList(); - foreach (var q in found) q.Deleted = true; + var outcome = found.Select(q => (Quest: q, Claim: ClaimAcrossProcesses(q, winner.Name2))).ToList(); + claimed = outcome.Where(o => o.Claim == true).Select(o => o.Quest).ToList(); + foreach (var o in outcome) if (o.Claim != null) o.Quest.Deleted = true; // a failed claim write leaves it unpaid and open foreach (var q in claimed) { q.Occupier = winner.Name2; q.OccupiedDays = 1; } } @@ -1671,11 +1675,12 @@ public static void OnNPCDefeated(Character player, NPC defeatedNPC, bool killed /// covers one process; separate door and world-sim processes each hold their own copy of the bounty. /// Without an SQL backend (offline, file saves) the lock is enough. A quest with no id cannot be /// claimed by id and is claimed under a key built from its fields (BountyClaimKey). + /// True: claimed here. False: claimed elsewhere. Null: the claim could not be written (a busy database). /// - internal static bool ClaimAcrossProcesses(Quest q, string claimer) + internal static bool? ClaimAcrossProcesses(Quest q, string claimer) { if (SaveSystem.Instance?.Backend is not SqlSaveBackend sql) return true; - return sql.TryClaimBounty(BountyClaimKey(q), claimer); + return sql.TryClaimBountyOrFail(BountyClaimKey(q), claimer); } /// @@ -1716,8 +1721,9 @@ public static long AutoCompleteBountyForNPC(Character player, string npcName, bo // v1.1.11: another process may hold the same bounty; only the one whose DB claim lands pays it. // A bounty claimed elsewhere is marked Deleted here too, unpaid. var found = matchingBounties; - matchingBounties = found.Where(q => ClaimAcrossProcesses(q, player?.Name2 ?? "")).ToList(); - foreach (var claimed in found) claimed.Deleted = true; // claimed for this payout + var outcome = found.Select(q => (Quest: q, Claim: ClaimAcrossProcesses(q, player?.Name2 ?? ""))).ToList(); + matchingBounties = outcome.Where(o => o.Claim == true).Select(o => o.Quest).ToList(); + foreach (var o in outcome) if (o.Claim != null) o.Quest.Deleted = true; // a failed claim write leaves it open } foreach (var bounty in matchingBounties) diff --git a/Scripts/Systems/SqlSaveBackend.cs b/Scripts/Systems/SqlSaveBackend.cs index d7a1f64b..9ba2ff3d 100644 --- a/Scripts/Systems/SqlSaveBackend.cs +++ b/Scripts/Systems/SqlSaveBackend.cs @@ -1301,7 +1301,10 @@ private static bool CouldBeNpcName(string name) /// first claim of the quest id; false when another process (or an earlier claim) already took it, or /// the claim could not be written. /// - public bool TryClaimBounty(string questId, string claimer) + public bool TryClaimBounty(string questId, string claimer) => TryClaimBountyOrFail(questId, claimer) == true; + + /// v1.1.11: as TryClaimBounty, but null when the claim could not be written, so the bounty stays open. + public bool? TryClaimBountyOrFail(string questId, string claimer) { if (string.IsNullOrWhiteSpace(questId)) return false; try @@ -1316,7 +1319,7 @@ public bool TryClaimBounty(string questId, string claimer) catch (Exception ex) { DebugLogger.Instance.LogError("SQL", $"TryClaimBounty failed for '{questId}': {ex.Message}"); - return false; + return null; } } diff --git a/Tests/NPCDefeatQuestTests.cs b/Tests/NPCDefeatQuestTests.cs index 0bcd823c..3c584255 100644 --- a/Tests/NPCDefeatQuestTests.cs +++ b/Tests/NPCDefeatQuestTests.cs @@ -278,6 +278,74 @@ await WithSqlBackend(async (db, _) => }); } + [Fact] + public async Task AFailedClaimWrite_LeavesTheBountyOpen_AndUnpaid() + { + // v1.1.11: a busy database made the claim fail; the bounty was marked Deleted, unpaid, and lost + await WithSqlBackend(async (db, path) => + { + using (var conn = new SqliteConnection($"Data Source={path}")) + { + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "DROP TABLE bounty_claims;"; // every claim write now fails + cmd.ExecuteNonQuery(); + } + db.TryClaimBountyOrFail("Qfail", "x").Should().BeNull(); + + var bounty = BountyOnPlayer("Busy Rogue", 4000); + var winner = new Character { Name1 = "sheriff_f", Name2 = "Sheriff F", Level = 30, Gold = 0 }; + var rogue = new Character { Name1 = "busy_rogue", Name2 = "Busy Rogue", Level = 30, IsLoadedPlayer = true }; + try + { + QuestSystem.CollectBountiesOnPlayer(winner, rogue).Should().BeEmpty(); + bounty.Deleted.Should().BeFalse("the claim was not written, so the bounty is still open"); + winner.Gold.Should().Be(0); + } + finally { bounty.Deleted = true; } + + var (hunter, target, npcBounty) = Wanted("Busy Mark"); + try + { + QuestSystem.AutoCompleteBountyForNPC(hunter, target.Name).Should().Be(0); + npcBounty.Deleted.Should().BeFalse(); + } + finally { npcBounty.Deleted = true; } + await Task.CompletedTask; + }); + } + + [Fact] + public void AWinnersMarriedName_DoesNotBlock_TheBountyOnThePlayerOfThatName() + { + // v1.1.11: "Bob" took the surname Smith and beat the real "Bob Smith"; the bounty is Bob Smith's, and paid + var bounty = BountyOnPlayer("Bob W Smith", 3000); + var winner = new Character { Name1 = "bob_w", Name2 = "Bob W", FamilySurname = "Smith", Level = 30, Gold = 0 }; + winner.DisplayName.Should().Be("Bob W Smith"); + var target = new Character { Name1 = "bob_w_smith", Name2 = "Bob W Smith", Level = 30, IsLoadedPlayer = true }; + try + { + QuestSystem.CollectBountiesOnPlayer(winner, target).Should().ContainSingle(); + winner.Gold.Should().BeGreaterThanOrEqualTo(3000); + } + finally { bounty.Deleted = true; } + } + + [Fact] + public void TheNPCThroneChallenge_IsNotLethal() + { + // v1.1.11: the NPC king's HP is put back after the fight, so beating them is not a kill for a contract + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Scripts"))) dir = dir.Parent; + string src = File.ReadAllText(Path.Combine(dir!.FullName, "Scripts", "Locations", "CastleLocation.cs")); + int npcKing = src.IndexOf("kingCharacter = kingNpc;", StringComparison.Ordinal); + npcKing.Should().BeGreaterThan(0); + int call = src.IndexOf("PlayerVsPlayer(", npcKing, StringComparison.Ordinal); + int restore = src.IndexOf("kingCharacter.HP = Math.Max(1, origHP);", npcKing, StringComparison.Ordinal); + call.Should().BeLessThan(restore); + src.Substring(call, src.IndexOf(';', call) - call).Should().Contain("lethal: false"); + } + [Fact] public void TheClaimTable_IsAlsoAMigration() { From 98c673c307b8768258a820a757113280fac8f39f Mon Sep 17 00:00:00 2001 From: binaryknight Date: Thu, 24 Sep 2026 03:57:57 +0000 Subject: [PATCH 37/42] Version 1.1.11 with release notes Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- DOCS/release-notes/README.md | 1 + DOCS/release-notes/RELEASE_NOTES_1.1.11.md | 72 +++++++++++++++++++ .../steam/STEAM_RELEASE_NOTES_1.1.11.txt | 10 +++ README.md | 7 +- Scripts/Core/GameConfig.cs | 2 +- 5 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 DOCS/release-notes/RELEASE_NOTES_1.1.11.md create mode 100644 DOCS/release-notes/steam/STEAM_RELEASE_NOTES_1.1.11.txt diff --git a/DOCS/release-notes/README.md b/DOCS/release-notes/README.md index 57723bfa..f68bd74d 100644 --- a/DOCS/release-notes/README.md +++ b/DOCS/release-notes/README.md @@ -3,6 +3,7 @@ One file per version, newest first. Steam-formatted copies (BBCode) are in `steam/`. The GitHub release for each tag carries the same text. +- [v1.1.11](RELEASE_NOTES_1.1.11.md) - [v1.1.10](RELEASE_NOTES_1.1.10.md) - [v1.1.9](RELEASE_NOTES_1.1.9.md) - [v1.1.8](RELEASE_NOTES_1.1.8.md) diff --git a/DOCS/release-notes/RELEASE_NOTES_1.1.11.md b/DOCS/release-notes/RELEASE_NOTES_1.1.11.md new file mode 100644 index 00000000..fb941d50 --- /dev/null +++ b/DOCS/release-notes/RELEASE_NOTES_1.1.11.md @@ -0,0 +1,72 @@ +# Usurper Reborn v1.1.11 + +## Team HQ + +- **Every upgrade reaches everything it names.** The Armory adds 5 percent + per level to every hit a player makes, the Barracks divides every hit a + player takes by 1 plus 5 percent per level (level 2: about 9 percent + less), the Infirmary adds 10 percent per + level to every healing potion, and Training adds 5 percent per level to + every combat XP award, once. +- **The levels are the team's current ones.** A player who joins a team + gets its upgrades at once, a teammate's upgrade reaches everyone within + a minute, and a player who leaves loses them straight away. +- **The status screen always shows the team's HQ bonuses.** +- **Team rankings count the real members.** A team that no player, NPC + or online player belongs to is removed, with its upgrades and vault, so + a later team of the same name starts fresh. +- **Leadership passes on when a leader leaves.** When a team or guild + leader quits, is deleted or dies for good, the highest-level remaining + player member leads. Teams already led by a former member are fixed by + the world save. + +## Bounties and quests + +- **Beating a WANTED target counts wherever it happens:** duels in the + Dormitory, the Dark Alley and its pit, the Inn and the Arena, sparing an + NPC who surrenders, and the Inn challenge. +- **A Crown bounty on a player is paid to whoever beats that player in a + duel.** It is paid once, never to the target themselves, and at the + amount posted. +- **A bounty is not lost when the database is busy;** it stays open. + +## Deleting a character + +- **A new character with a deleted one's name starts clean.** It no + longer inherits the old character's quests, bounties or deity. +- **Deleting a character also ends its reign, passes on the team or + guild it led, and removes its mail and auction listings.** Listings and + bounties of another player or NPC with the same name are left alone. +- **The permadeath for too many deaths deletes the character that died,** + not the account's main character. + +## Rewards + +- **An Old God kill pays its reward once.** The party leader's share is + Level x 6000 XP and Level x 1500 gold, paid in two parts: the fight's + share and the rest after the god's scene. +- **Wilderness gold finds pay on the dungeon's scale,** at the level the + wilderness fights use. +- **The Alchemist's Potion Mastery reaches every healing potion.** +- **World boss rewards use the team's current Training level.** + +## Other changes + +- **A spell short of mana says how much it needs,** for example "Magic + Missile (need 14 MP, have 6)". +- **Menu lines no longer show their key twice** (the Inn patron list, an + item's Equip and Drop options, the Dark Alley dice, the screen-reader + language line). +- **Winning the throne from an NPC king is not a kill,** so it does not + complete an Assassin contract on them. + +## Known and unchanged + +- Deleting a character does not yet clear NPC grudges against it or an + NPC's marriage to it; that comes in 1.1.12. +- Noctura's betrayal pays both of its rewards, and secret bosses pay + their flat XP on top of the fight, as designed. + +## Tests + +1,567 passing, up from 1,377. diff --git a/DOCS/release-notes/steam/STEAM_RELEASE_NOTES_1.1.11.txt b/DOCS/release-notes/steam/STEAM_RELEASE_NOTES_1.1.11.txt new file mode 100644 index 00000000..49721386 --- /dev/null +++ b/DOCS/release-notes/steam/STEAM_RELEASE_NOTES_1.1.11.txt @@ -0,0 +1,10 @@ +[h1]Usurper Reborn 1.1.11[/h1] + +[list] +[*]Team HQ upgrades reach everything they name: every hit you make and take, every healing potion and every combat XP award. Joining or leaving a team takes effect at once. +[*]Team rankings count real members, empty teams are cleared, and a team or guild passes to its next highest-level member when the leader leaves. +[*]Beating a WANTED target counts wherever it happens, and a Crown bounty on a player is paid to whoever beats them in a duel. +[*]A new character with a deleted one's name starts clean. Deleting a character also ends its reign and removes its mail and auction listings. +[*]An Old God kill pays its reward once, wilderness gold finds pay on the dungeon's scale, and the Alchemist's Potion Mastery works on every healing potion. +[*]A spell short of mana says how much it needs, and menu lines no longer show their key twice. +[/list] diff --git a/README.md b/README.md index 6c6023f7..38e50532 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## A Persistent Online Text RPG with a Living World -**v1.1.10 "Regalia"** | **FREE AND OPEN SOURCE** | **GPL v2** +**v1.1.11 "Regalia"** | **FREE AND OPEN SOURCE** | **GPL v2** 130+ autonomous NPCs wake up, go to work, visit taverns, fall in love, get married, have children, age, and eventually die of old age, all while you're offline. Log back in, read the news feed, and discover that the blacksmith married the barmaid, the king was assassinated, or a new generation just came of age. The world doesn't wait for you. @@ -282,6 +282,7 @@ The game ships small patches frequently. Each version has a dedicated release no - **v1.1.1:** a bug pass. Five review agents each took a domain of the codebase and about seventy findings were verified and fixed: NPCs losing their innate power on load, the world simulator editing the wrong player's relationships, a restored character unable to save, one player's autosave starving everyone else's, the Black Market re-rolling on relog, a closed connection spinning the server, bank and gambling exploits, buffs consumed a fight early, and raw placeholders in the text. See `DOCS/release-notes/RELEASE_NOTES_1.1.1.md`. - **v1.1.2:** seven of the eight open design items, each designed twice (Codex and Claude), reconciled against the code, and reviewed before implementation: grouped followers get their own cooldowns and a real death; haggling finally has a way in and its attempts persist; the bank vault is one persisted reserve per world, atomic online; relationships cool with neglect measured in days you were present; NPCs left to die while you held a heal remember it; ability and spell numbers are moddable from `GameData/`; the two intimacy lines that really dropped a name are fixed. Docker stack refreshed and verified. See `DOCS/release-notes/RELEASE_NOTES_1.1.2.md`. - **v1.1.3:** party survivability. Wounded allies shield up, brace (half damage on ordinary hits, specials, and life drain), and drink their own potion first; NPC allies who die in your party roll the 2 percent team permadeath rate they were always meant to; three stances per ally (Aggressive, Balanced, Cautious) set from the dungeon party menu or the Inn and saved with the character; monsters no longer prefer a wounded target and an ally's brace no longer pulls hits unless it is Aggressive; a shared potion belt (off by default, two loans per fight, never the player's last three), give-a-number, and the one-personal-potion rule; a fight summary per ally, a warning before a voluntary fight with an ally below 30 percent, and a floor guard that offers Cautious to an ally eleven levels behind. Planned by a council of Codex, a Claude design agent, and the supervisor session in `DOCS/PARTY_SURVIVABILITY_PLAN.md`; the downed state is the next release. +- **v1.1.11:** Team HQ upgrades reach every hit, potion and XP award and follow the team you are in; rankings count real members and leadership passes on when a leader leaves. WANTED targets count wherever they are beaten, and Crown bounties on players pay the duel winner. A same-name character starts clean, and deleting a character ends its reign and clears its mail and auctions. An Old God kill pays once, wilderness gold finds pay on the dungeon scale, and Potion Mastery works on every healing potion. See `DOCS/release-notes/RELEASE_NOTES_1.1.11.md`. - **v1.1.10:** Mael'Keth is tuned for a level-30s party, the five floors before every Old God have tougher monsters, a god's power surge lasts three rounds, its intro shows its fight HP, and dialogue answers apply. Companions are protected from special attacks like normal hits and tanks shield before they taunt. Items waiting for pack room reach alts, cannot be lost, and arrive on /boss; bequests from teams reach their leader, with an admin screen to set the leader of older teams. The Inn lists every patron, PvP control can no longer chain, and Team HQ upgrades cost what the menu shows. See `DOCS/release-notes/RELEASE_NOTES_1.1.10.md`. - **v1.1.9:** two player reports. The feet slot offers boots, not weapons, and armor "of Shielding" is no longer labelled a shield. Gear sets count the gear you already have: pieces from before v1.1.0 never recorded which template they dropped as, so the same Leather Cap could count or not depending on when it dropped. That template is now read from the name, never written into the save, and only when the name is one the game really generates for that piece, in any language and with any enchantment, compared against every item so the closest match wins. On the live server that switches on or raises 118 player set bonuses. See `DOCS/release-notes/RELEASE_NOTES_1.1.9.md`. - **v1.1.8:** the website stops stalling. On a server with 400 players the statistics page read every player's save blob without an index, which stalled the whole web service for up to 83 seconds out of every 115, whether or not anyone was visiting, and froze the news feed, the API and the browser terminal along with it. Seven indexes carry the fields those pages read, and the worst stall is now 7 seconds with the service burning 4 percent of a core instead of 68. A server upgrading from an older release has its indexes rebuilt once so every install matches. JSON responses are compressed, which takes the dashboard's main payload from 2.2 MB to 400 KB, and the web service's memory ceiling is raised to what it actually needs. Releases also deploy the website itself now, which they never did before, so the site can no longer drift behind the game. @@ -341,7 +342,7 @@ Join Discord for discussions, feedback, and updates: **https://discord.gg/EZhwgD *"You are not a wave fighting the ocean. You ARE the ocean, dreaming of being a wave."* -## Known Issues (v1.1.10) +## Known Issues (v1.1.11) - Save files from the earliest alpha versions may not be fully compatible. - BBS FOSSIL mode not natively supported (use `--stdio` flag for FOSSIL-based BBSes via host pipe). @@ -355,4 +356,4 @@ Join Discord for discussions, feedback, and updates: **https://discord.gg/EZhwgD --- -**Status:** v1.1.10 "Regalia". The world is running. [Watch it live.](https://usurper-reborn.net) +**Status:** v1.1.11 "Regalia". The world is running. [Watch it live.](https://usurper-reborn.net) diff --git a/Scripts/Core/GameConfig.cs b/Scripts/Core/GameConfig.cs index 4e1a335e..e612b35e 100644 --- a/Scripts/Core/GameConfig.cs +++ b/Scripts/Core/GameConfig.cs @@ -10,7 +10,7 @@ public static partial class GameConfig { // Version information - public const string Version = "1.1.10"; + public const string Version = "1.1.11"; public const string VersionName = "Regalia"; // 1.1 line: the gear and reward loop // v0.57.12: Alignment scale cap. Character.Chivalry and Character.Darkness setters clamp to [0, AlignmentCap] From fbc685c1a89962e50d622d11cd37468601d4b2fc Mon Sep 17 00:00:00 2001 From: binaryknight Date: Thu, 24 Sep 2026 03:58:47 +0000 Subject: [PATCH 38/42] Release notes: the Team HQ bonuses' exclusions Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- DOCS/release-notes/RELEASE_NOTES_1.1.11.md | 17 +++++++++++------ .../steam/STEAM_RELEASE_NOTES_1.1.11.txt | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/DOCS/release-notes/RELEASE_NOTES_1.1.11.md b/DOCS/release-notes/RELEASE_NOTES_1.1.11.md index fb941d50..2800599f 100644 --- a/DOCS/release-notes/RELEASE_NOTES_1.1.11.md +++ b/DOCS/release-notes/RELEASE_NOTES_1.1.11.md @@ -2,12 +2,17 @@ ## Team HQ -- **Every upgrade reaches everything it names.** The Armory adds 5 percent - per level to every hit a player makes, the Barracks divides every hit a - player takes by 1 plus 5 percent per level (level 2: about 9 percent - less), the Infirmary adds 10 percent per - level to every healing potion, and Training adds 5 percent per level to - every combat XP award, once. +- **Every upgrade reaches everything it names.** + - The Armory adds 5 percent per level to every hit a player makes, PvE + and PvP. Effects that follow a hit (riders, reflect, damage-over-time + ticks) are not boosted again. + - The Barracks divides every hit an enemy lands on a player by 1 plus 5 + percent per level (level 2: about 9 percent less). Status ticks + (poison, disease) and self-inflicted drains are not reduced. + - The Infirmary adds 10 percent per level to what every healing potion + restores, in and out of combat. + - Training adds 5 percent per level to every combat XP award, once, on + the player's own share. - **The levels are the team's current ones.** A player who joins a team gets its upgrades at once, a teammate's upgrade reaches everyone within a minute, and a player who leaves loses them straight away. diff --git a/DOCS/release-notes/steam/STEAM_RELEASE_NOTES_1.1.11.txt b/DOCS/release-notes/steam/STEAM_RELEASE_NOTES_1.1.11.txt index 49721386..2f7acc9f 100644 --- a/DOCS/release-notes/steam/STEAM_RELEASE_NOTES_1.1.11.txt +++ b/DOCS/release-notes/steam/STEAM_RELEASE_NOTES_1.1.11.txt @@ -1,7 +1,7 @@ [h1]Usurper Reborn 1.1.11[/h1] [list] -[*]Team HQ upgrades reach everything they name: every hit you make and take, every healing potion and every combat XP award. Joining or leaving a team takes effect at once. +[*]Team HQ upgrades reach everything they name: your hits (not damage-over-time ticks), the enemy hits you take (not poison or disease ticks), every healing potion and every combat XP award. Joining or leaving a team takes effect at once. [*]Team rankings count real members, empty teams are cleared, and a team or guild passes to its next highest-level member when the leader leaves. [*]Beating a WANTED target counts wherever it happens, and a Crown bounty on a player is paid to whoever beats them in a duel. [*]A new character with a deleted one's name starts clean. Deleting a character also ends its reign and removes its mail and auction listings. From a4a31f11138e38903f46c61a5e5e660158ee96f5 Mon Sep 17 00:00:00 2001 From: binaryknight Date: Thu, 24 Sep 2026 04:05:13 +0000 Subject: [PATCH 39/42] Fixes from the final whole-branch review of 1.1.11 - Deleting a character claims every bounty it removes, so another process's cached copy can never be paid, even against a later character of the same name. - A deletion in a door process with no guild system of its own still passes on the guild it led. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- Scripts/Systems/PermadeathHelper.cs | 18 +++++++++++++++--- Scripts/Systems/QuestSystem.cs | 12 ++++++++++-- Scripts/Systems/SqlSaveBackend.cs | 3 +++ Tests/CharacterRecreationTests.cs | 9 +++++++-- Tests/LeaderSuccessionTests.cs | 25 ++++++++++++++++++++++++- 5 files changed, 59 insertions(+), 8 deletions(-) diff --git a/Scripts/Systems/PermadeathHelper.cs b/Scripts/Systems/PermadeathHelper.cs index 105fdb0b..b6df707c 100644 --- a/Scripts/Systems/PermadeathHelper.cs +++ b/Scripts/Systems/PermadeathHelper.cs @@ -434,10 +434,20 @@ public static async Task PurgeDeletedCharacterAsync(SqlSaveBackend? backend, str // Claimed quests (Occupier / OfferedTo = display name) and the King's WANTED bounty on // the character. Pushed even when nothing was removed, since world_state may still hold // a stale copy that a same-name character would merge back on load. - int removed = QuestSystem.RemovePlayerQuests(questNames.ToArray()) + aliases.Sum(a => QuestSystem.RemoveBountiesOnPlayer(a)); + // v1.1.11: every removed bounty is also claimed, so another process's cached copy of it can + // never be paid, even against a later character of the same name + var claimKeys = new HashSet(); + int removed = QuestSystem.RemovePlayerQuests(questNames.ToArray()) + aliases.Sum(a => QuestSystem.RemoveBountiesOnPlayer(a, claimKeys)); // the shared record is edited in place, not replaced by this process's list (review) if (UsurperRemake.BBS.DoorMode.IsOnlineMode && OnlineStateManager.IsActive) - removed += await OnlineStateManager.Instance!.RemoveSharedQuestsAsync(q => QuestLeftByCharacter(q, questNames, aliases)); + removed += await OnlineStateManager.Instance!.RemoveSharedQuestsAsync(q => + { + bool left = QuestLeftByCharacter(q, questNames, aliases); + if (left && aliases.Any(a => QuestSystem.IsBountyOnPlayer(q.Initiator, q.TitleKey, q.TargetNPCName, q.IsPlayerBounty, a))) + claimKeys.Add(QuestSystem.BountyClaimKey(q)); + return left; + }); + foreach (var key in claimKeys) backend?.TryClaimBounty(key, "(deleted character)"); if (removed > 0) DebugLogger.Instance.LogInfo("DELETE", $"Removed {removed} quest(s) and bounties for deleted '{name}'."); } @@ -457,7 +467,9 @@ public static async Task PurgeDeletedCharacterAsync(SqlSaveBackend? backend, str if (!string.IsNullOrWhiteSpace(username)) { backend?.PassTeamLeadershipOfDeleted(username!); - GuildSystem.Instance?.PassLeadershipOf(username!); + // v1.1.11: a door process has no GuildSystem of its own (only the MUD server makes one) + var guilds = GuildSystem.Instance ?? (backend != null ? new GuildSystem(backend.DatabasePath, register: false) : null); + guilds?.PassLeadershipOf(username!); } } catch (Exception lex) { DebugLogger.Instance.LogWarning("DELETE", $"Leadership succession failed for '{name}': {lex.Message}"); } diff --git a/Scripts/Systems/QuestSystem.cs b/Scripts/Systems/QuestSystem.cs index e86ac056..b59183f5 100644 --- a/Scripts/Systems/QuestSystem.cs +++ b/Scripts/Systems/QuestSystem.cs @@ -1292,10 +1292,18 @@ bool Matches(string? field) => /// v1.1.11: remove the King's WANTED bounties posted on a player (PostBountyOnPlayer), so a deleted /// character's bounty does not hang over a new character given the same name. /// - public static int RemoveBountiesOnPlayer(string? playerName) + public static int RemoveBountiesOnPlayer(string? playerName) => RemoveBountiesOnPlayer(playerName, null); + + /// v1.1.11: claimKeys, when given, receives the claim key of every bounty removed. + public static int RemoveBountiesOnPlayer(string? playerName, ICollection? claimKeys) { if (string.IsNullOrWhiteSpace(playerName)) return 0; - return questDatabase.RemoveAll(q => IsBountyOnPlayer(q.Initiator, q.TitleKey, q.TargetNPCName, q.IsPlayerBounty, playerName)); + return questDatabase.RemoveAll(q => + { + if (!IsBountyOnPlayer(q.Initiator, q.TitleKey, q.TargetNPCName, q.IsPlayerBounty, playerName)) return false; + claimKeys?.Add(BountyClaimKey(q)); + return true; + }); } /// diff --git a/Scripts/Systems/SqlSaveBackend.cs b/Scripts/Systems/SqlSaveBackend.cs index 9ba2ff3d..32a042a8 100644 --- a/Scripts/Systems/SqlSaveBackend.cs +++ b/Scripts/Systems/SqlSaveBackend.cs @@ -63,6 +63,9 @@ public static bool IsAltCharacter(string key) => // already gone and any in-flight saves died with the old process. private static readonly System.Collections.Concurrent.ConcurrentDictionary RageEventErasedUsernames = new(); + /// v1.1.11: the database file, for a system that opens its own connection (guild succession). + public string DatabasePath => databasePath; + public SqlSaveBackend(string databasePath) { this.databasePath = databasePath; diff --git a/Tests/CharacterRecreationTests.cs b/Tests/CharacterRecreationTests.cs index 852f30ef..d0fa581a 100644 --- a/Tests/CharacterRecreationTests.cs +++ b/Tests/CharacterRecreationTests.cs @@ -507,8 +507,8 @@ await osm.SaveSharedQuests(new List } var purge = CodeOnly(Source("Systems", "PermadeathHelper.cs")); - purge.Should().Contain("aliases.Sum(a => QuestSystem.RemoveBountiesOnPlayer(a))"); - purge.Should().Contain("RemoveSharedQuestsAsync(q => QuestLeftByCharacter(q, questNames, aliases))"); + purge.Should().Contain("aliases.Sum(a => QuestSystem.RemoveBountiesOnPlayer(a, claimKeys))"); + purge.Should().Contain("bool left = QuestLeftByCharacter(q, questNames, aliases);"); purge.Should().Contain("QuestSystem.RemovePlayerQuests(questNames.ToArray())"); } @@ -596,6 +596,11 @@ await osm.SaveSharedQuests(new List InDatabase(onLiving).Should().BeTrue("the living Bob Smith's bounty is theirs"); InDatabase(onBob).Should().BeFalse(); (await osm.LoadSharedQuests())!.Select(q => q.Id).Should().Equal(new[] { "L1" }); + + // v1.1.11: the removed bounties are claimed, so another process's cached copy never pays + _db.TryClaimBounty(QuestSystem.BountyClaimKey(onBob), "a stale process").Should().BeFalse(); + _db.TryClaimBounty("L2", "a stale process").Should().BeFalse(); + _db.TryClaimBounty("L1", "a stale process").Should().BeTrue("the living Bob Smith's bounty is still open"); }); } finally diff --git a/Tests/LeaderSuccessionTests.cs b/Tests/LeaderSuccessionTests.cs index 6264c938..b8f71fee 100644 --- a/Tests/LeaderSuccessionTests.cs +++ b/Tests/LeaderSuccessionTests.cs @@ -259,7 +259,30 @@ public async Task TheDeletePurge_PassesOnTheTeam_AndItsGuildToo() await PermadeathHelper.PurgeDeletedCharacterAsync(_db, "leader", "Leader"); Leader("Black Band").Should().Be("bran"); - CodeOnly(Source("Systems", "PermadeathHelper.cs")).Should().Contain("GuildSystem.Instance?.PassLeadershipOf(username!)"); + CodeOnly(Source("Systems", "PermadeathHelper.cs")).Should().Contain("guilds?.PassLeadershipOf(username!)"); + } + + [Fact] + public async Task TheDeletePurge_InADoorProcessWithNoGuildSystem_StillPassesOnTheGuild() + { + // v1.1.11: only the MUD server makes a GuildSystem; a --online --stdio door process has none + _ = new GuildSystem(_path, register: false); // creates the guild tables + Player("gm", null, level: 70); + Player("heir", null, level: 30); + Guild("oak hall", "gm"); + GuildMember("gm", "oak hall", "2026-01-01 00:00:00", "Leader"); + GuildMember("heir", "oak hall", "2026-01-02 00:00:00"); + + var instance = typeof(GuildSystem).GetProperty("Instance")!; + var before = instance.GetValue(null); + instance.SetValue(null, null); + try + { + await PermadeathHelper.PurgeDeletedCharacterAsync(_db, "gm", "Gm"); + } + finally { instance.SetValue(null, before); } + GuildLeader("oak hall").Should().Be("heir"); + Scalar("SELECT rank FROM guild_members WHERE username = 'heir'").Should().Be("Leader"); } [Fact] From 715cf4f565b7773aa2c21b00a8c3f35c0318bbfb Mon Sep 17 00:00:00 2001 From: binaryknight Date: Thu, 24 Sep 2026 04:05:27 +0000 Subject: [PATCH 40/42] Release notes: bounties posted before 1.1.11 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- DOCS/release-notes/RELEASE_NOTES_1.1.11.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/DOCS/release-notes/RELEASE_NOTES_1.1.11.md b/DOCS/release-notes/RELEASE_NOTES_1.1.11.md index 2800599f..5f7c1dc5 100644 --- a/DOCS/release-notes/RELEASE_NOTES_1.1.11.md +++ b/DOCS/release-notes/RELEASE_NOTES_1.1.11.md @@ -69,6 +69,8 @@ - Deleting a character does not yet clear NPC grudges against it or an NPC's marriage to it; that comes in 1.1.12. +- A Crown bounty posted on a player before 1.1.11 is not paid in a duel; + bounties posted from this version are. - Noctura's betrayal pays both of its rewards, and secret bosses pay their flat XP on top of the fight, as designed. From b092b3bf300d364f61d8230f9302a2e32b8af254 Mon Sep 17 00:00:00 2001 From: binaryknight Date: Thu, 24 Sep 2026 04:06:40 +0000 Subject: [PATCH 41/42] Release notes: test count Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- DOCS/release-notes/RELEASE_NOTES_1.1.11.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DOCS/release-notes/RELEASE_NOTES_1.1.11.md b/DOCS/release-notes/RELEASE_NOTES_1.1.11.md index 5f7c1dc5..bee9d9fe 100644 --- a/DOCS/release-notes/RELEASE_NOTES_1.1.11.md +++ b/DOCS/release-notes/RELEASE_NOTES_1.1.11.md @@ -76,4 +76,4 @@ ## Tests -1,567 passing, up from 1,377. +1,568 passing, up from 1,377. From 930d4e3d3d5af85002301f22b3247ff7c19c1a99 Mon Sep 17 00:00:00 2001 From: binaryknight Date: Thu, 24 Sep 2026 04:12:27 +0000 Subject: [PATCH 42/42] Release notes: removed teams' war records and leaderless guilds Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CT1VB81uLs5VNysGendLKY --- DOCS/release-notes/RELEASE_NOTES_1.1.11.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/DOCS/release-notes/RELEASE_NOTES_1.1.11.md b/DOCS/release-notes/RELEASE_NOTES_1.1.11.md index bee9d9fe..6b14ff24 100644 --- a/DOCS/release-notes/RELEASE_NOTES_1.1.11.md +++ b/DOCS/release-notes/RELEASE_NOTES_1.1.11.md @@ -19,7 +19,7 @@ - **The status screen always shows the team's HQ bonuses.** - **Team rankings count the real members.** A team that no player, NPC or online player belongs to is removed, with its upgrades and vault, so - a later team of the same name starts fresh. + a later team of the same name does not inherit them. - **Leadership passes on when a leader leaves.** When a team or guild leader quits, is deleted or dies for good, the highest-level remaining player member leads. Teams already led by a former member are fixed by @@ -69,6 +69,10 @@ - Deleting a character does not yet clear NPC grudges against it or an NPC's marriage to it; that comes in 1.1.12. +- A removed team's war and siege records stay, and a new team of the + same name inherits them. +- A guild whose remaining members are all banned or emergency accounts + has no leader until an admin promotes one. - A Crown bounty posted on a player before 1.1.11 is not paid in a duel; bounties posted from this version are. - Noctura's betrayal pays both of its rewards, and secret bosses pay