Skip to content

Commit f20cab1

Browse files
VortexUKclaude
andcommitted
v0.1.10: skip imports + pre-existing fights + Import/Merge zone
Two reported bugs: 1. Importing a log file made the plugin auto-upload the historical fights it brought in. New rule: skip any encounter whose StartTime is more than InstanceStartGraceSeconds (= 5 min) before the plugin's own startup. Catches three scenarios with one check: * user enabled the plugin mid-session (pre-existing fights) * user imported a log file after enabling * ACT auto-loaded logs at startup before plugin init 5-minute grace accommodates an in-progress fight that started just before plugin-enable. The manual right-click upload path intentionally does NOT enforce this — it's the escape hatch for "yes actually, please upload that older one". 2. Merged/edited encounters in ACT's "Import/Merge" zone were uploadable. New rule: never upload from Import/Merge — in the auto path it's skipped with a visible reason, in the right-click menu it's greyed out, and Plugin.OnManualUploadRequested re-checks defensively in case a fast click races the Opening handler. Merged parses are user-customised (combining two real fights into an aggregate that doesn't correspond to any single in-game event) and would pollute the leaderboard with parses that didn't happen as shown. Implementation: * New src/Core/EncounterZone.cs with IsImportOrMerge(zoneName) — same pure-Core-testable-without-ACT pattern as EncounterTitle. * EncounterCapture.Poll: two new skip branches above the existing placeholder-title one. All three now share a tiny MarkProcessedNoCapture helper so the lock+queue+cap eviction isn't duplicated. * ActMenuExtension.OnMenuOpening: greys out the menu item for Import/Merge in addition to the existing "no real encounter selected" condition. * Plugin.OnManualUploadRequested: defensive Import/Merge re-check surfaced as "manual upload skipped (Import/Merge zone — ...)". CLAUDE.md updated: the manual-upload gate matrix now lists both new gates explicitly, and EncounterCapture's Key files entry calls out all three skip reasons. 13 new tests in EncounterZoneTests pinning the synthetic-zone string. Total 144/144. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4dc0808 commit f20cab1

8 files changed

Lines changed: 167 additions & 13 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ The split also unlocks GitHub Actions CI: the workflow at `.github/workflows/ci.
2525
|---|---|
2626
| `src/Plugin.cs` | `IActPluginV1` entry point. ACT calls `InitPlugin`/`DeInitPlugin`. Resolves the config path via `ActHelpers.GetConfigPath()` and passes it to `PluginConfig.Load`/`Save`. Owns lifecycles of `PluginConfig`, `SettingsPanel`, `EncounterCapture`, `UploadClient`. |
2727
| `src/SettingsPanel.cs` | WinForms settings UI hosted in ACT's plugin tab. Dark-theme card layout (`T` static class holds every colour). Three cards: Configuration / Logging As / Last Captured. Persistence happens via the `_onSave` callback so the panel stays path-free. |
28-
| `src/EncounterCapture.cs` | 2 s polling timer over `ActGlobals.oFormActMain.ActiveZone`. Detects "settled" encounters (no `EndTime` update for `SettleSeconds=15`), converts to `EncounterSnapshot` via the public static `CaptureSnapshot` (reused by the manual-upload path), then calls `PayloadBuilder.BuildPayload``SanitizePayload``SerializeJson`. Defers placeholder-titled encounters via `EncounterTitle.IsPlaceholder` for up to `MaxPlaceholderWaitSeconds=60` (then raises `OnSkipped` with a user-visible reason). |
28+
| `src/EncounterCapture.cs` | 2 s polling timer over `ActGlobals.oFormActMain.ActiveZone`. Detects "settled" encounters (no `EndTime` update for `SettleSeconds=15`), converts to `EncounterSnapshot` via the public static `CaptureSnapshot` (reused by the manual-upload path), then calls `PayloadBuilder.BuildPayload``SanitizePayload``SerializeJson`. Skip reasons (all raise `OnSkipped` with a user-visible message): Import/Merge zone, encounter started before `_instanceStartedAt - InstanceStartGraceSeconds` (= log imports + pre-existing fights), placeholder title that didn't resolve within `MaxPlaceholderWaitSeconds`. The three skip branches share `MarkProcessedNoCapture` so the encid eviction logic isn't duplicated. |
2929
| `src/ActMenuExtension.cs` | Adds "Upload to EQ2 Lexicon" to ACT's right-click encounter menu. See "ACT UI extension" below for the (undocumented) wiring details. |
3030
| `src/Core/EncounterTitle.cs` | `IsPlaceholder(title)` — the shared predicate used by both the polling and manual-upload paths to reject encounters ACT hasn't named yet ("Encounter", "Unknown", empty/whitespace). Lives in Core so it's testable without ACT. |
31+
| `src/Core/EncounterZone.cs` | `IsImportOrMerge(zoneName)` — the shared predicate used by the polling path, the right-click menu's Opening handler (greys out the item), and Plugin's manual-upload handler (defensive re-check). The Import/Merge bucket holds imported logs and merged/edited encounters that we must never upload. |
3132
| `src/ActHelpers.cs` | Reads the logging character name by parsing the active log file path (`eq2log_<name>.txt`). `ActGlobals.charName` returns `"YOU"` in EQ2 so it can't be used. Also owns `GetConfigPath()` — the `%APPDATA%\Advanced Combat Tracker\Config\` resolver kept here so `PluginConfig` stays ACT-free. |
3233
| `src/Core/PayloadBuilder.cs` | Pure transform: `EncounterSnapshot` → ingest-JSON dict shape. Owns `OutgoingGroupToSwingType`, `FormatTime` (emits ISO-8601 + Z), `SanitizePayload` (replaces NaN/∞ with 0), and `SerializeJson` (JavaScriptSerializer with an 8 MB ceiling). |
3334
| `src/Core/Snapshots.cs` | Plain DTOs (`EncounterSnapshot`, `CombatantSnapshot`, `DamageTypeAggregate`, `AttackTypeSnapshot`) that mirror the slice of ACT's data model the payload builder reads. |
@@ -161,6 +162,8 @@ Different from the polling path — bypasses user opt-in gates because the click
161162
|---|---|---|
162163
| Blacklist (don't-upload-as) | **bypassed** | enforced |
163164
| "Enable automatic upload" checkbox | **bypassed** | enforced |
165+
| Import/Merge zone (`EncounterZone.IsImportOrMerge`) | enforced (menu greyed + defensive re-check) | enforced (skipped with reason) |
166+
| Pre-plugin-startup encounter | **bypassed** (manual is the escape hatch) | enforced (skipped with reason) |
164167
| Placeholder title (`EncounterTitle.IsPlaceholder`) | enforced (rejects with "rename in ACT first") | deferred up to 60s, then skipped |
165168
| API token configured | enforced | enforced |
166169
| Version not too old | enforced | enforced |

src/ActMenuExtension.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,14 @@ private void OnMenuOpening(object sender, CancelEventArgs e)
128128
// Gray out when there's no real encounter selected (zone
129129
// node, empty area, etc.) so the user gets the right
130130
// affordance instead of clicking a no-op item.
131-
_menuItem.Enabled = GetSelectedEncounter() != null;
131+
//
132+
// Also greyed out for the Import/Merge zone — those are
133+
// user-customised parses (merged fights, imported logs)
134+
// that we never want uploaded. The click handler re-checks
135+
// defensively in case a fast click somehow races the
136+
// Opening event's enable computation.
137+
var enc = GetSelectedEncounter();
138+
_menuItem.Enabled = enc != null && !EncounterZone.IsImportOrMerge(enc.ZoneName);
132139
}
133140

134141
private void OnMenuClick(object sender, EventArgs e)

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.9</Version>
21+
<Version>0.1.10</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/EncounterZone.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using System;
2+
3+
namespace EQ2Lexicon.ACTPlugin
4+
{
5+
/// <summary>
6+
/// Classifies ACT zone names. Pure (no ACT references) so the test
7+
/// project can exercise the predicate set without ACT installed.
8+
/// </summary>
9+
public static class EncounterZone
10+
{
11+
/// <summary>
12+
/// True when the zone is ACT's synthetic "Import/Merge" bucket —
13+
/// where imported logs and merged/edited encounters end up. The
14+
/// plugin must never upload from this zone because:
15+
///
16+
/// * Merged encounters are USER-CUSTOMISED — combining two real
17+
/// fights produces an aggregate that doesn't correspond to
18+
/// any single in-game event. Uploading it would pollute the
19+
/// leaderboard with parses that didn't actually happen as
20+
/// shown.
21+
/// * Imported parses are old log replays — the user already
22+
/// had the chance to upload them when they happened. Doing
23+
/// it on import would back-date arbitrary historical fights.
24+
///
25+
/// Applied in both directions:
26+
/// * EncounterCapture.Poll skips with a visible reason.
27+
/// * ActMenuExtension.OnMenuOpening greys out the right-click
28+
/// menu item.
29+
/// * Plugin.OnManualUploadRequested re-checks defensively (in
30+
/// case a fast click races the menu's greyed-out state).
31+
/// </summary>
32+
public static bool IsImportOrMerge(string? zoneName)
33+
{
34+
if (string.IsNullOrWhiteSpace(zoneName)) return false;
35+
return string.Equals(zoneName.Trim(), "Import/Merge", StringComparison.OrdinalIgnoreCase);
36+
}
37+
}
38+
}

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.9</Version>
25+
<Version>0.1.10</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: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,25 @@ internal class EncounterCapture : IDisposable
4949
// UI so the user knows we didn't quietly drop a real fight.
5050
private const double MaxPlaceholderWaitSeconds = 60.0;
5151

52+
// Grace window for "is this encounter pre-existing or imported?".
53+
// An encounter whose StartTime is more than this far before
54+
// _instanceStartedAt is treated as not-ours: either it was already
55+
// in zone.Items when the plugin loaded (user enabled mid-session),
56+
// or the user just imported a log file with old fights. Either
57+
// way the auto path skips it — the manual right-click path stays
58+
// as the escape hatch.
59+
//
60+
// 5 minutes accommodates an in-progress fight that started
61+
// shortly before plugin-enable (we don't want to drop the current
62+
// raid pull just because the plugin loaded 30s into it).
63+
private const double InstanceStartGraceSeconds = 300.0;
64+
65+
// Stamped once in the ctor — used to draw the "before me" line.
66+
// DateTime.Now (local) because every other timestamp from ACT in
67+
// this class is also local (StartTime/EndTime are ACT-emitted
68+
// local-clock values).
69+
private readonly DateTime _instanceStartedAt = DateTime.Now;
70+
5271
// Last captured artefact — read by the settings panel for display.
5372
public string LastCapturedEncId { get; private set; } = "";
5473
public string LastCapturedTitle { get; private set; } = "";
@@ -118,6 +137,32 @@ private void Poll()
118137
// Not settled yet — ACT may still be appending actions.
119138
if ((now - enc.EndTime).TotalSeconds < SettleSeconds) continue;
120139

140+
// Import/Merge zone is ACT's bucket for imported logs
141+
// and merged/edited encounters. Never auto-upload — those
142+
// are user-customised, not authoritative parses. The
143+
// manual right-click path enforces this too (greyed out).
144+
if (EncounterZone.IsImportOrMerge(enc.ZoneName))
145+
{
146+
MarkProcessedNoCapture(encid);
147+
OnSkipped?.Invoke(
148+
$"skipped ({encid}: Import/Merge zone — customised parses aren't uploaded)");
149+
continue;
150+
}
151+
152+
// Pre-existing or freshly-imported encounter — the user
153+
// didn't actively play this one under our watch. Either
154+
// they enabled the plugin mid-session (these were already
155+
// here), or they just imported a log file. Auto path
156+
// skips; manual right-click upload still works as the
157+
// escape hatch for "yes actually, please upload that".
158+
if (enc.StartTime < _instanceStartedAt.AddSeconds(-InstanceStartGraceSeconds))
159+
{
160+
MarkProcessedNoCapture(encid);
161+
OnSkipped?.Invoke(
162+
$"skipped ({encid}: started before plugin enabled — likely import or pre-existing)");
163+
continue;
164+
}
165+
121166
// Placeholder title: defer in the hope ACT fills it in on
122167
// a later poll. Past MaxPlaceholderWaitSeconds, give up
123168
// and surface a skip reason so the user doesn't think the
@@ -129,15 +174,7 @@ private void Poll()
129174
// Don't add to _processed — retry next tick.
130175
continue;
131176
}
132-
lock (_lock)
133-
{
134-
_processed.Add(encid);
135-
_processedQueue.Enqueue(encid);
136-
while (_processedQueue.Count > ProcessedCap)
137-
{
138-
_processed.Remove(_processedQueue.Dequeue());
139-
}
140-
}
177+
MarkProcessedNoCapture(encid);
141178
OnSkipped?.Invoke(
142179
$"skipped ({encid}: title never resolved past '{enc.Title}')");
143180
continue;
@@ -147,6 +184,26 @@ private void Poll()
147184
}
148185
}
149186

187+
/// <summary>
188+
/// Add an encid to the processed set without producing a capture.
189+
/// Used by the three "skip reasons" branches above (Import/Merge
190+
/// zone, pre-plugin-startup encounter, placeholder title that
191+
/// never resolved) — all of them need to evict the encid from
192+
/// future poll iterations, none want to fire OnCaptured.
193+
/// </summary>
194+
private void MarkProcessedNoCapture(string encid)
195+
{
196+
lock (_lock)
197+
{
198+
_processed.Add(encid);
199+
_processedQueue.Enqueue(encid);
200+
while (_processedQueue.Count > ProcessedCap)
201+
{
202+
_processed.Remove(_processedQueue.Dequeue());
203+
}
204+
}
205+
}
206+
150207
// The placeholder-title predicate lives in Core
151208
// (EncounterTitle.IsPlaceholder) so the test project can
152209
// exercise it without ACT installed. Both the polling path

src/Plugin.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,18 @@ private void OnManualUploadRequested(EncounterData enc)
145145
{
146146
if (_settingsPanel == null || _config == null || _uploadClient == null) return;
147147

148+
// Defensive re-check of the Import/Merge gate even though
149+
// the menu item is greyed out for these encounters — a
150+
// fast click could in principle race the Opening handler's
151+
// enable computation.
152+
if (EncounterZone.IsImportOrMerge(enc.ZoneName))
153+
{
154+
_settingsPanel.SetUploadStatus(
155+
"manual upload skipped (Import/Merge zone — customised parses can't be uploaded)",
156+
success: false);
157+
return;
158+
}
159+
148160
// Placeholder check uses the SAME predicate as the
149161
// automatic path so the rule stays consistent. The
150162
// message tells the user how to fix it themselves.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using Xunit;
2+
3+
namespace EQ2Lexicon.ACTPlugin.Tests
4+
{
5+
/// <summary>
6+
/// Pins the synthetic-zone set. The auto-upload path AND the
7+
/// right-click manual path both call this predicate; a silent
8+
/// change here changes user-visible behaviour in both flows.
9+
/// </summary>
10+
public class EncounterZoneTests
11+
{
12+
[Theory]
13+
[InlineData("Import/Merge")] // exact ACT spelling
14+
[InlineData("import/merge")] // case-insensitive
15+
[InlineData("IMPORT/MERGE")]
16+
[InlineData(" Import/Merge ")] // whitespace tolerant
17+
public void IsImportOrMerge_TrueForTheSyntheticBucket(string zoneName)
18+
{
19+
Assert.True(EncounterZone.IsImportOrMerge(zoneName));
20+
}
21+
22+
[Theory]
23+
[InlineData("Great Divide")]
24+
[InlineData("The Peat Bog")]
25+
[InlineData("The Sinking Sands")]
26+
[InlineData("Import/Merge zone something")] // not a bare match
27+
[InlineData("Import")] // partial isn't enough
28+
[InlineData("Merge")]
29+
[InlineData("")] // empty zone name → real fight without zone metadata, allow
30+
[InlineData(" ")]
31+
[InlineData(null)]
32+
public void IsImportOrMerge_FalseForRealZonesAndEmpty(string? zoneName)
33+
{
34+
Assert.False(EncounterZone.IsImportOrMerge(zoneName));
35+
}
36+
}
37+
}

0 commit comments

Comments
 (0)