44using Snowcloak . API . Dto . Group ;
55using Snowcloak . API . Dto . Venue ;
66using Snowcloak . Configuration ;
7+ using Snowcloak . Configuration . Models ;
78using Snowcloak . PlayerData . Pairs ;
89using Snowcloak . Services . Housing ;
910using Snowcloak . Services . Mediator ;
@@ -18,6 +19,8 @@ namespace Snowcloak.Services.Venue;
1819
1920public sealed class VenueSyncshellService : DisposableMediatorSubscriberBase , IHostedService
2021{
22+ private static readonly TimeSpan AutoLeaveGracePeriod = TimeSpan . FromMinutes ( 90 ) ;
23+ private static readonly TimeSpan AutoLeaveWarningPeriod = TimeSpan . FromMinutes ( 5 ) ;
2124 private readonly ApiController _apiController ;
2225 private readonly SnowcloakConfigService _configService ;
2326 private readonly PairManager _pairManager ;
@@ -37,17 +40,19 @@ public VenueSyncshellService(ILogger<VenueSyncshellService> logger, SnowMediator
3740
3841 Mediator . Subscribe < HousingPlotEnteredMessage > ( this , msg => _ = HandleHousingPlotEntered ( msg . Location ) ) ;
3942 Mediator . Subscribe < HousingPlotLeftMessage > ( this , msg => HandleHousingPlotLeft ( msg . Location ) ) ;
40- Mediator . Subscribe < DisconnectedMessage > ( this , _ => ClearState ( ) ) ;
43+ Mediator . Subscribe < ConnectedMessage > ( this , _ => RestorePersistedVenueState ( ) ) ;
44+ Mediator . Subscribe < DisconnectedMessage > ( this , _ => ClearRuntimeState ( ) ) ;
4145 }
4246
4347 public Task StartAsync ( CancellationToken cancellationToken )
4448 {
49+ RestorePersistedVenueState ( ) ;
4550 return Task . CompletedTask ;
4651 }
4752
4853 public Task StopAsync ( CancellationToken cancellationToken )
4954 {
50- ClearState ( ) ;
55+ ClearRuntimeState ( ) ;
5156 return Task . CompletedTask ;
5257 }
5358
@@ -68,12 +73,15 @@ public async Task<bool> JoinVenueShellAsync(Guid promptId)
6873 {
6974 Logger . LogInformation ( "Joined venue syncshell {GID} for {Venue}" , joinGroupId , prompt . Venue . VenueName ) ;
7075 DisableAutoJoinedSyncshellChat ( joinGroupId ) ;
76+ var autoJoinedVenue = new AutoJoinedVenue ( prompt . Venue . JoinInfo . Group , prompt . Location ) ;
77+ CancelPendingRemoval ( joinGroupId , clearPersistedDeadline : true ) ;
7178 lock ( _syncRoot )
7279 {
73- _autoJoinedVenues [ joinGroupId ] = new ( prompt . Venue . JoinInfo . Group , prompt . Location ) ;
74- CancelPendingRemoval ( joinGroupId ) ;
80+ _autoJoinedVenues [ joinGroupId ] = autoJoinedVenue ;
7581 _activePrompt = null ;
7682 }
83+
84+ UpsertPersistedVenue ( autoJoinedVenue , leaveAfterUtc : null ) ;
7785 }
7886
7987 return joined ;
@@ -105,7 +113,7 @@ internal bool IsAutoJoined(GroupData group)
105113 }
106114 }
107115
108- private void ClearState ( )
116+ private void ClearRuntimeState ( )
109117 {
110118 Logger . LogDebug ( "Clearing venue syncshell state" ) ;
111119 lock ( _syncRoot )
@@ -121,7 +129,7 @@ private void ClearState()
121129 }
122130 }
123131
124- private void CancelPendingRemoval ( string groupId )
132+ private void CancelPendingRemoval ( string groupId , bool clearPersistedDeadline = false )
125133 {
126134 lock ( _syncRoot )
127135 {
@@ -131,6 +139,11 @@ private void CancelPendingRemoval(string groupId)
131139 _pendingRemovalTokens . Remove ( groupId ) ;
132140 }
133141 }
142+
143+ if ( clearPersistedDeadline )
144+ {
145+ ClearPersistedLeaveDeadline ( groupId ) ;
146+ }
134147 }
135148
136149 private void CancelPendingRemovalForLocation ( HousingPlotLocation location )
@@ -144,7 +157,7 @@ private void CancelPendingRemovalForLocation(HousingPlotLocation location)
144157
145158 foreach ( var groupId in groupIds )
146159 {
147- CancelPendingRemoval ( groupId ) ;
160+ CancelPendingRemoval ( groupId , clearPersistedDeadline : true ) ;
148161 }
149162 }
150163
@@ -217,9 +230,19 @@ private void HandleHousingPlotLeft(HousingPlotLocation location)
217230 }
218231
219232 private void ScheduleAutoLeave ( AutoJoinedVenue venue )
233+ {
234+ ScheduleAutoLeave ( venue , DateTime . UtcNow . Add ( AutoLeaveGracePeriod ) , persistDeadline : true ) ;
235+ }
236+
237+ private void ScheduleAutoLeave ( AutoJoinedVenue venue , DateTime leaveAfterUtc , bool persistDeadline , bool leaveWarningShown = false )
220238 {
221239 CancelPendingRemoval ( venue . Group . GID ) ;
222240
241+ if ( persistDeadline )
242+ {
243+ UpsertPersistedVenue ( venue , leaveAfterUtc ) ;
244+ }
245+
223246 var tokenSource = new CancellationTokenSource ( ) ;
224247 lock ( _syncRoot )
225248 {
@@ -230,18 +253,38 @@ private void ScheduleAutoLeave(AutoJoinedVenue venue)
230253 {
231254 try
232255 {
233- await Task . Delay ( TimeSpan . FromMinutes ( 2 ) , tokenSource . Token ) . ConfigureAwait ( false ) ;
256+ await ShowAutoLeaveWarningWhenDue ( venue , leaveAfterUtc , leaveWarningShown , tokenSource . Token ) . ConfigureAwait ( false ) ;
257+
258+ var delay = leaveAfterUtc - DateTime . UtcNow ;
259+ if ( delay < TimeSpan . Zero )
260+ {
261+ delay = TimeSpan . Zero ;
262+ }
263+
264+ await Task . Delay ( delay , tokenSource . Token ) . ConfigureAwait ( false ) ;
234265 if ( tokenSource . Token . IsCancellationRequested )
235266 return ;
236267
237268 if ( ! _apiController . IsConnected )
238269 {
239- Logger . LogWarning ( "Could not auto-leave venue syncshell {GID} because the client is not connected" , venue . Group . GID ) ;
270+ Logger . LogInformation ( "Deferring auto-leave for venue syncshell {GID} until the client reconnects" , venue . Group . GID ) ;
271+ return ;
272+ }
273+
274+ if ( ! IsGroupMember ( venue . Group . GID ) )
275+ {
276+ RemovePersistedVenue ( venue . Group . GID ) ;
277+ lock ( _syncRoot )
278+ {
279+ _autoJoinedVenues . Remove ( venue . Group . GID ) ;
280+ }
281+
240282 return ;
241283 }
242284
243285 await _apiController . GroupLeave ( new GroupDto ( venue . Group ) ) . ConfigureAwait ( false ) ;
244286 Logger . LogInformation ( "Auto-left venue syncshell {GID} after grace period" , venue . Group . GID ) ;
287+ RemovePersistedVenue ( venue . Group . GID ) ;
245288 lock ( _syncRoot )
246289 {
247290 _autoJoinedVenues . Remove ( venue . Group . GID ) ;
@@ -259,8 +302,13 @@ private void ScheduleAutoLeave(AutoJoinedVenue venue)
259302 {
260303 lock ( _syncRoot )
261304 {
262- _pendingRemovalTokens . Remove ( venue . Group . GID ) ;
305+ if ( _pendingRemovalTokens . TryGetValue ( venue . Group . GID , out var pending ) && ReferenceEquals ( pending , tokenSource ) )
306+ {
307+ _pendingRemovalTokens . Remove ( venue . Group . GID ) ;
308+ }
263309 }
310+
311+ tokenSource . Dispose ( ) ;
264312 }
265313 } , tokenSource . Token ) ;
266314 }
@@ -293,6 +341,177 @@ private void RemoveStaleAutoJoinedVenues()
293341 }
294342 }
295343 }
344+
345+ foreach ( var staleGroupId in staleGroupIds )
346+ {
347+ RemovePersistedVenue ( staleGroupId ) ;
348+ }
349+ }
350+
351+ private void RestorePersistedVenueState ( )
352+ {
353+ List < AutoJoinedVenue > venuesWithPendingLeave = [ ] ;
354+ var persisted = GetPersistedVenues ( ) ;
355+
356+ lock ( _syncRoot )
357+ {
358+ foreach ( var entry in persisted )
359+ {
360+ if ( string . IsNullOrWhiteSpace ( entry . GroupGid ) )
361+ {
362+ continue ;
363+ }
364+
365+ var venue = new AutoJoinedVenue (
366+ new GroupData ( entry . GroupGid , entry . GroupAlias , entry . GroupHexString ) ,
367+ new HousingPlotLocation ( entry . WorldId , entry . TerritoryId , entry . DivisionId , entry . WardId , entry . PlotId , entry . RoomId , entry . IsApartment ) ) ;
368+
369+ _autoJoinedVenues [ entry . GroupGid ] = venue ;
370+ if ( entry . LeaveAfterUtc . HasValue )
371+ {
372+ venuesWithPendingLeave . Add ( venue ) ;
373+ }
374+ }
375+ }
376+
377+ foreach ( var venue in venuesWithPendingLeave )
378+ {
379+ var entry = persisted . FirstOrDefault ( v => string . Equals ( v . GroupGid , venue . Group . GID , StringComparison . Ordinal ) ) ;
380+ if ( entry ? . LeaveAfterUtc != null )
381+ {
382+ ScheduleAutoLeave ( venue , NormalizeUtc ( entry . LeaveAfterUtc . Value ) , persistDeadline : false , entry . LeaveWarningShown ) ;
383+ }
384+ }
385+
386+ if ( _apiController . IsConnected )
387+ {
388+ RemoveStaleAutoJoinedVenues ( ) ;
389+ }
390+ }
391+
392+ private List < VenueAutoJoinedSyncshell > GetPersistedVenues ( )
393+ {
394+ return _configService . Current . AutoJoinedVenueSyncshells ??= [ ] ;
395+ }
396+
397+ private void UpsertPersistedVenue ( AutoJoinedVenue venue , DateTime ? leaveAfterUtc )
398+ {
399+ var persisted = GetPersistedVenues ( ) ;
400+ var entry = persisted . SingleOrDefault ( v => string . Equals ( v . GroupGid , venue . Group . GID , StringComparison . Ordinal ) ) ;
401+ if ( entry == null )
402+ {
403+ entry = new VenueAutoJoinedSyncshell
404+ {
405+ GroupGid = venue . Group . GID ,
406+ JoinedAtUtc = DateTime . UtcNow
407+ } ;
408+ persisted . Add ( entry ) ;
409+ }
410+
411+ entry . GroupAlias = venue . Group . Alias ;
412+ entry . GroupHexString = venue . Group . HexString ;
413+ entry . WorldId = venue . Location . WorldId ;
414+ entry . TerritoryId = venue . Location . TerritoryId ;
415+ entry . DivisionId = venue . Location . DivisionId ;
416+ entry . WardId = venue . Location . WardId ;
417+ entry . PlotId = venue . Location . PlotId ;
418+ entry . RoomId = venue . Location . RoomId ;
419+ entry . IsApartment = venue . Location . IsApartment ;
420+ entry . UpdatedAtUtc = DateTime . UtcNow ;
421+ entry . LeaveAfterUtc = leaveAfterUtc . HasValue ? NormalizeUtc ( leaveAfterUtc . Value ) : null ;
422+ entry . LeaveWarningShown = false ;
423+ _configService . Save ( ) ;
424+ }
425+
426+ private void ClearPersistedLeaveDeadline ( string groupId )
427+ {
428+ var entry = GetPersistedVenues ( ) . SingleOrDefault ( v => string . Equals ( v . GroupGid , groupId , StringComparison . Ordinal ) ) ;
429+ if ( entry ? . LeaveAfterUtc == null )
430+ {
431+ return ;
432+ }
433+
434+ entry . LeaveAfterUtc = null ;
435+ entry . LeaveWarningShown = false ;
436+ entry . UpdatedAtUtc = DateTime . UtcNow ;
437+ _configService . Save ( ) ;
438+ }
439+
440+ private async Task ShowAutoLeaveWarningWhenDue ( AutoJoinedVenue venue , DateTime leaveAfterUtc , bool warningAlreadyShown , CancellationToken token )
441+ {
442+ if ( warningAlreadyShown )
443+ {
444+ return ;
445+ }
446+
447+ var now = DateTime . UtcNow ;
448+ if ( leaveAfterUtc <= now )
449+ {
450+ return ;
451+ }
452+
453+ var warningAtUtc = leaveAfterUtc - AutoLeaveWarningPeriod ;
454+ var warningDelay = warningAtUtc - now ;
455+ if ( warningDelay > TimeSpan . Zero )
456+ {
457+ await Task . Delay ( warningDelay , token ) . ConfigureAwait ( false ) ;
458+ }
459+
460+ if ( token . IsCancellationRequested || leaveAfterUtc <= DateTime . UtcNow )
461+ {
462+ return ;
463+ }
464+
465+ PublishAutoLeaveWarning ( venue ) ;
466+ MarkPersistedLeaveWarningShown ( venue . Group . GID ) ;
467+ }
468+
469+ private void PublishAutoLeaveWarning ( AutoJoinedVenue venue )
470+ {
471+ Mediator . Publish ( new NotificationMessage (
472+ "Venue syncshell auto-leave" ,
473+ $ "You will leave { venue . Group . AliasOrGID } in 5 minutes unless you return to the venue.",
474+ NotificationType . Warning ,
475+ TimeSpan . FromSeconds ( 10 ) ) ) ;
476+
477+ Logger . LogInformation ( "Venue syncshell {GID} will auto-leave in {Minutes} minutes" , venue . Group . GID , AutoLeaveWarningPeriod . TotalMinutes ) ;
478+ }
479+
480+ private void MarkPersistedLeaveWarningShown ( string groupId )
481+ {
482+ var entry = GetPersistedVenues ( ) . SingleOrDefault ( v => string . Equals ( v . GroupGid , groupId , StringComparison . Ordinal ) ) ;
483+ if ( entry == null || entry . LeaveWarningShown )
484+ {
485+ return ;
486+ }
487+
488+ entry . LeaveWarningShown = true ;
489+ entry . UpdatedAtUtc = DateTime . UtcNow ;
490+ _configService . Save ( ) ;
491+ }
492+
493+ private void RemovePersistedVenue ( string groupId )
494+ {
495+ var persisted = GetPersistedVenues ( ) ;
496+ if ( persisted . RemoveAll ( v => string . Equals ( v . GroupGid , groupId , StringComparison . Ordinal ) ) > 0 )
497+ {
498+ _configService . Save ( ) ;
499+ }
500+ }
501+
502+ private bool IsGroupMember ( string groupId )
503+ {
504+ return _pairManager . Groups . Keys . Any ( g => string . Equals ( g . GID , groupId , StringComparison . Ordinal ) ) ;
505+ }
506+
507+ private static DateTime NormalizeUtc ( DateTime timestamp )
508+ {
509+ return timestamp . Kind switch
510+ {
511+ DateTimeKind . Utc => timestamp ,
512+ DateTimeKind . Local => timestamp . ToUniversalTime ( ) ,
513+ _ => DateTime . SpecifyKind ( timestamp , DateTimeKind . Utc )
514+ } ;
296515 }
297516
298517
0 commit comments