diff --git a/docs/codebase/TESTING.md b/docs/codebase/TESTING.md index 5822d84c..72c75c23 100644 --- a/docs/codebase/TESTING.md +++ b/docs/codebase/TESTING.md @@ -193,7 +193,7 @@ paths above. Still untested at the automated level: - **Module state machines** (`src/Perpetuum/Modules/ActiveModule.States.cs`) — state transitions for robot equipment are tested only by playing the game. - **Request handlers** (`src/Perpetuum.RequestHandlers/`) — 200+ handler classes have no test doubles or mock session/request infrastructure. - **Concurrent/threading code** — `ProcessManager`, `MessageSender`, `TcpConnection` use `ThreadPool` and `Task.Run` patterns that need a harness before they can be tested deterministically. -- **Season service logic** (`src/Perpetuum/Services/Seasons/SeasonService.cs`) — tier grant, objective completion, leaderboard delivery, intro mail idempotency, and end-of-season processing are exercised only via live play. +- **Season service logic** (`src/Perpetuum/Services/Seasons/SeasonService.cs`) — tier grant, objective completion, leaderboard delivery, announcement mail idempotency, and end-of-season processing are exercised only via live play. - **Mission engine** (`src/Perpetuum/Services/MissionEngine/`) — the most complex subsystem; `MissionResolveTester` exercises resolve logic but requires a live DB and has no assertions. Covering every file is not the goal. See `IMPROVEMENT-045` in `docs/backlog/improvements.md` for the diff --git a/docs/db_structure/database_schema_documentation.md b/docs/db_structure/database_schema_documentation.md index 7eafbc84..b6da46d3 100644 --- a/docs/db_structure/database_schema_documentation.md +++ b/docs/db_structure/database_schema_documentation.md @@ -6157,6 +6157,7 @@ Rows older than 90 days are pruned by `recalculate_raw_material_prices`. | `total_points` | `float [not null, default: 0]` | | `last_updated` | `datetime [not null, default: \`getutcdate()\`]` | | `intro_mail_sent` | `bit [not null, default: 0]` | +| `announcement_mail_sent` | `bit [not null, default: 0]` — season start announcement email was sent to this character | | `leaderboard_reward_delivered` | `bit [not null, default: 0]` | ### Indexes @@ -6325,6 +6326,7 @@ Rows older than 90 days are pruned by `recalculate_raw_material_prices`. | `recurrence_base_name` | `nvarchar(255) [null]` — operator-entered name; server appends `, Run #N` | | `scoring_mode` | `tinyint [not null, default: 0]` — scoring mode (0 = ActivityAndGlobal, 1 = ObjectivesOnly) | | `daily_objectives_per_day` | `smallint [null]` — when set, draw exactly N daily objectives per UTC day using a deterministic seed; NULL = all daily objectives active | +| `announcement_sent` | `bit [not null, default: 0]` — season start announcement was claimed for dispatch | ### Indexes diff --git a/docs/db_structure/migrations/2026-10-03-season-announcement-sent.sql b/docs/db_structure/migrations/2026-10-03-season-announcement-sent.sql new file mode 100644 index 00000000..497d301a --- /dev/null +++ b/docs/db_structure/migrations/2026-10-03-season-announcement-sent.sql @@ -0,0 +1,16 @@ +-- Persists season start announcement dispatch so server restarts do not repost it. +-- New and recurring season rows default to unsent. + +IF COL_LENGTH('dbo.seasons', 'announcement_sent') IS NULL +BEGIN + ALTER TABLE dbo.seasons + ADD announcement_sent BIT NOT NULL + CONSTRAINT DF_seasons_announcement_sent DEFAULT (0); +END; + +IF COL_LENGTH('dbo.season_character_points', 'announcement_mail_sent') IS NULL +BEGIN + ALTER TABLE dbo.season_character_points + ADD announcement_mail_sent BIT NOT NULL + CONSTRAINT DF_season_character_points_announcement_mail_sent DEFAULT (0); +END; diff --git a/src/Perpetuum/Services/Seasons/SeasonRepository.cs b/src/Perpetuum/Services/Seasons/SeasonRepository.cs index 466f18f5..cb07247b 100644 --- a/src/Perpetuum/Services/Seasons/SeasonRepository.cs +++ b/src/Perpetuum/Services/Seasons/SeasonRepository.cs @@ -341,13 +341,23 @@ public void DeactivateSeason(int seasonId) .ExecuteNonQuery(); } - // ── Intro mail tracking ────────────────────────────────────────────── + public bool TryMarkSeasonAnnouncementSent(int seasonId) + { + int rows = Db.Query("UPDATE seasons SET announcement_sent = 1 " + + "WHERE id = @seasonId AND announcement_sent = 0") + .SetParameter("@seasonId", seasonId) + .ExecuteNonQuery(); + + return rows == 1; + } + + // ── Season announcement mail tracking ──────────────────────────────── /// - /// Ensures a row exists for this character+season and marks intro mail sent. + /// Ensures a row exists for this character+season and marks announcement mail sent. /// Returns true if the mail had not been sent before. /// - public bool TryMarkIntroMailSent(int characterId, int seasonId) + public bool TryMarkAnnouncementMailSent(int characterId, int seasonId) { // Ensure row exists Db.Query(@" @@ -356,17 +366,17 @@ MERGE season_character_points WITH (HOLDLOCK) AS t ON t.character_id = s.character_id AND t.season_id = s.season_id WHEN NOT MATCHED THEN INSERT (character_id, season_id, total_points, last_updated, - intro_mail_sent, leaderboard_reward_delivered) - VALUES (@characterId, @seasonId, 0, GETUTCDATE(), 0, 0);") + intro_mail_sent, announcement_mail_sent, leaderboard_reward_delivered) + VALUES (@characterId, @seasonId, 0, GETUTCDATE(), 0, 0, 0);") .SetParameter("@characterId", characterId) .SetParameter("@seasonId", seasonId) .ExecuteNonQuery(); int rows = Db.Query("UPDATE season_character_points " + - "SET intro_mail_sent = 1 " + + "SET announcement_mail_sent = 1 " + "WHERE character_id = @characterId " + " AND season_id = @seasonId " + - " AND intro_mail_sent = 0") + " AND announcement_mail_sent = 0") .SetParameter("@characterId", characterId) .SetParameter("@seasonId", seasonId) .ExecuteNonQuery(); @@ -583,9 +593,9 @@ public Season CloneSeasonForNextIteration(Season previous) Db.Query( "INSERT INTO season_objectives " + "(season_id, name, description, activity_type, target_value, " + - "bonus_points, display_order, is_daily, package_id, equipment_set_id) " + + "bonus_points, display_order, is_daily, package_id, target_definition_id, equipment_set_id) " + "SELECT @newId, name, description, activity_type, target_value, " + - "bonus_points, display_order, is_daily, package_id, equipment_set_id " + + "bonus_points, display_order, is_daily, package_id, target_definition_id, equipment_set_id " + "FROM season_objectives WHERE season_id = @prevId") .SetParameter("@newId", newId) .SetParameter("@prevId", previous.Id) diff --git a/src/Perpetuum/Services/Seasons/SeasonService.cs b/src/Perpetuum/Services/Seasons/SeasonService.cs index 8fa2c547..ce37ee82 100644 --- a/src/Perpetuum/Services/Seasons/SeasonService.cs +++ b/src/Perpetuum/Services/Seasons/SeasonService.cs @@ -4,7 +4,6 @@ using Perpetuum.Services.Channels; using Perpetuum.Services.EventServices.EventMessages; using Perpetuum.Services.Mail; -using Perpetuum.Services.Sessions; using Perpetuum.Threading.Process; using System.Collections.Immutable; using System.Text; @@ -18,11 +17,12 @@ public class SeasonService : Process, ISeasonService // TODO: Add some parameter or flag to character to mark it as announcer to avoid hardcoding nick lookup every time we send mail private const string AnnouncerNick = "[OPP] Announcer"; + private const int DiscordMessageMaxLength = 2000; // TODO: Add some parameter or flag to channel to mark it as season info channel instead of hardcoding name lookup every time we send chat message private const string SeasonChannelName = "Seasons Info"; private readonly SeasonRepository _repository; - private readonly ISessionManager _sessionManager; + private readonly ICharacterProfileRepository _characterProfileRepository; private readonly ICustomDictionary _customDictionary; private readonly Lazy _announcer = new(() => Character.GetByNick(AnnouncerNick)); private readonly Lazy _channelManager; @@ -34,11 +34,6 @@ public class SeasonService : Process, ISeasonService private ImmutableList _activeTiers = ImmutableList.Empty; private ImmutableList _activeLeaderboard = ImmutableList.Empty; - // Tracks which season we have already dispatched intro mail for. 0 = never notified. - private volatile int _lastNotifiedSeasonId; - private readonly System.Collections.Concurrent.ConcurrentQueue _pendingIntroChars - = new System.Collections.Concurrent.ConcurrentQueue(); - // Trigger immediate load on first Update tick private TimeSpan _cacheAge = CacheRefreshInterval; private TimeSpan _leaderboardAge = LeaderboardAnnouncementInterval; @@ -49,22 +44,16 @@ private sealed record DailyPool(ImmutableHashSet Ids, DateOnly Date); public SeasonService( SeasonRepository repository, - ISessionManager sessionManager, + ICharacterProfileRepository characterProfileRepository, ICustomDictionary customDictionary, Lazy channelManager) { _repository = repository; - _sessionManager = sessionManager; + _characterProfileRepository = characterProfileRepository; _customDictionary = customDictionary; - _sessionManager.SessionAdded += OnSessionAdded; _channelManager = channelManager; } - private void OnSessionAdded(ISession session) - { - session.CharacterSelected += (_, character) => OnCharacterLogin(character); - } - // ── Process loop ───────────────────────────────────────────────────── public override void Update(TimeSpan time) @@ -138,9 +127,6 @@ internal void RefreshCache() if (pending != null) _repository.SetSeasonActive(pending.Id, true); } - // No active season — discard any pending login chars - while (_pendingIntroChars.TryDequeue(out _)) { } - return; } @@ -170,18 +156,9 @@ internal void RefreshCache() } } - if (_lastNotifiedSeasonId != season.Id) - { - _lastNotifiedSeasonId = season.Id; - NotifyOnlinePlayersSeasonStarted(season); - } - - // Send intro mail to characters that connected while cache was null - while (_pendingIntroChars.TryDequeue(out var character)) + if (_repository.TryMarkSeasonAnnouncementSent(season.Id)) { - if (DateTime.UtcNow <= season.EndTime && - _repository.TryMarkIntroMailSent(character.Id, season.Id)) - SendIntroMail(character, season); + NotifySeasonStarted(season); } } @@ -268,17 +245,7 @@ public void RecordActivity(int characterId, SeasonActivityType activityType, Act public void OnCharacterLogin(Character character) { - var season = _activeSeason; - if (season == null) - { - // Process loop hasn't warmed the cache yet — defer until RefreshCache runs - _pendingIntroChars.Enqueue(character); - return; - } - if (DateTime.UtcNow < season.StartTime || DateTime.UtcNow > season.EndTime) - return; - if (_repository.TryMarkIntroMailSent(character.Id, season.Id)) - SendIntroMail(character, season); + // Kept for ISeasonService compatibility. Announcements are sent at season start to all characters. } // ── Reward delivery ────────────────────────────────────────────────── @@ -432,7 +399,6 @@ private void ProcessSeasonEnd(Season season) // Null the cache immediately so no further activity is recorded _activeSeason = null; - _lastNotifiedSeasonId = 0; _repository.DeactivateSeason(season.Id); var rankings = _repository.GetParticipantRankings(season.Id) @@ -553,31 +519,6 @@ private void AnnounceDailyPool(IReadOnlyList pool, int totalDai // ── Mail helpers ───────────────────────────────────────────────────── - private void SendIntroMail(Character character, Season season) - { - var dict = _customDictionary.GetDictionary(0); - var sb = new StringBuilder(); - - if (!string.IsNullOrWhiteSpace(season.Description)) - sb.AppendLine(season.Description).AppendLine(); - - sb.AppendLine($"Season ends: {season.EndTime:yyyy-MM-dd HH:mm} UTC"); - - var rates = _activeRates; - if (rates.Count > 0) - { - sb.AppendLine().AppendLine("-- Scoring --"); - foreach (var rate in rates) - { - string unitDesc = rate.UnitScale > 1 ? $" per {rate.UnitScale:N0}" : ""; - sb.AppendLine($" {ActivityTypeName(rate.ActivityType)}: {rate.PointsPerUnit:G} pts{unitDesc}"); - } - } - - MailHandler.SendMail(_announcer.Value, character, $"Season Active: {season.Name}", - sb.ToString(), MailType.character, out _, out _); - } - private void SendObjectiveCompleteMail(int characterId, SeasonObjective obj, double total) { var character = Character.Get(characterId); @@ -652,7 +593,7 @@ public void SendActivationMailToOnlineCharacters(Season season) RefreshCache(); } - private void NotifyOnlinePlayersSeasonStarted(Season season) + private void NotifySeasonStarted(Season season) { var seasonChannel = _channelManager.Value.GetChannelByName(SeasonChannelName); if (seasonChannel != null) @@ -660,51 +601,79 @@ private void NotifyOnlinePlayersSeasonStarted(Season season) _channelManager.Value.SetTopic(SeasonChannelName, _announcer.Value, $"Season {season.Name}: {season.StartTime} - {season.EndTime}"); } - foreach (var character in _sessionManager.SelectedCharacters) + var announcementParts = BuildSeasonAnnouncementParts(season); + foreach (var part in announcementParts) + _channelManager.Value.Announcement(SeasonChannelName, _announcer.Value, part); + + foreach (var profile in _characterProfileRepository.GetAll()) { + var character = profile.character; if (character == null || character == Character.None) continue; - if (_repository.TryMarkIntroMailSent(character.Id, season.Id)) - SendIntroMail(character, season); + if (_repository.TryMarkAnnouncementMailSent(character.Id, season.Id)) + { + for (int i = 0; i < announcementParts.Count; i++) + { + string subject = announcementParts.Count == 1 + ? $"Season Active: {season.Name}" + : $"Season Active: {season.Name} ({i + 1}/{announcementParts.Count})"; + MailHandler.SendMail(_announcer.Value, character, subject, announcementParts[i], + MailType.character, out _, out _); + } + } } + } - var chatMessage = new StringBuilder(); - chatMessage.AppendLine(); - chatMessage.AppendLine($"New season just started!"); - chatMessage.AppendLine(); - chatMessage.AppendLine(season.Name); - chatMessage.AppendLine(); - chatMessage.AppendLine(season.Description); - chatMessage.AppendLine(); - chatMessage.AppendLine($"Season ends: {season.EndTime:yyyy-MM-dd HH:mm}"); - chatMessage.AppendLine(); - chatMessage.AppendLine("Activity rates:"); - foreach (var rate in _activeRates) - { - string unitDesc = rate.UnitScale > 1 ? $" per {rate.UnitScale:N0}" : ""; - chatMessage.AppendLine($" {ActivityTypeName(rate.ActivityType)}: {rate.PointsPerUnit:G} pts{unitDesc}"); - } + private IReadOnlyList BuildSeasonAnnouncementParts(Season season) + { + var announcement = new StringBuilder(); + announcement.AppendLine(season.Name).AppendLine(); + announcement.AppendLine(season.Description).AppendLine(); + announcement.AppendLine("Objectives:").AppendLine(); - chatMessage.AppendLine(); - chatMessage.AppendLine("Objectives:"); - foreach (var obj in _activeObjectives.OrderBy(o => o.DisplayOrder)) + SeasonActivityType? previousActivityType = null; + foreach (var objective in _activeObjectives + .Where(objective => !objective.IsDaily) + .OrderBy(objective => objective.DisplayOrder)) { - chatMessage.AppendLine($" {obj.Name}: {obj.Description} (Bonus: {obj.BonusPoints} pts)"); - //chatMessage.AppendLine($" Progress by performing {ActivityTypeName(obj.ActivityType)}. Target: {obj.TargetValue:N0}"); + if (previousActivityType.HasValue && previousActivityType.Value != objective.ActivityType) + announcement.AppendLine(); + + announcement.AppendLine($"{objective.Name} - {objective.Description}"); + previousActivityType = objective.ActivityType; } - chatMessage.AppendLine(); - chatMessage.AppendLine("Tiers:"); - foreach (var tier in _activeTiers.OrderBy(t => t.PointsRequired)) + announcement.AppendLine().AppendLine("Tiers:").AppendLine(); + foreach (var tier in _activeTiers.OrderBy(tier => tier.TierNumber)) + announcement.AppendLine($"{tier.TierName} - {tier.PointsRequired}"); + + // Discord prefixes bridged messages with ****: . Leave room for that prefix. + int maxPartLength = DiscordMessageMaxLength - $"**<{_announcer.Value.Nick}>**: ".Length; + return SplitMessage(announcement.ToString(), maxPartLength); + } + + private static IReadOnlyList SplitMessage(string message, int maxPartLength) + { + var parts = new List(); + int offset = 0; + + while (message.Length - offset > maxPartLength) { - chatMessage.AppendLine($" {tier.TierName}: {tier.PointsRequired} points"); + int breakAt = message.LastIndexOf('\n', offset + maxPartLength - 1, maxPartLength); + if (breakAt < offset) + breakAt = offset + maxPartLength; + else + breakAt++; + + parts.Add(message.Substring(offset, breakAt - offset)); + offset = breakAt; } - chatMessage.AppendLine(); - chatMessage.AppendLine("See you on leaderboard!"); + if (offset < message.Length) + parts.Add(message.Substring(offset)); - _channelManager.Value.Announcement(SeasonChannelName, _announcer.Value, chatMessage.ToString()); + return parts; } // ── Helpers ──────────────────────────────────────────────────────────