-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathSpellTimerPlugin.cs
More file actions
303 lines (263 loc) · 12.9 KB
/
SpellTimerPlugin.cs
File metadata and controls
303 lines (263 loc) · 12.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
using System.Text;
using System.Text.RegularExpressions;
using Genie.Plugins;
namespace Genie.Plugins.SpellTimer;
/// <summary>
/// The V5 port of GenieClient/Plugin_SpellTimer (originally by UFTimmy for Genie 4).
/// Turns DR's <b>Active Spells</b> window into persistent script variables — exactly
/// the surface the Genie 4 plugin exposed — and re-renders the live list into the
/// host's "Active Spells" dock panel.
///
/// <para><b>How DR sends it.</b> The active-spells window is the <c>percWindow</c>
/// stream. Each refresh is a <c><clearStream id="percWindow"/></c> followed by
/// one <c><pushStream id="percWindow"/>Spell Name (N roisaen)</c> line per
/// currently-active spell. The full list is re-sent on every refresh, so a spell that
/// drops off simply stops appearing.</para>
///
/// <para><b>Why this is driven from <see cref="OnXml"/>.</b> The percWindow content
/// also surfaces through <see cref="OnGameText"/> (stream "percWindow"), but the host
/// dispatches text events and raw-XML events on two separate subscriptions, so their
/// relative order isn't guaranteed. Parsing the whole raw chunk in <see cref="OnXml"/>
/// keeps the <c>clearStream</c> boundary and its spell lines in their true on-the-wire
/// order within one string — which is what the "deactivate anything not in the latest
/// refresh" logic depends on. <see cref="OnGameText"/> is therefore observe-only.</para>
///
/// <para><b>Script surface (Genie 4 parity).</b> For each spell the plugin publishes
/// <c>$SpellTimer.<Spell>.active</c> (1/0), <c>$SpellTimer.<Spell>.duration</c>
/// (roisaen remaining), and, for Stellar Collector, <c>$SpellTimer.<Spell>.charge</c>.
/// The <c><Spell></c> token strips spaces, apostrophes and hyphens, matching the
/// Genie 4 variable names so existing <c>.cmd</c> scripts keep working:
/// <code>if (!$SpellTimer.ClearVision.active) then put cast cv</code></para>
///
/// Output is via the named-window seam, so the plugin has no UI/Avalonia dependency —
/// the host surfaces "Active Spells" as a dock panel. It references only
/// <c>Genie.Plugins.Abstractions</c> and loads as a DLL from the Plugins folder.
/// </summary>
public sealed class SpellTimerPlugin : IGeniePlugin
{
// ── Identity / metadata ────────────────────────────────────────────────────
public string Id => "genie.spelltimer";
public string Name => "Spell Timer";
public string Version => "1.0";
public string Author => "Original by UFTimmy (ported to Genie 5)";
public string Description => "Exposes the Active Spells window as $SpellTimer.* script variables and a dock panel.";
public string MinHostVersion => "5.0.0";
/// <summary>Named window the host surfaces as a dock panel (matches DR's own
/// percWindow title). First <see cref="IPluginHost.SetWindow"/> call creates it;
/// open it via Window → Active Spells.</summary>
private const string WindowName = "Active Spells";
private bool _enabled = true;
public bool Enabled
{
get => _enabled;
set
{
if (_enabled == value) return;
_enabled = value;
if (!value) _host?.SetWindow(WindowName, "(Spell Timer plugin disabled)");
else _dirty = true;
}
}
private IPluginHost _host = null!;
private bool _dirty;
/// <summary>Value object for one tracked spell. <c>Indefinite</c>/<c>OM</c> map to
/// <see cref="Indefinite"/>; a fading spell is active with duration 0.</summary>
private sealed class Spell
{
public required string Name;
public bool Active;
public int Duration; // roisaen remaining (Indefinite => 999)
public int Charge; // Stellar Collector charge %
public bool Fading;
}
private const int Indefinite = 999;
// Every spell currently known, keyed by display name.
private readonly Dictionary<string, Spell> _spells = new(StringComparer.Ordinal);
// Names seen since the last <clearStream id="percWindow"/> — the current refresh.
private readonly HashSet<string> _poppedThisRound = new(StringComparer.Ordinal);
// Walk a raw chunk for percWindow events in wire order: either a clearStream
// boundary, or a pushStream line carrying "Spell Name (duration)".
private static readonly Regex PercTokenRe = new(
"<clearStream id=\"percWindow\"\\s*/>" +
"|<pushStream id=\"percWindow\"\\s*/>([^<\\n]*)",
RegexOptions.Compiled);
// "Clear Vision (18 roisaen)" -> name = "Clear Vision", inside = "18 roisaen".
private static readonly Regex SpellLineRe = new(@"^(.+?)\s+\((.+)\)\s*$", RegexOptions.Compiled);
// DR uses the singular "roisan" (no 'e') for a 1-roisaen duration; "roisae?n"
// matches both "roisan" and "roisaen".
private static readonly Regex RoisaenRe = new(@"(\d+)\s+roisae?n", RegexOptions.Compiled);
private static readonly Regex PercentRe = new(@"(\d+)%", RegexOptions.Compiled);
public void Initialize(IPluginHost host) => _host = host;
public void Shutdown() { }
public void OnXml(string xml)
{
if (xml.IndexOf("percWindow", StringComparison.Ordinal) < 0) return;
var any = false;
foreach (Match m in PercTokenRe.Matches(xml))
{
if (!m.Groups[1].Success) // a clearStream boundary
{
FinishRound(); // deactivate anything the last refresh dropped
}
else // a pushStream spell line
{
ApplySpellLine(m.Groups[1].Value);
any = true;
}
}
if (any) _dirty = true;
}
/// <summary>The percWindow content also arrives here as stream "percWindow";
/// we drive everything from <see cref="OnXml"/> instead (see class remarks), so
/// this hook is observe-only.</summary>
public string? OnGameText(string text, string stream) => text;
public void OnPrompt()
{
if (!_dirty) return;
_dirty = false;
_host.SetWindow(WindowName, Render());
}
public string? OnInput(string input)
{
var t = input.Trim();
if (!t.StartsWith("/spelltimer", StringComparison.OrdinalIgnoreCase)) return input;
var active = _spells.Values.Where(s => s.Active).OrderBy(DurationSort).ToList();
var inactive = _spells.Values.Where(s => !s.Active).OrderBy(s => s.Name, StringComparer.Ordinal).ToList();
_host.Echo("Active spells:");
if (active.Count == 0) _host.Echo(" (none)");
foreach (var s in active) _host.Echo(" " + Line(s));
if (inactive.Count > 0)
{
_host.Echo("Inactive (recently seen):");
foreach (var s in inactive) _host.Echo(" " + s.Name);
}
return null; // swallow — it's a plugin command, not a game command
}
public void OnCommandSent(string command) { }
public void OnVariableChanged(string name, string v) { }
// ── parsing ─────────────────────────────────────────────────────────────────
private void ApplySpellLine(string raw)
{
var text = raw.Trim();
if (text.Length == 0) return;
string name;
var active = true;
var duration = 0;
var charge = 0;
var fading = false;
var m = SpellLineRe.Match(text);
if (m.Success)
{
name = m.Groups[1].Value.Trim();
var inside = m.Groups[2].Value.Trim();
if (name.Equals("Stellar Collector", StringComparison.Ordinal))
{
var pc = PercentRe.Match(inside);
if (pc.Success) charge = int.Parse(pc.Groups[1].Value);
}
else if (name.Equals("Osrel Meraud", StringComparison.Ordinal))
{
var pc = PercentRe.Match(inside);
if (pc.Success) duration = int.Parse(pc.Groups[1].Value);
}
else
{
var rs = RoisaenRe.Match(inside);
if (rs.Success)
duration = int.Parse(rs.Groups[1].Value);
else if (inside.Equals("OM", StringComparison.OrdinalIgnoreCase)
|| inside.Equals("Indefinite", StringComparison.OrdinalIgnoreCase))
duration = Indefinite;
else if (inside.Equals("Fading", StringComparison.OrdinalIgnoreCase))
fading = true; // active, but about to expire (duration 0)
}
}
else if (text.EndsWith("small orbiting slivers of lunar magic", StringComparison.Ordinal))
{
// Moonblade slivers report as a count phrase, not a (duration).
if (text.StartsWith("Many", StringComparison.Ordinal)) duration = 2;
else if (text.StartsWith("No", StringComparison.Ordinal)) { duration = 0; active = false; }
else duration = 1;
name = "Moonblade Slivers";
}
else
{
return; // not a line we recognise
}
var spell = Get(name);
spell.Active = active;
spell.Duration = duration;
spell.Charge = charge;
spell.Fading = fading;
_poppedThisRound.Add(name);
Publish(spell);
}
/// <summary>A <c>clearStream</c> closes the previous refresh: any spell we know
/// about that the latest refresh did NOT list has dropped off — mark it inactive.
/// Because DR sends the clear boundary <i>before</i> the spell lines of the new
/// refresh, deactivation is observed one refresh late (the Genie 4 behaviour). DR
/// re-sends the list every prompt, so a dropped spell clears within a second or
/// two — acceptable, and never flickers a still-active spell off.</summary>
private void FinishRound()
{
foreach (var spell in _spells.Values)
{
if (_poppedThisRound.Contains(spell.Name)) continue;
if (!spell.Active && spell.Duration == 0 && spell.Charge == 0) continue; // already clear
spell.Active = false;
spell.Duration = 0;
spell.Charge = 0;
spell.Fading = false;
Publish(spell);
}
_poppedThisRound.Clear();
}
private Spell Get(string name)
{
if (!_spells.TryGetValue(name, out var s))
_spells[name] = s = new Spell { Name = name };
return s;
}
/// <summary>Publish the Genie 4-compatible script globals for one spell. Only
/// writes on change to avoid churning the variable store / OnVariableChanged.</summary>
private void Publish(Spell s)
{
var v = Var(s.Name);
Set($"SpellTimer.{v}.active", s.Active ? "1" : "0");
Set($"SpellTimer.{v}.duration", s.Duration.ToString());
if (s.Name.Equals("Stellar Collector", StringComparison.Ordinal))
Set($"SpellTimer.{v}.charge", s.Charge.ToString());
}
private void Set(string name, string value)
{
if (!string.Equals(_host.GetVariable(name), value, StringComparison.Ordinal))
_host.SetVariable(name, value);
}
/// <summary>Spell name → variable token: strip spaces, apostrophes and hyphens,
/// matching Genie 4's $SpellTimer.<name> convention.</summary>
private static string Var(string name) =>
name.Replace(" ", "").Replace("'", "").Replace("-", "");
// ── rendering ───────────────────────────────────────────────────────────────
private static int DurationSort(Spell s) =>
s.Duration == Indefinite ? int.MaxValue : (s.Fading ? -1 : s.Duration);
private static string Line(Spell s)
{
var when = s.Fading ? "fading"
: s.Duration == Indefinite ? "indefinite"
: $"{s.Duration} roisaen";
if (s.Name.Equals("Stellar Collector", StringComparison.Ordinal) && s.Charge > 0)
when = $"{s.Charge}% charged";
return $"{s.Name,-22} {when}";
}
private string Render()
{
var active = _spells.Values.Where(s => s.Active).OrderBy(DurationSort).ToList();
var sb = new StringBuilder();
sb.Append("Active: ").Append(active.Count).Append('\n');
sb.Append("──────────────────────────────────────\n");
foreach (var s in active) sb.AppendLine(Line(s));
if (active.Count == 0)
sb.Append("(no active spells)");
return sb.ToString().TrimEnd();
}
}