Skip to content

Commit f3df6a1

Browse files
VortexUKclaude
andcommitted
v0.1.9: skip placeholder titles + right-click "Upload to EQ2 Lexicon"
Two user-visible changes: 1. EncounterCapture no longer uploads encounters titled "Encounter" (or "Unknown"/empty/whitespace). Observed on evac-cut fights: ACT marks the encounter complete before its EQ2 log scanner has named the mob, so the auto-upload was firing on a settled-but-untitled fight. New behaviour: * defer the capture up to MaxPlaceholderWaitSeconds (=60s after settle) hoping ACT fills the title in on a later poll * if still unresolved past the window, mark processed and raise OnSkipped with a "title never resolved past '...'" reason that the SettingsPanel surfaces, so the user knows a fight was dropped and why The placeholder predicate lives in new src/Core/EncounterTitle.cs (EncounterTitle.IsPlaceholder) — testable without ACT, shared by both the auto path and the new manual-upload path below. 2. New "Upload to EQ2 Lexicon" right-click menu item on ACT's encounter tree (src/ActMenuExtension.cs). ACT has no documented extension point for its context menu — implementation follows the ActStatter approach: walk the control tree by name to find the TreeView "tvDG" (5s after InitPlugin, since controls don't exist yet at init time), append a ToolStripMenuItem to its ContextMenuStrip, gate Enabled on whether a real encounter node (Tag == "EncounterData") is selected. Click handler runs Plugin.OnManualUploadRequested, which bypasses the blacklist + upload-enabled toggle (the click IS the opt-in) but still enforces: * placeholder title check (with a "rename in ACT first" error) * API token configured * version-not-too-old gate (server compat) * HMAC signing Reuses EncounterCapture.CaptureSnapshot (newly public-static) so the manual and auto paths produce byte-identical payloads. DeInitPlugin unsubscribes handlers + removes the menu item so ACT's plugin-disable/reenable cycle (used by its auto-updater) doesn't leave dead delegates or duplicate menu entries. 15 new tests pinning the placeholder set (EncounterTitleTests). Total 125/125. CLAUDE.md updated with the ACT UI extension wiring notes (tvDG, delayed attach, EncounterData tag, plugin-reload cleanup) and the manual-upload gate matrix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d5a753d commit f3df6a1

8 files changed

Lines changed: 496 additions & 4 deletions

File tree

CLAUDE.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ 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`, then calls `PayloadBuilder.BuildPayload``SanitizePayload``SerializeJson`. |
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). |
29+
| `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. |
30+
| `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. |
2931
| `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. |
3032
| `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). |
3133
| `src/Core/Snapshots.cs` | Plain DTOs (`EncounterSnapshot`, `CombatantSnapshot`, `DamageTypeAggregate`, `AttackTypeSnapshot`) that mirror the slice of ACT's data model the payload builder reads. |
@@ -140,6 +142,30 @@ Failure modes (offline, GitHub 5xx, rate-limit, malformed JSON) all collapse to
140142

141143
No caching of the GitHub response — request volume is at most a handful per user per day, well under the unauthenticated 60/h/IP limit, and re-fetching on every ACT start means a user who just installed an update sees the banner clear instantly after restarting ACT.
142144

145+
## ACT UI extension (v0.1.9+)
146+
147+
ACT has **no documented extension point for its context menu** — plugins reach into the WinForms control tree by name and mutate the existing `ContextMenuStrip`. Reference implementation: [ActStatter](https://github.com/eq2reapp/ActStatter/blob/main/StatterMain.cs). The wiring lives in `src/ActMenuExtension.cs`; the gotchas worth knowing if you ever extend it:
148+
149+
- **Encounter view is a TreeView named `tvDG`** — not `lvEncounters` or anything ListView-shaped. `ActGlobals.oFormActMain.MainTreeView` exists as a public property but is documented "do not enumerate" (nodes populate lazily on expand) — use the `tvDG` lookup instead.
150+
- **Controls don't exist at `InitPlugin` time** — ActMenuExtension uses a one-shot WinForms Timer with a 5s delay. If `tvDG` still isn't found at 5s the menu silently doesn't appear (rare; user can disable+reenable the plugin to retry).
151+
- **Encounter nodes are tagged with the literal string `"EncounterData"`**`GetSelectedEncounter()` walks up parents until it finds one. Zone nodes have a different Tag, and the "All" pseudo-encounter isn't a tagged tree node, so this filter is sufficient.
152+
- **Resolve via `ActGlobals.oFormActMain.ZoneList[parent.Index].Items[node.Index]`** — returns the live `EncounterData`.
153+
- **`DeInitPlugin` MUST unsubscribe handlers and `Items.Remove` the menu item** — ACT's auto-updater swaps plugin DLLs by disabling+reenabling, and leftover dead delegates accumulate (the menu item appears twice after a reload otherwise).
154+
- **Threading**: WinForms Timer Tick + `ContextMenuStrip.Opening` + Click all fire on the UI thread. No marshalling needed inside ActMenuExtension; the upload work is kicked to a worker by the callback handler in `Plugin.cs`.
155+
156+
### Manual upload gate matrix (`Plugin.OnManualUploadRequested`)
157+
158+
Different from the polling path — bypasses user opt-in gates because the click IS the opt-in:
159+
160+
| Gate | Manual upload | Auto upload |
161+
|---|---|---|
162+
| Blacklist (don't-upload-as) | **bypassed** | enforced |
163+
| "Enable automatic upload" checkbox | **bypassed** | enforced |
164+
| Placeholder title (`EncounterTitle.IsPlaceholder`) | enforced (rejects with "rename in ACT first") | deferred up to 60s, then skipped |
165+
| API token configured | enforced | enforced |
166+
| Version not too old | enforced | enforced |
167+
| HMAC signing | applied | applied |
168+
143169
## ACT API gotchas worth remembering
144170

145171
- `ActGlobals.charName` returns the string `"YOU"` for EQ2 — useless. Use `ActHelpers.GetLoggingCharacterName()` instead (parses the log filename).

src/ActMenuExtension.cs

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
using System;
2+
using System.ComponentModel;
3+
using System.Windows.Forms;
4+
using Advanced_Combat_Tracker;
5+
6+
namespace EQ2Lexicon.ACTPlugin
7+
{
8+
/// <summary>
9+
/// Adds an "Upload to EQ2 Lexicon" item to the right-click menu on
10+
/// ACT's encounter tree. Click → invokes the callback the Plugin
11+
/// supplies, which runs the manual-upload path (bypasses blacklist
12+
/// + upload-enabled toggle, still enforces token + version + HMAC +
13+
/// placeholder-title check).
14+
///
15+
/// ── ACT integration notes ──────────────────────────────────────────
16+
///
17+
/// ACT does NOT expose a documented extension point for its
18+
/// context menu. Plugins reach into the WinForms control tree by
19+
/// name and mutate the existing ContextMenuStrip. Reference
20+
/// implementation: ActStatter
21+
/// (https://github.com/eq2reapp/ActStatter/blob/main/StatterMain.cs).
22+
///
23+
/// * The encounter view is a TreeView named "tvDG" — NOT
24+
/// "lvEncounters" or anything ListView-shaped. The MainTreeView
25+
/// property exists but is documented as "do not enumerate"
26+
/// (nodes are populated lazily on expand).
27+
///
28+
/// * The controls do NOT exist at IActPluginV1.InitPlugin() time.
29+
/// A delayed WinForms Timer (5s) waits for ACT's UI to finish
30+
/// loading before walking the tree. One-shot — if tvDG still
31+
/// isn't there at 5s, the menu just won't appear (rare; the
32+
/// user can disable+reenable the plugin to retry).
33+
///
34+
/// * Each encounter TreeNode has its Tag set to the literal string
35+
/// "EncounterData". Zone nodes have a different tag. Walk up
36+
/// parents until we find one — handles right-clicking on a
37+
/// child element under an encounter node.
38+
///
39+
/// * Resolution: ActGlobals.oFormActMain.ZoneList[parent.Index]
40+
/// .Items[node.Index] returns the live EncounterData. ACT's
41+
/// "All" pseudo-encounter is not in the tree as a tagged node,
42+
/// so the Tag check filters it out for free.
43+
///
44+
/// * DeInitPlugin MUST unsubscribe the handlers and Remove the
45+
/// menu item. ACT's auto-updater swaps plugin DLLs at runtime by
46+
/// disabling + re-enabling — leftover dead delegates pile up
47+
/// otherwise (and your menu item appears twice after a reload).
48+
///
49+
/// Threading: WinForms Timer Tick fires on the UI thread, as do
50+
/// the ContextMenuStrip Opening + Click handlers. No marshalling
51+
/// needed for anything touched here. The callback to Plugin may
52+
/// kick off async upload work — that's Plugin's concern.
53+
/// </summary>
54+
internal sealed class ActMenuExtension : IDisposable
55+
{
56+
// Walked by name from ActGlobals.oFormActMain. ActStatter and
57+
// other EQ2 plugins use the same string — change only if a
58+
// future ACT release renames the control.
59+
private const string EncounterTreeName = "tvDG";
60+
61+
// 5s lines up with ActStatter's empirically-derived wait. Long
62+
// enough for ACT's UI to construct the encounter tree on cold
63+
// start; short enough that a user opening the plugin tab right
64+
// after launch doesn't feel a noticeable lag.
65+
private const int AttachDelayMs = 5000;
66+
67+
private readonly Action<EncounterData> _onUploadClicked;
68+
private readonly Timer _delayedAttach;
69+
70+
// Populated on successful attach. Null means we never found
71+
// the tree (rare) or we've been Disposed.
72+
private TreeView? _tvDG;
73+
private ContextMenuStrip? _menu;
74+
private ToolStripMenuItem? _menuItem;
75+
private CancelEventHandler? _openingHandler;
76+
77+
/// <summary>True once the menu item has been added to ACT's
78+
/// context menu. Useful for diagnostics if the menu doesn't
79+
/// appear (e.g. ACT changed the tvDG control name).</summary>
80+
public bool IsAttached => _menuItem != null;
81+
82+
public ActMenuExtension(Action<EncounterData> onUploadClicked)
83+
{
84+
_onUploadClicked = onUploadClicked ?? throw new ArgumentNullException(nameof(onUploadClicked));
85+
86+
_delayedAttach = new Timer { Interval = AttachDelayMs };
87+
_delayedAttach.Tick += (s, e) =>
88+
{
89+
_delayedAttach.Stop();
90+
TryAttach();
91+
};
92+
_delayedAttach.Start();
93+
}
94+
95+
private void TryAttach()
96+
{
97+
try
98+
{
99+
_tvDG = FindControl(ActGlobals.oFormActMain, EncounterTreeName) as TreeView;
100+
if (_tvDG?.ContextMenuStrip == null)
101+
{
102+
// Either tvDG doesn't exist (ACT changed?) or its
103+
// ContextMenuStrip hasn't been wired up. Don't
104+
// create our own — appending to ACT's keeps the
105+
// existing items (Clear, Properties, etc.) intact.
106+
return;
107+
}
108+
_menu = _tvDG.ContextMenuStrip;
109+
110+
_menuItem = new ToolStripMenuItem("Upload to EQ2 Lexicon");
111+
_menuItem.Click += OnMenuClick;
112+
_menu.Items.Add(_menuItem);
113+
114+
_openingHandler = OnMenuOpening;
115+
_menu.Opening += _openingHandler;
116+
}
117+
catch
118+
{
119+
// Menu-extension failures must never crash ACT. The
120+
// worst-case is "the right-click menu doesn't have our
121+
// item" — the auto-upload path still works regardless.
122+
}
123+
}
124+
125+
private void OnMenuOpening(object sender, CancelEventArgs e)
126+
{
127+
if (_menuItem == null) return;
128+
// Gray out when there's no real encounter selected (zone
129+
// node, empty area, etc.) so the user gets the right
130+
// affordance instead of clicking a no-op item.
131+
_menuItem.Enabled = GetSelectedEncounter() != null;
132+
}
133+
134+
private void OnMenuClick(object sender, EventArgs e)
135+
{
136+
var enc = GetSelectedEncounter();
137+
if (enc == null) return;
138+
try
139+
{
140+
_onUploadClicked(enc);
141+
}
142+
catch
143+
{
144+
// Plugin's callback is expected to surface failures via
145+
// SettingsPanel.SetUploadStatus — this is a final
146+
// safety net so a bug in the upload path never throws
147+
// an unhandled exception into ACT's UI thread.
148+
}
149+
}
150+
151+
/// <summary>
152+
/// Resolve the current TreeView selection to a real
153+
/// EncounterData, or null if the selection isn't on an
154+
/// encounter node (zone, empty area, control selected
155+
/// programmatically with a non-Encounter tag, etc.).
156+
/// </summary>
157+
private EncounterData? GetSelectedEncounter()
158+
{
159+
var tn = _tvDG?.SelectedNode;
160+
if (tn == null) return null;
161+
// Walk up to the EncounterData-tagged node. The user might
162+
// have right-clicked on a child element (some ACT plugins
163+
// nest more under each encounter).
164+
while (tn != null && !"EncounterData".Equals(tn.Tag))
165+
{
166+
tn = tn.Parent;
167+
}
168+
if (tn?.Parent == null) return null;
169+
try
170+
{
171+
return ActGlobals.oFormActMain?.ZoneList[tn.Parent.Index].Items[tn.Index];
172+
}
173+
catch
174+
{
175+
// Indices can briefly point at stale state if ACT
176+
// mutates ZoneList during our walk. Treat as "no
177+
// selection" rather than throwing — the user just
178+
// right-clicks again.
179+
return null;
180+
}
181+
}
182+
183+
/// <summary>
184+
/// Recursive depth-first walk of the WinForms control tree to
185+
/// find a child by Name. ACT's encounter tree is several
186+
/// nested SplitContainers deep so a single Controls[name]
187+
/// lookup won't find it.
188+
/// </summary>
189+
private static Control? FindControl(Control? parent, string name)
190+
{
191+
if (parent == null) return null;
192+
if (parent.Name == name) return parent;
193+
foreach (Control c in parent.Controls)
194+
{
195+
var found = FindControl(c, name);
196+
if (found != null) return found;
197+
}
198+
return null;
199+
}
200+
201+
public void Dispose()
202+
{
203+
try
204+
{
205+
_delayedAttach.Stop();
206+
_delayedAttach.Dispose();
207+
}
208+
catch { /* swallow */ }
209+
210+
try
211+
{
212+
if (_menuItem != null && _menu != null)
213+
{
214+
_menuItem.Click -= OnMenuClick;
215+
_menu.Items.Remove(_menuItem);
216+
_menuItem.Dispose();
217+
}
218+
if (_openingHandler != null && _menu != null)
219+
{
220+
_menu.Opening -= _openingHandler;
221+
}
222+
}
223+
catch
224+
{
225+
// Plugin disable/reenable cycle (or ACT shutdown) must
226+
// not throw. Dead delegates would be ugly but the
227+
// process is going down anyway in the shutdown case.
228+
}
229+
finally
230+
{
231+
_menuItem = null;
232+
_menu = null;
233+
_openingHandler = null;
234+
_tvDG = null;
235+
}
236+
}
237+
}
238+
}

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.8</Version>
21+
<Version>0.1.9</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: 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 encounter titles. Lives in Core (not the
7+
/// UI-coupled EncounterCapture) so tests can pin the placeholder
8+
/// list without needing ACT installed — the exact set quietly
9+
/// matters: a new placeholder ACT emits that we don't recognise
10+
/// would result in a real fight being uploaded under a useless name.
11+
/// </summary>
12+
public static class EncounterTitle
13+
{
14+
/// <summary>
15+
/// True for titles that mean "ACT hasn't filled this in yet".
16+
/// The auto-upload path defers placeholders for up to
17+
/// MaxPlaceholderWaitSeconds; the manual right-click path
18+
/// rejects them outright with a "rename it in ACT first" error.
19+
///
20+
/// Observed placeholders to date:
21+
/// * "Encounter" — fights cut short by /evac before ACT's
22+
/// EQ2 log scanner names the mob
23+
/// * "Unknown" — defensive; ACT has emitted this in old
24+
/// forum threads though we haven't seen it
25+
/// on the current EQ2 parser
26+
/// * null / empty / whitespace — equivalent to a placeholder
27+
///
28+
/// Case-insensitive; tolerant of surrounding whitespace.
29+
/// </summary>
30+
public static bool IsPlaceholder(string? title)
31+
{
32+
if (string.IsNullOrWhiteSpace(title)) return true;
33+
var t = title.Trim();
34+
return string.Equals(t, "Encounter", StringComparison.OrdinalIgnoreCase)
35+
|| string.Equals(t, "Unknown", 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.8</Version>
25+
<Version>0.1.9</Version>
2626
<Company>EQ2 Lexicon</Company>
2727
<Product>EQ2 Lexicon Uploader</Product>
2828
<Description>Uploads ACT-parsed encounters to the EQ2 Lexicon site.</Description>

0 commit comments

Comments
 (0)