From d15a5b1ffc29905db8896bfdcce54029712c7524 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 16 May 2026 09:17:20 +0500 Subject: [PATCH 001/151] Fix fleeing NPC speed, training char season exclusion, stale leaderboard announcements IMPROVEMENT-011: FleeAI.Update now re-applies CurrentSpeed=0.75 after each movement update, preventing WaypointMovement.Start from resetting it to 1.0 each waypoint tick. ISSUE-003: RecordActivity guards training characters (IsInTraining) at the service boundary; ProcessSeasonEnd and AnnounceLeaderboard filter them from rankings so training chars accumulate no points, appear on no leaderboards, and receive no rewards. ISSUE-002: AnnounceLeaderboard fixes stale IsActive check (cached Season object stays IsActive=true after admin deactivation) by guarding on season==null and UtcNow>season.EndTime instead; also skips meaningless empty-ranking announcements. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 2 +- docs/backlog/improvements.md | 104 ++++++++++++++++++ docs/backlog/issues.md | 75 +++++++++++++ .../Services/Seasons/SeasonService.cs | 20 +++- src/Perpetuum/Zones/NpcSystem/AI/FleeAI.cs | 6 + 5 files changed, 200 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 53c015b..0dd1a04 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -337,7 +337,7 @@ When asked to: - "implement improvements" Claude should: -1. review backlog files +1. review backlog files, only check what you've been asked to, (e.g. issues or improvements) 2. prioritize unfinished HIGH priority items 3. prefer low-risk/high-impact work unless instructed otherwise 4. produce a short implementation plan diff --git a/docs/backlog/improvements.md b/docs/backlog/improvements.md index 77ce4bd..dbd11e3 100644 --- a/docs/backlog/improvements.md +++ b/docs/backlog/improvements.md @@ -228,6 +228,57 @@ Depends on [[IMPROVEMENT-007]] and [[IMPROVEMENT-008]] for NPC rank/role filteri Target filter should be stored as structured data (e.g. JSON column or normalised filter table) rather than freeform strings to allow reliable matching and Admin Tool rendering. Keep the filter evaluation path lightweight — it runs on every matching game event and must not introduce blocking or excessive allocation in hot paths. +## IMPROVEMENT-012 - Seasons Tiers tab: on-the-fly save generating a single change script + +Status: TODO +Priority: HIGH +Area: Seasons / Admin Tool + +### Description +The Tiers tab in the Seasons Admin Tool currently uses a different save mechanic from the Activity Rates and Objectives tabs. Activity Rates and Objectives already support on-the-fly editing that produces a single consolidated change script per save. The Tiers tab should adopt the same pattern so all three tabs behave consistently. + +### Impact +Inconsistent save mechanics increase operator confusion and risk: a different save flow for Tiers may require multiple manual steps or produce partial scripts, making season adjustments error-prone and harder to audit compared to the Activity Rates / Objectives workflow. + +### Proposed Implementation +- Audit how Activity Rates and Objectives generate their single change script on save — identify the shared pattern (diff computation, script generation, transaction wrapper). +- Refactor or extend that pattern to cover tier definitions (name, point threshold, reward). +- Tiers tab save flow: compute a diff between the current persisted tier state and the edited in-memory state, then emit a single SQL/migration script covering all inserts, updates, and deletes in one transaction. +- The generated script should follow the same format and conventions as those produced by Activity Rates and Objectives saves, so all three can be reviewed and applied uniformly. +- Ensure that editing tiers, activity rates, and objectives in the same session and saving each produces independently coherent scripts — no cross-tab state leakage. + +### Notes +See [[IMPROVEMENT-010]] — the Scoring Balancing tab depends on tiers being editable inline; consistent save mechanics here unblock a clean implementation of that tab. +Preserve existing tier DB schema — this improvement changes the save UI mechanic only, not the underlying data model. + +--- + +## IMPROVEMENT-011 - NPC fleeing state reduces max speed by 25% + +Status: DONE +Priority: CRITICAL +Area: NPCs / AI + +### Description +When an NPC enters the fleeing state its maximum speed should be capped at 75% of its normal maximum speed. The cap must be lifted and the original max speed fully restored as soon as the NPC exits the fleeing state. + +### Impact +Without this penalty a fleeing NPC moves at full speed, making it trivially easy to escape combat. Applying a speed reduction creates a meaningful tactical consequence for the fleeing state and improves gameplay authenticity. + +### Proposed Implementation +- Locate the code path that transitions an NPC into the fleeing state (likely in the AI state machine or NPC behaviour handler). +- On entering fleeing: record the NPC's current max speed, then apply a multiplier of `0.75` to the effective max speed. +- On exiting fleeing: restore the recorded original max speed, regardless of the exit reason (combat re-engagement, death, target lost, etc.). +- Prefer a modifier/buff approach consistent with how other temporary stat changes are applied to NPCs — avoid overwriting the base definition value directly. +- Ensure the speed is recalculated immediately on state transition so the change takes effect within the same update tick. + +### Notes +Verify how max speed is stored and applied for NPCs — consult NPC AI and movement subsystems before implementing. +The 75% cap applies to max speed only; acceleration and other movement parameters are unaffected unless a future improvement specifies otherwise. +Edge case: if the NPC is already speed-debuffed by a player effect, the fleeing cap should compose correctly with existing modifiers rather than overriding them. + +--- + ## IMPROVEMENT-010 - Seasons Scoring Balancing Tab Status: TODO @@ -253,3 +304,56 @@ The activities-to-objective computation is a display convenience; the authoritat Depends on [[IMPROVEMENT-005]] for the full set of activity types surfaced in the rates panel. Depends on [[IMPROVEMENT-009]] for targeted objectives appearing in the objectives panel with their filter displayed. Edits made here must write through the same save paths used by the individual objective and activity rate editors — no parallel write logic. + +--- + +## IMPROVEMENT-013 - Daily objectives grant their own reward packages on completion + +Status: TODO +Priority: MEDIUM +Area: Seasons / Objectives + +### Description +When a player completes a daily objective they should receive a dedicated reward package, separate from and in addition to any season point accumulation. Each daily objective should have a configurable reward package (items, NIC, or other reward types) that is granted immediately on completion. + +### Impact +Without per-completion rewards, daily objectives only contribute points toward season tiers — offering no immediate gratification. Instant reward packages make daily objectives more compelling, encourage consistent daily engagement, and allow designers to tune short-term incentives independently of long-term tier progression. + +### Proposed Implementation +- Extend the daily objective definition to include an optional `reward_package_id` (or equivalent structured reward payload) specifying what is granted on completion. +- On objective completion, trigger the reward grant pipeline with the associated package — reuse the existing reward distribution mechanism (used for season tier rewards or similar) rather than introducing a new path. +- Reward packages should be configurable per objective and per season; different daily objectives within the same season may grant different packages. +- If an objective has no reward package configured, completion behaves as today (points only) — no breaking change to existing objectives. +- Admin Tool: surface the reward package field in the daily objective editor. + +### Notes +Depends on [[IMPROVEMENT-006]] — daily objectives infrastructure must exist before per-completion rewards can be wired in. +Reward packages must be granted exactly once per completion per character per day — idempotency is critical given the daily reset cycle. +Consult the existing tier reward grant path for the reward package schema and delivery mechanism before designing the new hook. + +--- + +## IMPROVEMENT-014 - Standalone daily objectives/missions outside of Seasons + +Status: TODO +Priority: LOW +Area: Objectives / Missions + +### Description +Introduce a daily objective (or daily mission) system that operates independently of the Seasons system. These objectives generate no season points and have no season dependency — they simply reset daily and grant reward packages on completion, available to all players at all times regardless of whether a season is active. + +### Impact +Season-tied daily objectives are only meaningful during an active season, leaving a gap in daily engagement loops during off-season periods. A standalone daily objective system provides consistent daily incentives year-round, retains player engagement between seasons, and caters to players who are not focused on competitive season rankings. + +### Proposed Implementation +- Design the standalone daily objective system as a distinct subsystem from Seasons — it should not depend on a season being active, should not write to season activity or point tables, and should have its own objective definitions, completion tracking, and daily reset scheduling. +- Reuse the daily reset scheduler and objective completion/reward grant mechanisms from [[IMPROVEMENT-006]] and [[IMPROVEMENT-013]] where possible — extract shared infrastructure rather than duplicating it. +- Objective definitions: activity type, target filter (optional, see [[IMPROVEMENT-009]] patterns), completion threshold, reward package. +- Completion tracking: per-character, scoped to the current day's reset window; idempotent reset at UTC midnight (or configurable reset time). +- Reward grant: on completion, deliver the configured reward package via the existing reward distribution path — no points emitted. +- Admin Tool: a dedicated section for managing standalone daily objective templates (create, edit, enable/disable, assign reward packages); separate from the Seasons objective editor. + +### Notes +The absence of point generation is intentional and must be enforced — these objectives must not accidentally write to any season scoring table. +If the daily reset infrastructure from [[IMPROVEMENT-006]] is not yet built, this system should share that implementation rather than introducing a parallel reset scheduler. +Consider whether standalone daily objectives should be visible in the same in-game UI as season daily objectives, or in a separate panel — a clear UX distinction prevents player confusion about what generates season points. diff --git a/docs/backlog/issues.md b/docs/backlog/issues.md index bb86505..269111f 100644 --- a/docs/backlog/issues.md +++ b/docs/backlog/issues.md @@ -19,3 +19,78 @@ Season start/end boundaries may be evaluated incorrectly under non-UTC system ti ### Notes Related columns: `seasons.date_start`, `seasons.date_end`. Any `DateTime.Now` comparisons against these values should become `DateTime.UtcNow`. + +--- + +## ISSUE-002 - Suppress leadership announcements when no active season exists + +Status: DONE +Priority: HIGH +Area: Seasons / Chat + +### Problem +Leadership (top-player/corporation) announcements are broadcast even when there is no active season. This results in meaningless or misleading notifications being sent to players outside of any season window. + +### Impact +Players receive leadership announcements during inactive periods, causing confusion about season state and degrading trust in the announcement system. + +### Proposed Fix +- Before broadcasting any leadership announcement, check whether an active season currently exists. +- If no season is active, skip the announcement entirely. +- Reuse the existing active-season lookup pattern (e.g. `SeasonService` / `GetCurrentSeason`) rather than introducing a new query. + +### Notes +Related to the announcements added in the chat announcement feature (feat: float points, chat announcements, NIC filtering, anti-farming). +Ensure the guard is applied to all leadership announcement sites, not just one code path. + +--- + +## ISSUE-003 - Training characters must be excluded from Seasons participation and rewards + +Status: DONE +Priority: CRITICAL +Area: Seasons / Characters + +### Problem +Characters in training (tutorial/training state) are not currently excluded from Season participation. They can accumulate season activity points and receive season rewards, which is unintended — training characters are not fully active players and should have no influence on season standings or reward distribution. + +### Impact +Training characters polluting season standings undermines competitive integrity. They may also consume reward resources (NIC, items) that should only go to active, graduated players. + +### Proposed Fix +- Identify the flag or state that marks a character as "in training" — locate the relevant character property or DB column. +- Add a training-character guard at all Season entry points: + - Activity point accumulation: skip recording any points for training characters. + - Leaderboard queries: exclude training characters from standings. + - Reward distribution: skip reward grants for training characters at season end. +- Prefer a single shared predicate (e.g. `character.IsInTraining`) checked at the boundary rather than scattered inline checks. +- Ensure the guard covers both real-time activity tracking and any batch/end-of-season processing. + +### Notes +Verify the exact field or state that identifies a training character before implementing — consult character schema in `docs/db_structure/`. +The exclusion must be silent from the training character's perspective — no error, just no season interaction. +If training characters can graduate mid-season, define whether they retroactively become eligible or only participate from graduation onward (recommend: from graduation onward, no backfill). + +--- + +## ISSUE-004 - Avg. Points / Day shows negative values in Seasons Participation Health + +Status: TODO +Priority: LOW +Area: Seasons / Admin Tool + +### Problem +The "Avg. Points / Day" metric on the Seasons Participation Health view can display negative values, which is not a meaningful state for an average daily point rate. + +### Impact +Negative values are confusing to operators and indicate a calculation or data bug — they erode trust in the health dashboard and may mask real participation trends. + +### Proposed Fix +- Locate the query or computation that produces the Avg. Points / Day value. +- Identify the root cause: likely a division involving an elapsed-day count that can be zero or negative (e.g. when the season hasn't started yet, or when date arithmetic produces an unexpected sign). +- Guard against zero or negative elapsed days in the divisor — clamp to a minimum of 1 day or return `null`/`0` when no meaningful average can be computed. +- Ensure the displayed value is floored at zero; negative output should never reach the UI. + +### Notes +Check whether the issue occurs only before/at season start or also mid-season. +If the underlying data (total points) can itself be negative due to a separate bug, that should be treated as a distinct issue and not masked by clamping here. diff --git a/src/Perpetuum/Services/Seasons/SeasonService.cs b/src/Perpetuum/Services/Seasons/SeasonService.cs index 788f56b..f8a649d 100644 --- a/src/Perpetuum/Services/Seasons/SeasonService.cs +++ b/src/Perpetuum/Services/Seasons/SeasonService.cs @@ -139,6 +139,9 @@ public void RecordActivity(int characterId, SeasonActivityType activityType, lon if (season == null || DateTime.UtcNow > season.EndTime) return; + if (Character.Get(characterId).IsInTraining()) + return; + var rates = _activeRates.Where(r => r.ActivityType == activityType).ToList(); if (rates.Count == 0) return; @@ -235,7 +238,9 @@ private void ProcessSeasonEnd(Season season) _lastNotifiedSeasonId = 0; _repository.DeactivateSeason(season.Id); - var rankings = _repository.GetParticipantRankings(season.Id); + var rankings = _repository.GetParticipantRankings(season.Id) + .Where(r => !Character.Get(r.CharacterId).IsInTraining()) + .ToList(); var leaderboard = _activeLeaderboard; for (int rank = 1; rank <= rankings.Count; rank++) @@ -280,15 +285,18 @@ private void ProcessSeasonEnd(Season season) _channelManager.Value.Announcement(SeasonChannelName, _announcer.Value, chatMessage.ToString()); } - internal void AnnounceLeaderboard(Season season) + internal void AnnounceLeaderboard(Season? season) { - if (_activeSeason == null || _activeSeason.IsActive == false) - { + if (season == null || _activeSeason == null || DateTime.UtcNow > season.EndTime) return; - } - var rankings = _repository.GetParticipantRankings(season.Id); + var rankings = _repository.GetParticipantRankings(season.Id) + .Where(r => !Character.Get(r.CharacterId).IsInTraining()) + .ToList(); int displayCount = Math.Min(10, rankings.Count); + + if (displayCount == 0) + return; var chatMessage = new StringBuilder(); chatMessage.AppendLine(); chatMessage.AppendLine($"Top {displayCount} of this season:"); diff --git a/src/Perpetuum/Zones/NpcSystem/AI/FleeAI.cs b/src/Perpetuum/Zones/NpcSystem/AI/FleeAI.cs index a649044..8b8d523 100644 --- a/src/Perpetuum/Zones/NpcSystem/AI/FleeAI.cs +++ b/src/Perpetuum/Zones/NpcSystem/AI/FleeAI.cs @@ -61,6 +61,12 @@ public override void Update(TimeSpan time) movement = null; StartRetreatPath(); } + else + { + // WaypointMovement.Start resets CurrentSpeed to 1.0 on each new waypoint; + // re-apply the 75% cap so the NPC actually moves slower while fleeing. + smartCreature.CurrentSpeed = 0.75; + } } base.Update(time); From 33aa7345ff45a6c5bd961bc843bc42bdd2c5b89e Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 16 May 2026 10:42:30 +0500 Subject: [PATCH 002/151] feat(seasons): add Phase 1 activity type enum values and display names --- .../Services/Seasons/SeasonActivityType.cs | 23 ++++++++++++------- .../Services/Seasons/SeasonService.cs | 23 +++++++++++-------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/Perpetuum/Services/Seasons/SeasonActivityType.cs b/src/Perpetuum/Services/Seasons/SeasonActivityType.cs index 917e24c..6231ca1 100644 --- a/src/Perpetuum/Services/Seasons/SeasonActivityType.cs +++ b/src/Perpetuum/Services/Seasons/SeasonActivityType.cs @@ -2,13 +2,20 @@ namespace Perpetuum.Services.Seasons { public enum SeasonActivityType { - NpcKill = 1, - PvpKill = 2, - MissionComplete = 3, - MineralMined = 4, - EpSpent = 5, - NicEarned = 6, - NicSpent = 7, - IntrusionPoint = 8, + NpcKill = 1, + PvpKill = 2, + MissionComplete = 3, + MineralMined = 4, + EpSpent = 5, + NicEarned = 6, + NicSpent = 7, + IntrusionPoint = 8, + + // Phase 1 — non-combat + Prototyping = 9, + ReverseEngineering = 10, + Production = 11, + ArtifactFound = 12, + EpEarned = 13, } } diff --git a/src/Perpetuum/Services/Seasons/SeasonService.cs b/src/Perpetuum/Services/Seasons/SeasonService.cs index f8a649d..c9d4179 100644 --- a/src/Perpetuum/Services/Seasons/SeasonService.cs +++ b/src/Perpetuum/Services/Seasons/SeasonService.cs @@ -471,15 +471,20 @@ private static string Translate(string key, Dictionary? dict) private static string ActivityTypeName(SeasonActivityType type) => type switch { - SeasonActivityType.NpcKill => "NPC Kill", - SeasonActivityType.PvpKill => "PvP Kill", - SeasonActivityType.MissionComplete => "Mission Completed", - SeasonActivityType.MineralMined => "Mineral Mined", - SeasonActivityType.EpSpent => "EP Spent", - SeasonActivityType.NicEarned => "NIC Earned", - SeasonActivityType.NicSpent => "NIC Spent", - SeasonActivityType.IntrusionPoint => "Intrusion SAP", - _ => type.ToString(), + SeasonActivityType.NpcKill => "NPC Kill", + SeasonActivityType.PvpKill => "PvP Kill", + SeasonActivityType.MissionComplete => "Mission Completed", + SeasonActivityType.MineralMined => "Mineral Mined", + SeasonActivityType.EpSpent => "EP Spent", + SeasonActivityType.NicEarned => "NIC Earned", + SeasonActivityType.NicSpent => "NIC Spent", + SeasonActivityType.IntrusionPoint => "Intrusion SAP", + SeasonActivityType.Prototyping => "Prototyping", + SeasonActivityType.ReverseEngineering => "Reverse Engineering", + SeasonActivityType.Production => "Production", + SeasonActivityType.ArtifactFound => "Artifact Found", + SeasonActivityType.EpEarned => "EP Earned", + _ => type.ToString(), }; } } From d6e453caf14216f601b7ef18b9c6ca5eb17753f2 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 16 May 2026 10:46:27 +0500 Subject: [PATCH 003/151] feat(seasons): wire Prototyping, ReverseEngineering, Production activity hooks --- .../ProductionEngine/ProductionProcessor.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/Perpetuum/Services/ProductionEngine/ProductionProcessor.cs b/src/Perpetuum/Services/ProductionEngine/ProductionProcessor.cs index 1093733..3269ed3 100644 --- a/src/Perpetuum/Services/ProductionEngine/ProductionProcessor.cs +++ b/src/Perpetuum/Services/ProductionEngine/ProductionProcessor.cs @@ -19,6 +19,7 @@ using Perpetuum.Services.ProductionEngine.CalibrationPrograms; using Perpetuum.Services.ProductionEngine.Facilities; using Perpetuum.Services.ProductionEngine.ResearchKits; +using Perpetuum.Services.Seasons; using Perpetuum.Timers; using Perpetuum.Zones; @@ -239,6 +240,20 @@ private void EndProduction(ProductionInProgress productionInProgress, bool force productionInProgress.character.AddExtensionPointsBoostAndLog( EpForActivityType.Production, ep); + var seasonType = productionInProgress.type switch + { + ProductionInProgressType.prototype => (SeasonActivityType?)SeasonActivityType.Prototyping, + ProductionInProgressType.research => SeasonActivityType.ReverseEngineering, + ProductionInProgressType.massProduction => SeasonActivityType.Production, + _ => null, + }; + if (seasonType.HasValue) + { + SeasonServiceLocator.Instance?.RecordActivity( + productionInProgress.character.Id, + seasonType.Value, + productionInProgress.amountOfCycles); + } if (replyDict != null) { From 98e3098a2947d475e818e39bf26c06b91d28e487 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 16 May 2026 10:49:25 +0500 Subject: [PATCH 004/151] feat(seasons): wire ArtifactFound activity hook (scanner and relic paths) --- src/Perpetuum/Services/Relics/Relics/AbstractRelic.cs | 2 ++ src/Perpetuum/Zones/Artifacts/Scanners/ArtifactScanner.cs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/Perpetuum/Services/Relics/Relics/AbstractRelic.cs b/src/Perpetuum/Services/Relics/Relics/AbstractRelic.cs index 9425824..6935e18 100644 --- a/src/Perpetuum/Services/Relics/Relics/AbstractRelic.cs +++ b/src/Perpetuum/Services/Relics/Relics/AbstractRelic.cs @@ -2,6 +2,7 @@ using Perpetuum.ExportedTypes; using Perpetuum.Players; using Perpetuum.Services.Looting; +using Perpetuum.Services.Seasons; using Perpetuum.Threading; using Perpetuum.Units; using Perpetuum.Zones; @@ -148,6 +149,7 @@ public virtual void PopRelic(Player player) { LootContainer.Create().SetOwner(player).SetEnterBeamType(BeamType.loot_bolt).AddLoot(_loots.LootItems).BuildAndAddToZone(_zone, CurrentPosition); if (ep > 0) player.Character.AddExtensionPointsBoostAndLog(EpForActivityType.Artifact, ep); + SeasonServiceLocator.Instance?.RecordActivity(player.Character.Id, SeasonActivityType.ArtifactFound, 1); scope.Complete(); } }); diff --git a/src/Perpetuum/Zones/Artifacts/Scanners/ArtifactScanner.cs b/src/Perpetuum/Zones/Artifacts/Scanners/ArtifactScanner.cs index cef32bb..a2fc901 100644 --- a/src/Perpetuum/Zones/Artifacts/Scanners/ArtifactScanner.cs +++ b/src/Perpetuum/Zones/Artifacts/Scanners/ArtifactScanner.cs @@ -4,6 +4,7 @@ using Perpetuum.Players; using Perpetuum.Services.Looting; using Perpetuum.Services.MissionEngine.MissionTargets; +using Perpetuum.Services.Seasons; using Perpetuum.Units; using Perpetuum.Zones.Artifacts.Generators; using Perpetuum.Zones.Artifacts.Generators.Loot; @@ -59,6 +60,7 @@ public IEnumerable Scan(Player player, int scanRange, double var ep = _zone.Configuration.IsBeta ? 10 : 5; if (_zone.Configuration.Type == ZoneType.Training) ep = 0; if (ep > 0) player.Character.AddExtensionPointsBoostAndLog(EpForActivityType.Artifact, ep); + SeasonServiceLocator.Instance?.RecordActivity(player.Character.Id, SeasonActivityType.ArtifactFound, 1); player.MissionHandler.EnqueueMissionEventInfo(new FindArtifactEventInfo(player, artifact.Info.type, artifact.Position)); } From 6f68d8bf66c1613ee8e76417c261297b8f16c8ca Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 16 May 2026 10:52:10 +0500 Subject: [PATCH 005/151] feat(seasons): wire EpEarned activity hook (activity boosts and passive daily grant) --- src/Perpetuum/Accounting/AccountManager.cs | 1 + .../Services/ExtensionService/GiveExtensionPointsService.cs | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/src/Perpetuum/Accounting/AccountManager.cs b/src/Perpetuum/Accounting/AccountManager.cs index c5f01c0..b319572 100644 --- a/src/Perpetuum/Accounting/AccountManager.cs +++ b/src/Perpetuum/Accounting/AccountManager.cs @@ -380,6 +380,7 @@ public int AddExtensionPointsBoostAndLog(Account account, Character character, E }); AddExtensionPoints(account, boostedPoints); + SeasonServiceLocator.Instance?.RecordActivity(character.Id, SeasonActivityType.EpEarned, boostedPoints); return boostedPoints; } diff --git a/src/Perpetuum/Services/ExtensionService/GiveExtensionPointsService.cs b/src/Perpetuum/Services/ExtensionService/GiveExtensionPointsService.cs index 210feac..f20f342 100644 --- a/src/Perpetuum/Services/ExtensionService/GiveExtensionPointsService.cs +++ b/src/Perpetuum/Services/ExtensionService/GiveExtensionPointsService.cs @@ -3,6 +3,7 @@ using Perpetuum.Accounting.Characters; using Perpetuum.Data; using Perpetuum.Log; +using Perpetuum.Services.Seasons; using Perpetuum.Threading.Process; namespace Perpetuum.Services.ExtensionService @@ -73,6 +74,8 @@ public void InformAffectedCharacters(DateTime now) var affectedLeechers = grp.Select(r => Character.Get(r.GetValue(0))).Distinct().ToArray(); Logger.Info($"Daily Extension Point Add: {affectedLeechers.Length} characters will be informed with point {BASEPOINTS} - leechers."); ExtensionHelper.CreateExtensionPointsIncreasedMessage(BASEPOINTS).ToCharacters(affectedLeechers).Send(); + foreach (var c in affectedLeechers) + SeasonServiceLocator.Instance?.RecordActivity(c.Id, SeasonActivityType.EpEarned, BASEPOINTS); } else { @@ -80,6 +83,8 @@ public void InformAffectedCharacters(DateTime now) var affectedPayingCustomers = grp.Select(r => Character.Get(r.GetValue(0))).Distinct().ToArray(); Logger.Info($"Daily Extension Point Add: {affectedPayingCustomers.Length} characters will be informed with point {BONUSPOINTS} - good guys."); ExtensionHelper.CreateExtensionPointsIncreasedMessage(BONUSPOINTS).ToCharacters(affectedPayingCustomers).Send(); + foreach (var c in affectedPayingCustomers) + SeasonServiceLocator.Instance?.RecordActivity(c.Id, SeasonActivityType.EpEarned, BONUSPOINTS); } } } From 55684e65571c31d1662b59c08cf5aacf86099237 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 16 May 2026 10:55:15 +0500 Subject: [PATCH 006/151] feat(seasons): add Phase 2 activity type enum values and display names --- .../Services/Seasons/SeasonActivityType.cs | 35 ++++++++++++------- .../Services/Seasons/SeasonService.cs | 35 +++++++++++-------- 2 files changed, 43 insertions(+), 27 deletions(-) diff --git a/src/Perpetuum/Services/Seasons/SeasonActivityType.cs b/src/Perpetuum/Services/Seasons/SeasonActivityType.cs index 6231ca1..6bdc7bc 100644 --- a/src/Perpetuum/Services/Seasons/SeasonActivityType.cs +++ b/src/Perpetuum/Services/Seasons/SeasonActivityType.cs @@ -2,20 +2,29 @@ namespace Perpetuum.Services.Seasons { public enum SeasonActivityType { - NpcKill = 1, - PvpKill = 2, - MissionComplete = 3, - MineralMined = 4, - EpSpent = 5, - NicEarned = 6, - NicSpent = 7, - IntrusionPoint = 8, + NpcKill = 1, + PvpKill = 2, + MissionComplete = 3, + MineralMined = 4, + EpSpent = 5, + NicEarned = 6, + NicSpent = 7, + IntrusionPoint = 8, // Phase 1 — non-combat - Prototyping = 9, - ReverseEngineering = 10, - Production = 11, - ArtifactFound = 12, - EpEarned = 13, + Prototyping = 9, + ReverseEngineering = 10, + Production = 11, + ArtifactFound = 12, + EpEarned = 13, + + // Phase 2 — combat + DamageDone = 14, + DamageReceived = 15, + ArmorRestored = 16, + EnergyDrainDealt = 17, + EnergyDrainReceived = 18, + EnergyTransferDealt = 19, + EnergyTransferReceived = 20, } } diff --git a/src/Perpetuum/Services/Seasons/SeasonService.cs b/src/Perpetuum/Services/Seasons/SeasonService.cs index c9d4179..62cca44 100644 --- a/src/Perpetuum/Services/Seasons/SeasonService.cs +++ b/src/Perpetuum/Services/Seasons/SeasonService.cs @@ -471,20 +471,27 @@ private static string Translate(string key, Dictionary? dict) private static string ActivityTypeName(SeasonActivityType type) => type switch { - SeasonActivityType.NpcKill => "NPC Kill", - SeasonActivityType.PvpKill => "PvP Kill", - SeasonActivityType.MissionComplete => "Mission Completed", - SeasonActivityType.MineralMined => "Mineral Mined", - SeasonActivityType.EpSpent => "EP Spent", - SeasonActivityType.NicEarned => "NIC Earned", - SeasonActivityType.NicSpent => "NIC Spent", - SeasonActivityType.IntrusionPoint => "Intrusion SAP", - SeasonActivityType.Prototyping => "Prototyping", - SeasonActivityType.ReverseEngineering => "Reverse Engineering", - SeasonActivityType.Production => "Production", - SeasonActivityType.ArtifactFound => "Artifact Found", - SeasonActivityType.EpEarned => "EP Earned", - _ => type.ToString(), + SeasonActivityType.NpcKill => "NPC Kill", + SeasonActivityType.PvpKill => "PvP Kill", + SeasonActivityType.MissionComplete => "Mission Completed", + SeasonActivityType.MineralMined => "Mineral Mined", + SeasonActivityType.EpSpent => "EP Spent", + SeasonActivityType.NicEarned => "NIC Earned", + SeasonActivityType.NicSpent => "NIC Spent", + SeasonActivityType.IntrusionPoint => "Intrusion SAP", + SeasonActivityType.Prototyping => "Prototyping", + SeasonActivityType.ReverseEngineering => "Reverse Engineering", + SeasonActivityType.Production => "Production", + SeasonActivityType.ArtifactFound => "Artifact Found", + SeasonActivityType.EpEarned => "EP Earned", + SeasonActivityType.DamageDone => "Damage Done", + SeasonActivityType.DamageReceived => "Damage Received", + SeasonActivityType.ArmorRestored => "Armor Restored", + SeasonActivityType.EnergyDrainDealt => "Energy Drained (Dealt)", + SeasonActivityType.EnergyDrainReceived => "Energy Drained (Received)", + SeasonActivityType.EnergyTransferDealt => "Energy Transferred (Dealt)", + SeasonActivityType.EnergyTransferReceived => "Energy Transferred (Received)", + _ => type.ToString(), }; } } From 822962560003df716c6e66163880a4f9630b4cc0 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 16 May 2026 10:56:39 +0500 Subject: [PATCH 007/151] feat(seasons): wire DamageDone and DamageReceived activity hooks --- src/Perpetuum/Units/Unit.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Perpetuum/Units/Unit.cs b/src/Perpetuum/Units/Unit.cs index 42df8be..849bbcb 100644 --- a/src/Perpetuum/Units/Unit.cs +++ b/src/Perpetuum/Units/Unit.cs @@ -9,6 +9,7 @@ using Perpetuum.Players; using Perpetuum.Robots; using Perpetuum.Services.RiftSystem; +using Perpetuum.Services.Seasons; using Perpetuum.Timers; using Perpetuum.Units.ItemProperties; using Perpetuum.Units.UnitProperties; @@ -403,6 +404,15 @@ protected virtual void OnDamageTaken(Unit source, DamageTakenEventArgs e) Armor -= e.TotalDamage; + var damageAmount = (long)e.TotalDamage; + if (damageAmount > 0) + { + if (source is Player attacker) + SeasonServiceLocator.Instance?.RecordActivity(attacker.Character.Id, SeasonActivityType.DamageDone, damageAmount); + if (this is Player victim) + SeasonServiceLocator.Instance?.RecordActivity(victim.Character.Id, SeasonActivityType.DamageReceived, damageAmount); + } + OnCombatEvent(source, e); if (Armor <= 0.0) From 99471cb98f7a83263fb5e238049a8af82c005e03 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 16 May 2026 11:25:23 +0500 Subject: [PATCH 008/151] feat(seasons): wire ArmorRestored activity hook (local and remote repair modules) --- src/Perpetuum/Modules/ArmorRepairModule.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Perpetuum/Modules/ArmorRepairModule.cs b/src/Perpetuum/Modules/ArmorRepairModule.cs index b60bb03..5a013f7 100644 --- a/src/Perpetuum/Modules/ArmorRepairModule.cs +++ b/src/Perpetuum/Modules/ArmorRepairModule.cs @@ -1,6 +1,8 @@ using Perpetuum.EntityFramework; using Perpetuum.ExportedTypes; using Perpetuum.Modules.ModuleProperties; +using Perpetuum.Players; +using Perpetuum.Services.Seasons; using Perpetuum.Units; using Perpetuum.Zones; using Perpetuum.Zones.NpcSystem.ThreatManaging; @@ -62,6 +64,10 @@ protected void OnRepair(Unit target, double amount) packet.AppendDouble(amount); packet.AppendDouble(total); packet.Send(target, ParentRobot); + + var repaired = (long)total; + if (repaired > 0 && ParentRobot is Player repairer) + SeasonServiceLocator.Instance?.RecordActivity(repairer.Character.Id, SeasonActivityType.ArmorRestored, repaired); } } From c004fc20f0aee909cee941d37deb8b7a45532639 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 16 May 2026 11:29:04 +0500 Subject: [PATCH 009/151] feat(seasons): wire EnergyDrainDealt and EnergyDrainReceived activity hooks --- src/Perpetuum/Modules/EnergyNeutralizerModule.cs | 13 ++++++++++++- src/Perpetuum/Modules/EnergyVampireModule.cs | 11 +++++++++++ src/Perpetuum/Modules/ScorcherModule.cs | 11 +++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/Perpetuum/Modules/EnergyNeutralizerModule.cs b/src/Perpetuum/Modules/EnergyNeutralizerModule.cs index 2a3f856..c73d54a 100644 --- a/src/Perpetuum/Modules/EnergyNeutralizerModule.cs +++ b/src/Perpetuum/Modules/EnergyNeutralizerModule.cs @@ -3,6 +3,8 @@ using Perpetuum.ExportedTypes; using Perpetuum.Items; using Perpetuum.Modules.ModuleProperties; +using Perpetuum.Players; +using Perpetuum.Services.Seasons; using Perpetuum.Units; using Perpetuum.Zones; using Perpetuum.Zones.Locking.Locks; @@ -44,7 +46,7 @@ protected override void OnAction() ModifyValueByReactorRadiation(unitLock.Target,ref coreNeutralized); coreNeutralized = ModifyValueByOptimalRange(unitLock.Target,coreNeutralized); - + if ( coreNeutralized > 0.0 ) { var core = unitLock.Target.Core; @@ -56,6 +58,15 @@ protected override void OnAction() var threatValue = (coreNeutralizedDone / 2) + 1; unitLock.Target.AddThreat(ParentRobot, new Threat(ThreatType.EnWar, threatValue)); + + var drainAmount = (long)coreNeutralizedDone; + if (drainAmount > 0) + { + if (ParentRobot is Player attacker) + SeasonServiceLocator.Instance?.RecordActivity(attacker.Character.Id, SeasonActivityType.EnergyDrainDealt, drainAmount); + if (unitLock.Target is Player victim) + SeasonServiceLocator.Instance?.RecordActivity(victim.Character.Id, SeasonActivityType.EnergyDrainReceived, drainAmount); + } } var packet = new CombatLogPacket(CombatLogType.EnergyNeutralize, unitLock.Target, ParentRobot, this); diff --git a/src/Perpetuum/Modules/EnergyVampireModule.cs b/src/Perpetuum/Modules/EnergyVampireModule.cs index 9875dd6..2ac71be 100644 --- a/src/Perpetuum/Modules/EnergyVampireModule.cs +++ b/src/Perpetuum/Modules/EnergyVampireModule.cs @@ -3,6 +3,8 @@ using Perpetuum.ExportedTypes; using Perpetuum.Items; using Perpetuum.Modules.ModuleProperties; +using Perpetuum.Players; +using Perpetuum.Services.Seasons; using Perpetuum.Units; using Perpetuum.Zones; using Perpetuum.Zones.Locking.Locks; @@ -63,6 +65,15 @@ protected override void OnAction() ParentRobot.Core += coreNeutralized; coreTransfered = Math.Abs(core - ParentRobot.Core); unitLock.Target.AddThreat(ParentRobot, new Threat(ThreatType.EnWar, coreTransfered + 1)); + + var drainAmount = (long)coreNeutralized; + if (drainAmount > 0) + { + if (ParentRobot is Player attacker) + SeasonServiceLocator.Instance?.RecordActivity(attacker.Character.Id, SeasonActivityType.EnergyDrainDealt, drainAmount); + if (unitLock.Target is Player victim) + SeasonServiceLocator.Instance?.RecordActivity(victim.Character.Id, SeasonActivityType.EnergyDrainReceived, drainAmount); + } } var packet = new CombatLogPacket(CombatLogType.EnergyVampire, unitLock.Target, ParentRobot, this); diff --git a/src/Perpetuum/Modules/ScorcherModule.cs b/src/Perpetuum/Modules/ScorcherModule.cs index 92c02da..130c725 100644 --- a/src/Perpetuum/Modules/ScorcherModule.cs +++ b/src/Perpetuum/Modules/ScorcherModule.cs @@ -3,7 +3,9 @@ using Perpetuum.Items; using Perpetuum.Modules.ModuleProperties; using Perpetuum.Modules.Weapons; +using Perpetuum.Players; using Perpetuum.Robots; +using Perpetuum.Services.Seasons; using Perpetuum.Units; using Perpetuum.Zones; using Perpetuum.Zones.Beams; @@ -87,6 +89,15 @@ protected override void OnAction() double threatValue = (coreNeutralizedDone / 2) + 1; target.AddThreat(ParentRobot, new Threat(ThreatType.EnWar, threatValue)); + + var drainAmount = (long)coreNeutralizedDone; + if (drainAmount > 0) + { + if (ParentRobot is Player attacker) + SeasonServiceLocator.Instance?.RecordActivity(attacker.Character.Id, SeasonActivityType.EnergyDrainDealt, drainAmount); + if (target is Player victim) + SeasonServiceLocator.Instance?.RecordActivity(victim.Character.Id, SeasonActivityType.EnergyDrainReceived, drainAmount); + } } IDamageBuilder builder = GetDamageBuilder(coreNeutralizedDone); From 36c5ac891afa4103eccb2356ef8b0e0a79805434 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 16 May 2026 11:37:05 +0500 Subject: [PATCH 010/151] feat(seasons): wire EnergyTransferDealt and EnergyTransferReceived activity hooks --- src/Perpetuum/Modules/EnergyTransfererModule.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Perpetuum/Modules/EnergyTransfererModule.cs b/src/Perpetuum/Modules/EnergyTransfererModule.cs index 1ffd387..f61e962 100644 --- a/src/Perpetuum/Modules/EnergyTransfererModule.cs +++ b/src/Perpetuum/Modules/EnergyTransfererModule.cs @@ -2,6 +2,8 @@ using Perpetuum.ExportedTypes; using Perpetuum.Items; using Perpetuum.Modules.ModuleProperties; +using Perpetuum.Players; +using Perpetuum.Services.Seasons; using Perpetuum.Units; using Perpetuum.Zones; using Perpetuum.Zones.Locking.Locks; @@ -53,6 +55,11 @@ protected override void OnAction() unitLock.Target.Core += coreNeutralized; coreTransfered = Math.Abs(targetCore - unitLock.Target.Core); unitLock.Target.SpreadAssistThreatToNpcs(ParentRobot, new Threat(ThreatType.Support, coreAmount * 2)); + + if (ParentRobot is Player giver && coreNeutralized > 0.0) + SeasonServiceLocator.Instance?.RecordActivity(giver.Character.Id, SeasonActivityType.EnergyTransferDealt, (long)coreNeutralized); + if (unitLock.Target is Player receiver && coreTransfered > 0.0) + SeasonServiceLocator.Instance?.RecordActivity(receiver.Character.Id, SeasonActivityType.EnergyTransferReceived, (long)coreTransfered); } CombatLogPacket packet = new CombatLogPacket(CombatLogType.EnergyTransfer, unitLock.Target, ParentRobot, this); From 3a3f92657f5eaa3783bd875c9cee6864099c72ab Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 16 May 2026 12:51:54 +0500 Subject: [PATCH 011/151] docs: mark IMPROVEMENT-005 done, add ISSUE-005 and ISSUE-006 to backlog --- docs/backlog/improvements.md | 62 +++++++++++++++++++++++++++--------- docs/backlog/issues.md | 42 ++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 15 deletions(-) diff --git a/docs/backlog/improvements.md b/docs/backlog/improvements.md index dbd11e3..2b4f95a 100644 --- a/docs/backlog/improvements.md +++ b/docs/backlog/improvements.md @@ -100,29 +100,36 @@ See [[IMPROVEMENT-003]] for the related Item Designer — shared UI patterns (st ## IMPROVEMENT-005 - Seasons: Additional Activity Types -Status: TODO +Status: DONE Priority: MEDIUM Area: Seasons / Activities ### Description -Expand the Seasons activity tracking system with new activity types beyond the current set. Candidate types include: production runs, artifacting, module or deployable usage, and island visitation. Each new type should integrate with the existing scoring and point-accumulation pipeline. +Expand the Seasons activity tracking system with 12 new activity types implemented in two phases. All types integrate with the existing `RecordActivity` pipeline with no DB schema changes. -### Impact -A broader set of tracked activities makes seasons more engaging for a wider range of playstyles (industrialists, explorers, etc.), not just combat-focused players. It also provides more levers for season designers to tune the competitive balance of each season's objectives. +### Phase 1 — Non-combat types (enum values 9–13) +- `Prototyping` (9) — hook in ProductionProcessor.cs at job completion, branch on job type +- `ReverseEngineering` (10) — same hook, different job type branch +- `Production` (11) — same hook, combined items + robots +- `ArtifactFound` (12) — hook in ArtifactScanner.cs after EP boost call; amount = 1 +- `EpEarned` (13) — hook all `AddExtensionPointsBoostAndLog` call sites + passive EP accumulation path -### Proposed Implementation -- **Production** — award points when a production job completes; parameterisable by item category, tier, or quantity produced. -- **Artifacting** — award points on successful artifact scan/loot events; parameterisable by artifact tier or island type. -- **Module / Deployable Usage** — award points when a specific module type or deployable is activated/deployed; parameterisable by module category or deployable type. -- **Island Visitation** — award points the first time (or each time, configurable) a character enters a specific island or island category (alpha/beta/gamma) within a season. -- Each new activity type should follow the existing activity handler pattern: a discrete handler class, registration in the activity type registry, and a corresponding `season_activity_types` DB record. -- Point values and activity parameters should remain data-driven (DB/config) rather than hardcoded, consistent with existing activity types. -- Ensure anti-farming guards (cooldowns, per-session caps) can be configured per activity type, consistent with [[IMPROVEMENT-001]] recurring season design. +### Phase 2 — Combat types (enum values 14–20) +- `DamageDone` (14) / `DamageReceived` (15) — hook in TakeDamage/ApplyDamageResult; amount = HP dealt +- `ArmorRestored` (16) — hook repair module application; character = repairer; amount = HP restored +- `EnergyDrainDealt` (17) / `EnergyDrainReceived` (18) — neutralizer + drainer modules; amount = energy removed +- `EnergyTransferDealt` (19) / `EnergyTransferReceived` (20) — transfer module; amount = energy transferred + +### Anti-farming +Handled via `unit_scale` in rates (set high for high-frequency types). Training character filter applies automatically. No new cap infrastructure needed. + +### Spec +`docs/superpowers/specs/2026-05-16-improvement-005-additional-activity-types-design.md` ### Notes -Audit existing activity tracking hooks in the production, scanning, module, and zone subsystems before wiring new event sources — prefer tapping existing domain events over introducing new ones. -Island visitation tracking must be zone-thread-safe; consult zone update loop constraints in `docs/CONCERNS.md`. -Anti-farming considerations are especially important for high-frequency events (module usage, production) — caps must be configurable at the season level. +Distance Travelled was deferred — see [[IMPROVEMENT-015]]. +Verify passive EP accumulation call site (AccountManager.cs or dedicated scheduler) before wiring EpEarned. +Confirm NPCs do not have character IDs that would cause accidental season point accumulation on DamageReceived. ## IMPROVEMENT-006 - Daily Objectives @@ -357,3 +364,28 @@ Season-tied daily objectives are only meaningful during an active season, leavin The absence of point generation is intentional and must be enforced — these objectives must not accidentally write to any season scoring table. If the daily reset infrastructure from [[IMPROVEMENT-006]] is not yet built, this system should share that implementation rather than introducing a parallel reset scheduler. Consider whether standalone daily objectives should be visible in the same in-game UI as season daily objectives, or in a separate panel — a clear UX distinction prevents player confusion about what generates season points. + +--- + +## IMPROVEMENT-015 - Seasons: Distance Travelled Activity Type + +Status: TODO +Priority: LOW +Area: Seasons / Activities + +### Problem +Distance travelled was scoped out of [[IMPROVEMENT-005]] due to zone-thread-safety concerns. There is no existing hook point for movement/distance metrics in the zone update loop, and per-movement-event `RecordActivity` calls would be too frequent. + +### Impact +Without this type, season designers cannot reward exploration or movement-intensive playstyles. It is a lower-priority gap since the 12 types from IMPROVEMENT-005 already cover most playstyle categories. + +### Proposed Fix +- Instrument the zone movement system to accumulate distance per character over a configurable tick interval (e.g. every 5 seconds) +- At the end of each interval, emit a single `RecordActivity(characterId, DistanceTravelled, accumulatedDistance)` call +- The accumulator must be zone-thread-safe — stored per-unit alongside other movement state, written only from the zone update loop +- Amount unit: metres (or internal distance units); `unit_scale` in rates handles point conversion + +### Notes +Accumulation interval should be configurable to avoid excessive DB writes in high-population zones. +Must not introduce blocking or allocation in the hot movement path — accumulate, don't write inline. +Consult `docs/CONCERNS.md` zone update loop constraints before implementation. diff --git a/docs/backlog/issues.md b/docs/backlog/issues.md index 269111f..8c30ef4 100644 --- a/docs/backlog/issues.md +++ b/docs/backlog/issues.md @@ -94,3 +94,45 @@ Negative values are confusing to operators and indicate a calculation or data bu ### Notes Check whether the issue occurs only before/at season start or also mid-season. If the underlying data (total points) can itself be negative due to a separate bug, that should be treated as a distinct issue and not masked by clamping here. + +--- + +## ISSUE-005 - RecordActivity IsInTraining() causes synchronous DB queries in combat hot path + +Status: TODO +Priority: MEDIUM +Area: Seasons / Performance + +### Problem +`RecordActivity` calls `Character.Get(characterId).IsInTraining()` which issues two synchronous `ExecuteScalar` DB queries per call with no caching. For low-frequency events (NPC kills, artifact finds, mission completes) this is acceptable. However, `DamageDone` and `DamageReceived` (added in IMPROVEMENT-005 Phase 2) wire `RecordActivity` into `Unit.OnDamageTaken`, which fires every weapon cycle in the zone update loop — potentially tens of times per second per engagement when a season is active and damage rates are configured. + +### Impact +When a season is active with DamageDone/DamageReceived rates configured, each combat hit incurs 2 synchronous DB round trips for training-character filtering. Under load this could degrade zone update performance. If no season is active, the early-exit at `_activeSeason == null` prevents DB access entirely. + +### Proposed Fix +Move the `IsInTraining()` check to after the rate lookup in `RecordActivity`, so it only runs when a matching rate exists for the activity type — this eliminates the DB cost entirely for activity types with no configured rate. Longer term, cache the `IsInTraining` result per character (it is immutable once a character graduates from training). + +### Notes +Introduced by IMPROVEMENT-005 (DamageDone/DamageReceived hooks). Other high-frequency hooks (ArmorRestored, EnergyDrain*, EnergyTransfer*) have the same exposure once rates are configured. +See `SeasonService.cs` `RecordActivity` method for the current check order. + +--- + +## ISSUE-006 - DamageDone not credited to player when attacking via RCC + +Status: TODO +Priority: LOW +Area: Seasons / Activities + +### Problem +When a player controls a Remote Controlled Creature (RCC), damage attributed to the RCC arrives in `Unit.OnDamageTaken` with `source` set to the `RemoteControlledCreature` instance, not the controlling `Player`. The `source is Player` check does not match, so the controlling player receives no `DamageDone` season credit for RCC damage. + +### Impact +Players using RCCs in combat cannot accumulate `DamageDone` season points. This is a known limitation of the current implementation — a low-impact gap since RCC usage is a niche playstyle. + +### Proposed Fix +Resolve the RCC owner player via the zone (similar to how the NPC kill path uses `Zone.ToPlayerOrGetOwnerPlayer`). This requires zone context at the damage attribution point, which is not available in `Unit.OnDamageTaken`. Options: override `OnDamageTaken` in `RemoteControlledCreature` to resolve owner, or add owner resolution to the `Unit` base class using a virtual property. + +### Notes +The NPC kill path in `Npc.cs` handles this via `Zone.ToPlayerOrGetOwnerPlayer` — use that as a reference for the resolution approach. +Do not fix until the design decision is made: should RCC damage count toward `DamageDone`? From 21bb2eea87ab0e966979da94c532c79fe7ef192d Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 16 May 2026 12:54:22 +0500 Subject: [PATCH 012/151] docs: add IMPROVEMENT-005 spec, plan, and CLAUDE.md update Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 2 +- ...provement-005-additional-activity-types.md | 758 ++++++++++++++++++ ...nt-005-additional-activity-types-design.md | 153 ++++ 3 files changed, 912 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-05-16-improvement-005-additional-activity-types.md create mode 100644 docs/superpowers/specs/2026-05-16-improvement-005-additional-activity-types-design.md diff --git a/CLAUDE.md b/CLAUDE.md index 0dd1a04..331d831 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -337,7 +337,7 @@ When asked to: - "implement improvements" Claude should: -1. review backlog files, only check what you've been asked to, (e.g. issues or improvements) +1. review backlog files, only check what you've been asked to, (e.g. issues or improvements), unless issues and improvements are depending on each other 2. prioritize unfinished HIGH priority items 3. prefer low-risk/high-impact work unless instructed otherwise 4. produce a short implementation plan diff --git a/docs/superpowers/plans/2026-05-16-improvement-005-additional-activity-types.md b/docs/superpowers/plans/2026-05-16-improvement-005-additional-activity-types.md new file mode 100644 index 0000000..ae61b45 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-improvement-005-additional-activity-types.md @@ -0,0 +1,758 @@ +# IMPROVEMENT-005: Additional Season Activity Types — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add 12 new season activity types in two phases — 5 non-combat types (Phase 1) and 7 combat types (Phase 2) — all integrated with the existing `RecordActivity` pipeline. + +**Architecture:** Each new type adds an enum value to `SeasonActivityType`, a display name in the `ActivityTypeName` switch, and a `SeasonServiceLocator.Instance?.RecordActivity(...)` call at the relevant game event hook point. No DB schema changes are required. + +**Tech Stack:** C# 12, .NET 8, SQL Server, existing `SeasonServiceLocator` / `RecordActivity` pattern. + +--- + +## File Map + +| File | Change | +|---|---| +| `src/Perpetuum/Services/Seasons/SeasonActivityType.cs` | Add 12 new enum values (Tasks 1 + 5) | +| `src/Perpetuum/Services/Seasons/SeasonService.cs` | Add 12 display names to `ActivityTypeName` switch (Tasks 1 + 5) | +| `src/Perpetuum/Services/ProductionEngine/ProductionProcessor.cs` | Hook production job completion (Task 2) | +| `src/Perpetuum/Zones/Artifacts/Scanners/ArtifactScanner.cs` | Hook scanner artifact find (Task 3) | +| `src/Perpetuum/Services/Relics/Relics/AbstractRelic.cs` | Hook relic artifact find (Task 3) | +| `src/Perpetuum/Accounting/AccountManager.cs` | Hook activity-based EP grant (Task 4) | +| `src/Perpetuum/Services/ExtensionService/GiveExtensionPointsService.cs` | Hook passive EP grant (Task 4) | +| `src/Perpetuum/Units/Unit.cs` | Hook damage dealt/received (Task 6) | +| `src/Perpetuum/Modules/ArmorRepairModule.cs` | Hook armor restored (Task 7) | +| `src/Perpetuum/Modules/EnergyNeutralizerModule.cs` | Hook energy drain dealt/received (Task 8) | +| `src/Perpetuum/Modules/EnergyTransfererModule.cs` | Hook energy transfer dealt/received (Task 9) | + +--- + +## Phase 1 — Non-Combat Types + +--- + +### Task 1: Add Phase 1 Enum Values and Display Names + +**Files:** +- Modify: `src/Perpetuum/Services/Seasons/SeasonActivityType.cs` +- Modify: `src/Perpetuum/Services/Seasons/SeasonService.cs:472-483` + +- [ ] **Step 1: Add enum values** + +Open `src/Perpetuum/Services/Seasons/SeasonActivityType.cs`. Replace the entire file content with: + +```csharp +namespace Perpetuum.Services.Seasons +{ + public enum SeasonActivityType + { + NpcKill = 1, + PvpKill = 2, + MissionComplete = 3, + MineralMined = 4, + EpSpent = 5, + NicEarned = 6, + NicSpent = 7, + IntrusionPoint = 8, + + // Phase 1 — non-combat + Prototyping = 9, + ReverseEngineering = 10, + Production = 11, + ArtifactFound = 12, + EpEarned = 13, + } +} +``` + +- [ ] **Step 2: Add display names** + +Open `src/Perpetuum/Services/Seasons/SeasonService.cs`. Find the `ActivityTypeName` method at line ~472. Replace it with: + +```csharp +private static string ActivityTypeName(SeasonActivityType type) => type switch +{ + SeasonActivityType.NpcKill => "NPC Kill", + SeasonActivityType.PvpKill => "PvP Kill", + SeasonActivityType.MissionComplete => "Mission Completed", + SeasonActivityType.MineralMined => "Mineral Mined", + SeasonActivityType.EpSpent => "EP Spent", + SeasonActivityType.NicEarned => "NIC Earned", + SeasonActivityType.NicSpent => "NIC Spent", + SeasonActivityType.IntrusionPoint => "Intrusion SAP", + SeasonActivityType.Prototyping => "Prototyping", + SeasonActivityType.ReverseEngineering => "Reverse Engineering", + SeasonActivityType.Production => "Production", + SeasonActivityType.ArtifactFound => "Artifact Found", + SeasonActivityType.EpEarned => "EP Earned", + _ => type.ToString(), +}; +``` + +- [ ] **Step 3: Build** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: Build succeeds, 0 errors. + +- [ ] **Step 4: Commit** + +``` +git add src/Perpetuum/Services/Seasons/SeasonActivityType.cs src/Perpetuum/Services/Seasons/SeasonService.cs +git commit -m "feat(seasons): add Phase 1 activity type enum values and display names" +``` + +--- + +### Task 2: Wire Production Hook + +**Files:** +- Modify: `src/Perpetuum/Services/ProductionEngine/ProductionProcessor.cs` + +The hook fires at job completion inside `EndProduction`. The production type is on `productionInProgress.type` (`ProductionInProgressType` enum). Amount is `productionInProgress.amountOfCycles` (number of production cycles/jobs). + +- [ ] **Step 1: Add using** + +Open `src/Perpetuum/Services/ProductionEngine/ProductionProcessor.cs`. At the top, check if `using Perpetuum.Services.Seasons;` is present. If not, add it with the other `using` statements. + +- [ ] **Step 2: Add the hook** + +Find the block at line ~238 (inside `EndProduction`, inside the transaction scope): + +```csharp +var ep =CalculateEp(facility, productionInProgress); + +productionInProgress.character.AddExtensionPointsBoostAndLog( EpForActivityType.Production, ep); +``` + +Add the season hook immediately after the `AddExtensionPointsBoostAndLog` call: + +```csharp +var ep =CalculateEp(facility, productionInProgress); + +productionInProgress.character.AddExtensionPointsBoostAndLog( EpForActivityType.Production, ep); + +var seasonType = productionInProgress.type switch +{ + ProductionInProgressType.prototype => (SeasonActivityType?)SeasonActivityType.Prototyping, + ProductionInProgressType.research => SeasonActivityType.ReverseEngineering, + ProductionInProgressType.massProduction => SeasonActivityType.Production, + _ => null, +}; +if (seasonType.HasValue) +{ + SeasonServiceLocator.Instance?.RecordActivity( + productionInProgress.character.Id, + seasonType.Value, + productionInProgress.amountOfCycles); +} +``` + +- [ ] **Step 3: Build** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: Build succeeds, 0 errors. + +- [ ] **Step 4: Manual validation** + +Using an admin character on a test server: +1. Use `#SeasonAddRate 9 1 1` to set Prototyping rate to 1 pt per cycle. +2. Use `#SeasonAddRate 10 1 1` for ReverseEngineering. +3. Use `#SeasonAddRate 11 1 1` for Production. +4. Complete a prototyping job — verify character season points increase. +5. Complete a research (reverse engineering) job — verify points increase. +6. Complete a mass production job — verify points increase proportional to `amountOfCycles`. +7. Complete a job of any other type (e.g., refine, reprocess) — verify points do NOT change. + +- [ ] **Step 5: Commit** + +``` +git add src/Perpetuum/Services/ProductionEngine/ProductionProcessor.cs +git commit -m "feat(seasons): wire Prototyping, ReverseEngineering, Production activity hooks" +``` + +--- + +### Task 3: Wire ArtifactFound Hook + +**Files:** +- Modify: `src/Perpetuum/Zones/Artifacts/Scanners/ArtifactScanner.cs` +- Modify: `src/Perpetuum/Services/Relics/Relics/AbstractRelic.cs` + +Two distinct artifact systems both grant EP on find — both must record the season activity. + +- [ ] **Step 1: Add using to ArtifactScanner.cs** + +Open `src/Perpetuum/Zones/Artifacts/Scanners/ArtifactScanner.cs`. Add `using Perpetuum.Services.Seasons;` if not already present. + +- [ ] **Step 2: Hook in ArtifactScanner.cs** + +Find line ~61: + +```csharp +if (ep > 0) player.Character.AddExtensionPointsBoostAndLog(EpForActivityType.Artifact, ep); +``` + +Add the season hook on the next line: + +```csharp +if (ep > 0) player.Character.AddExtensionPointsBoostAndLog(EpForActivityType.Artifact, ep); +SeasonServiceLocator.Instance?.RecordActivity(player.Character.Id, SeasonActivityType.ArtifactFound, 1); +``` + +Note: the `RecordActivity` call is unconditional (not guarded by `ep > 0`). An artifact is found even on training zones where EP is 0. If training zone filtering is desired, `RecordActivity` already filters training characters internally. + +- [ ] **Step 3: Add using to AbstractRelic.cs** + +Open `src/Perpetuum/Services/Relics/Relics/AbstractRelic.cs`. Add `using Perpetuum.Services.Seasons;` if not already present. + +- [ ] **Step 4: Hook in AbstractRelic.cs** + +Find line ~150 (inside `Task.Run(() => { ... })`): + +```csharp +if (ep > 0) player.Character.AddExtensionPointsBoostAndLog(EpForActivityType.Artifact, ep); +``` + +Add the season hook on the next line, still inside the `Task.Run` lambda and inside the `using (var scope = Db.CreateTransaction())` block: + +```csharp +if (ep > 0) player.Character.AddExtensionPointsBoostAndLog(EpForActivityType.Artifact, ep); +SeasonServiceLocator.Instance?.RecordActivity(player.Character.Id, SeasonActivityType.ArtifactFound, 1); +``` + +- [ ] **Step 5: Build** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: Build succeeds, 0 errors. + +- [ ] **Step 6: Manual validation** + +1. Set rate: `#SeasonAddRate 12 1 1` +2. Find an artifact via scanner on a non-training zone — verify season points increase by 1. +3. Find a relic (the other artifact system) — verify season points increase by 1. +4. Confirm no crash on training zones (points will not accumulate for training chars, but the call must not throw). + +- [ ] **Step 7: Commit** + +``` +git add src/Perpetuum/Zones/Artifacts/Scanners/ArtifactScanner.cs src/Perpetuum/Services/Relics/Relics/AbstractRelic.cs +git commit -m "feat(seasons): wire ArtifactFound activity hook (scanner and relic paths)" +``` + +--- + +### Task 4: Wire EpEarned Hook + +**Files:** +- Modify: `src/Perpetuum/Accounting/AccountManager.cs` +- Modify: `src/Perpetuum/Services/ExtensionService/GiveExtensionPointsService.cs` + +Two EP sources: activity-based boosts (via `AddExtensionPointsBoostAndLog`) and passive daily grants (via `GiveExtensionPointsService`). + +- [ ] **Step 1: Add using to AccountManager.cs** + +Open `src/Perpetuum/Accounting/AccountManager.cs`. Add `using Perpetuum.Services.Seasons;` if not present. + +- [ ] **Step 2: Hook activity-based EP in AccountManager.cs** + +Find `AddExtensionPointsBoostAndLog` at line ~356. The method currently ends with: + +```csharp +AddExtensionPoints(account, boostedPoints); +return boostedPoints; +``` + +Add the season hook before the `return`: + +```csharp +AddExtensionPoints(account, boostedPoints); +SeasonServiceLocator.Instance?.RecordActivity(character.Id, SeasonActivityType.EpEarned, boostedPoints); +return boostedPoints; +``` + +- [ ] **Step 3: Add using to GiveExtensionPointsService.cs** + +Open `src/Perpetuum/Services/ExtensionService/GiveExtensionPointsService.cs`. Add `using Perpetuum.Services.Seasons;` if not present. + +- [ ] **Step 4: Hook passive EP in GiveExtensionPointsService.cs** + +Find `InformAffectedCharacters` at line ~55. The method currently sends messages to `affectedLeechers` and `affectedPayingCustomers`. Add season recording for each group: + +```csharp +if (grp.Key == BASEPOINTS) +{ + var affectedLeechers = grp.Select(r => Character.Get(r.GetValue(0))).Distinct().ToArray(); + Logger.Info($"Daily Extension Point Add: {affectedLeechers.Length} characters will be informed with point {BASEPOINTS} - leechers."); + ExtensionHelper.CreateExtensionPointsIncreasedMessage(BASEPOINTS).ToCharacters(affectedLeechers).Send(); + foreach (var c in affectedLeechers) + SeasonServiceLocator.Instance?.RecordActivity(c.Id, SeasonActivityType.EpEarned, BASEPOINTS); +} +else +{ + var affectedPayingCustomers = grp.Select(r => Character.Get(r.GetValue(0))).Distinct().ToArray(); + Logger.Info($"Daily Extension Point Add: {affectedPayingCustomers.Length} characters will be informed with point {BONUSPOINTS} - good guys."); + ExtensionHelper.CreateExtensionPointsIncreasedMessage(BONUSPOINTS).ToCharacters(affectedPayingCustomers).Send(); + foreach (var c in affectedPayingCustomers) + SeasonServiceLocator.Instance?.RecordActivity(c.Id, SeasonActivityType.EpEarned, BONUSPOINTS); +} +``` + +- [ ] **Step 5: Build** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: Build succeeds, 0 errors. + +- [ ] **Step 6: Manual validation** + +1. Set rate: `#SeasonAddRate 13 1 1000` (1 pt per 1000 EP — the daily grant is 1440/2880 so use scale 1 for easy testing) +2. Kill an NPC (triggers activity-based EP from `AddExtensionPointsBoostAndLog`) — verify season `EpEarned` activity is recorded (check DB or season score update). +3. Complete a mission — verify additional EP earned is recorded. +4. Passive EP: wait for or simulate the `GiveExtensionPointsService` daily tick in a test environment — verify the passive grant amount is also recorded per character. + +- [ ] **Step 7: Commit** + +``` +git add src/Perpetuum/Accounting/AccountManager.cs src/Perpetuum/Services/ExtensionService/GiveExtensionPointsService.cs +git commit -m "feat(seasons): wire EpEarned activity hook (activity boosts and passive daily grant)" +``` + +--- + +## Phase 2 — Combat Types + +--- + +### Task 5: Add Phase 2 Enum Values and Display Names + +**Files:** +- Modify: `src/Perpetuum/Services/Seasons/SeasonActivityType.cs` +- Modify: `src/Perpetuum/Services/Seasons/SeasonService.cs` + +- [ ] **Step 1: Add Phase 2 enum values** + +Open `src/Perpetuum/Services/Seasons/SeasonActivityType.cs`. Replace the file content with: + +```csharp +namespace Perpetuum.Services.Seasons +{ + public enum SeasonActivityType + { + NpcKill = 1, + PvpKill = 2, + MissionComplete = 3, + MineralMined = 4, + EpSpent = 5, + NicEarned = 6, + NicSpent = 7, + IntrusionPoint = 8, + + // Phase 1 — non-combat + Prototyping = 9, + ReverseEngineering = 10, + Production = 11, + ArtifactFound = 12, + EpEarned = 13, + + // Phase 2 — combat + DamageDone = 14, + DamageReceived = 15, + ArmorRestored = 16, + EnergyDrainDealt = 17, + EnergyDrainReceived = 18, + EnergyTransferDealt = 19, + EnergyTransferReceived = 20, + } +} +``` + +- [ ] **Step 2: Add Phase 2 display names** + +Open `src/Perpetuum/Services/Seasons/SeasonService.cs`. Find `ActivityTypeName` and replace with: + +```csharp +private static string ActivityTypeName(SeasonActivityType type) => type switch +{ + SeasonActivityType.NpcKill => "NPC Kill", + SeasonActivityType.PvpKill => "PvP Kill", + SeasonActivityType.MissionComplete => "Mission Completed", + SeasonActivityType.MineralMined => "Mineral Mined", + SeasonActivityType.EpSpent => "EP Spent", + SeasonActivityType.NicEarned => "NIC Earned", + SeasonActivityType.NicSpent => "NIC Spent", + SeasonActivityType.IntrusionPoint => "Intrusion SAP", + SeasonActivityType.Prototyping => "Prototyping", + SeasonActivityType.ReverseEngineering => "Reverse Engineering", + SeasonActivityType.Production => "Production", + SeasonActivityType.ArtifactFound => "Artifact Found", + SeasonActivityType.EpEarned => "EP Earned", + SeasonActivityType.DamageDone => "Damage Done", + SeasonActivityType.DamageReceived => "Damage Received", + SeasonActivityType.ArmorRestored => "Armor Restored", + SeasonActivityType.EnergyDrainDealt => "Energy Drained (Dealt)", + SeasonActivityType.EnergyDrainReceived => "Energy Drained (Received)", + SeasonActivityType.EnergyTransferDealt => "Energy Transferred (Dealt)", + SeasonActivityType.EnergyTransferReceived => "Energy Transferred (Received)", + _ => type.ToString(), +}; +``` + +- [ ] **Step 3: Build** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: Build succeeds, 0 errors. + +- [ ] **Step 4: Commit** + +``` +git add src/Perpetuum/Services/Seasons/SeasonActivityType.cs src/Perpetuum/Services/Seasons/SeasonService.cs +git commit -m "feat(seasons): add Phase 2 activity type enum values and display names" +``` + +--- + +### Task 6: Wire DamageDone / DamageReceived Hook + +**Files:** +- Modify: `src/Perpetuum/Units/Unit.cs` + +`Unit.cs` already imports `Perpetuum.Players`, so the `Player` type is available. Hook goes into `OnDamageTaken`, which fires for all units. We guard with `is Player` checks: attacker only records `DamageDone` if they are a player; victim only records `DamageReceived` if they are a player (NPCs have no character ID and do not accumulate season points). + +- [ ] **Step 1: Add using** + +Open `src/Perpetuum/Units/Unit.cs`. Add `using Perpetuum.Services.Seasons;` if not already present. + +- [ ] **Step 2: Modify OnDamageTaken** + +Find `OnDamageTaken` at line ~389. Replace the method with: + +```csharp +protected virtual void OnDamageTaken(Unit source, DamageTakenEventArgs e) +{ + DamageTaken?.Invoke(this, source, e); + + CombatLogPacket packet = new CombatLogPacket(CombatLogType.Damage, this, source); + packet.AppendByte((byte)(e.IsCritical ? 1 : 0)); + packet.AppendDouble(e.TotalDamage); + packet.AppendDouble(e.TotalKers); + packet.Send(this, source); + + if (!(e.TotalDamage >= 0.0)) + { + return; + } + + Armor -= e.TotalDamage; + + var damageAmount = (long)e.TotalDamage; + if (damageAmount > 0) + { + if (source is Player attacker) + SeasonServiceLocator.Instance?.RecordActivity(attacker.Character.Id, SeasonActivityType.DamageDone, damageAmount); + if (this is Player victim) + SeasonServiceLocator.Instance?.RecordActivity(victim.Character.Id, SeasonActivityType.DamageReceived, damageAmount); + } + + OnCombatEvent(source, e); + + if (Armor <= 0.0) + { + Kill(source); + } +} +``` + +- [ ] **Step 3: Build** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: Build succeeds, 0 errors. + +- [ ] **Step 4: Manual validation** + +1. Set rates: `#SeasonAddRate 14 1 100` and `#SeasonAddRate 15 1 100` (1 pt per 100 HP). +2. With a player character, fire weapons at an NPC — verify `DamageDone` is recorded for the player, and no crash occurs for the NPC. +3. Let an NPC fire at a player — verify `DamageReceived` is recorded for the player. +4. Two players fight each other — verify both `DamageDone` for the attacker and `DamageReceived` for the victim are recorded. +5. Confirm overall game stability under normal combat — no performance regression visible. + +- [ ] **Step 5: Commit** + +``` +git add src/Perpetuum/Units/Unit.cs +git commit -m "feat(seasons): wire DamageDone and DamageReceived activity hooks" +``` + +--- + +### Task 7: Wire ArmorRestored Hook + +**Files:** +- Modify: `src/Perpetuum/Modules/ArmorRepairModule.cs` + +Both `ArmorRepairModule` (self-repair) and `RemoteArmorRepairModule` (remote repair) call `OnRepair` on the base class `ArmorRepairerBaseModule`. The hook goes into `OnRepair` so both module types are covered. `ParentRobot` is the unit activating the module; we record the activity only if that unit is a player. + +- [ ] **Step 1: Add using** + +Open `src/Perpetuum/Modules/ArmorRepairModule.cs`. Add `using Perpetuum.Services.Seasons;` and `using Perpetuum.Players;` if not already present. + +- [ ] **Step 2: Modify OnRepair** + +Find `protected void OnRepair(Unit target, double amount)` at line ~48. Replace the method with: + +```csharp +protected void OnRepair(Unit target, double amount) +{ + if (amount <= 0.0) + { + return; + } + + double armor = target.Armor; + + target.Armor += amount; + + double total = Math.Abs(armor - target.Armor); + CombatLogPacket packet = new CombatLogPacket(CombatLogType.ArmorRepair, target, ParentRobot, this); + + packet.AppendDouble(amount); + packet.AppendDouble(total); + packet.Send(target, ParentRobot); + + var repaired = (long)total; + if (repaired > 0 && ParentRobot is Player repairer) + SeasonServiceLocator.Instance?.RecordActivity(repairer.Character.Id, SeasonActivityType.ArmorRestored, repaired); +} +``` + +- [ ] **Step 3: Build** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: Build succeeds, 0 errors. + +- [ ] **Step 4: Manual validation** + +1. Set rate: `#SeasonAddRate 16 1 10` (1 pt per 10 HP restored). +2. Activate a local armor repair module while at partial HP — verify `ArmorRestored` is recorded for the player activating the module. +3. Activate a remote armor repair module targeting an ally — verify `ArmorRestored` is recorded for the player activating the module (not the target). +4. Let an NPC repair itself (if applicable) — verify no `ArmorRestored` is recorded for NPC units. + +- [ ] **Step 5: Commit** + +``` +git add src/Perpetuum/Modules/ArmorRepairModule.cs +git commit -m "feat(seasons): wire ArmorRestored activity hook (local and remote repair modules)" +``` + +--- + +### Task 8: Wire EnergyDrainDealt / EnergyDrainReceived Hook + +**Files:** +- Modify: `src/Perpetuum/Modules/EnergyNeutralizerModule.cs` + +`EnergyNeutralizerModule` covers energy neutralizers. Energy drainer modules (if any) should be checked for a separate class — if a drainer module inherits from `EnergyDispersionModule` and has its own `OnAction`, it needs the same hook. The current codebase only has `EnergyNeutralizerModule` as a concrete class — this task covers that class. + +- [ ] **Step 1: Add usings** + +Open `src/Perpetuum/Modules/EnergyNeutralizerModule.cs`. Add `using Perpetuum.Services.Seasons;` and `using Perpetuum.Players;` if not present. + +- [ ] **Step 2: Modify OnAction** + +Find `protected override void OnAction()` at line ~31. Replace the method with: + +```csharp +protected override void OnAction() +{ + var unitLock = GetLock().ThrowIfNotType(ErrorCodes.InvalidLockType); + + if (!LOSCheckAndCreateBeam(unitLock.Target)) + { + OnError(ErrorCodes.LOSFailed); + + return; + } + + var coreNeutralized = _energyNeutralizedAmount.Value; + var coreNeutralizedDone = 0.0; + + ModifyValueByReactorRadiation(unitLock.Target,ref coreNeutralized); + coreNeutralized = ModifyValueByOptimalRange(unitLock.Target,coreNeutralized); + + if ( coreNeutralized > 0.0 ) + { + var core = unitLock.Target.Core; + + unitLock.Target.Core -= coreNeutralized; + coreNeutralizedDone = Math.Abs(core - unitLock.Target.Core); + unitLock.Target.OnCombatEvent(ParentRobot, new EnergyDispersionEventArgs(coreNeutralizedDone)); + + var threatValue = (coreNeutralizedDone / 2) + 1; + + unitLock.Target.AddThreat(ParentRobot, new Threat(ThreatType.EnWar, threatValue)); + + var drainAmount = (long)coreNeutralizedDone; + if (drainAmount > 0) + { + if (ParentRobot is Player attacker) + SeasonServiceLocator.Instance?.RecordActivity(attacker.Character.Id, SeasonActivityType.EnergyDrainDealt, drainAmount); + if (unitLock.Target is Player victim) + SeasonServiceLocator.Instance?.RecordActivity(victim.Character.Id, SeasonActivityType.EnergyDrainReceived, drainAmount); + } + } + + var packet = new CombatLogPacket(CombatLogType.EnergyNeutralize, unitLock.Target, ParentRobot, this); + + packet.AppendDouble(coreNeutralized); + packet.AppendDouble(coreNeutralizedDone); + packet.Send(unitLock.Target,ParentRobot); +} +``` + +- [ ] **Step 3: Check for other energy drain module classes** + +Run the build and search for any other class that inherits from `EnergyDispersionModule`: + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Also grep for additional concrete drain classes: + +``` +grep -r "EnergyDispersionModule" src/ --include="*.cs" -l +``` + +If any additional class exists with its own `OnAction` that drains energy, apply the same hook pattern from Step 2 to that class. + +- [ ] **Step 4: Manual validation** + +1. Set rates: `#SeasonAddRate 17 1 10` and `#SeasonAddRate 18 1 10`. +2. Activate an energy neutralizer on a player target — verify `EnergyDrainDealt` for attacker and `EnergyDrainReceived` for the victim player. +3. Activate an energy neutralizer on an NPC — verify only `EnergyDrainDealt` for the player attacker (NPC has no season tracking). +4. Let an NPC use an energy neutralizer on a player — verify only `EnergyDrainReceived` for the player victim. + +- [ ] **Step 5: Commit** + +``` +git add src/Perpetuum/Modules/EnergyNeutralizerModule.cs +git commit -m "feat(seasons): wire EnergyDrainDealt and EnergyDrainReceived activity hooks" +``` + +--- + +### Task 9: Wire EnergyTransferDealt / EnergyTransferReceived Hook + +**Files:** +- Modify: `src/Perpetuum/Modules/EnergyTransfererModule.cs` + +`coreNeutralized` is energy actually removed from the giver (already clamped by their available core). `coreTransfered` is energy actually added to the receiver (may differ due to range modifiers). Track each direction separately with its actual value. + +- [ ] **Step 1: Add usings** + +Open `src/Perpetuum/Modules/EnergyTransfererModule.cs`. Add `using Perpetuum.Services.Seasons;` and `using Perpetuum.Players;` if not present. + +- [ ] **Step 2: Modify OnAction** + +Find `protected override void OnAction()` at line ~24. Replace the method with: + +```csharp +protected override void OnAction() +{ + UnitLock unitLock = GetLock().ThrowIfNotType(ErrorCodes.InvalidLockType); + + (ParentIsPlayer() && unitLock.Target is Npc).ThrowIfTrue(ErrorCodes.ThisModuleIsNotSupportedOnNPCs); + + if (!LOSCheckAndCreateBeam(unitLock.Target)) + { + OnError(ErrorCodes.LOSFailed); + + return; + } + + double coreAmount = _energyTransferAmount.Value; + + coreAmount = ModifyValueByOptimalRange(unitLock.Target, coreAmount); + + double coreNeutralized = 0.0; + double coreTransfered = 0.0; + + if (coreAmount > 0.0) + { + double core = ParentRobot.Core; + + ParentRobot.Core -= coreAmount; + coreNeutralized = Math.Abs(core - ParentRobot.Core); + + double targetCore = unitLock.Target.Core; + + unitLock.Target.Core += coreNeutralized; + coreTransfered = Math.Abs(targetCore - unitLock.Target.Core); + unitLock.Target.SpreadAssistThreatToNpcs(ParentRobot, new Threat(ThreatType.Support, coreAmount * 2)); + + if (ParentRobot is Player giver && coreNeutralized > 0.0) + SeasonServiceLocator.Instance?.RecordActivity(giver.Character.Id, SeasonActivityType.EnergyTransferDealt, (long)coreNeutralized); + if (unitLock.Target is Player receiver && coreTransfered > 0.0) + SeasonServiceLocator.Instance?.RecordActivity(receiver.Character.Id, SeasonActivityType.EnergyTransferReceived, (long)coreTransfered); + } + + CombatLogPacket packet = new CombatLogPacket(CombatLogType.EnergyTransfer, unitLock.Target, ParentRobot, this); + + packet.AppendDouble(coreAmount); + packet.AppendDouble(coreNeutralized); + packet.AppendDouble(coreTransfered); + packet.Send(unitLock.Target, ParentRobot); +} +``` + +- [ ] **Step 3: Build** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: Build succeeds, 0 errors. + +- [ ] **Step 4: Manual validation** + +1. Set rates: `#SeasonAddRate 19 1 10` and `#SeasonAddRate 20 1 10`. +2. Activate energy transfer from a player to another player — verify `EnergyTransferDealt` for the giver and `EnergyTransferReceived` for the receiver. +3. Transfer with a range penalty (target near edge of range) — verify `EnergyTransferDealt` (coreNeutralized) may differ from `EnergyTransferReceived` (coreTransfered). +4. Confirm the module still correctly rejects targeting NPCs (existing error code behavior unchanged). + +- [ ] **Step 5: Commit** + +``` +git add src/Perpetuum/Modules/EnergyTransfererModule.cs +git commit -m "feat(seasons): wire EnergyTransferDealt and EnergyTransferReceived activity hooks" +``` + +--- + +## Final Validation Checklist + +- [ ] All 12 new activity types appear correctly in `#SeasonInfo ` output +- [ ] Training characters do not accumulate points for any new type (existing filter in `RecordActivity`) +- [ ] Activity outside an active season does not cause errors (null-safe `SeasonServiceLocator.Instance?` pattern) +- [ ] No performance regression visible in combat zone under normal player load +- [ ] IMPROVEMENT-005 backlog status updated to DONE diff --git a/docs/superpowers/specs/2026-05-16-improvement-005-additional-activity-types-design.md b/docs/superpowers/specs/2026-05-16-improvement-005-additional-activity-types-design.md new file mode 100644 index 0000000..9690fc1 --- /dev/null +++ b/docs/superpowers/specs/2026-05-16-improvement-005-additional-activity-types-design.md @@ -0,0 +1,153 @@ +# IMPROVEMENT-005: Seasons — Additional Activity Types + +**Date:** 2026-05-16 +**Status:** Approved +**Area:** Seasons / Activities + +--- + +## Overview + +Expand the Seasons activity tracking system with 12 new activity types, implemented in two phases. All types integrate with the existing `RecordActivity` pipeline, `season_activity_rates` table, and objective progress tracking with no schema changes. + +--- + +## New Activity Types + +| Enum Value | Name | Phase | Amount Unit | +|---|---|---|---| +| 9 | `Prototyping` | 1 | items produced | +| 10 | `ReverseEngineering` | 1 | items produced | +| 11 | `Production` | 1 | items produced | +| 12 | `ArtifactFound` | 1 | 1 per artifact | +| 13 | `EpEarned` | 1 | EP granted | +| 14 | `DamageDone` | 2 | HP dealt | +| 15 | `DamageReceived` | 2 | HP taken | +| 16 | `ArmorRestored` | 2 | HP restored | +| 17 | `EnergyDrainDealt` | 2 | energy removed | +| 18 | `EnergyDrainReceived` | 2 | energy removed | +| 19 | `EnergyTransferDealt` | 2 | energy transferred | +| 20 | `EnergyTransferReceived` | 2 | energy transferred | + +*Distance Travelled was deferred — see IMPROVEMENT-015.* + +--- + +## Architecture + +No new infrastructure required. Each type: + +1. Adds an enum value to `SeasonActivityType.cs` (continuing from 8) +2. Adds a display name entry in the `ActivityTypeName()` switch in `SeasonService.cs` +3. Calls `SeasonServiceLocator.Instance?.RecordActivity(characterId, type, amount)` at the hook point + +The existing pipeline handles point calculation, objective progress, leaderboard updates, and training character filtering automatically. + +--- + +## Phase 1: Non-Combat Types + +### Prototyping, ReverseEngineering, Production + +- **Hook:** `ProductionProcessor.cs` ~line 240, at job completion +- **Branching:** Inspect the production job type to determine which of the three activity types to emit +- **Amount:** Quantity of items produced +- **Notes:** Three distinct production job types exist in the engine; each maps to exactly one activity type + +### ArtifactFound + +- **Hook:** `ArtifactScanner.cs` ~line 61, immediately after the EP boost call +- **Amount:** Always 1 (discrete event) +- **Notes:** `unit_scale = 1`, season designers set `points_per_unit` directly + +### EpEarned + +- **Hook:** Two sources must both be instrumented: + - Activity-based EP boosts: all `AddExtensionPointsBoostAndLog` call sites (Npc.cs, ProductionProcessor.cs, ArtifactScanner.cs, GathererModule.cs, Outpost.cs, etc.) + - Passive time-based EP: the EP accumulation scheduler in the account/EP system (exact call site to be confirmed during implementation) +- **Amount:** EP granted +- **Notes:** Verify passive EP accumulation path in `AccountManager.cs` or a dedicated EP scheduler before wiring + +--- + +## Phase 2: Combat Types + +All Phase 2 hooks fire inside the zone update loop. This is already the accepted pattern for NPC kill and intrusion point tracking. + +### DamageDone / DamageReceived + +- **Hook:** Damage application path — `TakeDamage` or `ApplyDamageResult` in the zone unit system +- **Attacker** receives `DamageDone` with HP dealt — fires regardless of whether the target is a player or NPC +- **Victim** receives `DamageReceived` — fires only when the victim is a player character (has a character ID); NPC victims are skipped +- **Amount:** Actual HP dealt after mitigation + +### ArmorRestored + +- **Hook:** Repair module application (local and remote repair modules) +- **Character:** The repairing character (the one activating the module) +- **Amount:** HP restored +- **Notes:** Covers both self-repair and remote repair; target's ID is not used + +### EnergyDrainDealt / EnergyDrainReceived + +- **Hook:** Energy neutralizer and energy drainer module application (both module types feed the same two activity types) +- **Attacker** receives `EnergyDrainDealt`; **victim** receives `EnergyDrainReceived` +- **Amount:** Energy removed from the victim + +### EnergyTransferDealt / EnergyTransferReceived + +- **Hook:** Energy transfer module application +- **Giver** receives `EnergyTransferDealt`; **receiver** receives `EnergyTransferReceived` +- **Amount:** Energy transferred + +--- + +## Anti-Farming + +No new cooldown or cap infrastructure is required. Magnitude is controlled by `unit_scale` in `season_activity_rates` (e.g., 1 point per 1000 HP damage). Season designers set `unit_scale` high enough on high-frequency types to make farming impractical. + +The existing training character filter at the `RecordActivity` entry point covers all new types automatically. + +--- + +## DB Changes + +None. All new types use existing tables: + +- `season_activity_rates` — `activity_type` column accepts any integer enum value +- `season_objective_progress` — tracks progress against any configured objective +- `season_character_points` — accumulates points regardless of activity type source + +--- + +## Deferred: Distance Travelled (IMPROVEMENT-015) + +Distance travelled was scoped out due to zone-thread-safety concerns and the lack of an existing hook point. It requires accumulated reporting over a tick interval rather than per-event calls. Tracked as a separate backlog item. + +--- + +## Validation Steps + +**Phase 1:** +1. Configure a test season with rates for each of the 5 new types +2. Complete a prototyping, reverse-engineering, and production job — verify points credited per type +3. Find an artifact — verify 1 unit recorded +4. Spend EP on an extension — verify `EpEarned` records the granted amount (not `EpSpent`) +5. Confirm training characters receive no points for any new type + +**Phase 2:** +1. Configure rates for all 7 combat types +2. Fire weapons at an NPC — verify `DamageDone` credited to attacker; confirm no `DamageReceived` recorded for the NPC +3. Take damage from an NPC — verify `DamageReceived` credited to the player character +4. Activate a repair module — verify `ArmorRestored` for repairing character +5. Activate energy neutralizer/drainer — verify `EnergyDrainDealt` for attacker, `EnergyDrainReceived` for victim +6. Activate energy transfer — verify `EnergyTransferDealt` for giver, `EnergyTransferReceived` for receiver +7. Confirm no measurable performance regression in zone update loop under combat load + +--- + +## Potential Regressions + +- Passive EP hook: if the passive EP accumulation path is not correctly identified, `EpEarned` may under-count +- NPC characters: verify that NPCs do not have character IDs that would cause them to accidentally accumulate `DamageReceived` / `EnergyDrainReceived` season points +- Remote repair: confirm remote repair module correctly attributes `ArmorRestored` to the repairing player, not the target From 85b1fe4ebe83766df22cd51d56cffaf41d6a95a0 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 16 May 2026 13:03:21 +0500 Subject: [PATCH 013/151] perf(seasons): defer IsInTraining() check until after rate lookup in RecordActivity Eliminates 2 synchronous DB round trips per combat hit when no rate is configured for the activity type (e.g. DamageDone/DamageReceived). Fixes ISSUE-005. Co-Authored-By: Claude Sonnet 4.6 --- docs/backlog/issues.md | 2 +- src/Perpetuum/Services/Seasons/SeasonService.cs | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/backlog/issues.md b/docs/backlog/issues.md index 8c30ef4..751c6f2 100644 --- a/docs/backlog/issues.md +++ b/docs/backlog/issues.md @@ -99,7 +99,7 @@ If the underlying data (total points) can itself be negative due to a separate b ## ISSUE-005 - RecordActivity IsInTraining() causes synchronous DB queries in combat hot path -Status: TODO +Status: DONE Priority: MEDIUM Area: Seasons / Performance diff --git a/src/Perpetuum/Services/Seasons/SeasonService.cs b/src/Perpetuum/Services/Seasons/SeasonService.cs index 62cca44..b8d7938 100644 --- a/src/Perpetuum/Services/Seasons/SeasonService.cs +++ b/src/Perpetuum/Services/Seasons/SeasonService.cs @@ -139,13 +139,15 @@ public void RecordActivity(int characterId, SeasonActivityType activityType, lon if (season == null || DateTime.UtcNow > season.EndTime) return; - if (Character.Get(characterId).IsInTraining()) - return; - var rates = _activeRates.Where(r => r.ActivityType == activityType).ToList(); if (rates.Count == 0) return; + // DB lookup deferred until a rate match is confirmed — avoids 2 synchronous + // ExecuteScalar round-trips on every high-frequency call (e.g. each weapon cycle). + if (Character.Get(characterId).IsInTraining()) + return; + double basePoints = 0; foreach (var rate in rates) { From 22965f6ea6f3dca5b38223b25db18e7942234d11 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 16 May 2026 13:24:03 +0500 Subject: [PATCH 014/151] docs: add IMPROVEMENT-001 recurring seasons design spec Co-Authored-By: Claude Sonnet 4.6 --- .../2026-05-16-recurring-seasons-design.md | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-16-recurring-seasons-design.md diff --git a/docs/superpowers/specs/2026-05-16-recurring-seasons-design.md b/docs/superpowers/specs/2026-05-16-recurring-seasons-design.md new file mode 100644 index 0000000..566eef5 --- /dev/null +++ b/docs/superpowers/specs/2026-05-16-recurring-seasons-design.md @@ -0,0 +1,251 @@ +# Recurring Seasons — Design Spec + +**Feature:** IMPROVEMENT-001 +**Date:** 2026-05-16 +**Status:** Approved + +--- + +## Overview + +Add opt-in recurrence to seasons. A recurring season automatically spawns the next iteration when it ends, with a configurable rest gap between runs. One-time seasons are unchanged. Recurrence runs indefinitely until an admin disables it on the active season. + +--- + +## Constraints + +- One-time seasons (existing behavior) are fully preserved — no migration, no behavioral change. +- Only one season may be active at any time (existing invariant, unchanged). +- Iterations of the same recurring season must not overlap. +- The gap between end of one iteration and start of the next is configurable per season. +- Recurrence has no automatic bound — it continues until an admin sets `is_recurring = 0`. +- Each iteration is a full clone of the previous: rates, objectives, tiers, leaderboard rewards all copied. +- Each iteration gets an auto-suffixed name: `", Run #N"`. + +--- + +## Section 1: Database Schema + +Four additive columns on the `seasons` table. No existing rows are touched. + +```sql +ALTER TABLE seasons + ADD is_recurring BIT NOT NULL DEFAULT 0, + recurrence_gap_days INT NULL, + recurrence_iteration INT NOT NULL DEFAULT 1, + recurrence_base_name NVARCHAR(255) NULL; +``` + +### Column semantics + +| Column | Type | Purpose | +|---|---|---| +| `is_recurring` | `BIT NOT NULL DEFAULT 0` | Enables recurrence. `0` = one-time season (existing behavior). | +| `recurrence_gap_days` | `INT NULL` | Days between `end_time` of one iteration and `start_time` of the next. NULL for one-time seasons. | +| `recurrence_iteration` | `INT NOT NULL DEFAULT 1` | Which run this row represents. `1` = first. Increments on each spawn. | +| `recurrence_base_name` | `NVARCHAR(255) NULL` | The operator-entered name, stored separately so the server can compose `", Run #N"` without suffix stripping. NULL for one-time seasons. | + +### Invariants + +- `is_recurring = 1` requires `recurrence_gap_days IS NOT NULL` and `recurrence_base_name IS NOT NULL`. +- `name` for a recurring season always equals `recurrence_base_name + ", Run #" + recurrence_iteration`. +- A one-time season has `is_recurring = 0`; the other three columns are NULL/default and ignored. +- The existing single-active-season constraint is preserved by existing code — no additional DB constraint needed. + +--- + +## Section 2: C# Models & Repository + +### `Season` model (`SeasonModels.cs`) + +Four new properties: + +```csharp +public bool IsRecurring { get; set; } +public int? RecurrenceGapDays { get; set; } +public int RecurrenceIteration { get; set; } = 1; +public string? RecurrenceBaseName { get; set; } +``` + +### `SeasonRepository` changes + +**`GetActiveSeason()` and `GetSeasonById()`** — extend SELECT and mapping to include the four new columns. No query restructuring. + +**`CreateSeason()`** — gains parameters: `isRecurring`, `recurrenceGapDays`, `recurrenceBaseName`, `recurrenceIteration`. Writes all four columns. Caller is responsible for setting `name = ", Run #1"` when recurring. + +**`GetPendingRecurringSeason()`** — new method: + +```sql +SELECT TOP 1 id, name, description, start_time, end_time, is_active, + is_recurring, recurrence_gap_days, recurrence_iteration, recurrence_base_name +FROM seasons +WHERE is_active = 0 + AND is_recurring = 1 + AND start_time <= GETUTCDATE() +ORDER BY start_time ASC +``` + +Returns `Season?`. Used by `RefreshCache` to detect and activate a due pending iteration. + +**`CloneSeasonForNextIteration(Season previous)`** — new method. Runs a single SQL transaction: + +1. Computes: + - `nextStart = previous.EndTime + TimeSpan.FromDays(previous.RecurrenceGapDays!.Value)` + - `nextEnd = nextStart + (previous.EndTime - previous.StartTime)` (preserves duration) + - `nextIteration = previous.RecurrenceIteration + 1` + - `nextName = previous.RecurrenceBaseName + ", Run #" + nextIteration` + +2. INSERTs a new `seasons` row: `is_active = 0`, `is_recurring = 1`, all recurrence fields copied from `previous`, iteration and name updated. + +3. Captures `SCOPE_IDENTITY()` as `@newSeasonId`. + +4. Clones sub-data into the new season id: + - `season_activity_rates` — all rows for `previous.Id` + - `season_objectives` — all rows for `previous.Id` + - `season_tiers` — all rows for `previous.Id` + - `season_leaderboard_rewards` — all rows for `previous.Id` + +5. Returns the new `Season` object. + +A partial clone is impossible because everything runs in one transaction. + +--- + +## Section 3: Server-Side Logic (`SeasonService`) + +Two targeted changes only. No other methods touched. + +### `ProcessSeasonEnd` — spawn next iteration + +After all existing end-of-season work (deactivate, distribute rewards, send announcements), append: + +```csharp +if (season.IsRecurring) + _repository.CloneSeasonForNextIteration(season); +``` + +The new row sits inactive in the DB with a future `start_time`. `RefreshCache` picks it up when its `start_time` arrives. + +### `RefreshCache` — auto-activate pending recurring season + +Current behavior when no active season found: clear cache, return. + +New behavior: after clearing the cache, check for a due pending iteration: + +```csharp +if (_activeSeason == null) +{ + var pending = _repository.GetPendingRecurringSeason(); + if (pending != null) + _repository.SetSeasonActive(pending.Id, true); + // next RefreshCache tick loads it via GetActiveSeason() +} +``` + +`SetSeasonActive` already exists. The activated season loads on the next `RefreshCache` tick via `GetActiveSeason()`, triggering `NotifyOnlinePlayersSeasonStarted` through the existing path. + +### Overlap prevention + +Guaranteed by the existing architecture: +- `ProcessSeasonEnd` nulls `_activeSeason` before spawning. +- The spawned row has `is_active = 0` and a future `start_time` (gap enforced). +- `GetActiveSeason` queries `WHERE is_active = 1` — the pending row is invisible until activated. +- `GetPendingRecurringSeason` queries `start_time <= GETUTCDATE()` — it cannot fire early. + +### Admin stop + +No new code path. Admin sets `is_recurring = 0` on the active season via Admin Tool. `RefreshCache` re-reads the full season every 5 minutes, so the flag is seen within one cache cycle. When `ProcessSeasonEnd` fires, `season.IsRecurring` is false and no iteration is spawned. + +--- + +## Section 4: Admin Tool + +### Season Wizard — Step 1 (Season Info) + +Add to the existing step: + +- **"Recurring" checkbox** — bound to `IsRecurring` (bool). Default: unchecked. +- **"Gap between runs (days)" numeric field** — visible and required only when `IsRecurring = true`. Bound to `RecurrenceGapDays` (int, min 1). + +`BuildSeasonScript()` changes: +- If recurring: `name = ", Run #1"`, writes all four recurrence columns. +- If one-time: `name = ` (unchanged), `is_recurring = 0`, other columns omitted. + +Step 1 validation adds: if `IsRecurring` and `RecurrenceGapDays < 1`, block advance with "Gap must be at least 1 day." + +### Season List / Detail View + +- `SeasonRow` gains `IsRecurring`, `RecurrenceGapDays`, `RecurrenceIteration`, `RecurrenceBaseName`. +- Season card in list shows a `↻` indicator and `Run #N` label when `IsRecurring = true`. +- Detail view gains a **Recurrence** section: + - Toggle to enable/disable `is_recurring` (queued as `UPDATE seasons SET is_recurring = @v WHERE id = @id`). + - Gap days field — editable, queued as `UPDATE seasons SET recurrence_gap_days = @v WHERE id = @id`. + - Disabling recurrence on the active season is the admin stop mechanism. + +### Admin Tool `SeasonRepository` + +All season SELECT queries extended to read the four new columns. All season INSERT/UPDATE paths extended to write them where applicable. + +--- + +## Data Flow Summary + +``` +Admin creates recurring season (wizard) + → seasons row: is_recurring=1, recurrence_iteration=1, name="Base, Run #1", is_active=0 + +Admin activates it manually + → is_active=1, SeasonService loads it, players notified + +Season ends (SeasonService.ProcessSeasonEnd) + → rewards distributed, is_active=0 + → CloneSeasonForNextIteration → new seasons row + cloned sub-data + name="Base, Run #2", start_time=prev.end+gap, is_active=0 + +Gap period elapses + → RefreshCache tick: GetPendingRecurringSeason finds Run #2, SetSeasonActive(true) + → next tick: GetActiveSeason loads Run #2, NotifyOnlinePlayersSeasonStarted fires + +Admin stops recurrence + → UPDATE seasons SET is_recurring=0 WHERE id= + → RefreshCache reads updated flag within 5 min + → ProcessSeasonEnd sees IsRecurring=false, no clone spawned +``` + +--- + +## Files Affected + +| File | Change | +|---|---| +| `docs/db_structure/database_schema_documentation.md` | Document new columns | +| `src/Perpetuum/Services/Seasons/SeasonModels.cs` | 4 new properties on `Season` | +| `src/Perpetuum/Services/Seasons/SeasonRepository.cs` | Extend reads/writes, add 2 new methods | +| `src/Perpetuum/Services/Seasons/SeasonService.cs` | `ProcessSeasonEnd` + `RefreshCache` | +| `src/Perpetuum.AdminTool/Seasons/SeasonRow.cs` | 4 new properties on `SeasonRow` and `SeasonSnapshot` (defined in same file) | +| `src/Perpetuum.AdminTool/ViewModels/SeasonWizardViewModel.cs` | `IsRecurring`, `RecurrenceGapDays`, validation, script gen | +| `src/Perpetuum.AdminTool/ViewModels/SeasonDetailViewModel.cs` | Recurrence section | +| `src/Perpetuum.AdminTool/Views/SeasonWizardWindow.xaml` | New controls in Step 1 | +| `src/Perpetuum.AdminTool/Views/SeasonDetailView.xaml` | Recurrence section | +| `src/Perpetuum.AdminTool/Seasons/SeasonRepository.cs` | Extend reads/writes | + +--- + +## Manual Validation Steps + +1. Create a one-time season — verify no recurrence columns appear in behavior, existing flow unchanged. +2. Create a recurring season (gap = 1 day) via wizard — verify DB row has correct columns and `name = "X, Run #1"`. +3. Activate it manually, wait for/simulate end — verify `CloneSeasonForNextIteration` fires, new row appears with `name = "X, Run #2"`, correct `start_time`. +4. Verify gap is respected — new season does not activate until `start_time <= NOW`. +5. Verify full clone — rates, objectives, tiers, leaderboard rewards all present on the new row. +6. Disable recurrence on the active season — verify no Run #3 is spawned after Run #2 ends. +7. Verify intro mail and announcements fire correctly when Run #2 auto-activates. + +--- + +## Potential Regressions + +- `GetActiveSeason` — extended SELECT; verify existing mapping still correct for one-time seasons. +- `RefreshCache` — new activation branch must not fire when a one-time season is active or when no pending recurring season exists. +- `ProcessSeasonEnd` — clone must not fire for one-time seasons (`IsRecurring = false`). +- Admin Tool season list — `SeasonRow` snapshot round-trip must handle NULL recurrence columns gracefully. From afef266263d0e30b76f7c3f8343e1852f47e34d5 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 16 May 2026 13:31:45 +0500 Subject: [PATCH 015/151] docs: add IMPROVEMENT-001 recurring seasons implementation plan Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-05-16-recurring-seasons.md | 920 ++++++++++++++++++ 1 file changed, 920 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-16-recurring-seasons.md diff --git a/docs/superpowers/plans/2026-05-16-recurring-seasons.md b/docs/superpowers/plans/2026-05-16-recurring-seasons.md new file mode 100644 index 0000000..daa4264 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-recurring-seasons.md @@ -0,0 +1,920 @@ +# Recurring Seasons Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add opt-in recurrence to seasons so each iteration auto-spawns the next after a configurable rest gap, running until an admin disables it. + +**Architecture:** Four additive columns on `seasons` drive all behavior. `ProcessSeasonEnd` clones the season + sub-data into a new inactive row on end; `RefreshCache` auto-activates a pending recurring season once its `start_time` arrives. Admin Tool wizard and detail view expose the new fields. + +**Tech Stack:** .NET 8 / C# 12, SQL Server, WPF (Admin Tool — CommunityToolkit.Mvvm), existing `Db.Query()` / `ExecuteScalar` / `ExecuteNonQuery` pattern. + +**Spec:** `docs/superpowers/specs/2026-05-16-recurring-seasons-design.md` + +--- + +## File Map + +| File | Change | +|---|---| +| `docs/db_structure/database_schema_documentation.md` | Document 4 new columns on `seasons` | +| `src/Perpetuum/Services/Seasons/SeasonModels.cs` | 4 new properties on `Season` | +| `src/Perpetuum/Services/Seasons/SeasonRepository.cs` | Extend `GetActiveSeason` + `GetSeasonById` reads; add `GetPendingRecurringSeason` + `CloneSeasonForNextIteration` | +| `src/Perpetuum/Services/Seasons/SeasonService.cs` | `ProcessSeasonEnd` spawns next; `RefreshCache` auto-activates pending | +| `src/Perpetuum.AdminTool/Seasons/SeasonRow.cs` | 4 new properties on `SeasonRow` and `SeasonSnapshot` | +| `src/Perpetuum.AdminTool/Seasons/SeasonRepository.cs` | Extend `LoadAllSeasonsAsync` to read 4 new columns | +| `src/Perpetuum.AdminTool/Seasons/SeasonChanges.cs` | Update `BuildInsert` and `BuildUpdate` to write recurrence fields | +| `src/Perpetuum.AdminTool/ViewModels/SeasonWizardViewModel.cs` | `IsRecurring`, `RecurrenceGapDays` props, validation, `BuildSeasonScript` update | +| `src/Perpetuum.AdminTool/Views/SeasonWizardWindow.xaml` | Step 1: recurring checkbox + gap field | +| `src/Perpetuum.AdminTool/Views/SeasonDetailView.xaml` | General tab: recurrence section | + +--- + +## Task 1: DB Migration + +**Files:** +- Create: `docs/db_structure/migrations/2026-05-16-recurring-seasons.sql` +- Modify: `docs/db_structure/database_schema_documentation.md` + +- [ ] **Step 1: Create the migration SQL file** + +Create `docs/db_structure/migrations/2026-05-16-recurring-seasons.sql`: + +```sql +-- IMPROVEMENT-001: Recurring Seasons +-- Adds recurrence support to the seasons table. +-- All columns are additive; existing rows are unaffected (defaults keep existing behavior). + +ALTER TABLE seasons + ADD is_recurring BIT NOT NULL DEFAULT 0, + recurrence_gap_days INT NULL, + recurrence_iteration INT NOT NULL DEFAULT 1, + recurrence_base_name NVARCHAR(255) NULL; +``` + +- [ ] **Step 2: Update DB schema documentation** + +In `docs/db_structure/database_schema_documentation.md`, find the `## seasons` section (around line 6169). Replace the Columns table: + +```markdown +| Column | Definition | +|---|---| +| `id` | `"int IDENTITY(1,1)" [not null]` | +| `name` | `varchar(128) [not null]` | +| `description` | `varchar(512) [not null, default: '']` | +| `start_time` | `datetime [not null]` | +| `end_time` | `datetime [not null]` | +| `is_active` | `bit [not null, default: 0]` | +| `is_recurring` | `bit [not null, default: 0]` — enables auto-recurrence | +| `recurrence_gap_days` | `int [null]` — days between end of one run and start of next | +| `recurrence_iteration` | `int [not null, default: 1]` — which run this row represents | +| `recurrence_base_name` | `nvarchar(255) [null]` — operator-entered name; server appends `, Run #N` | +``` + +- [ ] **Step 3: Apply the migration to your database** + +Run `docs/db_structure/migrations/2026-05-16-recurring-seasons.sql` against your SQL Server instance. Verify with: + +```sql +SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_NAME = 'seasons' + AND COLUMN_NAME IN ('is_recurring','recurrence_gap_days','recurrence_iteration','recurrence_base_name') +ORDER BY COLUMN_NAME; +``` + +Expected: 4 rows returned. + +- [ ] **Step 4: Commit** + +``` +git add docs/db_structure/migrations/2026-05-16-recurring-seasons.sql docs/db_structure/database_schema_documentation.md +git commit -m "docs: add DB migration and schema docs for recurring seasons (IMPROVEMENT-001)" +``` + +--- + +## Task 2: Server — Season Model + Existing Repository Reads + +**Files:** +- Modify: `src/Perpetuum/Services/Seasons/SeasonModels.cs` +- Modify: `src/Perpetuum/Services/Seasons/SeasonRepository.cs` + +- [ ] **Step 1: Add 4 properties to the `Season` model** + +In `src/Perpetuum/Services/Seasons/SeasonModels.cs`, extend the `Season` class: + +```csharp +public class Season +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Description { get; set; } = ""; + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public bool IsActive { get; set; } + public bool IsRecurring { get; set; } + public int? RecurrenceGapDays { get; set; } + public int RecurrenceIteration { get; set; } = 1; + public string? RecurrenceBaseName { get; set; } +} +``` + +- [ ] **Step 2: Extend `GetActiveSeason()` in the server `SeasonRepository`** + +In `src/Perpetuum/Services/Seasons/SeasonRepository.cs`, replace the `GetActiveSeason` method: + +```csharp +public Season? GetActiveSeason() +{ + var record = Db.Query( + "SELECT id, name, description, start_time, end_time, is_active, " + + "is_recurring, recurrence_gap_days, recurrence_iteration, recurrence_base_name " + + "FROM seasons WHERE is_active = 1") + .ExecuteSingleRow(); + + if (record == null) return null; + + return new Season + { + Id = record.GetValue("id"), + Name = record.GetValue("name"), + Description = record.GetValue("description"), + StartTime = DateTime.SpecifyKind(record.GetValue("start_time"), DateTimeKind.Utc), + EndTime = DateTime.SpecifyKind(record.GetValue("end_time"), DateTimeKind.Utc), + IsActive = record.GetValue("is_active"), + IsRecurring = record.GetValue("is_recurring"), + RecurrenceGapDays = record.GetValue("recurrence_gap_days"), + RecurrenceIteration = record.GetValue("recurrence_iteration"), + RecurrenceBaseName = record.GetValue("recurrence_base_name"), + }; +} +``` + +- [ ] **Step 3: Extend `GetSeasonById()` in the server `SeasonRepository`** + +Replace the `GetSeasonById` method with: + +```csharp +public Season? GetSeasonById(int seasonId) +{ + var record = Db.Query( + "SELECT id, name, description, start_time, end_time, is_active, " + + "is_recurring, recurrence_gap_days, recurrence_iteration, recurrence_base_name " + + "FROM seasons WHERE id = @id") + .SetParameter("@id", seasonId) + .ExecuteSingleRow(); + + if (record == null) return null; + + return new Season + { + Id = record.GetValue("id"), + Name = record.GetValue("name"), + Description = record.GetValue("description"), + StartTime = DateTime.SpecifyKind(record.GetValue("start_time"), DateTimeKind.Utc), + EndTime = DateTime.SpecifyKind(record.GetValue("end_time"), DateTimeKind.Utc), + IsActive = record.GetValue("is_active"), + IsRecurring = record.GetValue("is_recurring"), + RecurrenceGapDays = record.GetValue("recurrence_gap_days"), + RecurrenceIteration = record.GetValue("recurrence_iteration"), + RecurrenceBaseName = record.GetValue("recurrence_base_name"), + }; +} +``` + +- [ ] **Step 4: Build and verify** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: `Build succeeded.` with 0 errors. + +- [ ] **Step 5: Commit** + +``` +git add src/Perpetuum/Services/Seasons/SeasonModels.cs src/Perpetuum/Services/Seasons/SeasonRepository.cs +git commit -m "feat(seasons): extend Season model and existing repository reads for recurrence fields" +``` + +--- + +## Task 3: Server — New Repository Methods + +**Files:** +- Modify: `src/Perpetuum/Services/Seasons/SeasonRepository.cs` + +- [ ] **Step 1: Add `GetPendingRecurringSeason()`** + +Add this method to `SeasonRepository` (after `GetSeasonById`): + +```csharp +public Season? GetPendingRecurringSeason() +{ + var record = Db.Query( + "SELECT TOP 1 id, name, description, start_time, end_time, is_active, " + + "is_recurring, recurrence_gap_days, recurrence_iteration, recurrence_base_name " + + "FROM seasons " + + "WHERE is_active = 0 AND is_recurring = 1 AND start_time <= GETUTCDATE() " + + "ORDER BY start_time ASC") + .ExecuteSingleRow(); + + if (record == null) return null; + + return new Season + { + Id = record.GetValue("id"), + Name = record.GetValue("name"), + Description = record.GetValue("description"), + StartTime = DateTime.SpecifyKind(record.GetValue("start_time"), DateTimeKind.Utc), + EndTime = DateTime.SpecifyKind(record.GetValue("end_time"), DateTimeKind.Utc), + IsActive = record.GetValue("is_active"), + IsRecurring = record.GetValue("is_recurring"), + RecurrenceGapDays = record.GetValue("recurrence_gap_days"), + RecurrenceIteration = record.GetValue("recurrence_iteration"), + RecurrenceBaseName = record.GetValue("recurrence_base_name"), + }; +} +``` + +- [ ] **Step 2: Add `CloneSeasonForNextIteration()`** + +Add this method to `SeasonRepository` (after `GetPendingRecurringSeason`). This performs 5 sequential DB calls: one INSERT for the new season row, then four INSERT...SELECT to clone sub-data. No transaction wrapper — follows existing repo patterns. + +```csharp +public Season CloneSeasonForNextIteration(Season previous) +{ + int nextIteration = previous.RecurrenceIteration + 1; + DateTime nextStart = previous.EndTime.AddDays(previous.RecurrenceGapDays!.Value); + DateTime nextEnd = nextStart + (previous.EndTime - previous.StartTime); + string baseName = previous.RecurrenceBaseName ?? previous.Name; + string nextName = $"{baseName}, Run #{nextIteration}"; + + int newId = Db.Query( + "INSERT INTO seasons (name, description, start_time, end_time, is_active, " + + "is_recurring, recurrence_gap_days, recurrence_iteration, recurrence_base_name) " + + "VALUES (@name, @description, @start, @end, 0, 1, @gapDays, @iteration, @baseName); " + + "SELECT CAST(SCOPE_IDENTITY() AS INT)") + .SetParameter("@name", nextName) + .SetParameter("@description", previous.Description) + .SetParameter("@start", nextStart) + .SetParameter("@end", nextEnd) + .SetParameter("@gapDays", previous.RecurrenceGapDays.Value) + .SetParameter("@iteration", nextIteration) + .SetParameter("@baseName", baseName) + .ExecuteScalar(); + + Db.Query( + "INSERT INTO season_activity_rates (season_id, activity_type, points_per_unit, unit_scale) " + + "SELECT @newId, activity_type, points_per_unit, unit_scale " + + "FROM season_activity_rates WHERE season_id = @prevId") + .SetParameter("@newId", newId) + .SetParameter("@prevId", previous.Id) + .ExecuteNonQuery(); + + Db.Query( + "INSERT INTO season_objectives (season_id, name, description, activity_type, " + + "target_value, bonus_points, display_order) " + + "SELECT @newId, name, description, activity_type, target_value, bonus_points, display_order " + + "FROM season_objectives WHERE season_id = @prevId") + .SetParameter("@newId", newId) + .SetParameter("@prevId", previous.Id) + .ExecuteNonQuery(); + + Db.Query( + "INSERT INTO season_tiers (season_id, tier_number, tier_name, points_required, package_id) " + + "SELECT @newId, tier_number, tier_name, points_required, package_id " + + "FROM season_tiers WHERE season_id = @prevId") + .SetParameter("@newId", newId) + .SetParameter("@prevId", previous.Id) + .ExecuteNonQuery(); + + Db.Query( + "INSERT INTO season_leaderboard_rewards (season_id, rank_min, rank_max, package_id) " + + "SELECT @newId, rank_min, rank_max, package_id " + + "FROM season_leaderboard_rewards WHERE season_id = @prevId") + .SetParameter("@newId", newId) + .SetParameter("@prevId", previous.Id) + .ExecuteNonQuery(); + + return new Season + { + Id = newId, + Name = nextName, + Description = previous.Description, + StartTime = nextStart, + EndTime = nextEnd, + IsActive = false, + IsRecurring = true, + RecurrenceGapDays = previous.RecurrenceGapDays, + RecurrenceIteration = nextIteration, + RecurrenceBaseName = baseName, + }; +} +``` + +- [ ] **Step 3: Build and verify** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: `Build succeeded.` with 0 errors. + +- [ ] **Step 4: Commit** + +``` +git add src/Perpetuum/Services/Seasons/SeasonRepository.cs +git commit -m "feat(seasons): add GetPendingRecurringSeason and CloneSeasonForNextIteration to server repository" +``` + +--- + +## Task 4: Server — SeasonService Logic + +**Files:** +- Modify: `src/Perpetuum/Services/Seasons/SeasonService.cs` + +- [ ] **Step 1: Spawn next iteration in `ProcessSeasonEnd`** + +In `src/Perpetuum/Services/Seasons/SeasonService.cs`, find `ProcessSeasonEnd`. It ends with the `_channelManager.Value.Announcement(...)` call. Add the spawn call as the very last statement in the method (after the announcement): + +```csharp + _channelManager.Value.Announcement(SeasonChannelName, _announcer.Value, chatMessage.ToString()); + + if (season.IsRecurring) + _repository.CloneSeasonForNextIteration(season); + } +``` + +- [ ] **Step 2: Auto-activate pending recurring season in `RefreshCache`** + +In `RefreshCache`, find the `else` branch inside `if (season == null)` — this is the branch that clears the cache when no active season is found and there's no early-deactivation case. Add the pending season check at the end of that `else` block: + +```csharp + else + { + _activeSeason = null; + _activeRates = ImmutableList.Empty; + _activeObjectives = ImmutableList.Empty; + _activeTiers = ImmutableList.Empty; + _activeLeaderboard = ImmutableList.Empty; + + var pending = _repository.GetPendingRecurringSeason(); + if (pending != null) + _repository.SetSeasonActive(pending.Id, true); + } +``` + +The activated season is picked up on the next `RefreshCache` tick (within 5 minutes) by `GetActiveSeason()`, which triggers the normal `NotifyOnlinePlayersSeasonStarted` flow. + +- [ ] **Step 3: Build and verify** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: `Build succeeded.` with 0 errors. + +- [ ] **Step 4: Commit** + +``` +git add src/Perpetuum/Services/Seasons/SeasonService.cs +git commit -m "feat(seasons): auto-spawn next iteration on end and auto-activate pending recurring seasons" +``` + +--- + +## Task 5: Admin Tool — Models, Repository, SeasonChanges + +**Files:** +- Modify: `src/Perpetuum.AdminTool/Seasons/SeasonRow.cs` +- Modify: `src/Perpetuum.AdminTool/Seasons/SeasonRepository.cs` +- Modify: `src/Perpetuum.AdminTool/Seasons/SeasonChanges.cs` + +- [ ] **Step 1: Extend `SeasonRow` with 4 new observable properties** + +In `src/Perpetuum.AdminTool/Seasons/SeasonRow.cs`, add four `[ObservableProperty]` fields to `SeasonRow` (after `_isActive`): + +```csharp +[ObservableProperty] private bool _isRecurring; +[ObservableProperty] private int? _recurrenceGapDays; +[ObservableProperty] private int _recurrenceIteration = 1; +[ObservableProperty] private string? _recurrenceBaseName; +``` + +- [ ] **Step 2: Update `ApplySnapshot` and `RefreshOriginalFromCurrent` in `SeasonRow`** + +Replace `ApplySnapshot`: + +```csharp +public void ApplySnapshot(SeasonSnapshot s) +{ + Original = s; + Name = s.Name; + Description = s.Description; + StartTime = s.StartTime; + EndTime = s.EndTime; + IsActive = s.IsActive; + IsRecurring = s.IsRecurring; + RecurrenceGapDays = s.RecurrenceGapDays; + RecurrenceIteration = s.RecurrenceIteration; + RecurrenceBaseName = s.RecurrenceBaseName; +} +``` + +Replace `RefreshOriginalFromCurrent`: + +```csharp +public void RefreshOriginalFromCurrent() +{ + Original = new SeasonSnapshot + { + Id = Id, + Name = Name, + Description = Description, + StartTime = StartTime, + EndTime = EndTime, + IsActive = IsActive, + IsRecurring = IsRecurring, + RecurrenceGapDays = RecurrenceGapDays, + RecurrenceIteration = RecurrenceIteration, + RecurrenceBaseName = RecurrenceBaseName, + }; +} +``` + +- [ ] **Step 3: Extend `SeasonSnapshot` with 4 new properties** + +In the same file (`SeasonRow.cs`), add to `SeasonSnapshot`: + +```csharp +public class SeasonSnapshot +{ + public int Id { get; init; } + public string Name { get; init; } = ""; + public string Description { get; init; } = ""; + public DateTime StartTime { get; init; } + public DateTime EndTime { get; init; } + public bool IsActive { get; init; } + public bool IsRecurring { get; init; } + public int? RecurrenceGapDays { get; init; } + public int RecurrenceIteration { get; init; } = 1; + public string? RecurrenceBaseName { get; init; } +} +``` + +- [ ] **Step 4: Extend `LoadAllSeasonsAsync` in the Admin Tool `SeasonRepository`** + +In `src/Perpetuum.AdminTool/Seasons/SeasonRepository.cs`, replace `LoadAllSeasonsAsync`: + +```csharp +public async Task> LoadAllSeasonsAsync() +{ + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT id, name, description, start_time, end_time, is_active, " + + "is_recurring, recurrence_gap_days, recurrence_iteration, recurrence_base_name " + + "FROM seasons ORDER BY start_time DESC"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var snap = new SeasonSnapshot + { + Id = reader.GetInt32(0), + Name = reader.IsDBNull(1) ? "" : reader.GetString(1), + Description = reader.IsDBNull(2) ? "" : reader.GetString(2), + StartTime = DateTime.SpecifyKind(reader.GetDateTime(3), DateTimeKind.Utc), + EndTime = DateTime.SpecifyKind(reader.GetDateTime(4), DateTimeKind.Utc), + IsActive = !reader.IsDBNull(5) && reader.GetBoolean(5), + IsRecurring = !reader.IsDBNull(6) && reader.GetBoolean(6), + RecurrenceGapDays = reader.IsDBNull(7) ? (int?)null : reader.GetInt32(7), + RecurrenceIteration = reader.IsDBNull(8) ? 1 : reader.GetInt32(8), + RecurrenceBaseName = reader.IsDBNull(9) ? null : reader.GetString(9), + }; + result.Add(new SeasonRow(snap)); + } + return result; +} +``` + +- [ ] **Step 5: Update `BuildInsert` in `SeasonChanges`** + +In `src/Perpetuum.AdminTool/Seasons/SeasonChanges.cs`, replace `BuildInsert`: + +```csharp +public static IPendingChange BuildInsert(SeasonRow row) +{ + string gapSql = row.IsRecurring && row.RecurrenceGapDays.HasValue + ? row.RecurrenceGapDays.Value.ToString() + : "NULL"; + string baseNameSql = row.IsRecurring && row.RecurrenceBaseName != null + ? SqlLiteral.Of(row.RecurrenceBaseName) + : "NULL"; + return new RawSqlChange( + $"seasons: insert '{row.Name}'", + $"INSERT INTO seasons (name, description, start_time, end_time, is_active, " + + $"is_recurring, recurrence_gap_days, recurrence_iteration, recurrence_base_name) VALUES (" + + $"{SqlLiteral.Of(row.Name)}, {SqlLiteral.Of(row.Description)}, " + + $"'{DateTime.SpecifyKind(row.StartTime, DateTimeKind.Utc):yyyy-MM-dd HH:mm:ss}', " + + $"'{DateTime.SpecifyKind(row.EndTime, DateTimeKind.Utc):yyyy-MM-dd HH:mm:ss}', 0, " + + $"{(row.IsRecurring ? 1 : 0)}, {gapSql}, 1, {baseNameSql})"); +} +``` + +- [ ] **Step 6: Update `BuildUpdate` in `SeasonChanges`** + +Replace `BuildUpdate`: + +```csharp +public static IPendingChange BuildUpdate(SeasonRow row) +{ + string gapSql = row.IsRecurring && row.RecurrenceGapDays.HasValue + ? row.RecurrenceGapDays.Value.ToString() + : "NULL"; + string baseNameSql = row.IsRecurring && row.RecurrenceBaseName != null + ? SqlLiteral.Of(row.RecurrenceBaseName) + : "NULL"; + var sets = $"name = {SqlLiteral.Of(row.Name)}, description = {SqlLiteral.Of(row.Description)}, " + + $"start_time = '{DateTime.SpecifyKind(row.StartTime, DateTimeKind.Utc):yyyy-MM-dd HH:mm:ss}', " + + $"end_time = '{DateTime.SpecifyKind(row.EndTime, DateTimeKind.Utc):yyyy-MM-dd HH:mm:ss}', " + + $"is_recurring = {(row.IsRecurring ? 1 : 0)}, " + + $"recurrence_gap_days = {gapSql}, " + + $"recurrence_base_name = {baseNameSql}"; + return new RawSqlChange( + $"seasons: update id {row.Id} ('{row.Name}')", + $"UPDATE seasons SET {sets} WHERE id = {row.Id}"); +} +``` + +- [ ] **Step 7: Build and verify** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: `Build succeeded.` with 0 errors. + +- [ ] **Step 8: Commit** + +``` +git add src/Perpetuum.AdminTool/Seasons/SeasonRow.cs src/Perpetuum.AdminTool/Seasons/SeasonRepository.cs src/Perpetuum.AdminTool/Seasons/SeasonChanges.cs +git commit -m "feat(seasons): extend Admin Tool models, repository reads, and SQL builders for recurrence fields" +``` + +--- + +## Task 6: Admin Tool — Season Wizard ViewModel + +**Files:** +- Modify: `src/Perpetuum.AdminTool/ViewModels/SeasonWizardViewModel.cs` + +- [ ] **Step 1: Add `IsRecurring` and `RecurrenceGapDays` observable properties** + +In `SeasonWizardViewModel`, add two new fields after the existing `_endTimeText` field: + +```csharp +[ObservableProperty] private bool _isRecurring; +[ObservableProperty] private int _recurrenceGapDays = 7; +``` + +- [ ] **Step 2: Update `ValidateStep1` to guard the gap value** + +Find `ValidateStep1`. It currently ends with: + +```csharp +else if (EndTime <= StartTime) + Step1Validation = "End time must be after start time."; +else + Step1Validation = ""; +``` + +Add one more guard before the final `else`: + +```csharp +else if (IsRecurring && RecurrenceGapDays < 1) + Step1Validation = "Gap between runs must be at least 1 day."; +else + Step1Validation = ""; +``` + +- [ ] **Step 3: Add `OnIsRecurringChanged` to re-run validation** + +After the existing `partial void OnEndTimeTextChanged` handler, add: + +```csharp +partial void OnIsRecurringChanged(bool value) => ValidateStep1(); +partial void OnRecurrenceGapDaysChanged(int value) => ValidateStep1(); +``` + +- [ ] **Step 4: Update `BuildSeasonScript` to write recurrence columns** + +Find the `BuildSeasonScript` method. The `seasons` INSERT currently is: + +```csharp +sb.AppendLine($"INSERT INTO seasons (name, description, start_time, end_time, is_active)"); +sb.AppendLine($"VALUES ({SqlLiteral.Of(Name)}, {SqlLiteral.Of(Description)},"); +sb.AppendLine($" '{DateTime.SpecifyKind(StartTime, DateTimeKind.Utc):yyyy-MM-dd HH:mm:ss}', '{DateTime.SpecifyKind(EndTime, DateTimeKind.Utc):yyyy-MM-dd HH:mm:ss}', 0);"); +``` + +Replace those three lines with: + +```csharp +string displayName = IsRecurring ? $"{Name}, Run #1" : Name; +string gapSql = IsRecurring ? RecurrenceGapDays.ToString() : "NULL"; +string baseNameSql = IsRecurring ? SqlLiteral.Of(Name) : "NULL"; +sb.AppendLine("INSERT INTO seasons (name, description, start_time, end_time, is_active, " + + "is_recurring, recurrence_gap_days, recurrence_iteration, recurrence_base_name)"); +sb.AppendLine($"VALUES ({SqlLiteral.Of(displayName)}, {SqlLiteral.Of(Description)},"); +sb.AppendLine($" '{DateTime.SpecifyKind(StartTime, DateTimeKind.Utc):yyyy-MM-dd HH:mm:ss}', " + + $"'{DateTime.SpecifyKind(EndTime, DateTimeKind.Utc):yyyy-MM-dd HH:mm:ss}', 0, " + + $"{(IsRecurring ? 1 : 0)}, {gapSql}, 1, {baseNameSql});"); +``` + +- [ ] **Step 5: Build and verify** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: `Build succeeded.` with 0 errors. + +- [ ] **Step 6: Commit** + +``` +git add src/Perpetuum.AdminTool/ViewModels/SeasonWizardViewModel.cs +git commit -m "feat(seasons): add recurrence fields, validation, and script generation to season wizard ViewModel" +``` + +--- + +## Task 7: Admin Tool — Season Wizard XAML + +**Files:** +- Modify: `src/Perpetuum.AdminTool/Views/SeasonWizardWindow.xaml` + +- [ ] **Step 1: Add two `RowDefinition` entries to the Step 1 grid** + +In `SeasonWizardWindow.xaml`, find the Step 1 `` (inside the `IsStep1` StackPanel). Its `RowDefinitions` currently defines 4 rows (for Name, Description, Start, End). Add two more: + +```xml + + + + + + + + +``` + +- [ ] **Step 2: Add recurring controls at rows 4 and 5** + +After the existing End date row (row 3) content and before the closing `` of Step 1, add: + +```xml + + + + + + + + + + + + + + + + + +``` + +- [ ] **Step 3: Build and verify** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: `Build succeeded.` with 0 errors. + +- [ ] **Step 4: Smoke-test the wizard in the Admin Tool** + +Launch the Admin Tool, open Seasons, click New Season. On Step 1: +- Verify the Recurring checkbox appears below the date fields. +- Check it — verify the "Gap between runs" field appears with default value 7. +- Set gap to 0, click Next — verify validation error "Gap between runs must be at least 1 day." blocks navigation. +- Uncheck recurring — verify gap field hides. + +- [ ] **Step 5: Commit** + +``` +git add src/Perpetuum.AdminTool/Views/SeasonWizardWindow.xaml +git commit -m "feat(seasons): add recurring checkbox and gap field to season wizard Step 1" +``` + +--- + +## Task 8: Admin Tool — Season Detail XAML + +**Files:** +- Modify: `src/Perpetuum.AdminTool/Views/SeasonDetailView.xaml` + +The detail view General tab currently has a 2-column grid with 6 rows (rows 0–5: ID, Name, Description, Start, End, Save button). The `SaveGeneral` command already calls `SeasonChanges.BuildUpdate(Season)`, which now writes recurrence fields — so no ViewModel changes are needed. + +This task extends the grid with 2 new rows for recurrence fields, moving the Save button down. + +- [ ] **Step 1: Add two `RowDefinition` entries to the General tab grid** + +Find the `` inside the General ``. Its `RowDefinitions` currently defines 6 rows. Add two more: + +```xml + + + + + + + + + + +``` + +- [ ] **Step 2: Move the Save button from row 5 to row 7** + +Find: +```xml + +``` + +Change to: +```xml + +``` + +- [ ] **Step 3: Add recurrence fields at rows 5 and 6** + +After the End time row content (row 4) and before the Save button StackPanel, add: + +```xml + + + + + + + + + + + + + + + + + +``` + +- [ ] **Step 4: Build and verify** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: `Build succeeded.` with 0 errors. + +- [ ] **Step 5: Smoke-test the detail view** + +Open a season in the Admin Tool detail view: +- Verify the General tab shows a "Recurring" checkbox below End time. +- For a non-recurring season: checkbox unchecked, gap field hidden. +- Check the box — gap field appears. Enter a gap value. Click Save General → verify the change queue contains an UPDATE with `is_recurring = 1` and the correct `recurrence_gap_days`. +- Uncheck the box. Click Save General → verify the UPDATE sets `is_recurring = 0, recurrence_gap_days = NULL`. + +- [ ] **Step 6: Commit** + +``` +git add src/Perpetuum.AdminTool/Views/SeasonDetailView.xaml +git commit -m "feat(seasons): add recurrence section to season detail General tab" +``` + +--- + +## Task 9: Admin Tool — Season List Card Indicator + +**Files:** +- Modify: `src/Perpetuum.AdminTool/Views/SeasonsView.xaml` + +- [ ] **Step 1: Add `↻ Run #N` indicator to the season card template** + +In `SeasonsView.xaml`, find the `` inside the card `DataTemplate` (the one containing the Name, date range, and Description TextBlocks). Add a new TextBlock after the Name TextBlock: + +```xml + + + + + + + + +``` + +- [ ] **Step 2: Build and verify** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: `Build succeeded.` with 0 errors. + +- [ ] **Step 3: Smoke-test the season list** + +Load the Admin Tool seasons list. For a recurring season row in DB (`is_recurring = 1`, `recurrence_iteration = 3`), verify the card shows `↻ Run #3` in blue below the name. For a one-time season, verify the indicator is hidden. + +- [ ] **Step 4: Commit** + +``` +git add src/Perpetuum.AdminTool/Views/SeasonsView.xaml +git commit -m "feat(seasons): show recurrence indicator on season list cards" +``` + +--- + +## Manual Validation (Full Flow) + +After all tasks are committed, verify the end-to-end flow: + +1. **One-time season unaffected** — create a non-recurring season via wizard, activate it, wait for end. Verify no new row spawned in `seasons` table. + +2. **Recurring season wizard** — create a recurring season (gap = 1 day) via wizard. Verify DB row: `is_recurring = 1`, `recurrence_iteration = 1`, `name = ', Run #1'`, `recurrence_base_name = ''`. + +3. **Clone on end** — activate Run #1 and force-end it via `#SeasonForceEnd,` (sets `end_time` to 1 minute ago). Within the next process cycle, verify a new row appears in `seasons`: `name = ', Run #2'`, `start_time = Run#1.end_time + gap`, `is_active = 0`, all rates/objectives/tiers/rewards cloned. + +4. **Auto-activate** — when Run #2's `start_time` arrives (or temporarily set it to the past in the DB), verify the server activates it within 5 minutes and sends start announcements. + +5. **Admin stop** — while Run #2 is active, open detail view, uncheck Recurring, Save General, commit. Wait for Run #2 to end. Verify no Run #3 is spawned. + +6. **Detail view** — for a recurring season row in DB, verify the Admin Tool shows the correct `↻ Run #N` values and that the gap field is editable and saved correctly. + +--- + +## Potential Regressions + +- `GetActiveSeason` — extended SELECT; verify one-time seasons still map correctly (new nullable columns default to `false`/`null`/`1`). +- `RefreshCache` `else` branch — new pending-season check must not fire when a one-time season is active or no recurring season is pending. +- `ProcessSeasonEnd` — `IsRecurring = false` on one-time seasons must suppress the clone call. +- Admin Tool season list — `SeasonRow` snapshot round-trip must handle `NULL` recurrence columns gracefully (ordinal column reads with `IsDBNull` checks added in Task 5 Step 4). +- `BuildUpdate` — the new recurrence fields in the UPDATE must not corrupt one-time season rows (they write `is_recurring = 0, recurrence_gap_days = NULL, recurrence_base_name = NULL` when `IsRecurring = false`). From e9a15882327e5ac41231821e54a33ffa70cbd207 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 16 May 2026 14:20:40 +0500 Subject: [PATCH 016/151] docs: add DB migration and schema docs for recurring seasons (IMPROVEMENT-001) --- docs/db_structure/database_schema_documentation.md | 4 ++++ .../migrations/2026-05-16-recurring-seasons.sql | 9 +++++++++ 2 files changed, 13 insertions(+) create mode 100644 docs/db_structure/migrations/2026-05-16-recurring-seasons.sql diff --git a/docs/db_structure/database_schema_documentation.md b/docs/db_structure/database_schema_documentation.md index c5056e8..2050183 100644 --- a/docs/db_structure/database_schema_documentation.md +++ b/docs/db_structure/database_schema_documentation.md @@ -6180,6 +6180,10 @@ Generated from DBML structure. | `start_time` | `datetime [not null]` | | `end_time` | `datetime [not null]` | | `is_active` | `bit [not null, default: 0]` | +| `is_recurring` | `bit [not null, default: 0]` — enables auto-recurrence | +| `recurrence_gap_days` | `int [null]` — days between end of one run and start of next | +| `recurrence_iteration` | `int [not null, default: 1]` — which run this row represents | +| `recurrence_base_name` | `nvarchar(255) [null]` — operator-entered name; server appends `, Run #N` | ### Indexes diff --git a/docs/db_structure/migrations/2026-05-16-recurring-seasons.sql b/docs/db_structure/migrations/2026-05-16-recurring-seasons.sql new file mode 100644 index 0000000..6b94e16 --- /dev/null +++ b/docs/db_structure/migrations/2026-05-16-recurring-seasons.sql @@ -0,0 +1,9 @@ +-- IMPROVEMENT-001: Recurring Seasons +-- Adds recurrence support to the seasons table. +-- All columns are additive; existing rows are unaffected (defaults keep existing behavior). + +ALTER TABLE seasons + ADD is_recurring BIT NOT NULL DEFAULT 0, + recurrence_gap_days INT NULL, + recurrence_iteration INT NOT NULL DEFAULT 1, + recurrence_base_name NVARCHAR(255) NULL; From 568289c435eed4f6632936532289560dc2cf86ea Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 16 May 2026 14:27:31 +0500 Subject: [PATCH 017/151] feat(seasons): extend Season model and existing repository reads for recurrence fields --- .../Services/Seasons/SeasonModels.cs | 4 +++ .../Services/Seasons/SeasonRepository.cs | 26 ++++++++++++++----- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/Perpetuum/Services/Seasons/SeasonModels.cs b/src/Perpetuum/Services/Seasons/SeasonModels.cs index 1e802ce..61895b5 100644 --- a/src/Perpetuum/Services/Seasons/SeasonModels.cs +++ b/src/Perpetuum/Services/Seasons/SeasonModels.cs @@ -8,6 +8,10 @@ public class Season public DateTime StartTime { get; set; } public DateTime EndTime { get; set; } public bool IsActive { get; set; } + public bool IsRecurring { get; set; } + public int? RecurrenceGapDays { get; set; } + public int RecurrenceIteration { get; set; } = 1; + public string? RecurrenceBaseName { get; set; } } public class SeasonActivityRate diff --git a/src/Perpetuum/Services/Seasons/SeasonRepository.cs b/src/Perpetuum/Services/Seasons/SeasonRepository.cs index f003cf6..ba50951 100644 --- a/src/Perpetuum/Services/Seasons/SeasonRepository.cs +++ b/src/Perpetuum/Services/Seasons/SeasonRepository.cs @@ -8,9 +8,11 @@ public class SeasonRepository public Season? GetActiveSeason() { - var record = Db.Query("SELECT id, name, description, start_time, end_time, is_active " + - "FROM seasons WHERE is_active = 1") - .ExecuteSingleRow(); + var record = Db.Query( + "SELECT id, name, description, start_time, end_time, is_active, " + + "is_recurring, recurrence_gap_days, recurrence_iteration, recurrence_base_name " + + "FROM seasons WHERE is_active = 1") + .ExecuteSingleRow(); if (record == null) return null; @@ -22,6 +24,10 @@ public class SeasonRepository StartTime = DateTime.SpecifyKind(record.GetValue("start_time"), DateTimeKind.Utc), EndTime = DateTime.SpecifyKind(record.GetValue("end_time"), DateTimeKind.Utc), IsActive = record.GetValue("is_active"), + IsRecurring = record.GetValue("is_recurring"), + RecurrenceGapDays = record.GetValue("recurrence_gap_days"), + RecurrenceIteration = record.GetValue("recurrence_iteration"), + RecurrenceBaseName = record.GetValue("recurrence_base_name"), }; } @@ -412,10 +418,12 @@ public void AddLeaderboardReward(int seasonId, int rankMin, int rankMax, int pac public Season? GetSeasonById(int seasonId) { - var record = Db.Query("SELECT id, name, description, start_time, end_time, is_active " + - "FROM seasons WHERE id = @id") - .SetParameter("@id", seasonId) - .ExecuteSingleRow(); + var record = Db.Query( + "SELECT id, name, description, start_time, end_time, is_active, " + + "is_recurring, recurrence_gap_days, recurrence_iteration, recurrence_base_name " + + "FROM seasons WHERE id = @id") + .SetParameter("@id", seasonId) + .ExecuteSingleRow(); if (record == null) return null; @@ -427,6 +435,10 @@ public void AddLeaderboardReward(int seasonId, int rankMin, int rankMax, int pac StartTime = DateTime.SpecifyKind(record.GetValue("start_time"), DateTimeKind.Utc), EndTime = DateTime.SpecifyKind(record.GetValue("end_time"), DateTimeKind.Utc), IsActive = record.GetValue("is_active"), + IsRecurring = record.GetValue("is_recurring"), + RecurrenceGapDays = record.GetValue("recurrence_gap_days"), + RecurrenceIteration = record.GetValue("recurrence_iteration"), + RecurrenceBaseName = record.GetValue("recurrence_base_name"), }; } } From 6ef1fc4a1b60052cbb2ed25a69ccc562ee9034fe Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 16 May 2026 14:39:59 +0500 Subject: [PATCH 018/151] feat(seasons): add GetPendingRecurringSeason and CloneSeasonForNextIteration to server repository --- .../Services/Seasons/SeasonRepository.cs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/src/Perpetuum/Services/Seasons/SeasonRepository.cs b/src/Perpetuum/Services/Seasons/SeasonRepository.cs index ba50951..3544edc 100644 --- a/src/Perpetuum/Services/Seasons/SeasonRepository.cs +++ b/src/Perpetuum/Services/Seasons/SeasonRepository.cs @@ -441,5 +441,102 @@ public void AddLeaderboardReward(int seasonId, int rankMin, int rankMax, int pac RecurrenceBaseName = record.GetValue("recurrence_base_name"), }; } + + public Season? GetPendingRecurringSeason() + { + var record = Db.Query( + "SELECT TOP 1 id, name, description, start_time, end_time, is_active, " + + "is_recurring, recurrence_gap_days, recurrence_iteration, recurrence_base_name " + + "FROM seasons " + + "WHERE is_active = 0 AND is_recurring = 1 AND start_time <= GETUTCDATE() " + + "ORDER BY start_time ASC") + .ExecuteSingleRow(); + + if (record == null) return null; + + return new Season + { + Id = record.GetValue("id"), + Name = record.GetValue("name"), + Description = record.GetValue("description"), + StartTime = DateTime.SpecifyKind(record.GetValue("start_time"), DateTimeKind.Utc), + EndTime = DateTime.SpecifyKind(record.GetValue("end_time"), DateTimeKind.Utc), + IsActive = record.GetValue("is_active"), + IsRecurring = record.GetValue("is_recurring"), + RecurrenceGapDays = record.GetValue("recurrence_gap_days"), + RecurrenceIteration = record.GetValue("recurrence_iteration"), + RecurrenceBaseName = record.GetValue("recurrence_base_name"), + }; + } + + public Season CloneSeasonForNextIteration(Season previous) + { + int nextIteration = previous.RecurrenceIteration + 1; + DateTime nextStart = previous.EndTime.AddDays(previous.RecurrenceGapDays!.Value); + DateTime nextEnd = nextStart + (previous.EndTime - previous.StartTime); + string baseName = previous.RecurrenceBaseName ?? previous.Name; + string nextName = $"{baseName}, Run #{nextIteration}"; + + int newId = Db.Query( + "INSERT INTO seasons (name, description, start_time, end_time, is_active, " + + "is_recurring, recurrence_gap_days, recurrence_iteration, recurrence_base_name) " + + "VALUES (@name, @description, @start, @end, 0, 1, @gapDays, @iteration, @baseName); " + + "SELECT CAST(SCOPE_IDENTITY() AS INT)") + .SetParameter("@name", nextName) + .SetParameter("@description", previous.Description) + .SetParameter("@start", nextStart) + .SetParameter("@end", nextEnd) + .SetParameter("@gapDays", previous.RecurrenceGapDays!.Value) + .SetParameter("@iteration", nextIteration) + .SetParameter("@baseName", baseName) + .ExecuteScalar(); + + Db.Query( + "INSERT INTO season_activity_rates (season_id, activity_type, points_per_unit, unit_scale) " + + "SELECT @newId, activity_type, points_per_unit, unit_scale " + + "FROM season_activity_rates WHERE season_id = @prevId") + .SetParameter("@newId", newId) + .SetParameter("@prevId", previous.Id) + .ExecuteNonQuery(); + + Db.Query( + "INSERT INTO season_objectives (season_id, name, description, activity_type, " + + "target_value, bonus_points, display_order) " + + "SELECT @newId, name, description, activity_type, target_value, bonus_points, display_order " + + "FROM season_objectives WHERE season_id = @prevId") + .SetParameter("@newId", newId) + .SetParameter("@prevId", previous.Id) + .ExecuteNonQuery(); + + Db.Query( + "INSERT INTO season_tiers (season_id, tier_number, tier_name, points_required, package_id) " + + "SELECT @newId, tier_number, tier_name, points_required, package_id " + + "FROM season_tiers WHERE season_id = @prevId") + .SetParameter("@newId", newId) + .SetParameter("@prevId", previous.Id) + .ExecuteNonQuery(); + + Db.Query( + "INSERT INTO season_leaderboard_rewards (season_id, rank_min, rank_max, package_id) " + + "SELECT @newId, rank_min, rank_max, package_id " + + "FROM season_leaderboard_rewards WHERE season_id = @prevId") + .SetParameter("@newId", newId) + .SetParameter("@prevId", previous.Id) + .ExecuteNonQuery(); + + return new Season + { + Id = newId, + Name = nextName, + Description = previous.Description, + StartTime = nextStart, + EndTime = nextEnd, + IsActive = false, + IsRecurring = true, + RecurrenceGapDays = previous.RecurrenceGapDays, + RecurrenceIteration = nextIteration, + RecurrenceBaseName = baseName, + }; + } } } From ea48bbba561a847e64fd38cf045f428543ca7d6e Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 16 May 2026 15:14:16 +0500 Subject: [PATCH 019/151] feat(seasons): auto-spawn next iteration on end and auto-activate pending recurring seasons --- src/Perpetuum/Services/Seasons/SeasonService.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Perpetuum/Services/Seasons/SeasonService.cs b/src/Perpetuum/Services/Seasons/SeasonService.cs index b8d7938..8dfbabd 100644 --- a/src/Perpetuum/Services/Seasons/SeasonService.cs +++ b/src/Perpetuum/Services/Seasons/SeasonService.cs @@ -103,6 +103,10 @@ internal void RefreshCache() _activeObjectives = ImmutableList.Empty; _activeTiers = ImmutableList.Empty; _activeLeaderboard = ImmutableList.Empty; + + var pending = _repository.GetPendingRecurringSeason(); + if (pending != null) + _repository.SetSeasonActive(pending.Id, true); } // No active season — discard any pending login chars while (_pendingIntroChars.TryDequeue(out _)) { } @@ -285,6 +289,9 @@ private void ProcessSeasonEnd(Season season) chatMessage.AppendLine("Thanks for participating! Stay tuned for the next season."); _channelManager.Value.Announcement(SeasonChannelName, _announcer.Value, chatMessage.ToString()); + + if (season.IsRecurring) + _repository.CloneSeasonForNextIteration(season); } internal void AnnounceLeaderboard(Season? season) From 2f8209f14de28b56767dace9119b75a446d8b230 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 16 May 2026 15:30:33 +0500 Subject: [PATCH 020/151] feat(seasons): extend Admin Tool models, repository reads, and SQL builders for recurrence fields Co-Authored-By: Claude Sonnet 4.6 --- .../Seasons/SeasonChanges.cs | 29 +++++++++++++++---- .../Seasons/SeasonRepository.cs | 9 ++++-- src/Perpetuum.AdminTool/Seasons/SeasonRow.cs | 18 +++++++++++- 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/Perpetuum.AdminTool/Seasons/SeasonChanges.cs b/src/Perpetuum.AdminTool/Seasons/SeasonChanges.cs index 17ead6a..fc1609a 100644 --- a/src/Perpetuum.AdminTool/Seasons/SeasonChanges.cs +++ b/src/Perpetuum.AdminTool/Seasons/SeasonChanges.cs @@ -6,19 +6,38 @@ namespace Perpetuum.AdminTool.Seasons { public static class SeasonChanges { - public static IPendingChange BuildInsert(SeasonRow row) => - new RawSqlChange( + public static IPendingChange BuildInsert(SeasonRow row) + { + string gapSql = row.IsRecurring && row.RecurrenceGapDays.HasValue + ? row.RecurrenceGapDays.Value.ToString() + : "NULL"; + string baseNameSql = row.IsRecurring && row.RecurrenceBaseName != null + ? SqlLiteral.Of(row.RecurrenceBaseName) + : "NULL"; + return new RawSqlChange( $"seasons: insert '{row.Name}'", - $"INSERT INTO seasons (name, description, start_time, end_time, is_active) VALUES (" + + $"INSERT INTO seasons (name, description, start_time, end_time, is_active, " + + $"is_recurring, recurrence_gap_days, recurrence_iteration, recurrence_base_name) VALUES (" + $"{SqlLiteral.Of(row.Name)}, {SqlLiteral.Of(row.Description)}, " + $"'{DateTime.SpecifyKind(row.StartTime, DateTimeKind.Utc):yyyy-MM-dd HH:mm:ss}', " + - $"'{DateTime.SpecifyKind(row.EndTime, DateTimeKind.Utc):yyyy-MM-dd HH:mm:ss}', 0)"); + $"'{DateTime.SpecifyKind(row.EndTime, DateTimeKind.Utc):yyyy-MM-dd HH:mm:ss}', 0, " + + $"{(row.IsRecurring ? 1 : 0)}, {gapSql}, 1, {baseNameSql})"); + } public static IPendingChange BuildUpdate(SeasonRow row) { + string gapSql = row.IsRecurring && row.RecurrenceGapDays.HasValue + ? row.RecurrenceGapDays.Value.ToString() + : "NULL"; + string baseNameSql = row.IsRecurring && row.RecurrenceBaseName != null + ? SqlLiteral.Of(row.RecurrenceBaseName) + : "NULL"; var sets = $"name = {SqlLiteral.Of(row.Name)}, description = {SqlLiteral.Of(row.Description)}, " + $"start_time = '{DateTime.SpecifyKind(row.StartTime, DateTimeKind.Utc):yyyy-MM-dd HH:mm:ss}', " + - $"end_time = '{DateTime.SpecifyKind(row.EndTime, DateTimeKind.Utc):yyyy-MM-dd HH:mm:ss}'"; + $"end_time = '{DateTime.SpecifyKind(row.EndTime, DateTimeKind.Utc):yyyy-MM-dd HH:mm:ss}', " + + $"is_recurring = {(row.IsRecurring ? 1 : 0)}, " + + $"recurrence_gap_days = {gapSql}, " + + $"recurrence_base_name = {baseNameSql}"; return new RawSqlChange( $"seasons: update id {row.Id} ('{row.Name}')", $"UPDATE seasons SET {sets} WHERE id = {row.Id}"); diff --git a/src/Perpetuum.AdminTool/Seasons/SeasonRepository.cs b/src/Perpetuum.AdminTool/Seasons/SeasonRepository.cs index 5c545b9..08b0467 100644 --- a/src/Perpetuum.AdminTool/Seasons/SeasonRepository.cs +++ b/src/Perpetuum.AdminTool/Seasons/SeasonRepository.cs @@ -20,7 +20,8 @@ public async Task> LoadAllSeasonsAsync() await cn.OpenAsync(); await using var cmd = cn.CreateCommand(); cmd.CommandText = - "SELECT id, name, description, start_time, end_time, is_active " + + "SELECT id, name, description, start_time, end_time, is_active, " + + "is_recurring, recurrence_gap_days, recurrence_iteration, recurrence_base_name " + "FROM seasons ORDER BY start_time DESC"; await using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) @@ -32,7 +33,11 @@ public async Task> LoadAllSeasonsAsync() Description = reader.IsDBNull(2) ? "" : reader.GetString(2), StartTime = DateTime.SpecifyKind(reader.GetDateTime(3), DateTimeKind.Utc), EndTime = DateTime.SpecifyKind(reader.GetDateTime(4), DateTimeKind.Utc), - IsActive = !reader.IsDBNull(5) && reader.GetBoolean(5) + IsActive = !reader.IsDBNull(5) && reader.GetBoolean(5), + IsRecurring = !reader.IsDBNull(6) && reader.GetBoolean(6), + RecurrenceGapDays = reader.IsDBNull(7) ? (int?)null : reader.GetInt32(7), + RecurrenceIteration = reader.IsDBNull(8) ? 1 : reader.GetInt32(8), + RecurrenceBaseName = reader.IsDBNull(9) ? null : reader.GetString(9) }; result.Add(new SeasonRow(snap)); } diff --git a/src/Perpetuum.AdminTool/Seasons/SeasonRow.cs b/src/Perpetuum.AdminTool/Seasons/SeasonRow.cs index 139a6fa..27e1cd3 100644 --- a/src/Perpetuum.AdminTool/Seasons/SeasonRow.cs +++ b/src/Perpetuum.AdminTool/Seasons/SeasonRow.cs @@ -14,6 +14,10 @@ public partial class SeasonRow : ObservableObject [ObservableProperty] private DateTime _startTime; [ObservableProperty] private DateTime _endTime; [ObservableProperty] private bool _isActive; + [ObservableProperty] private bool _isRecurring; + [ObservableProperty] private int? _recurrenceGapDays; + [ObservableProperty] private int _recurrenceIteration = 1; + [ObservableProperty] private string? _recurrenceBaseName; public SeasonRow(SeasonSnapshot snapshot) { @@ -30,6 +34,10 @@ public void ApplySnapshot(SeasonSnapshot s) StartTime = s.StartTime; EndTime = s.EndTime; IsActive = s.IsActive; + IsRecurring = s.IsRecurring; + RecurrenceGapDays = s.RecurrenceGapDays; + RecurrenceIteration = s.RecurrenceIteration; + RecurrenceBaseName = s.RecurrenceBaseName; } public void RefreshOriginalFromCurrent() @@ -41,7 +49,11 @@ public void RefreshOriginalFromCurrent() Description = Description, StartTime = StartTime, EndTime = EndTime, - IsActive = IsActive + IsActive = IsActive, + IsRecurring = IsRecurring, + RecurrenceGapDays = RecurrenceGapDays, + RecurrenceIteration = RecurrenceIteration, + RecurrenceBaseName = RecurrenceBaseName }; } @@ -65,6 +77,10 @@ public class SeasonSnapshot public DateTime StartTime { get; init; } public DateTime EndTime { get; init; } public bool IsActive { get; init; } + public bool IsRecurring { get; init; } + public int? RecurrenceGapDays { get; init; } + public int RecurrenceIteration { get; init; } = 1; + public string? RecurrenceBaseName { get; init; } } public enum SeasonCardState { Active, Draft, Ended } From bb3105100073262ff4ff3257d5d48588b16d799f Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 16 May 2026 15:43:23 +0500 Subject: [PATCH 021/151] feat(seasons): add recurrence fields, validation, and script generation to season wizard ViewModel --- .../ViewModels/SeasonWizardViewModel.cs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/Perpetuum.AdminTool/ViewModels/SeasonWizardViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/SeasonWizardViewModel.cs index 35838aa..c445996 100644 --- a/src/Perpetuum.AdminTool/ViewModels/SeasonWizardViewModel.cs +++ b/src/Perpetuum.AdminTool/ViewModels/SeasonWizardViewModel.cs @@ -47,6 +47,8 @@ public partial class SeasonWizardViewModel : ObservableObject [ObservableProperty] private DateTime _endTime = DateTime.UtcNow.Date.AddDays(30); [ObservableProperty] private string _startTimeText = "00:00"; [ObservableProperty] private string _endTimeText = "00:00"; + [ObservableProperty] private bool _isRecurring; + [ObservableProperty] private int _recurrenceGapDays = 7; public ObservableCollection ActivityRates { get; } = new(); public ObservableCollection Objectives { get; } = new(); @@ -178,6 +180,8 @@ partial void OnCurrentStepChanged(int value) partial void OnEndTimeChanged(DateTime value) => ValidateStep1(); partial void OnStartTimeTextChanged(string value) => ApplyTimeText(value, isStart: true); partial void OnEndTimeTextChanged(string value) => ApplyTimeText(value, isStart: false); + partial void OnIsRecurringChanged(bool value) => ValidateStep1(); + partial void OnRecurrenceGapDaysChanged(int value) => ValidateStep1(); private void ApplyTimeText(string text, bool isStart) { @@ -211,6 +215,8 @@ private void ValidateStep1() Step1Validation = "End time must be in HH:mm format (UTC)."; else if (EndTime <= StartTime) Step1Validation = "End time must be after start time."; + else if (IsRecurring && RecurrenceGapDays < 1) + Step1Validation = "Gap between runs must be at least 1 day."; else Step1Validation = ""; OnPropertyChanged(nameof(Step1Validation)); @@ -310,9 +316,15 @@ private IPendingChange BuildSeasonScript() { var sb = new StringBuilder(); sb.AppendLine("DECLARE @seasonId INT;"); - sb.AppendLine($"INSERT INTO seasons (name, description, start_time, end_time, is_active)"); - sb.AppendLine($"VALUES ({SqlLiteral.Of(Name)}, {SqlLiteral.Of(Description)},"); - sb.AppendLine($" '{DateTime.SpecifyKind(StartTime, DateTimeKind.Utc):yyyy-MM-dd HH:mm:ss}', '{DateTime.SpecifyKind(EndTime, DateTimeKind.Utc):yyyy-MM-dd HH:mm:ss}', 0);"); + string displayName = IsRecurring ? $"{Name}, Run #1" : Name; + string gapSql = IsRecurring ? RecurrenceGapDays.ToString() : "NULL"; + string baseNameSql = IsRecurring ? SqlLiteral.Of(Name) : "NULL"; + sb.AppendLine("INSERT INTO seasons (name, description, start_time, end_time, is_active, " + + "is_recurring, recurrence_gap_days, recurrence_iteration, recurrence_base_name)"); + sb.AppendLine($"VALUES ({SqlLiteral.Of(displayName)}, {SqlLiteral.Of(Description)},"); + sb.AppendLine($" '{DateTime.SpecifyKind(StartTime, DateTimeKind.Utc):yyyy-MM-dd HH:mm:ss}', " + + $"'{DateTime.SpecifyKind(EndTime, DateTimeKind.Utc):yyyy-MM-dd HH:mm:ss}', 0, " + + $"{(IsRecurring ? 1 : 0)}, {gapSql}, 1, {baseNameSql});"); sb.AppendLine("SET @seasonId = SCOPE_IDENTITY();"); foreach (var rate in ActivityRates.Where(r => r.PointsPerUnit > 0)) From 2363cb46fd16b264cace56f8bfbd404e02591fd9 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 16 May 2026 16:07:50 +0500 Subject: [PATCH 022/151] feat(seasons): add recurring checkbox and gap field to season wizard Step 1 --- .../Views/SeasonWizardWindow.xaml | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/Perpetuum.AdminTool/Views/SeasonWizardWindow.xaml b/src/Perpetuum.AdminTool/Views/SeasonWizardWindow.xaml index 233e1e4..a48313f 100644 --- a/src/Perpetuum.AdminTool/Views/SeasonWizardWindow.xaml +++ b/src/Perpetuum.AdminTool/Views/SeasonWizardWindow.xaml @@ -132,6 +132,8 @@ + + @@ -152,6 +154,43 @@ + + + + + + + + + + + + + + + + + + From 188be2fd6a79f5974254ec04e7b28f7b7aa61635 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 16 May 2026 16:15:58 +0500 Subject: [PATCH 023/151] feat(seasons): add recurrence section to season detail General tab --- .../Views/SeasonDetailView.xaml | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/src/Perpetuum.AdminTool/Views/SeasonDetailView.xaml b/src/Perpetuum.AdminTool/Views/SeasonDetailView.xaml index adaa6f6..646bc55 100644 --- a/src/Perpetuum.AdminTool/Views/SeasonDetailView.xaml +++ b/src/Perpetuum.AdminTool/Views/SeasonDetailView.xaml @@ -66,6 +66,8 @@ + + @@ -84,7 +86,44 @@ - + + + + + + + + + + + + + + + + + + +