-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathLockTimerPlugin.cs
More file actions
403 lines (341 loc) · 14.4 KB
/
LockTimerPlugin.cs
File metadata and controls
403 lines (341 loc) · 14.4 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
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
using System.IO;
using System.Linq;
using DeadworksManaged.Api;
using LockTimer.Commands;
using LockTimer.Data;
using LockTimer.Hud;
using LockTimer.Records;
using LockTimer.Timing;
using LockTimer.Zones;
namespace LockTimer;
public class LockTimerPlugin : DeadworksPluginBase
{
public override string Name => "LockTimer";
private LockTimerDb? _db;
private ZoneRepository? _zones;
private RecordRepository? _records;
private ZoneRenderer? _renderer;
private TimerEngine? _engine;
private ZoneEditor? _editor;
private InteractiveEditor? _interactive;
private ChatCommands? _commands;
private SpeedHud? _speedHud;
private TimerHud? _timerHud;
private readonly Dictionary<int, ulong> _slotToSteamId = new();
private readonly Dictionary<int, long> _slotReadyAt = new();
private readonly Dictionary<int, long> _editConfirmCooldown = new();
private IHandle? _tickTimer;
private bool _zonesRendered;
private Zone? _startZone;
private Zone? _endZone;
public override void OnLoad(bool isReload)
{
try
{
var dir = Path.Combine(AppContext.BaseDirectory, "LockTimer");
Directory.CreateDirectory(dir);
var dbPath = Path.Combine(dir, "locktimer.db");
_db = LockTimerDb.Open(dbPath);
_zones = new ZoneRepository(_db.Connection);
_records = new RecordRepository(_db.Connection);
_renderer = new ZoneRenderer();
_engine = new TimerEngine();
_editor = new ZoneEditor(_zones, _engine);
_interactive = new InteractiveEditor(_editor);
_commands = new ChatCommands(_editor, _interactive, _renderer, _records, _engine);
_speedHud = new SpeedHud();
_timerHud = new TimerHud();
// Use Timer.Every instead of OnGameFrame to avoid per-tick native interop
// overhead that causes thread starvation and client timeouts during connection.
_tickTimer = Timer.Every(100.Milliseconds(), TickPlayers);
Console.WriteLine($"[{Name}] {(isReload ? "Reloaded" : "Loaded")}. DB: {dbPath}");
}
catch (Exception ex)
{
Console.WriteLine($"[{Name}] OnLoad failed: {ex}");
}
}
public override void OnUnload()
{
try
{
_tickTimer?.Cancel();
_renderer?.ClearAll();
_db?.Dispose();
Console.WriteLine($"[{Name}] Unloaded.");
}
catch (Exception ex)
{
Console.WriteLine($"[{Name}] OnUnload failed: {ex}");
}
}
public override void OnStartupServer()
{
try
{
if (_zones is null || _engine is null || _renderer is null) return;
_renderer.ClearAll();
_engine.ResetAll();
var map = Server.MapName;
if (string.IsNullOrEmpty(map)) return;
var zones = _zones.GetForMap(map);
var start = zones.FirstOrDefault(z => z.Kind == ZoneKind.Start);
var end = zones.FirstOrDefault(z => z.Kind == ZoneKind.End);
_engine.SetZones(start, end);
_commands?.SetSavedZones(start, end);
_startZone = start;
_endZone = end;
_zonesRendered = false;
Console.WriteLine($"[{Name}] Loaded {zones.Count} zone(s) for {map}.");
}
catch (Exception ex)
{
Console.WriteLine($"[{Name}] OnStartupServer failed: {ex}");
}
}
public override bool OnClientConnect(ClientConnectEvent args)
{
try
{
_slotToSteamId[args.Slot] = args.SteamId;
// Delay ticking this player for 5 seconds to let the pawn fully initialize.
// Accessing pawn.Position before initialization causes a native segfault.
_slotReadyAt[args.Slot] = Environment.TickCount64 + 5000;
}
catch (Exception ex)
{
Console.WriteLine($"[{Name}] OnClientConnect failed: {ex}");
}
return true;
}
public override void OnClientDisconnect(ClientDisconnectedEvent args)
{
try
{
_engine?.Remove(args.Slot);
_interactive?.Remove(args.Slot);
_editConfirmCooldown.Remove(args.Slot);
_speedHud?.Remove(args.Slot);
_timerHud?.Remove(args.Slot);
_slotToSteamId.Remove(args.Slot);
_slotReadyAt.Remove(args.Slot);
}
catch (Exception ex)
{
Console.WriteLine($"[{Name}] OnClientDisconnect failed: {ex}");
}
}
public override void OnAbilityAttempt(AbilityAttemptEvent args)
{
if (_interactive?.IsEditing(args.PlayerSlot) != true) return;
// Block all combat inputs while in editing mode
args.Block(InputButton.Attack | InputButton.Attack2);
args.BlockAllAbilities();
args.BlockAllItems();
// Update the cached aim position every frame. The physics raycast only
// works on the game thread (here), not from Timer callbacks.
var pawn = args.Controller?.GetHeroPawn();
if (pawn is not null)
_interactive.UpdateAim(args.PlayerSlot, pawn);
// Only react to attack press (rising edge)
if (!args.IsChanged(InputButton.Attack) || !args.IsHeld(InputButton.Attack)) return;
// Cooldown prevents double-fire: OnAbilityAttempt fires multiple times
// per frame (once per ability slot) with the same ChangedButtons state.
long now = Environment.TickCount64;
if (_editConfirmCooldown.TryGetValue(args.PlayerSlot, out var until) && now < until)
return;
_editConfirmCooldown[args.PlayerSlot] = now + 500;
var outcome = _interactive.Confirm(args.PlayerSlot);
switch (outcome)
{
case ConfirmOutcome.Corner1Set:
Chat.PrintToChat(args.PlayerSlot,
"[LockTimer] Corner 1 placed. Aim at the opposite corner and shoot.");
break;
case ConfirmOutcome.StartZoneReady:
_editConfirmCooldown.Remove(args.PlayerSlot);
AutoSaveZone(args.PlayerSlot, ZoneKind.Start);
break;
case ConfirmOutcome.EndZoneReady:
_editConfirmCooldown.Remove(args.PlayerSlot);
AutoSaveZone(args.PlayerSlot, ZoneKind.End);
break;
}
}
private void AutoSaveZone(int slot, ZoneKind kind)
{
if (_editor is null || _engine is null || _renderer is null) return;
var zone = _editor.SaveSingleZone(kind, Server.MapName, DateTimeOffset.UtcNow.ToUnixTimeSeconds());
if (zone is null)
{
Chat.PrintToChat(slot, $"[LockTimer] {kind} zone has zero volume — not saved.");
return;
}
// Update cached zones and engine
if (kind == ZoneKind.Start)
_startZone = zone;
else
_endZone = zone;
_engine.SetZones(_startZone, _endZone);
_commands?.SetSavedZones(_startZone, _endZone);
// Re-render all zones
_renderer.ClearAll();
if (_startZone is not null) _renderer.Render(_startZone);
if (_endZone is not null) _renderer.Render(_endZone);
Chat.PrintToChat(slot, $"[LockTimer] {kind} zone saved!");
}
private void TickPlayers()
{
if (_engine is null || _records is null) return;
try
{
long now = Environment.TickCount64;
foreach (var controller in Players.GetAll())
{
int slot = controller.EntityIndex - 1;
// Skip players whose pawn may not be fully initialized yet
if (_slotReadyAt.TryGetValue(slot, out var readyAt) && now < readyAt)
continue;
var pawn = controller.GetHeroPawn();
if (pawn is null) continue;
// Render zone markers once the first player is fully connected.
// Can't do this at server startup — entity system isn't ready yet.
if (!_zonesRendered && _renderer is not null)
{
_zonesRendered = true;
try
{
if (_startZone is not null) _renderer.Render(_startZone);
if (_endZone is not null) _renderer.Render(_endZone);
Console.WriteLine($"[{Name}] Zone markers rendered.");
}
catch (Exception ex)
{
Console.WriteLine($"[{Name}] Zone render failed: {ex}");
}
}
_speedHud?.Tick(slot, pawn);
_interactive?.Tick(slot);
var run = _engine.GetRun(slot);
// Paint the HUD before ticking the engine. On the finish tick the
// engine flips state→Idle and clears StartTickMs, so the HUD must
// render `now - StartTickMs` first — that value matches what the
// engine then returns as the finished time, and chat prints.
_timerHud?.Tick(slot, pawn, run, now);
var finished = _engine.Tick(slot, pawn.Position, now);
if (finished is null) continue;
OnRunFinished(controller, finished.Value);
}
}
catch (Exception ex)
{
Console.WriteLine($"[{Name}] TickPlayers failed: {ex}");
}
}
private void OnRunFinished(CCitadelPlayerController player, FinishedRun run)
{
if (_records is null) return;
// SteamId comes from the slot→steamid map populated by OnClientConnect.
int slot = player.EntityIndex - 1;
if (!_slotToSteamId.TryGetValue(slot, out var steamId))
return; // player has no tracked SteamId yet — skip the record write
var result = _records.UpsertIfFaster(
steamId: (long)steamId,
map: Server.MapName,
timeMs: run.ElapsedMs,
playerName: player.PlayerName,
nowUnix: DateTimeOffset.UtcNow.ToUnixTimeSeconds());
var formatted = TimeFormatter.FormatTime(run.ElapsedMs);
string msg;
if (result.Changed && result.PreviousMs is null)
msg = $"[LockTimer] {player.PlayerName} finished in {formatted} (new PB!)";
else if (result.Changed)
msg = $"[LockTimer] {player.PlayerName} finished in {formatted} " +
$"(new PB! prev {TimeFormatter.FormatTime(result.PreviousMs!.Value)})";
else
msg = $"[LockTimer] {player.PlayerName} finished in {formatted} " +
$"(pb {TimeFormatter.FormatTime(result.PreviousMs!.Value)})";
// Chat.SayToAll does not exist; use Chat.PrintToChatAll (Chat.cs line 25).
Chat.PrintToChatAll(msg);
}
// --- Chat command wrappers ---
// The plugin loader scans this class's methods for [ChatCommand] attributes
// (PluginLoader.ChatCommands.cs line 59). Each wrapper delegates to _commands.
[ChatCommand("start")]
public HookResult OnStartInteractive(ChatCommandContext ctx)
=> _commands?.OnStartInteractive(ctx) ?? HookResult.Continue;
[ChatCommand("end")]
public HookResult OnEndInteractive(ChatCommandContext ctx)
=> _commands?.OnEndInteractive(ctx) ?? HookResult.Continue;
[ChatCommand("cancel")]
public HookResult OnCancelEdit(ChatCommandContext ctx)
=> _commands?.OnCancelEdit(ctx) ?? HookResult.Continue;
[ChatCommand("start1")]
public HookResult OnStart1(ChatCommandContext ctx)
=> _commands?.OnStart1(ctx) ?? HookResult.Continue;
[ChatCommand("start2")]
public HookResult OnStart2(ChatCommandContext ctx)
=> _commands?.OnStart2(ctx) ?? HookResult.Continue;
[ChatCommand("end1")]
public HookResult OnEnd1(ChatCommandContext ctx)
=> _commands?.OnEnd1(ctx) ?? HookResult.Continue;
[ChatCommand("end2")]
public HookResult OnEnd2(ChatCommandContext ctx)
=> _commands?.OnEnd2(ctx) ?? HookResult.Continue;
[ChatCommand("savezones")]
public HookResult OnSaveZones(ChatCommandContext ctx)
=> _commands?.OnSaveZones(ctx) ?? HookResult.Continue;
[ChatCommand("delzones")]
public HookResult OnDelZones(ChatCommandContext ctx)
=> _commands?.OnDelZones(ctx) ?? HookResult.Continue;
[ChatCommand("zones")]
public HookResult OnZonesStatus(ChatCommandContext ctx)
=> _commands?.OnZonesStatus(ctx) ?? HookResult.Continue;
[ChatCommand("pb")]
public HookResult OnPb(ChatCommandContext ctx)
{
if (_commands is null) return HookResult.Continue;
int slot = ctx.Message.SenderSlot;
long sid = _slotToSteamId.TryGetValue(slot, out var s) ? (long)s : 0;
return _commands.OnPb(ctx, sid);
}
[ChatCommand("top")]
public HookResult OnTop(ChatCommandContext ctx)
=> _commands?.OnTop(ctx) ?? HookResult.Continue;
[ChatCommand("reset")]
public HookResult OnReset(ChatCommandContext ctx)
=> _commands?.OnReset(ctx) ?? HookResult.Continue;
[ChatCommand("pos")]
public HookResult OnPos(ChatCommandContext ctx)
{
var pawn = ctx.Controller?.GetHeroPawn();
if (pawn is null) return HookResult.Handled;
var p = pawn.Position;
Chat.PrintToChat(ctx.Message.SenderSlot,
$"[LockTimer] pos: ({p.X:F1}, {p.Y:F1}, {p.Z:F1})");
if (_startZone is not null)
{
var s = _startZone;
Chat.PrintToChat(ctx.Message.SenderSlot,
$"[LockTimer] start: ({s.Min.X:F1},{s.Min.Y:F1},{s.Min.Z:F1}) -> ({s.Max.X:F1},{s.Max.Y:F1},{s.Max.Z:F1}) in={s.Contains(p)}");
}
if (_endZone is not null)
{
var e = _endZone;
Chat.PrintToChat(ctx.Message.SenderSlot,
$"[LockTimer] end: ({e.Min.X:F1},{e.Min.Y:F1},{e.Min.Z:F1}) -> ({e.Max.X:F1},{e.Max.Y:F1},{e.Max.Z:F1}) in={e.Contains(p)}");
}
return HookResult.Handled;
}
[ChatCommand("speed")]
public HookResult OnSpeed(ChatCommandContext ctx)
{
if (_speedHud is null) return HookResult.Continue;
var pawn = ctx.Controller?.GetHeroPawn();
if (pawn is null) return HookResult.Handled;
int slot = ctx.Message.SenderSlot;
bool enabled = _speedHud.Toggle(slot, pawn);
Chat.PrintToChat(slot, $"[LockTimer] speed HUD {(enabled ? "enabled" : "disabled")}");
return HookResult.Handled;
}
}