Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/codebase/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/db_structure/database_schema_documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
28 changes: 19 additions & 9 deletions src/Perpetuum/Services/Seasons/SeasonRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────

/// <summary>
/// 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.
/// </summary>
public bool TryMarkIntroMailSent(int characterId, int seasonId)
public bool TryMarkAnnouncementMailSent(int characterId, int seasonId)
{
// Ensure row exists
Db.Query(@"
Expand All @@ -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();
Expand Down Expand Up @@ -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)
Expand Down
165 changes: 67 additions & 98 deletions src/Perpetuum/Services/Seasons/SeasonService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<Character> _announcer = new(() => Character.GetByNick(AnnouncerNick));
private readonly Lazy<IChannelManager> _channelManager;
Expand All @@ -34,11 +34,6 @@ public class SeasonService : Process, ISeasonService
private ImmutableList<SeasonTier> _activeTiers = ImmutableList<SeasonTier>.Empty;
private ImmutableList<SeasonLeaderboardReward> _activeLeaderboard = ImmutableList<SeasonLeaderboardReward>.Empty;

// Tracks which season we have already dispatched intro mail for. 0 = never notified.
private volatile int _lastNotifiedSeasonId;
private readonly System.Collections.Concurrent.ConcurrentQueue<Character> _pendingIntroChars
= new System.Collections.Concurrent.ConcurrentQueue<Character>();

// Trigger immediate load on first Update tick
private TimeSpan _cacheAge = CacheRefreshInterval;
private TimeSpan _leaderboardAge = LeaderboardAnnouncementInterval;
Expand All @@ -49,22 +44,16 @@ private sealed record DailyPool(ImmutableHashSet<int> Ids, DateOnly Date);

public SeasonService(
SeasonRepository repository,
ISessionManager sessionManager,
ICharacterProfileRepository characterProfileRepository,
ICustomDictionary customDictionary,
Lazy<IChannelManager> 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)
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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 ──────────────────────────────────────────────────
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -553,31 +519,6 @@ private void AnnounceDailyPool(IReadOnlyList<SeasonObjective> 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);
Expand Down Expand Up @@ -652,59 +593,87 @@ 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)
{
_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<string> 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 **<nick>**: . Leave room for that prefix.
int maxPartLength = DiscordMessageMaxLength - $"**<{_announcer.Value.Nick}>**: ".Length;
return SplitMessage(announcement.ToString(), maxPartLength);
}

private static IReadOnlyList<string> SplitMessage(string message, int maxPartLength)
{
var parts = new List<string>();
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 ──────────────────────────────────────────────────────────
Expand Down
Loading