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 ──────────────────────────────────────────────────────────