using Content.Server.Database; using Content.Server.Mind; using Content.Server.Players.PlayTimeTracking; using Content.Server.Roles.Jobs; using Content.Server.Tips; using Content.Shared._DV.Tips; using Content.Shared._DV.Tips.Conditions; using Content.Shared.GameTicking; using Content.Shared.Roles.Components; using Robust.Shared.Enums; using Robust.Shared.Network; using Robust.Shared.Player; using Robust.Shared.Prototypes; using Robust.Shared.Timing; namespace Content.Server._DV.Tips; /// /// Server-side system that handles showing tips to players after they spawn alongside validation. /// Not to be confused with . /// public sealed class TipSystem : SharedTipSystem { [Dependency] private readonly IComponentFactory _component = default!; [Dependency] private readonly IGameTiming _timing = default!; [Dependency] private readonly IServerDbManager _db = default!; [Dependency] private readonly JobSystem _job = default!; [Dependency] private readonly PlayTimeTrackingManager _playtime = default!; /// /// Tracks scheduled tips for each player session. /// private readonly Dictionary> _scheduledTips = new(); /// /// Cache of seen tips per player to avoid repeated DB queries. /// private readonly Dictionary> _seenTipsCache = new(); private readonly List _toRemove = new(); private sealed class ScheduledTip { public ProtoId TipId; public TimeSpan ShowTime; public EntityUid PlayerEntity; } public override void Initialize() { base.Initialize(); SubscribeLocalEvent(OnPlayerSpawnComplete); SubscribeLocalEvent(OnRoundRestart); SubscribeNetworkEvent(OnTipDismissed); SubscribeNetworkEvent(OnResetAllSeenTipsRequest); } public override void Update(float frameTime) { base.Update(frameTime); var currentTime = _timing.CurTime; _toRemove.Clear(); foreach (var (session, tips) in _scheduledTips) { if (session.Status != SessionStatus.InGame) { _toRemove.Add(session); continue; } for (var i = tips.Count - 1; i >= 0; i--) { var scheduled = tips[i]; if (currentTime < scheduled.ShowTime) continue; // Re-check conditions at show time in case state changed if (Prototype.TryIndex(scheduled.TipId, out var tipProto) && CheckConditions(scheduled.PlayerEntity, session, tipProto)) { ShowTip(session, tipProto); } tips.RemoveAt(i); } if (tips.Count == 0) _toRemove.Add(session); } foreach (var session in _toRemove) { _scheduledTips.Remove(session); } } private void OnPlayerSpawnComplete(PlayerSpawnCompleteEvent ev) { if (ev.Player is not { } session) return; ScheduleTipsForPlayer(session, ev.Mob); } private void OnRoundRestart(RoundRestartCleanupEvent ev) { _scheduledTips.Clear(); _seenTipsCache.Clear(); } private void OnTipDismissed(TipDismissedEvent ev, EntitySessionEventArgs args) { if (args.SenderSession is not { } session) return; // Only persist if the player checked "Don't show again" if (!ev.DontShowAgain) return; MarkTipSeen(session.UserId, ev.TipId); } private void OnResetAllSeenTipsRequest(ResetAllSeenTipsRequest ev, EntitySessionEventArgs args) { if (args.SenderSession is not { } session) return; ResetAllSeenTips(session.UserId); Log.Info($"Player {session.Name} reset all seen tips."); } /// /// Marks a tip as seen for a player, updating both cache and database. /// public async void MarkTipSeen(NetUserId player, ProtoId tipId) { // Update cache immediately if (!_seenTipsCache.TryGetValue(player, out var seenTips)) { seenTips = new HashSet(); _seenTipsCache[player] = seenTips; } seenTips.Add(tipId.Id); // Persist to database await _db.MarkTipSeen(player, tipId); } /// /// Resets a seen tip for a player, allowing it to be shown again. /// public async void ResetSeenTip(NetUserId player, ProtoId tipId) { // Update cache if (_seenTipsCache.TryGetValue(player, out var seenTips)) { seenTips.Remove(tipId.Id); } // Update database await _db.ResetSeenTip(player, tipId); } /// /// Resets all seen tips for a player. /// public async void ResetAllSeenTips(NetUserId player) { // Clear cache _seenTipsCache.Remove(player); // Clear database await _db.ResetAllSeenTips(player); } /// /// Checks if a tip has been seen by a player. /// public bool HasSeenTip(NetUserId player, ProtoId tipId) { if (!_seenTipsCache.TryGetValue(player, out var seenTips)) return false; return seenTips.Contains(tipId.Id); } private async void ScheduleTipsForPlayer(ICommonSession session, EntityUid playerEntity) { // Load seen tips from database if not cached if (!_seenTipsCache.ContainsKey(session.UserId)) { var seenTips = await _db.GetSeenTips(session.UserId); _seenTipsCache[session.UserId] = seenTips; } var currentTime = _timing.CurTime; var tips = new List(); foreach (var tipProto in Prototype.EnumeratePrototypes()) { // Skip tips the player has already dismissed with "Don't show again" if (HasSeenTip(session.UserId, tipProto.ID)) continue; // Check conditions at schedule time if (!CheckConditions(playerEntity, session, tipProto)) continue; tips.Add(new ScheduledTip { TipId = tipProto.ID, ShowTime = currentTime + tipProto.Delay, PlayerEntity = playerEntity }); } if (tips.Count == 0) return; // Sort by priority then show time tips.Sort((a, b) => { var protoA = Prototype.Index(a.TipId); var protoB = Prototype.Index(b.TipId); var priorityCompare = protoA.Priority.CompareTo(protoB.Priority); return priorityCompare != 0 ? priorityCompare : a.ShowTime.CompareTo(b.ShowTime); }); _scheduledTips.Remove(session); _scheduledTips[session] = tips; } /// /// Checks if all conditions for a tip are met. /// private bool CheckConditions(EntityUid player, ICommonSession session, TipPrototype tip) { foreach (var condition in tip.Conditions) { var result = EvaluateCondition(player, session, condition); // Apply inversion result ^= condition.Invert; if (!result) return false; } return true; } /// /// Evaluates a single condition based on its type. /// private bool EvaluateCondition(EntityUid player, ICommonSession session, TipCondition condition) { return condition switch { HasCompCondition hasComp => EvaluateHasComp(player, hasComp), HasJobCondition hasJob => EvaluateHasJob(player, hasJob), HasRoleTypeCondition hasRoleType => EvaluateHasRoleType(player, hasRoleType), MinPlaytimeCondition minTracker => EvaluateMinPlaytime(session, minTracker), MaxPlaytimeCondition maxTracker => EvaluateMaxPlaytime(session, maxTracker), _ => true // Unknown condition types pass by default }; } private bool EvaluateHasComp(EntityUid player, HasCompCondition condition) { if (!_component.TryGetRegistration(condition.Comp, out var registration)) { Log.Warning($"Tip condition references unknown component: {condition.Comp}"); return false; } return HasComp(player, registration.Type); } private bool EvaluateHasJob(EntityUid player, HasJobCondition condition) { if (!_job.MindTryGetJobId(Mind.GetMind(player), out var jobId)) return false; return jobId == condition.Job; } private bool EvaluateHasRoleType(EntityUid player, HasRoleTypeCondition condition) { if (!Mind.TryGetMind(player, out _, out var mind)) return false; foreach (var role in mind.MindRoleContainer.ContainedEntities) { if (!TryComp(role, out var mindRole)) continue; if (mindRole.RoleType == condition.RoleType) return true; } return false; } private bool EvaluateMinPlaytime(ICommonSession session, MinPlaytimeCondition condition) { if (!_playtime.TryGetTrackerTime(session, condition.Tracker, out var time)) return false; return time.Value >= condition.Time; } private bool EvaluateMaxPlaytime(ICommonSession session, MaxPlaytimeCondition condition) { if (!_playtime.TryGetTrackerTime(session, condition.Tracker, out var time)) return true; // No time tracked = 0 minutes, which is less than any positive max return time.Value < condition.Time; } /// /// Cancels all scheduled tips for a player. /// public void CancelTipsForPlayer(ICommonSession session) { _scheduledTips.Remove(session); } }