Skip to content

Commit ffd05d9

Browse files
VortexUKclaude
andcommitted
v0.1.15: detect and skip ACT-renamed encounters
Adds a "title-vs-combatants" heuristic that rejects uploads whose encounter title doesn't match any enemy combatant in the fight — the only signal we have that a user has manually renamed the encounter in ACT (right-click → Rename Encounter), since ACT preserves no original-title shadow, no rename flag, no audit trail of any kind (confirmed empirically via a reflection sweep over EncounterData). Without this gate, a user can rename a trash encounter to a boss name and either auto-upload it (if uploads-enabled is on) or manually right-click → "Upload to EQ2 Lexicon" — and pollute the boss leaderboards on the site with what amounts to forged data. The server-side enforcement (a follow-up PR in EQ2Lexicon-raids) is the real defense; this client-side check is the friendly UX layer that catches the casual case and tells the user why nothing uploaded. Core - EncounterTitle.MatchesAnEnemy(title, enemyNames) — case-insensitive, trims whitespace, permissive in both substring directions to handle EQ2's "Boss the Epithet" naming (ACT sometimes uses the short name; a user might append a note like "Pawbuster - take 2" to a legitimate name). Returns false on null/empty title or empty enemy list, so the gate fails CLOSED. - 14 new tests covering exact match, both substring directions, multi-enemy fights, case-insensitivity, whitespace trim, the exact "TESTING" rename observed in the diagnostic session, empty input short-circuits, and null-element tolerance in the enemy list. UI - EncounterCapture.ProcessEncounter (auto path): runs the check after CaptureSnapshot. Failure → MarkProcessedNoCapture + OnSkipped with message "title '<X>' doesn't match any enemy — was this fight renamed in ACT?". - Plugin.OnManualUploadRequested (manual right-click path): same check, same message phrasing scoped to "manual upload skipped". - New EncounterCapture.EnumerateEnemyNames helper extracts the non-ally combatant names from a snapshot — single source of truth for both call sites. Investigation residue - All TEMPORARY DEBUG INSTRUMENTATION from the diagnostic-sweep iteration is removed. The reflection-based dump on every settled encounter, the "Dump diagnostic to event log" right-click item, and the OnDiag event are gone. Full suite: 248 passing (was 234, +14). ruff … wait, this is the .NET plugin: dotnet format clean, dotnet build clean, dotnet test clean, no vulnerable packages. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3b6fd73 commit ffd05d9

6 files changed

Lines changed: 192 additions & 2 deletions

File tree

src/Core/EQ2Lexicon.ACTPlugin.Core.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
Build metadata is mirrored from the UI csproj so this assembly's
1919
version tracks the user-facing plugin version.
2020
-->
21-
<Version>0.1.14</Version>
21+
<Version>0.1.15</Version>
2222
<Company>EQ2 Lexicon</Company>
2323
<Product>EQ2 Lexicon Uploader (Core)</Product>
2424
<Description>Pure payload / config / HTTP layer shared by EQ2Lexicon.ACTPlugin.</Description>

src/Core/EncounterTitle.cs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Collections.Generic;
23

34
namespace EQ2Lexicon.ACTPlugin
45
{
@@ -34,5 +35,58 @@ public static bool IsPlaceholder(string? title)
3435
return string.Equals(t, "Encounter", StringComparison.OrdinalIgnoreCase)
3536
|| string.Equals(t, "Unknown", StringComparison.OrdinalIgnoreCase);
3637
}
38+
39+
/// <summary>
40+
/// True when <paramref name="title"/> appears as (or within) one
41+
/// of the supplied enemy combatant names. The expected guarantee
42+
/// is that ACT's auto-derived title for an encounter is always
43+
/// the name of an enemy that was actually fought — so a title
44+
/// that matches NO enemy is a strong signal the user manually
45+
/// renamed the encounter via right-click → Rename Encounter in
46+
/// ACT.
47+
///
48+
/// Confirmed empirically (the diagnostic-dump session that led
49+
/// to this code) that ACT preserves no original-title shadow,
50+
/// no `Tags` marker, and no `HistoryRecord` audit field — every
51+
/// title-bearing property mutates with the rename. The
52+
/// combatant-list cross-check is the only signal we have.
53+
///
54+
/// Matching is permissive in both directions to handle EQ2's
55+
/// "Boss the Epithet" mob naming style:
56+
/// * exact (case-insensitive): "Pawbuster" == "Pawbuster"
57+
/// * title-is-substring-of-enemy: title "Pawbuster" matches
58+
/// enemy "Pawbuster the Crusher"
59+
/// * enemy-is-substring-of-title: title "Pawbuster (Wipe 1)"
60+
/// matches enemy "Pawbuster"
61+
///
62+
/// Both directions are needed because:
63+
/// * ACT sometimes uses the short form ("Pawbuster") even
64+
/// when the EQ2 log gives the full epithet,
65+
/// * and a legitimate user note appended to the title
66+
/// ("Pawbuster - take 2") shouldn't trip the filter.
67+
///
68+
/// Returns false when the title is null/whitespace, or when
69+
/// <paramref name="enemyNames"/> is empty (no enemies = nothing
70+
/// to compare against, treat as suspect).
71+
///
72+
/// Whitespace at the edges is trimmed on both sides before
73+
/// comparing — copy-pasted-with-trailing-space titles shouldn't
74+
/// false-positive.
75+
/// </summary>
76+
public static bool MatchesAnEnemy(string? title, IEnumerable<string?>? enemyNames)
77+
{
78+
if (string.IsNullOrWhiteSpace(title)) return false;
79+
if (enemyNames == null) return false;
80+
var t = title.Trim();
81+
foreach (var name in enemyNames)
82+
{
83+
if (string.IsNullOrWhiteSpace(name)) continue;
84+
var n = name.Trim();
85+
if (n.Equals(t, StringComparison.OrdinalIgnoreCase)) return true;
86+
if (n.IndexOf(t, StringComparison.OrdinalIgnoreCase) >= 0) return true;
87+
if (t.IndexOf(n, StringComparison.OrdinalIgnoreCase) >= 0) return true;
88+
}
89+
return false;
90+
}
3791
}
3892
}

src/EQ2Lexicon.ACTPlugin.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
<ActInstallDir Condition="'$(ActInstallDir)' == ''">C:\Program Files (x86)\Advanced Combat Tracker</ActInstallDir>
2323

2424
<!-- Build metadata shown in ACT's plugin list -->
25-
<Version>0.1.14</Version>
25+
<Version>0.1.15</Version>
2626
<Company>EQ2 Lexicon</Company>
2727
<Product>EQ2 Lexicon Uploader</Product>
2828
<Description>Uploads ACT-parsed encounters to the EQ2 Lexicon site.</Description>

src/EncounterCapture.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,12 +209,53 @@ private void MarkProcessedNoCapture(string encid)
209209
// exercise it without ACT installed. Both the polling path
210210
// above and Plugin.OnManualUploadRequested call into it.
211211

212+
/// <summary>
213+
/// Yields every combatant name in <paramref name="snap"/> that
214+
/// isn't in AllyNames — i.e. the enemies fought during the
215+
/// encounter. Used by the rename-detection gate (both auto and
216+
/// manual paths) to cross-reference the title against actual
217+
/// participants. Lives here rather than in Core because the
218+
/// snapshot DTO already lives in Core and we want a single
219+
/// canonical enemy filter that both call sites share.
220+
/// </summary>
221+
public static IEnumerable<string> EnumerateEnemyNames(EncounterSnapshot snap)
222+
{
223+
if (snap?.Combatants == null) yield break;
224+
foreach (var c in snap.Combatants)
225+
{
226+
if (c == null) continue;
227+
if (string.IsNullOrWhiteSpace(c.Name)) continue;
228+
if (snap.AllyNames != null && snap.AllyNames.Contains(c.Name)) continue;
229+
yield return c.Name;
230+
}
231+
}
232+
212233
private void ProcessEncounter(EncounterData enc, string encid)
213234
{
214235
// Snapshot the ACT state THEN build the payload — keeps the
215236
// ACT-coupled extraction separate from the pure transformation
216237
// so the latter is unit-testable.
217238
var snapshot = CaptureSnapshot(enc);
239+
240+
// Rename-detection gate: ACT's right-click → Rename Encounter
241+
// mutates EncounterData.Title with no audit trail (no
242+
// OldTitle, no Tags marker, no HistoryRecord original —
243+
// confirmed empirically via reflection dump). A title that
244+
// doesn't appear in any enemy combatant's name is a strong
245+
// signal someone retitled the fight, and uploading it would
246+
// pollute the boss rankings on the site. The check lives
247+
// here (not Poll) because it needs the snapshot's
248+
// AllyNames set to identify enemies. See
249+
// EncounterTitle.MatchesAnEnemy for the matching rules.
250+
var enemyNames = EnumerateEnemyNames(snapshot);
251+
if (!EncounterTitle.MatchesAnEnemy(snapshot.Title, enemyNames))
252+
{
253+
MarkProcessedNoCapture(encid);
254+
OnSkipped?.Invoke(
255+
$"skipped ({encid}: title '{snapshot.Title}' doesn't match any enemy — was this fight renamed in ACT?)");
256+
return;
257+
}
258+
218259
var payload = PayloadBuilder.BuildPayload(
219260
ActHelpers.GetLoggingCharacterName(),
220261
ActHelpers.GetLoggingServerName(),

src/Plugin.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,26 @@ private void OnManualUploadRequested(EncounterData enc)
391391
{
392392
var snapshot = EncounterCapture.CaptureSnapshot(enc);
393393
title = snapshot.Title;
394+
395+
// Rename-detection gate — mirrors the polling path.
396+
// ACT's right-click → Rename Encounter mutates Title
397+
// with no audit trail, so a title that doesn't appear
398+
// in any enemy combatant is the only signal we have
399+
// that the user retitled the fight. Block here too:
400+
// the manual upload path skips the blacklist + the
401+
// upload-enabled toggle, but a deliberate rename is
402+
// a different category — clearly the user typed
403+
// something, and uploading it would mislabel the parse
404+
// on the site.
405+
var enemyNames = EncounterCapture.EnumerateEnemyNames(snapshot);
406+
if (!EncounterTitle.MatchesAnEnemy(title, enemyNames))
407+
{
408+
var msg = $"manual upload skipped (title '{title}' doesn't match any enemy — looks renamed in ACT)";
409+
_settingsPanel.SetUploadStatus(msg, success: false);
410+
_eventLog?.Log(EventSeverity.Warning, "upload", msg);
411+
return;
412+
}
413+
394414
var payload = PayloadBuilder.BuildPayload(
395415
ActHelpers.GetLoggingCharacterName(),
396416
ActHelpers.GetLoggingServerName(),

tests/EQ2Lexicon.ACTPlugin.Tests/EncounterTitleTests.cs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,80 @@ public void IsPlaceholder_FalseForRealMobNames(string title)
4141
{
4242
Assert.False(EncounterTitle.IsPlaceholder(title));
4343
}
44+
45+
// ── MatchesAnEnemy ─────────────────────────────────────────────────
46+
//
47+
// Heuristic detector for "user renamed this encounter in ACT".
48+
// ACT's right-click → Rename Encounter literally just assigns to
49+
// EncounterData.Title and leaves no audit trail (confirmed
50+
// empirically via reflection — every title-bearing property
51+
// mutates together). The only signal we have is that an
52+
// auto-derived title always matches an actual enemy combatant
53+
// in the fight; a rename usually doesn't.
54+
55+
[Theory]
56+
[InlineData("Pawbuster", "Pawbuster")] // exact match
57+
[InlineData("pawbuster", "Pawbuster")] // case-insensitive
58+
[InlineData(" Pawbuster ", "Pawbuster")] // trim both sides
59+
[InlineData("Pawbuster", "Pawbuster the Crusher")] // title is substring of enemy (EQ2 epithet)
60+
[InlineData("Pawbuster the Crusher", "Pawbuster")] // enemy is substring of title (ACT short-name)
61+
[InlineData("a krait patriarch", "a krait patriarch")] // EQ2 lowercase article-prefixed name
62+
[InlineData("Pawbuster (Wipe 1)", "Pawbuster")] // user-appended note still passes
63+
public void MatchesAnEnemy_TrueForRealisticAutoTitles(string title, string enemyName)
64+
{
65+
Assert.True(EncounterTitle.MatchesAnEnemy(title, new[] { enemyName }));
66+
}
67+
68+
[Fact]
69+
public void MatchesAnEnemy_TrueWhenAnyEnemyInListMatches()
70+
{
71+
// Multi-enemy fight — title only needs to match ONE of them.
72+
// Mirrors a boss + adds encounter where the title is the boss
73+
// and the adds are also in the combatant list.
74+
var enemies = new[] { "an adder", "a wisp", "Pawbuster the Crusher" };
75+
Assert.True(EncounterTitle.MatchesAnEnemy("Pawbuster", enemies));
76+
}
77+
78+
[Theory]
79+
[InlineData("TESTING", "a krait patriarch")] // user renamed to "TESTING" — the diagnostic-session smoking gun
80+
[InlineData("Pawbuster", "a brain magnate")] // title is a real boss but the fight was something else
81+
[InlineData("dps test", "training dummy")] // user labelling a parse run
82+
public void MatchesAnEnemy_FalseWhenTitleIsClearlyRenamed(string title, string enemyName)
83+
{
84+
Assert.False(EncounterTitle.MatchesAnEnemy(title, new[] { enemyName }));
85+
}
86+
87+
[Fact]
88+
public void MatchesAnEnemy_FalseForEmptyTitle()
89+
{
90+
// Empty / whitespace title shouldn't be considered a match
91+
// even if there's an empty-string enemy in the list. Belt-
92+
// and-brace — Plugin already filters placeholder titles via
93+
// IsPlaceholder before this check ever runs, but a future
94+
// call site might not.
95+
Assert.False(EncounterTitle.MatchesAnEnemy("", new[] { "Pawbuster" }));
96+
Assert.False(EncounterTitle.MatchesAnEnemy(" ", new[] { "Pawbuster" }));
97+
Assert.False(EncounterTitle.MatchesAnEnemy(null, new[] { "Pawbuster" }));
98+
}
99+
100+
[Fact]
101+
public void MatchesAnEnemy_FalseWhenNoEnemiesProvided()
102+
{
103+
// Caller must pass at least one enemy name for the heuristic
104+
// to mean anything. No enemies = nothing to compare against —
105+
// treat as "doesn't match" so the upload is gated.
106+
Assert.False(EncounterTitle.MatchesAnEnemy("Pawbuster", System.Array.Empty<string>()));
107+
Assert.False(EncounterTitle.MatchesAnEnemy("Pawbuster", new string?[] { null, "" }));
108+
Assert.False(EncounterTitle.MatchesAnEnemy("Pawbuster", null));
109+
}
110+
111+
[Fact]
112+
public void MatchesAnEnemy_SkipsNullAndEmptyEnemyEntries()
113+
{
114+
// A mix of legit + junk entries — the real ones still drive
115+
// the answer; null / whitespace shouldn't false-positive.
116+
var enemies = new string?[] { null, " ", "", "Pawbuster the Crusher" };
117+
Assert.True(EncounterTitle.MatchesAnEnemy("Pawbuster", enemies));
118+
}
44119
}
45120
}

0 commit comments

Comments
 (0)