Skip to content

Commit 871cdc0

Browse files
VortexUKclaude
andcommitted
detect EQ2 server from log path, stamp logger_server on uploads
EQ2 writes per-character logs at <install>/logs/<server>/eq2log_<character>.txt — the parent directory of the log file IS the server name. We already use the same path to extract the character name; this adds the server side of it. New src/Core/LogPathParser.cs with pure ParseServerName(logPath): * happy path (logs/Varsoon/...) → "Varsoon" * legacy generic log (logs/eq2log.txt, no server subdir) → "" * multi-word servers ("Antonia Bayle") pass through unmodified — Daybreak's Census API accepts them as-is (the directory naming convention isn't documented for multi-word servers; pinning the behaviour here means a future user report points the finger at the game's filesystem, not at our parser) * null/empty/bare-filename → "" ActHelpers.GetLoggingServerName() wraps it for the UI side. PayloadBuilder.BuildPayload now takes loggerServer alongside loggerName and emits a new top-level "logger_server" field. Both capture sites (auto-poll + manual right-click) pass the detected server through. SettingsPanel: "Currently logging as: Kayleigh (Varsoon)" — server name appended in parens when known, silently omitted otherwise so the user isn't drawn to a missing data point. Companion server-side change in EQ2Lexicon repo (commit 6c3b91f) uses logger_server to override EQ2_WORLD for the Census guild lookup, so a Varsoon-configured deployment correctly handles a Kaladim character. Backward compat preserved: server falls back to EQ2_WORLD when the field is missing or empty. 11 new LogPathParser tests + 1 new BuildPayload test. 156/156. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f20cab1 commit 871cdc0

9 files changed

Lines changed: 226 additions & 6 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ The split also unlocks GitHub Actions CI: the workflow at `.github/workflows/ci.
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. |
3131
| `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. |
32+
| `src/Core/LogPathParser.cs` | `ParseServerName(logPath)` — extracts the EQ2 server name (Varsoon, Kaladim, …) from ACT's active log file path. EQ2 writes logs at `<install>/logs/<server>/eq2log_<character>.txt`, so the parent directory IS the server. Returns "" for the legacy generic `logs/eq2log.txt` path (no server subdirectory) or anything else that doesn't fit; server falls back to `EQ2_WORLD` in that case. ActHelpers.GetLoggingServerName() is the UI-side wrapper. |
3233
| `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. |
3334
| `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). |
3435
| `src/Core/Snapshots.cs` | Plain DTOs (`EncounterSnapshot`, `CombatantSnapshot`, `DamageTypeAggregate`, `AttackTypeSnapshot`) that mirror the slice of ACT's data model the payload builder reads. |
@@ -172,6 +173,7 @@ Different from the polling path — bypasses user opt-in gates because the click
172173
## ACT API gotchas worth remembering
173174

174175
- `ActGlobals.charName` returns the string `"YOU"` for EQ2 — useless. Use `ActHelpers.GetLoggingCharacterName()` instead (parses the log filename).
176+
- `ActGlobals.oFormActMain.LogFilePath` is also the source of the EQ2 server name — EQ2 writes per-character logs into `<install>/logs/<server>/eq2log_<character>.txt`, so the parent directory of the log file is the server (Varsoon, Kaladim, etc.). Use `ActHelpers.GetLoggingServerName()` which wraps the pure `LogPathParser.ParseServerName`. Returns "" on the legacy `logs/eq2log.txt` path — server then falls back to its `EQ2_WORLD` env var.
175177
- `EncounterData.EndTime` is updated to the time of the last combat action continuously while a fight is in progress — it is NOT set only when the encounter ends. `EncounterCapture.Poll` uses a settle-time debounce (no EndTime change for `SettleSeconds` = 15 s) to detect "fight is actually over".
176178
- ACT puts a zone-aggregate pseudo-encounter with `Title="All"` at the front of `zone.Items`. Skip it — it duplicates real data and has no real mob.
177179
- `EncounterData.GetAllies()` returns ACT's authoritative ally list (the EQ2 parser already attributes pets to their owners). Don't try to write your own name-shape heuristic for player vs enemy.

src/ActHelpers.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,29 @@ public static string GetLoggingCharacterName()
5757
return "";
5858
}
5959

60+
/// <summary>
61+
/// Return the EQ2 server name (Varsoon, Kaladim, Butcherblock,
62+
/// etc.) derived from the active log file's parent directory.
63+
/// Empty when the path doesn't fit the per-server layout (user
64+
/// is on the legacy /logs/eq2log.txt path, ACT hasn't picked
65+
/// up a log yet, etc.) — server falls back to its EQ2_WORLD
66+
/// env-var default in that case.
67+
///
68+
/// Parsing logic lives in the Core <see cref="LogPathParser"/>
69+
/// so it's unit-testable without ACT.
70+
/// </summary>
71+
public static string GetLoggingServerName()
72+
{
73+
try
74+
{
75+
return LogPathParser.ParseServerName(ActGlobals.oFormActMain?.LogFilePath);
76+
}
77+
catch
78+
{
79+
return "";
80+
}
81+
}
82+
6083
/// <summary>
6184
/// Production location for the plugin config XML. Lives in
6285
/// %APPDATA%\Advanced Combat Tracker\Config\ so it survives ACT

src/Core/LogPathParser.cs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
using System;
2+
using System.IO;
3+
4+
namespace EQ2Lexicon.ACTPlugin
5+
{
6+
/// <summary>
7+
/// Pure parsers for ACT's active log file path. Lives in Core (no
8+
/// ACT references) so the path-shape rules are unit-testable
9+
/// without an ACT install.
10+
///
11+
/// EQ2's standard log layout is:
12+
/// &lt;install&gt;/logs/&lt;server&gt;/eq2log_&lt;character&gt;.txt
13+
///
14+
/// e.g. C:\Program Files\...\EverQuest II\logs\Varsoon\eq2log_Kayleigh.txt
15+
///
16+
/// The character-name parsing lives in ActHelpers because it has
17+
/// other fallbacks (ActGlobals.charName) that need ACT to be loaded.
18+
/// </summary>
19+
public static class LogPathParser
20+
{
21+
/// <summary>
22+
/// Extract the EQ2 server name from an ACT log file path.
23+
/// Returns "" when the path doesn't look like the per-server
24+
/// layout — caller treats that as "unknown" and the server
25+
/// falls back to its EQ2_WORLD env-var default.
26+
///
27+
/// Returns "" for:
28+
/// * null / whitespace / unparseable paths
29+
/// * the legacy generic log at &lt;install&gt;/logs/eq2log.txt
30+
/// (parent dir is "logs" — not a real server name)
31+
/// * paths where Path.GetDirectoryName / Path.GetFileName
32+
/// produce nothing usable
33+
///
34+
/// Server names with spaces (e.g. "Antonia Bayle") pass through
35+
/// unmodified — Daybreak's Census API accepts them as-is.
36+
/// </summary>
37+
public static string ParseServerName(string? logPath)
38+
{
39+
if (string.IsNullOrWhiteSpace(logPath)) return "";
40+
41+
string? dir;
42+
try
43+
{
44+
dir = Path.GetDirectoryName(logPath);
45+
}
46+
catch (ArgumentException)
47+
{
48+
// Path contains invalid characters — treat as unknown.
49+
return "";
50+
}
51+
if (string.IsNullOrEmpty(dir)) return "";
52+
53+
var name = Path.GetFileName(dir);
54+
if (string.IsNullOrEmpty(name)) return "";
55+
56+
// Legacy fallback: a user who hasn't run /log in-game gets
57+
// <install>/logs/eq2log.txt, where the parent directory is
58+
// literally "logs" rather than a server name. Don't stamp
59+
// "logs" onto uploads — return empty so the server falls
60+
// back to its configured default.
61+
if (name.Equals("logs", StringComparison.OrdinalIgnoreCase)) return "";
62+
63+
return name;
64+
}
65+
}
66+
}

src/Core/PayloadBuilder.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,26 @@ public static class PayloadBuilder
3434
{ "Threat (Out)", 100 },
3535
};
3636

37-
public static Dictionary<string, object?> BuildPayload(string loggerName, EncounterSnapshot enc)
37+
/// <summary>
38+
/// Build the upload payload.
39+
///
40+
/// <paramref name="loggerServer"/> is the EQ2 server name
41+
/// (Varsoon / Kaladim / Butcherblock / …) detected from the
42+
/// active log file's parent directory via
43+
/// <c>LogPathParser.ParseServerName</c>. Pass empty string when
44+
/// unknown — server falls back to its EQ2_WORLD env-var default
45+
/// and the field is sent as "" so old server versions that
46+
/// don't know about the field simply ignore it.
47+
/// </summary>
48+
public static Dictionary<string, object?> BuildPayload(
49+
string loggerName,
50+
string loggerServer,
51+
EncounterSnapshot enc)
3852
{
3953
return new Dictionary<string, object?>
4054
{
4155
["logger_name"] = loggerName,
56+
["logger_server"] = loggerServer ?? "",
4257
["encounter"] = BuildEncounter(enc),
4358
["combatants"] = BuildCombatants(enc),
4459
["damage_types"] = BuildDamageTypes(enc),

src/EncounterCapture.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,10 @@ private void ProcessEncounter(EncounterData enc, string encid)
215215
// ACT-coupled extraction separate from the pure transformation
216216
// so the latter is unit-testable.
217217
var snapshot = CaptureSnapshot(enc);
218-
var payload = PayloadBuilder.BuildPayload(ActHelpers.GetLoggingCharacterName(), snapshot);
218+
var payload = PayloadBuilder.BuildPayload(
219+
ActHelpers.GetLoggingCharacterName(),
220+
ActHelpers.GetLoggingServerName(),
221+
snapshot);
219222
PayloadBuilder.SanitizePayload(payload);
220223
var json = PayloadBuilder.SerializeJson(payload);
221224

src/Plugin.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,10 @@ private void OnManualUploadRequested(EncounterData enc)
185185
{
186186
var snapshot = EncounterCapture.CaptureSnapshot(enc);
187187
title = snapshot.Title;
188-
var payload = PayloadBuilder.BuildPayload(ActHelpers.GetLoggingCharacterName(), snapshot);
188+
var payload = PayloadBuilder.BuildPayload(
189+
ActHelpers.GetLoggingCharacterName(),
190+
ActHelpers.GetLoggingServerName(),
191+
snapshot);
189192
PayloadBuilder.SanitizePayload(payload);
190193
json = PayloadBuilder.SerializeJson(payload);
191194
}

src/SettingsPanel.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -555,10 +555,16 @@ private void SetHeaderStatus(string text, Color colour)
555555
/// Show the current ACT logging character at the top of the
556556
/// blacklist card so the user can easily see what name to add. Also
557557
/// drives the header pill colour.
558+
///
559+
/// Appends the detected EQ2 server in parens when known
560+
/// (e.g. "Kayleigh (Varsoon)") — derived from the log file's
561+
/// parent directory; empty/unknown is silently omitted rather
562+
/// than drawing attention to the missing data point.
558563
/// </summary>
559564
private void UpdateCurrentCharLabel()
560565
{
561566
var currentChar = ActHelpers.GetLoggingCharacterName();
567+
var server = ActHelpers.GetLoggingServerName();
562568

563569
if (string.IsNullOrWhiteSpace(currentChar))
564570
{
@@ -567,14 +573,18 @@ private void UpdateCurrentCharLabel()
567573
return;
568574
}
569575

576+
var nameWithServer = string.IsNullOrWhiteSpace(server)
577+
? currentChar
578+
: $"{currentChar} ({server})";
579+
570580
if (_config.IsBlacklisted(currentChar))
571581
{
572-
_currentCharLabel.Text = $"Currently logging as: {currentChar} — blacklisted";
582+
_currentCharLabel.Text = $"Currently logging as: {nameWithServer} — blacklisted";
573583
_currentCharLabel.ForeColor = T.Warning;
574584
}
575585
else
576586
{
577-
_currentCharLabel.Text = $"Currently logging as: {currentChar}";
587+
_currentCharLabel.Text = $"Currently logging as: {nameWithServer}";
578588
_currentCharLabel.ForeColor = T.Text;
579589
}
580590
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
using Xunit;
2+
3+
namespace EQ2Lexicon.ACTPlugin.Tests
4+
{
5+
/// <summary>
6+
/// Pins the EQ2 log-path → server-name parser. Tests use forward
7+
/// slashes in path inputs because Path.GetDirectoryName /
8+
/// Path.GetFileName accept both separators on Windows (the only
9+
/// platform ACT runs on); keeps test strings readable.
10+
/// </summary>
11+
public class LogPathParserTests
12+
{
13+
[Theory]
14+
[InlineData(@"C:\Program Files\Sony\EverQuest II\logs\Varsoon\eq2log_Kayleigh.txt", "Varsoon")]
15+
[InlineData(@"C:\Program Files\Sony\EverQuest II\logs\Kaladim\eq2log_Kayleigh.txt", "Kaladim")]
16+
[InlineData(@"C:\Program Files\Sony\EverQuest II\logs\Butcherblock\eq2log_SomeAlt.txt", "Butcherblock")]
17+
[InlineData(@"D:\Games\EQ2\logs\Nagafen\eq2log_PvP.txt", "Nagafen")]
18+
public void ParseServerName_HappyPath(string path, string expected)
19+
{
20+
Assert.Equal(expected, LogPathParser.ParseServerName(path));
21+
}
22+
23+
[Fact]
24+
public void ParseServerName_PassesMultiWordServersThrough()
25+
{
26+
// EQ2 wiki / fan sites don't conclusively document whether
27+
// "Antonia Bayle" appears with the space in the directory
28+
// name or stripped. We pass the directory name through
29+
// unmodified — Daybreak's Census API accepts world names
30+
// with spaces, so either form will work if the directory
31+
// matches. Pinning the behaviour here means a future user
32+
// report ("AB doesn't work") points the finger at the
33+
// game's filesystem choice, not at our parser.
34+
Assert.Equal("Antonia Bayle",
35+
LogPathParser.ParseServerName(@"C:\EQ2\logs\Antonia Bayle\eq2log_Char.txt"));
36+
}
37+
38+
[Fact]
39+
public void ParseServerName_ReturnsEmptyForLegacyGenericLog()
40+
{
41+
// User never ran /log in-game — EQ2 wrote to the generic
42+
// logs/eq2log.txt path. Parent directory is literally
43+
// "logs", which is not a server name. Server has to fall
44+
// back to its EQ2_WORLD default.
45+
Assert.Equal("",
46+
LogPathParser.ParseServerName(@"C:\Program Files\Sony\EverQuest II\logs\eq2log.txt"));
47+
}
48+
49+
[Fact]
50+
public void ParseServerName_CaseInsensitiveLogsDirectoryCheck()
51+
{
52+
// Some EQ2 installs may use lowercase, some uppercase.
53+
// The "logs" sentinel comparison must be case-insensitive.
54+
Assert.Equal("",
55+
LogPathParser.ParseServerName(@"C:\EQ2\Logs\eq2log.txt"));
56+
Assert.Equal("",
57+
LogPathParser.ParseServerName(@"C:\EQ2\LOGS\eq2log.txt"));
58+
}
59+
60+
[Theory]
61+
[InlineData(null)]
62+
[InlineData("")]
63+
[InlineData(" ")]
64+
public void ParseServerName_ReturnsEmptyForMissingPath(string? path)
65+
{
66+
Assert.Equal("", LogPathParser.ParseServerName(path));
67+
}
68+
69+
[Fact]
70+
public void ParseServerName_ReturnsEmptyForBareFilename()
71+
{
72+
// No directory component at all → nothing to extract.
73+
Assert.Equal("", LogPathParser.ParseServerName("eq2log_Kayleigh.txt"));
74+
}
75+
}
76+
}

tests/EQ2Lexicon.ACTPlugin.Tests/PayloadBuilderTests.cs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,15 +450,37 @@ public void BuildPayload_ProducesEveryTopLevelKey()
450450
enc.Combatants.Add(Player("Menludiir"));
451451
enc.AllyNames.Add("Menludiir");
452452

453-
var payload = PayloadBuilder.BuildPayload("Menludiir", enc);
453+
var payload = PayloadBuilder.BuildPayload("Menludiir", "Varsoon", enc);
454454

455455
Assert.Equal("Menludiir", payload["logger_name"]);
456+
Assert.Equal("Varsoon", payload["logger_server"]);
456457
Assert.IsType<Dictionary<string, object?>>(payload["encounter"]);
457458
Assert.IsType<List<Dictionary<string, object?>>>(payload["combatants"]);
458459
Assert.IsType<List<Dictionary<string, object?>>>(payload["damage_types"]);
459460
Assert.IsType<List<Dictionary<string, object?>>>(payload["attack_types"]);
460461
}
461462

463+
[Fact]
464+
public void BuildPayload_EmptyServerSentAsEmptyString()
465+
{
466+
// The plugin sends "" when the log path doesn't fit the
467+
// per-server layout (legacy generic log, no log picked up
468+
// yet). Server reads "" as "fall back to EQ2_WORLD".
469+
// Pinning the wire shape so the server's contract stays
470+
// unambiguous: it's always a string, never missing/null.
471+
var enc = MinimalEncounter();
472+
enc.Combatants.Add(Player("Menludiir"));
473+
enc.AllyNames.Add("Menludiir");
474+
475+
var payload = PayloadBuilder.BuildPayload("Menludiir", "", enc);
476+
Assert.Equal("", payload["logger_server"]);
477+
478+
// Null defensiveness — caller shouldn't pass null but if
479+
// they do we must not crash or emit JSON null.
480+
var payload2 = PayloadBuilder.BuildPayload("Menludiir", null!, enc);
481+
Assert.Equal("", payload2["logger_server"]);
482+
}
483+
462484
// ── OutgoingGroupToSwingType constant ───────────────────────────────
463485

464486
[Fact]

0 commit comments

Comments
 (0)