forked from zaphim12/RL2Archipelago
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathAPClient.cs
More file actions
1343 lines (1164 loc) · 58.1 KB
/
Copy pathAPClient.cs
File metadata and controls
1343 lines (1164 loc) · 58.1 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
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
using Archipelago.MultiClient.Net;
using Archipelago.MultiClient.Net.BounceFeatures.DeathLink;
using Archipelago.MultiClient.Net.Enums;
using Archipelago.MultiClient.Net.Helpers;
using Archipelago.MultiClient.Net.MessageLog.Messages;
using Archipelago.MultiClient.Net.Models;
using HarmonyLib;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using RL2Archipelago.Items;
using RL2Archipelago.Locations;
using RL2Archipelago.Patches;
using RL2Archipelago.Traps;
using RL2Archipelago.UI;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace RL2Archipelago;
public enum JournalChecksMode { Disabled = 0, Individual = 1, Grouped = 2 }
/// <summary>
/// High-level connection lifecycle state. Distinct from <see cref="APClient.IsConnected"/>
/// (a raw socket bool) so the UI can tell an in-progress reconnect from a clean
/// user-initiated disconnect.
/// </summary>
public enum APConnectionState { Disconnected = 0, Connected = 1, Reconnecting = 2 }
/// <summary>
/// Manages the Archipelago session lifecycle: connect, disconnect, item/message
/// event handling, and thread-safe main-thread dispatch.
/// </summary>
public static class APClient
{
public static ArchipelagoSession Session { get; private set; }
/// <summary>A list of options determined by the yaml which can modify the AP client's behavior. E.g. death_link, completion_criteria, etc.</summary>
public static Dictionary<string, object> SlotData { get; private set; }
/// <summary>True only when the websocket is live. Use for decisions that require a
/// real connection right now (sending to the server, queue-vs-send).</summary>
public static bool IsConnected => Session?.Socket?.Connected ?? false;
/// <summary>
/// True whenever an AP session is logically active, i.e. connected OR temporarily
/// reconnecting after a drop. Gameplay patches gate on this (rather than the raw
/// <see cref="IsConnected"/>) so checks keep being recorded locally and queued for
/// resync, and randomizer behavior stays consistent, while the connection is down.
/// </summary>
public static bool IsSessionActive => ConnectionState != APConnectionState.Disconnected;
/// <summary>True while an AP session's save directory is active; controls the SaveFileSystem path redirect.</summary>
public static bool APSaveActive { get; private set; }
/// <summary>Sanitized "{RoomId}_{SlotName}" used as the save subdirectory name.</summary>
public static string APSaveDirectoryName { get; private set; }
/// <summary>Persistent state for the active run: checked locations, received items, etc.</summary>
public static APRunState RunState { get; private set; }
/// <summary>Levels granted per manor upgrade item received. Read from slot data on connect.</summary>
public static int ManorUpgradeBundleSize { get; private set; } = 5;
/// <summary>Controls whether journal/memory reads generate location checks. Read from slot data on connect.</summary>
public static JournalChecksMode JournalChecksMode { get; private set; } = JournalChecksMode.Grouped;
/// <summary>Percent chance (1–100) a bronze chest triggers an AP check. Read from slot data on connect.</summary>
public static int BronzeChestApChance { get; private set; } = 15;
/// <summary>Percent chance (1–100) a silver chest triggers an AP check. Read from slot data on connect.</summary>
public static int SilverChestApChance { get; private set; } = 99;
/// <summary>Percent chance (1–100) a fairy chest triggers an AP check. Read from slot data on connect.</summary>
public static int FairyChestApChance { get; private set; } = 100;
/// <summary>True once the player has left the main menu and entered an active run. Item processing is gated on this flag.</summary>
public static bool IsInGame { get; internal set; } = false;
/// <summary>True when death_link is enabled for this slot.</summary>
public static bool DeathLinkEnabled { get; private set; }
/// <summary>True when the randomize_starting_class option is on for this seed.</summary>
public static bool RandomizeStartingClass { get; private set; } = false;
/// <summary>
/// Index of the chosen starting class: 0 = Knight (vanilla), 1–14 = one of the 14
/// non-Knight classes in ManorSlots[44]–ManorSlots[57] order.
/// </summary>
public static int StartingClassIndex { get; private set; } = 0;
/// <summary>
/// True once the Knight Class item has been received from the multiworld.
/// When false (and RandomizeStartingClass is on), Knight is excluded from available heirs.
/// </summary>
public static bool KnightClassReceived { get; private set; } = false;
/// <summary>
/// Pre-computed gold costs for each manor upgrade slot, keyed by <see cref="SkillTreeType"/>.
/// Populated from slot data on connect. Empty when not connected.
/// </summary>
public static IReadOnlyDictionary<SkillTreeType, int> ManorUpgradeCosts { get; private set; }
= new Dictionary<SkillTreeType, int>();
/// <summary>True while we are applying an incoming death beacon, so the death patch skips sending an echo.</summary>
internal static bool IsReceivingDeathLink { get; private set; }
private static DeathLinkService _deathLinkService;
private static string _slotName;
// Our own player slot number, cached on (re)connect so send notifications can resolve
// "found by yourself" vs "sent to X" even while disconnected (Session may be null then).
private static int _ourSlot = -1;
// Incoming death beacon queued for main-thread application. Written by the websocket thread,
// read by the main thread; volatile ensures the flag write is visible after the string fields.
private static volatile bool _pendingDeathLink;
private static string _pendingDeathSource;
private static string _pendingDeathCause;
// Profile slot that was active before AP mode was entered; restored on disconnect.
private static byte _previousProfile;
/// <summary>Fired on the main thread after a successful login.</summary>
public static event Action<ArchipelagoSession> OnSessionOpened;
/// <summary>Fired on the main thread when a session is manually closed or the application is closed.</summary>
public static event Action OnSessionClosed;
// Items received on the AP websocket thread; drained each Update() tick on
// the Unity main thread so game-state mutations are thread-safe. The display
// fields are captured here (not at grant time) because they're sourced from
// the ItemInfo we dequeue, which lives on the websocket thread.
private struct PendingItem
{
public int Index;
public long ItemId;
public string ItemDisplayName;
public int SourceSlot;
public string SourcePlayerName;
}
private static readonly ConcurrentQueue<PendingItem> _pendingItems = new();
// Scouted item info keyed by location ID. Populated asynchronously after login so
// in-world graphics (e.g. heirloom pedestals) can show what item will drop at a
// given location. Cleared on disconnect.
private static readonly ConcurrentDictionary<long, ScoutedItemInfo> _scoutedItems = new();
// Tracks the next item index to assign. Reset to 0 on each connect because
// AllItems causes the server to replay from index 0 on every reconnect.
// Tracked locally because the server's index stores the total number of items,
// not the index of the item's being received. So when receiving items after a reconnect,
// the server's index will be higher than the index of the item being received until we catch up.
private static int _nextItemIndex = 0;
// Trap state lives only in memory, so it's lost on game restart. This flag
// gates a one-time restore of RunState.ActiveTraps on the first ProcessPendingItems
// tick after entering a game; reset on each Connect so a fresh session restores correctly.
private static bool _trapsRestored = false;
// ── Connection lifecycle / auto-reconnect ───────────────────────────────────
/// <summary>High-level connection state used by the UI (e.g. the disconnect overlay).</summary>
public static APConnectionState ConnectionState
{
get => _connectionState;
private set => _connectionState = value;
}
private static volatile APConnectionState _connectionState = APConnectionState.Disconnected;
// True from the moment a user-initiated Disconnect() begins until the next Connect().
// Lets the SocketClosed handler and reconnect worker distinguish an intentional
// teardown from a dropped connection so they don't fight a manual disconnect.
private static volatile bool _intentionalDisconnect;
// The connection parameters for the active session, captured on Connect() so the
// background reconnect worker can re-establish the session after a drop.
private static APConnectionData _activeConnData;
// Guards RunState.CheckedLocations against concurrent access: the main thread adds
// checks via SendLocationCheck while the reconnect worker reads them in Resync.
private static readonly object _checkedLock = new object();
// 0 = no reconnect worker running, 1 = running. Toggled via Interlocked so only one
// backoff loop exists at a time.
private static int _reconnectWorkerRunning;
private static readonly Random _reconnectRng = new Random();
private const int ReconnectBaseDelayMs = 2000;
private const int ReconnectMaxDelayMs = 30000;
// Client-side cap on a single connect+login attempt. The sync TryConnectAndLogin can
// block indefinitely if the TCP socket connects but the AP handshake never completes,
// which would stall the whole reconnect loop on one attempt.
private const int ConnectTimeoutMs = 10000;
// Set by the reconnect worker (background thread) on success; drained on the main
// thread by ProcessConnectionEvents() so the toast is raised where Unity is safe.
private static volatile bool _pendingReconnectedToast;
// ── Public API ─────────────────────────────────────────────────────────────
/// <summary>
/// Attempts to connect and login to the Archipelago server described by
/// <paramref name="connData"/>. Both callbacks are invoked on the calling
/// thread (Unity main thread when triggered from UI code).
/// </summary>
public static void Connect(
APConnectionData connData,
Action onSuccess,
Action<string> onFailure)
{
Plugin.Log.LogInfo(
$"Connecting to AP server {connData.Hostname}:{connData.Port} " +
$"as slot \"{connData.SlotName}\"");
try
{
// Tear down any existing session cleanly before creating a new one.
if (IsConnected || ConnectionState != APConnectionState.Disconnected)
Disconnect(manual: false);
// Clear the intentional-disconnect latch the teardown above set, so the
// SocketClosed handler will react to drops on this new session.
_intentionalDisconnect = false;
if (!OpenSessionAndLogin(connData, out var loginError))
{
Plugin.Log.LogError($"AP login failed:\n{loginError}");
onFailure?.Invoke(loginError);
return;
}
_activeConnData = connData;
// ── One-time per-session setup (slot data, save redirect, run state). ──
// The reconnect worker skips all of this; it reuses the state established here.
if (SlotData.TryGetValue("blueprint_checks_per_biome", out var bpCountObj))
LocationRegistry.SetBlueprintChecksPerBiome(Convert.ToInt32(bpCountObj));
else
LocationRegistry.SetBlueprintChecksPerBiome(11);
if (SlotData.TryGetValue("rune_checks_per_biome", out var runeCountObj))
LocationRegistry.SetRuneChecksPerBiome(Convert.ToInt32(runeCountObj));
else
LocationRegistry.SetRuneChecksPerBiome(4);
BronzeChestApChance = SlotData.TryGetValue("bronze_chest_ap_chance", out var bcObj)
? Convert.ToInt32(bcObj) : 15;
SilverChestApChance = SlotData.TryGetValue("silver_chest_ap_chance", out var scObj)
? Convert.ToInt32(scObj) : 99;
FairyChestApChance = SlotData.TryGetValue("fairy_chest_ap_chance", out var fcObj)
? Convert.ToInt32(fcObj) : 100;
ManorUpgradeBundleSize = SlotData.TryGetValue("manor_upgrade_bundle_size", out var bundleSizeObj)
? Convert.ToInt32(bundleSizeObj) : 5;
JournalChecksMode = SlotData.TryGetValue("journal_checks", out var jObj)
? (JournalChecksMode)Convert.ToInt32(jObj) : JournalChecksMode.Grouped;
_slotName = connData.SlotName;
bool serverDeathLink = SlotData.TryGetValue("death_link", out var dlObj) && Convert.ToInt32(dlObj) != 0;
if (SlotData.TryGetValue("manor_upgrade_costs", out var costsObj) && costsObj is JArray costsArray)
{
var types = ItemRegistry.SkillTreeTypes;
var map = new Dictionary<SkillTreeType, int>(types.Count);
int i = 0;
foreach (var token in costsArray)
{
if (i >= types.Count) break;
map[types[i++]] = token.Value<int>();
}
ManorUpgradeCosts = map;
}
Plugin.Log.LogInfo(
$"Connected! Room: {Session.RoomState.Seed} " +
$"Slot data keys: {string.Join(", ", SlotData.Keys)}");
// Redirect all save I/O to a directory scoped to this room + slot.
_previousProfile = SaveManager.ConfigData.CurrentProfile;
APSaveDirectoryName = SanitizeDirectoryName($"{connData.RoomId}_{connData.SlotName}");
APSaveActive = true;
SaveManager.ConfigData.CurrentProfile = 0;
EnsureRedirectedSaveDirectories();
SaveManager.LoadCurrentProfileData();
Plugin.Log.LogInfo($"[AP] Save redirected to AP_Saves/{APSaveDirectoryName}");
// Load any prior run state (checked locations, etc.) for this seed+slot.
RunState = APRunState.Load(APSaveDirectoryName);
_trapsRestored = false;
RandomizeStartingClass = SlotData.TryGetValue("randomize_starting_class", out var rscObj)
&& Convert.ToInt32(rscObj) != 0;
StartingClassIndex = SlotData.TryGetValue("starting_class_index", out var sciObj)
? Convert.ToInt32(sciObj) : 0;
KnightClassReceived = RunState.KnightClassReceived;
// Load per-seed player settings; user override wins over server value.
APSettings.DeathLink = null;
LoadPlayerSettings(APSaveDirectoryName);
DeathLinkEnabled = APSettings.DeathLink ?? serverDeathLink;
// ── Per-(re)connect wiring: item/message handlers, DeathLink service,
// check resync, and location scouting. The reconnect worker runs the
// same helper after re-establishing a session. ──
WirePostLogin();
// Fire the session-opened event and the caller's success callback on
// the main thread. We're already on the main thread here (called from UI),
// so invoke directly.
OnSessionOpened?.Invoke(Session);
onSuccess?.Invoke();
}
catch (Exception ex)
{
Plugin.Log.LogError($"AP connection threw an exception:\n{ex.Message}\n{ex.StackTrace}");
Session = null;
onFailure?.Invoke(ex.Message);
}
}
/// <summary>
/// Creates a fresh session, wires the diagnostic + lifecycle socket handlers,
/// and attempts login. On success assigns <see cref="Session"/> / <see cref="SlotData"/>,
/// stamps <c>connData.RoomId</c>, and returns true. On failure tears the failed
/// session down, sets <paramref name="error"/>, and returns false.
/// Shared by the initial <see cref="Connect"/> and the reconnect worker.
/// </summary>
private static bool OpenSessionAndLogin(APConnectionData connData, out string error)
{
error = null;
Session = ArchipelagoSessionFactory.CreateSession(connData.Hostname, connData.Port);
// Hook diagnostics + lifecycle before TryConnectAndLogin so we capture events
// that fire during the handshake. SocketClosed drives the auto-reconnect.
Session.Socket.SocketOpened += APSession_SocketOpened;
Session.Socket.ErrorReceived += APSession_ErrorReceived;
Session.Socket.SocketClosed += APSession_SocketClosed;
Plugin.Log.LogDebug("[AP] Calling TryConnectAndLogin...");
// Run the blocking login on a worker task with a client-side timeout: TryConnectAndLogin
// can block forever when the TCP socket connects but the AP handshake never finishes,
// which would hang the single reconnect worker and stall every later attempt. Aborting
// the session on timeout unblocks the abandoned task shortly after.
LoginResult loginResult;
try
{
var session = Session;
var loginTask = Task.Run(() => session.TryConnectAndLogin(
"Rogue Legacy 2",
connData.SlotName,
ItemsHandlingFlags.AllItems,
password: string.IsNullOrEmpty(connData.Password) ? null : connData.Password,
requestSlotData: true));
if (!loginTask.Wait(ConnectTimeoutMs))
{
error = $"Connect attempt timed out after {ConnectTimeoutMs}ms.";
Plugin.Log.LogDebug($"[AP] {error} Abandoning attempt.");
ObserveFault(loginTask, "Abandoned login"); // don't let the orphan task fault unobserved
DetachAndAbort(Session);
Session = null;
return false;
}
loginResult = loginTask.Result;
}
catch (Exception ex)
{
error = ex.InnerException?.Message ?? ex.Message;
DetachAndAbort(Session);
Session = null;
return false;
}
Session.Socket.SocketOpened -= APSession_SocketOpened;
Plugin.Log.LogDebug($"[AP] TryConnectAndLogin returned. Successful={loginResult.Successful}");
if (!loginResult.Successful)
{
error = string.Join("\n", ((LoginFailure)loginResult).Errors);
// Abort (not just detach): leaving a half-open socket behind makes the *next*
// reconnect attempt hang instead of timing out cleanly.
DetachAndAbort(Session);
Session = null;
return false;
}
SlotData = ((LoginSuccessful)loginResult).SlotData;
// Persist the room ID so we can warn on multiworld mismatch later.
connData.RoomId = Session.RoomState.Seed;
return true;
}
/// <summary>
/// Per-(re)connect wiring that must run every time a session is established:
/// websocket item/message handlers, the DeathLink service, check resync, and
/// location scouting. Assumes the one-time setup (slot-data parsing, save
/// redirect, RunState load, <see cref="DeathLinkEnabled"/>) has already happened.
/// Ends by marking the connection <see cref="APConnectionState.Connected"/>.
/// May run on the reconnect worker thread; touches only network/library state.
/// </summary>
private static void WirePostLogin()
{
// Reset the index BEFORE wiring/draining so concurrently-arriving items get
// correct indices. AllItems replays from index 0 on every (re)connect; the
// GrantedItemCount guard in ProcessPendingItems skips already-applied items.
_nextItemIndex = 0;
Session.Items.ItemReceived += APSession_ItemReceived;
Session.MessageLog.OnMessageReceived += APSession_OnMessageReceived;
// Manually drain items the server sent during the handshake before the handler
// was wired; those sit in the library's buffer unfired.
APSession_ItemReceived(Session.Items);
// Recreate the DeathLink service against the new session.
// Cache our slot so notifications work without a live Session during a drop.
_ourSlot = Session.ConnectionInfo.Slot;
_deathLinkService = Session.CreateDeathLinkService();
if (DeathLinkEnabled)
{
_deathLinkService.OnDeathLinkReceived += APSession_DeathLinkReceived;
_deathLinkService.EnableDeathLink();
Plugin.Log.LogInfo("[AP] DeathLink enabled.");
}
// Reconcile local and server state. If the client recorded a check that never
// made it to the server (network drop mid-send, or checks made while offline),
// resend it now so the multiworld stays consistent.
ResyncCheckedLocations();
// Re-send goal completion if the final boss was beaten while disconnected.
// SetGoalAchieved is idempotent server-side.
if (RunState?.GoalAchieved == true)
{
try
{
Session.SetGoalAchieved();
Plugin.Log.LogInfo("[AP] Re-sent goal completion after reconnect.");
}
catch (Exception ex)
{
Plugin.Log.LogError($"[AP] Failed to re-send goal completion: {ex.Message}");
}
}
// Scout every unchecked tracked location so in-world graphics (e.g. heirloom
// pedestals) can show the item that will drop there.
ScoutTrackedLocations();
ConnectionState = APConnectionState.Connected;
}
private static void APSession_SocketClosed(string reason) => HandleConnectionDropped(reason);
/// <summary>Observes a fire-and-forget task so a fault is logged here instead of
/// surfacing later as an unobserved TaskScheduler exception at GC time.</summary>
private static void ObserveFault(Task task, string what) =>
task?.ContinueWith(
t => Plugin.Log.LogWarning($"[AP] {what} failed: {t.Exception?.Flatten().Message}"),
TaskContinuationOptions.OnlyOnFaulted);
/// <summary>Detaches our handlers from a dead/abandoned session and aborts its socket so
/// its polling loop exits instead of looping on errors. Safe to call with a null session.</summary>
private static void DetachAndAbort(ArchipelagoSession dead)
{
if (dead == null) return;
dead.Socket.SocketOpened -= APSession_SocketOpened;
dead.Items.ItemReceived -= APSession_ItemReceived;
dead.MessageLog.OnMessageReceived -= APSession_OnMessageReceived;
dead.Socket.ErrorReceived -= APSession_ErrorReceived;
dead.Socket.SocketClosed -= APSession_SocketClosed;
try { ObserveFault(dead.Socket.DisconnectAsync(), "Socket abort"); } catch { /* best-effort */ }
}
/// <summary>
/// Handles a dropped connection on the websocket thread. Triggered both by
/// <see cref="IArchipelagoSocketHelper.SocketClosed"/> and by a fatal
/// <see cref="IArchipelagoSocketHelper.ErrorReceived"/>. The MultiClient.Net polling
/// loop does not always raise SocketClosed on a forcible close; it can instead spin
/// firing ErrorReceived, so we must react to the error too.
///
/// <para>Only the first event while <see cref="APConnectionState.Connected"/> acts:
/// it transitions to Reconnecting, detaches and aborts the dead session (so its
/// polling loop stops spamming), and starts the backoff worker. Local state
/// (checked locations, scout cache, save redirect) is left intact so play
/// continues offline.</para>
/// </summary>
private static void HandleConnectionDropped(string reason)
{
if (_intentionalDisconnect) return;
// Only a live session going down should start a reconnect. Ignore errors while
// Disconnected (no session) or Reconnecting (already handling). This also
// collapses the rapid ErrorReceived spam down to a single reconnect trigger.
if (ConnectionState != APConnectionState.Connected) return;
ConnectionState = APConnectionState.Reconnecting;
Plugin.Log.LogWarning($"[AP] Connection lost: {reason}. Attempting to reconnect.");
// Detach + abort the dead session so its polling loop stops firing into us.
DetachAndAbort(Session);
StartReconnectWorker();
}
/// <summary>
/// Launches (at most one) background task that retries the connection with
/// exponential backoff + jitter, capped at <see cref="ReconnectMaxDelayMs"/>,
/// until it reconnects or the player manually disconnects. Runs off the main
/// thread because <see cref="ArchipelagoSession.TryConnectAndLogin"/> blocks.
/// </summary>
private static void StartReconnectWorker()
{
if (Interlocked.CompareExchange(ref _reconnectWorkerRunning, 1, 0) != 0)
return; // a worker is already running
Task.Run(() =>
{
try
{
int attempt = 0;
while (ConnectionState == APConnectionState.Reconnecting && !_intentionalDisconnect)
{
int delay = (int)Math.Min(
ReconnectBaseDelayMs * Math.Pow(2, attempt), ReconnectMaxDelayMs);
// ±20% jitter so many clients reconnecting at once don't sync up.
int jitter = delay / 5;
delay += _reconnectRng.Next(-jitter, jitter + 1);
Thread.Sleep(Math.Max(500, delay));
if (_intentionalDisconnect || ConnectionState != APConnectionState.Reconnecting)
break;
var conn = _activeConnData;
if (conn == null) break; // disconnected out from under us
Plugin.Log.LogInfo($"[AP] Reconnect attempt {attempt + 1}...");
try
{
if (OpenSessionAndLogin(conn, out var error))
{
// The player may have manually disconnected during the blocking
// login; if so, abandon the just-opened session.
if (_intentionalDisconnect)
{
DetachAndAbort(Session);
Session = null;
break;
}
WirePostLogin();
Plugin.Log.LogInfo("[AP] Reconnected successfully.");
// Defer the toast to the main thread (Unity isn't safe here).
_pendingReconnectedToast = true;
break;
}
Plugin.Log.LogWarning($"[AP] Reconnect attempt {attempt + 1} failed: {error}");
}
catch (Exception ex)
{
Plugin.Log.LogError($"[AP] Reconnect attempt threw: {ex.Message}");
// If login succeeded but WirePostLogin threw, a live half-wired session
// is attached to our handlers. Tear it down before retrying so we don't
// leak a second event source feeding items/errors into the client.
if (Session != null && ConnectionState != APConnectionState.Connected)
{
DetachAndAbort(Session);
Session = null;
}
}
attempt++;
}
}
finally
{
Interlocked.Exchange(ref _reconnectWorkerRunning, 0);
}
});
}
/// <summary>Tears down the current session and fires <see cref="OnSessionClosed"/>.
/// Safe to call mid-reconnect (when no live <see cref="Session"/> exists).</summary>
public static void Disconnect(bool manual = true)
{
// Nothing to tear down (and avoid double-teardown from the Connect() preamble).
if (Session == null && RunState == null && ConnectionState == APConnectionState.Disconnected)
return;
// Latch this first so the reconnect worker / SocketClosed handler stand down.
_intentionalDisconnect = true;
ConnectionState = APConnectionState.Disconnected;
if (Session != null)
{
Session.Items.ItemReceived -= APSession_ItemReceived;
Session.MessageLog.OnMessageReceived -= APSession_OnMessageReceived;
Session.Socket.ErrorReceived -= APSession_ErrorReceived;
Session.Socket.SocketClosed -= APSession_SocketClosed;
// Guard the blocking close: on a faulted socket .Wait() throws, and the
// save-redirect revert below MUST still run or saves keep going to AP_Saves/.
if (Session.Socket.Connected)
{
try { Session.Socket.DisconnectAsync().Wait(2000); }
catch (Exception ex)
{
Plugin.Log.LogWarning($"[AP] Error closing socket during disconnect: {ex.Message}");
}
}
}
if (_deathLinkService is not null)
{
_deathLinkService.OnDeathLinkReceived -= APSession_DeathLinkReceived;
_deathLinkService = null;
}
DeathLinkEnabled = false;
_pendingDeathLink = false;
RandomizeStartingClass = false;
StartingClassIndex = 0;
KnightClassReceived = false;
// Deactivate the save redirect before restoring the vanilla profile so
// LoadCurrentProfileData reads from the original paths.
if (APSaveActive)
{
APSaveActive = false;
SaveManager.ConfigData.CurrentProfile = _previousProfile;
// Force the game to re-verify its save directories against the now-restored vanilla path
// on the next save, mirroring what we do on connect. Guards against the reverse of the
// AP-save-directory bug (solved via EnsureRedirectedSaveDirectories())
// for a session that connected before any vanilla save occurred.
ResetSaveDirectoryCheck();
SaveManager.LoadCurrentProfileData();
Plugin.Log.LogInfo("[AP] Save redirect deactivated; vanilla profile restored.");
}
APNotifications.Reset();
_scoutedItems.Clear();
RunState = null;
IsInGame = false;
Session = null;
_activeConnData = null;
if (manual)
OnSessionClosed?.Invoke();
Plugin.Log.LogInfo("Disconnected from AP server.");
}
/// <summary>
/// Replaces invalid filename chars in the given name with underscores. TODO: Determine if this is necessary
/// </summary>
/// <param name="name">Directory name which will be sanitized</param>
/// <returns>The sanitized directory name</returns>
private static string SanitizeDirectoryName(string name)
{
foreach (char c in Path.GetInvalidFileNameChars())
name = name.Replace(c, '_');
return name;
}
/// <summary>
/// Ensures the game's save directory tree exists under the active AP save redirect.
///
/// RL2 builds its save directories only once per session, guarded by SaveManager's private
/// <c>m_checkedForSaveDirectories</c> flag. If anything triggered a save before the player
/// connected (an autosave, or changing a config option), that flag is consumed against the
/// vanilla save path. Once we redirect <see cref="SaveFileSystem.PersistentDataPath"/> to
/// <c>AP_Saves/{dir}</c>, the game then never creates that redirected tree:
/// - game saves (<c>SaveGameData</c>) fail silently, because <c>CreateSaveFile</c> swallows
/// the IO exception, so the player's run progress quietly never reaches disk; and
/// - <c>SaveConfigFile</c> re-throws on menu close, and that exception propagates out of
/// <c>SuboptionsWindowController.OnClose</c> before the window is removed, leaving the
/// options submenu permanently unclosable (force-close is the only escape, losing the
/// unsaved run).
///
/// We fix both by (1) creating the config directory immediately so the very next config save
/// can't throw, and (2) clearing the one-time flag so the game rebuilds its full profile and
/// backup tree under the redirected path on the next save.
/// </summary>
private static void EnsureRedirectedSaveDirectories()
{
try
{
// (1) Immediately create the directory GameConfig.ini lives in so that backing out of
// a settings submenu (which calls SaveConfigFile) can't throw before any other
// save has had a chance to create it.
var configDir = Path.GetDirectoryName(SaveManager.GetConfigPath());
if (!string.IsNullOrEmpty(configDir))
Directory.CreateDirectory(configDir);
// (2) Clear SaveManager's one-time directory-check flag so its own
// CreateSaveDirectoriesIfNeeded() rebuilds the full profile/backup tree under the
// redirected path on the next save. This also restores reliable run saves.
ResetSaveDirectoryCheck();
Plugin.Log.LogInfo("[AP] Ensured redirected save directories exist.");
}
catch (Exception ex)
{
Plugin.Log.LogError($"[AP] Failed to ensure redirected save directories: {ex.Message}");
}
}
/// <summary>
/// Clears SaveManager's private <c>m_checkedForSaveDirectories</c> flag so the game rebuilds
/// its save directory tree against the current <see cref="SaveFileSystem.PersistentDataPath"/>
/// on the next save. Called whenever the save path changes (connect and disconnect) so neither
/// the redirected AP tree nor the vanilla tree can be left uncreated for the active path.
/// </summary>
private static void ResetSaveDirectoryCheck()
{
var instance = Traverse.Create(typeof(SaveManager)).Field("m_instance").GetValue();
if (instance != null)
Traverse.Create(instance).Field("m_checkedForSaveDirectories").SetValue(false);
else
Plugin.Log.LogWarning("[AP] Could not access SaveManager instance to reset the save-directory flag.");
}
/// <summary>
/// Reports a completed location check to the Archipelago server.
///
/// Persists the check to <see cref="RunState"/> before sending so that if
/// the network send is lost, the next successful connect will resync it.
/// Repeated calls for the same location ID are no-ops.
/// </summary>
public static void SendLocationCheck(long locationId)
{
if (RunState == null)
{
Plugin.Log.LogWarning(
$"[AP] SendLocationCheck({locationId}) called while no run is active. ignoring.");
return;
}
var displayName = LocationRegistry.Names.TryGetValue(locationId, out var n) ? n : locationId.ToString();
// Persist first, then send. If the send is dropped, Resync will retry it.
// Lock guards the HashSet against the reconnect worker reading it in Resync.
bool added;
lock (_checkedLock)
added = RunState.CheckedLocations.Add(locationId);
if (!added)
{
Plugin.Log.LogDebug($"[AP] Location '{displayName}' already checked. skipping re-send.");
return;
}
RunState.Save(APSaveDirectoryName);
// Show the "found/sent" toast from the scout cache regardless of connection so
// offline pickups still surface in the AP log (the cache survives a drop).
EnqueueSendNotification(locationId);
if (!IsConnected)
{
Plugin.Log.LogInfo(
$"[AP] Not connected; queued location '{displayName}' for resync on next connect.");
return;
}
if (!Session.Locations.AllLocations.Contains(locationId))
Plugin.Log.LogWarning(
$"[AP] Location '{displayName}' (ID {locationId}) is not in this slot's location list. ensure the apworld defines it.");
try
{
Session.Locations.CompleteLocationChecksAsync(new[] { locationId })
.ContinueWith(t =>
{
if (t.IsFaulted)
Plugin.Log.LogError($"[AP] Failed to send location check '{displayName}': {t.Exception?.Flatten().Message}");
else
Plugin.Log.LogInfo($"[AP] Sent location check: '{displayName}' (ID {locationId})");
});
}
catch (Exception ex)
{
Plugin.Log.LogError(
$"[AP] Failed to send location check '{displayName}': {ex.Message}");
}
}
/// <summary>
/// Pushes the "you sent X to Y" (or "you discovered your own item") HUD,
/// using the scout reply cached at connect time. Silently no-ops when the
/// scout hasn't returned yet; the message log still shows the activity.
/// </summary>
private static void EnqueueSendNotification(long locationId)
{
if (!_scoutedItems.TryGetValue(locationId, out var scouted))
return;
var itemName = scouted.ItemDisplayName ?? scouted.ItemName ?? "an item";
var ourSlot = _ourSlot;
var isHeirloom = ItemRegistry.ToHeirloomType(scouted.ItemId).HasValue;
if (scouted.Player.Slot == ourSlot)
{
// Self-item: GrantItem will skip its own notification so this is the
// only HUD that fires for this check.
APNotifications.Enqueue(
title: "Item Found",
subtitle: itemName,
description: "Discovered by yourself",
critical: isHeirloom);
}
else
{
var recipient = !string.IsNullOrEmpty(scouted.Player.Alias)
? scouted.Player.Alias
: (scouted.Player.Name ?? $"Player {scouted.Player.Slot}");
APNotifications.Enqueue(
title: "Item Sent",
subtitle: itemName,
description: $"Sent to {recipient}",
critical: isHeirloom);
}
}
/// <summary>
/// Returns the item that will drop at <paramref name="locationId"/>, or
/// <c>null</c> if the scout hasn't come back yet or the location isn't tracked.
/// Scouts are requested asynchronously right after a successful connect.
/// </summary>
public static ScoutedItemInfo GetScoutedItem(long locationId) =>
_scoutedItems.TryGetValue(locationId, out var info) ? info : null;
/// <summary>
/// Asynchronously scouts every location in <see cref="LocationRegistry.Names"/>
/// and caches the result in <see cref="_scoutedItems"/>. Consumers (e.g. the
/// heirloom-statue icon swap) should handle the cache being empty if the
/// player enters the area before the scout reply arrives.
/// </summary>
private static void ScoutTrackedLocations()
{
if (Session == null) return;
// Only scout locations the server actually knows about (AllLocations is
// the slot's location list). Filtering here avoids a warning from the
// library when an ID isn't in this slot.
var ids = LocationRegistry.Names.Keys
.Where(Session.Locations.AllLocations.Contains)
.ToArray();
if (ids.Length == 0) return;
try
{
Session.Locations.ScoutLocationsAsync(HintCreationPolicy.None, ids)
.ContinueWith(t =>
{
if (t.IsFaulted)
{
Plugin.Log.LogError($"[AP] Scout request failed: {t.Exception?.Flatten().Message}");
return;
}
foreach (var kv in t.Result)
_scoutedItems[kv.Key] = kv.Value;
Plugin.Log.LogInfo($"[AP] Scouted {t.Result.Count} tracked location(s).");
});
}
catch (Exception ex)
{
Plugin.Log.LogError($"[AP] Failed to issue scout request: {ex.Message}");
}
}
/// <summary>
/// Re-sends any locally-checked locations that the server doesn't know about.
/// Called once after a successful connect/login.
/// </summary>
private static void ResyncCheckedLocations()
{
if (RunState == null || Session == null) return;
// Snapshot under lock: this may run on the reconnect worker thread while the
// main thread is adding checks via SendLocationCheck.
long[] localChecks;
lock (_checkedLock)
localChecks = RunState.CheckedLocations.ToArray();
var serverKnown = Session.Locations.AllLocationsChecked;
var missing = localChecks.Where(id => !serverKnown.Contains(id)).ToArray();
if (missing.Length == 0)
{
Plugin.Log.LogDebug("[AP] Checked-location state is already in sync with the server.");
return;
}
Plugin.Log.LogInfo(
$"[AP] Resyncing {missing.Length} location(s) the server hadn't recorded yet.");
try
{
ObserveFault(Session.Locations.CompleteLocationChecksAsync(missing), "Resync");
}
catch (Exception ex)
{
Plugin.Log.LogError($"[AP] Resync failed: {ex.Message}");
}
}
/// <summary>
/// Notifies the Archipelago server that the player has achieved the goal condition.
/// Call this instead of <see cref="SendLocationCheck"/> when the final boss is defeated.
/// </summary>
public static void SendGoalAchieved()
{
// Persist first so a goal reached while disconnected survives and is re-sent on
// reconnect (WirePostLogin), mirroring how location checks are queued.
if (RunState != null && !RunState.GoalAchieved)
{
RunState.GoalAchieved = true;
RunState.Save(APSaveDirectoryName);
}
if (!IsConnected) return;
try
{
Session.SetGoalAchieved();
Plugin.Log.LogInfo("[AP] Goal achieved. sent status update to server.");
}
catch (Exception ex)
{
Plugin.Log.LogError($"[AP] Failed to send goal achieved: {ex.Message}");
}
}
/// <summary>Sends a death beacon to all other DeathLink-enabled players in the multiworld.</summary>
public static void SendDeathLink()
{
if (_deathLinkService is null) return;
try
{
_deathLinkService.SendDeathLink(new DeathLink(_slotName));
Plugin.Log.LogInfo("[AP] DeathLink sent.");
}
catch (Exception ex)
{
Plugin.Log.LogError($"[AP] Failed to send DeathLink: {ex.Message}");
}
}
/// <summary>
/// Called each frame from the main thread to apply any incoming death beacon.
/// Drops the beacon silently if the player is already dead or not yet in-game.
/// </summary>
public static void ProcessPendingDeaths()
{
if (!_pendingDeathLink || !IsInGame) return;
if (!WorldBuilder.IsInstantiated || WorldBuilder.State != BiomeBuildStateID.Complete)
{
_pendingDeathLink = false;
return;
}
if (!PlayerManager.IsInstantiated) return;
var player = PlayerManager.GetPlayerController();
if (player == null || player.IsDead)
{
_pendingDeathLink = false;
return;
}
if (PlayerManager.GetCurrentPlayerRoom()?.BiomeType == BiomeType.HubTown)
{
_pendingDeathLink = false;
return;
}
_pendingDeathLink = false;
var source = _pendingDeathSource ?? "Another player";
var cause = !string.IsNullOrEmpty(_pendingDeathCause) ? _pendingDeathCause : $"{source} has died";
IsReceivingDeathLink = true;
try
{
player.KillCharacter(null, broadcastEvent: true);
}
finally
{
IsReceivingDeathLink = false;
}