From 8ab92b793364152385171837fcf3a8494e2248ba Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 23 May 2026 20:08:11 +0500 Subject: [PATCH 01/60] docs: add IMPROVEMENT-024 design spec (daily objective announcement + Admin Tool stats) Co-Authored-By: Claude Sonnet 4.6 --- .../2026-05-23-improvement-024-design.md | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-23-improvement-024-design.md diff --git a/docs/superpowers/specs/2026-05-23-improvement-024-design.md b/docs/superpowers/specs/2026-05-23-improvement-024-design.md new file mode 100644 index 0000000..bd333a2 --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-improvement-024-design.md @@ -0,0 +1,187 @@ +# IMPROVEMENT-024 Design — Server Restart Announcement & Admin Tool Daily Stats + +Date: 2026-05-23 +Status: Approved + +--- + +## Overview + +Two independent improvements to daily objective visibility: + +1. **Server restart announcement** — on cold boot, if an active season with a daily pool is configured, announce today's active daily objectives to all players via the existing Seasons Info channel. +2. **Admin Tool Statistics tab** — add a "Today's Daily Objectives" section showing today's active pool and per-objective completion counts. + +--- + +## Architecture + +Both sub-features are additive. Neither requires new DB tables, new interfaces, or new server request handlers. + +Sub-feature 1 is a 4-line guard added to `SeasonService.RefreshCache()`. + +Sub-feature 2 adds one new method to the Admin Tool's `SeasonRepository`, one new collection to `SeasonStatisticsViewModel`, and one new XAML section in `SeasonDetailView.xaml`. The daily pool is computed in C# using the same deterministic seeded algorithm as the server — no DB materialization required because the algorithm is pure and side-effect-free. + +--- + +## Sub-feature 1 — Server Restart Announcement + +### Affected file + +`src/Perpetuum/Services/Seasons/SeasonService.cs` + +### Change + +In `RefreshCache()`, the pool computation branch currently computes the pool silently on cold boot. Add an `isColdBoot` guard that fires `AnnounceDailyPool()` exactly once per server start: + +```csharp +else if (previous?.Id != season.Id || _dailyPool.Date == DateOnly.MinValue) +{ + bool isColdBoot = _dailyPool.Date == DateOnly.MinValue; + var today = DateOnly.FromDateTime(DateTime.UtcNow); + _dailyPool = new DailyPool(SelectDailyPool(season, _activeObjectives, today), today); + if (isColdBoot) + { + int totalDaily = _activeObjectives.Count(o => o.IsDaily); + var poolObjs = _activeObjectives.Where(o => _dailyPool.Ids.Contains(o.Id)).ToList(); + if (poolObjs.Count > 0) + AnnounceDailyPool(poolObjs, totalDaily); + } +} +``` + +### Behaviour + +- Fires only when `_dailyPool.Date == DateOnly.MinValue` before the update — i.e., the server has just started cold. +- Does not fire on the 5-minute periodic `RefreshCache` calls within the same day. +- Does not fire when a season activates mid-day (only fires at server start). +- If no active season or no daily objectives are configured, the guard never reaches `AnnounceDailyPool` — silent no-op. +- `AnnounceDailyPool` already exists and is called identically from the daily rollover in `Update()`. + +--- + +## Sub-feature 2 — Admin Tool Season Statistics: Today's Daily Objectives + +### New row type + +**File:** `src/Perpetuum.AdminTool/Seasons/TodaysDailyObjectiveRow.cs` + +```csharp +public record TodaysDailyObjectiveRow( + string Name, + SeasonActivityType ActivityType, + long TargetValue, + int CompletionsToday); +``` + +### SeasonRepository — new method + +**File:** `src/Perpetuum.AdminTool/Seasons/SeasonRepository.cs` + +```csharp +public async Task> LoadTodaysDailyObjectivesAsync(int seasonId) +``` + +**Algorithm:** + +1. Load `daily_objectives_per_day` for the season from the `seasons` table. +2. Load all `is_daily = 1` objectives for the season from `season_objectives`. +3. If no daily objectives exist, return empty list. +4. Compute today's pool IDs using the same seeded Fisher-Yates shuffle as the server: + - `seed = seasonId * 397 ^ DateOnly.FromDateTime(DateTime.UtcNow).DayNumber` + - Shuffle the daily objectives list with `new Random(seed)` + - Take first `daily_objectives_per_day` entries (or all if `daily_objectives_per_day` is null or >= count) +4a. If the resulting pool is empty, return an empty list immediately — do not proceed to the SQL query (avoids constructing an invalid `IN ()` clause). +5. Query completion counts for those IDs only: + +```sql +SELECT o.name, o.activity_type, o.target_value, + COUNT(DISTINCT p.character_id) AS completions_today +FROM season_objectives o +LEFT JOIN season_objective_progress p + ON p.objective_id = o.id + AND p.season_id = @seasonId + AND p.day_window = CAST(GETUTCDATE() AS date) + AND p.completed = 1 +WHERE o.season_id = @seasonId + AND o.id IN () +GROUP BY o.id, o.name, o.activity_type, o.target_value, o.display_order +ORDER BY o.display_order +``` + +Pool IDs are parameterised into the `IN` clause from the C# shuffle result. + +### SeasonStatisticsViewModel + +**File:** `src/Perpetuum.AdminTool/ViewModels/SeasonStatisticsViewModel.cs` + +Add: + +```csharp +public ObservableCollection TodaysDailyObjectives { get; } = new(); +``` + +In `LoadAsync`, after the existing `ObjectiveCompletion` load: + +```csharp +TodaysDailyObjectives.Clear(); +foreach (var r in await _repo.LoadTodaysDailyObjectivesAsync(seasonId)) + TodaysDailyObjectives.Add(r); +``` + +### SeasonDetailView.xaml + +**File:** `src/Perpetuum.AdminTool/Views/SeasonDetailView.xaml` + +Append a new section after the existing "Objective Completion Rates" `DataGrid`. The section is hidden when `TodaysDailyObjectives` is empty: + +``` +Today's Daily Objectives +┌─────────────────────┬──────────────────┬──────────┬─────────────────┐ +│ Objective │ Activity Type │ Target │ Completed Today │ +├─────────────────────┼──────────────────┼──────────┼─────────────────┤ +│ Kill 10 NPCs │ NPC Kill │ 10 │ 42 │ +└─────────────────────┴──────────────────┴──────────┴─────────────────┘ +``` + +Visibility is bound to `TodaysDailyObjectives.Count > 0` via a converter or `DataTrigger` — no error state, silent absence when the season has no daily pool. + +--- + +## Dependencies + +- Requires IMPROVEMENT-006 (daily objective infrastructure: `season_objectives.is_daily`, `season_objective_progress.day_window`, `season_objectives.daily_objectives_per_day` on `seasons`). +- No dependency on IMPROVEMENT-022 — pool selection logic is already fully implemented in `SeasonService.SelectDailyPool()` and replicated here. + +--- + +## Out of Scope + +- Historical per-day completion stats (only today's window is shown). +- Retroactive announcement when a season activates after server start. +- Any changes to `ISeasonService` or the game protocol. + +--- + +## Manual Validation Steps + +**Sub-feature 1:** +1. Configure a season with `daily_objectives_per_day` set and at least one `is_daily` objective. +2. Start the server cold. Verify an announcement appears in the Seasons Info channel listing today's pool objectives. +3. Wait for or trigger the 5-minute `RefreshCache` cycle. Verify no duplicate announcement fires. +4. Restart the server again the next UTC day. Verify the announcement reflects the new day's pool. +5. Start the server with no active season. Verify no announcement fires. + +**Sub-feature 2:** +1. Open the Admin Tool, navigate to a season with daily objectives and `daily_objectives_per_day` configured. +2. Go to Statistics tab → click Refresh. +3. Verify "Today's Daily Objectives" section appears with the correct pool size. +4. Verify completion counts match raw DB counts in `season_objective_progress` for today's `day_window`. +5. Navigate to a season with no daily objectives. Verify the section is absent. + +--- + +## Potential Regressions + +- `RefreshCache()` is called from `SendActivationMailToOnlineCharacters()` (which calls `RefreshCache()` directly). Since `_dailyPool.Date` will not be `DateOnly.MinValue` at that point, the announcement guard will not fire — correct. +- The seeded shuffle in the Admin Tool must use identical logic to `SeasonService.SelectDailyPool()`. Any future change to the server-side seed formula must be mirrored in the Admin Tool method. From b9e6178cf62fc964289b6ededf8d4733f4d20be1 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 23 May 2026 20:14:14 +0500 Subject: [PATCH 02/60] docs: add IMPROVEMENT-024 implementation plan Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-05-23-improvement-024.md | 444 ++++++++++++++++++ 1 file changed, 444 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-23-improvement-024.md diff --git a/docs/superpowers/plans/2026-05-23-improvement-024.md b/docs/superpowers/plans/2026-05-23-improvement-024.md new file mode 100644 index 0000000..688fd31 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-improvement-024.md @@ -0,0 +1,444 @@ +# IMPROVEMENT-024 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:** Announce today's daily objectives on server cold boot, and surface today's active daily pool with per-objective completion counts in the Admin Tool Statistics tab. + +**Architecture:** Sub-feature 1 is a 4-line guard added to `SeasonService.RefreshCache()` that fires the already-existing `AnnounceDailyPool()` exactly once per server start. Sub-feature 2 adds a new `TodaysDailyObjectiveRow` record, a new `LoadTodaysDailyObjectivesAsync` method on the Admin Tool's `SeasonRepository`, a new observable collection on `SeasonStatisticsViewModel`, and a new XAML section in `SeasonDetailView.xaml`. The pool is computed in C# using the same seeded Fisher-Yates algorithm as the server — no new DB tables required. + +**Tech Stack:** C# 12, .NET 8, WPF, CommunityToolkit.Mvvm, Microsoft.Data.SqlClient, SQL Server. + +**Note on testing:** This project has no automated test suite. Each task ends with a build verification step. Manual end-to-end validation steps are at the end of the plan. + +--- + +## File Map + +| Action | File | +|--------|------| +| Modify | `src/Perpetuum/Services/Seasons/SeasonService.cs` | +| Create | `src/Perpetuum.AdminTool/Seasons/TodaysDailyObjectiveRow.cs` | +| Modify | `src/Perpetuum.AdminTool/Seasons/SeasonRepository.cs` | +| Modify | `src/Perpetuum.AdminTool/ViewModels/SeasonStatisticsViewModel.cs` | +| Modify | `src/Perpetuum.AdminTool/Views/SeasonDetailView.xaml` | + +--- + +## Task 1: Server cold-boot announcement guard + +**Files:** +- Modify: `src/Perpetuum/Services/Seasons/SeasonService.cs` (lines 148–156 of `RefreshCache()`) + +### Context + +`RefreshCache()` already computes the daily pool on cold boot and on season change, but never announces it. The daily rollover in `Update()` does call `AnnounceDailyPool()`, but `Update()` skips the announcement when `_dailyPool.Date` already equals today — which it does immediately after `RefreshCache()` runs on startup. + +The fix is a single `isColdBoot` flag captured before the pool is assigned. `_dailyPool` starts as `EmptyDailyPool` (date = `DateOnly.MinValue`). On the 5-minute periodic `RefreshCache`, the date is already set, so the guard is false — no duplicate announcement. + +- [ ] **Step 1: Locate the pool computation block in `RefreshCache()`** + +Find this block (approximately lines 148–156): + +```csharp +if (!season.DailyObjectivesPerDay.HasValue) +{ + _dailyPool = EmptyDailyPool; +} +else if (previous?.Id != season.Id || _dailyPool.Date == DateOnly.MinValue) +{ + var today = DateOnly.FromDateTime(DateTime.UtcNow); + _dailyPool = new DailyPool(SelectDailyPool(season, _activeObjectives, today), today); +} +``` + +- [ ] **Step 2: Replace the `else if` branch with the cold-boot guard** + +Replace only the `else if` block (leave the `if (!season.DailyObjectivesPerDay.HasValue)` block untouched): + +```csharp +else if (previous?.Id != season.Id || _dailyPool.Date == DateOnly.MinValue) +{ + bool isColdBoot = _dailyPool.Date == DateOnly.MinValue; + var today = DateOnly.FromDateTime(DateTime.UtcNow); + _dailyPool = new DailyPool(SelectDailyPool(season, _activeObjectives, today), today); + if (isColdBoot) + { + int totalDaily = _activeObjectives.Count(o => o.IsDaily); + var poolObjs = _activeObjectives.Where(o => _dailyPool.Ids.Contains(o.Id)).ToList(); + if (poolObjs.Count > 0) + AnnounceDailyPool(poolObjs, totalDaily); + } +} +``` + +- [ ] **Step 3: Build the server project to verify no errors** + +``` +dotnet build src/Perpetuum/Perpetuum.csproj -c Release -p:Platform=x64 +``` + +Expected: build succeeds with 0 errors. + +- [ ] **Step 4: Commit** + +``` +git add src/Perpetuum/Services/Seasons/SeasonService.cs +git commit -m "feat: announce daily objective pool on server cold boot (IMPROVEMENT-024)" +``` + +--- + +## Task 2: TodaysDailyObjectiveRow record + +**Files:** +- Create: `src/Perpetuum.AdminTool/Seasons/TodaysDailyObjectiveRow.cs` + +- [ ] **Step 1: Create the file** + +```csharp +using Perpetuum.Services.Seasons; + +namespace Perpetuum.AdminTool.Seasons +{ + public record TodaysDailyObjectiveRow( + string Name, + SeasonActivityType ActivityType, + long TargetValue, + int CompletionsToday); +} +``` + +- [ ] **Step 2: Build the Admin Tool project to verify no errors** + +``` +dotnet build src/Perpetuum.AdminTool/Perpetuum.AdminTool.csproj -c Release -p:Platform=x64 +``` + +Expected: build succeeds with 0 errors. + +- [ ] **Step 3: Commit** + +``` +git add src/Perpetuum.AdminTool/Seasons/TodaysDailyObjectiveRow.cs +git commit -m "feat: add TodaysDailyObjectiveRow record (IMPROVEMENT-024)" +``` + +--- + +## Task 3: SeasonRepository — LoadTodaysDailyObjectivesAsync + +**Files:** +- Modify: `src/Perpetuum.AdminTool/Seasons/SeasonRepository.cs` + +### Context + +This method: +1. Loads `daily_objectives_per_day` for the season. +2. Loads all `is_daily = 1` objectives for the season. +3. Computes today's pool using the identical seeded Fisher-Yates algorithm as `SeasonService.SelectDailyPool()`: + - `seed = seasonId * 397 ^ DateOnly.FromDateTime(DateTime.UtcNow).DayNumber` + - Fisher-Yates shuffle, take first `daily_objectives_per_day` entries. + - If `daily_objectives_per_day` is null or ≥ total daily count, all daily objectives are in the pool. +4. Returns early with an empty list if the pool is empty (avoids an invalid `IN ()` SQL clause). +5. Queries `season_objective_progress` scoped to today's `day_window` for pool IDs only, counting distinct completions. + +All three SQL statements share one open `SqlConnection` and run sequentially — each reader is fully disposed before the next command executes. + +- [ ] **Step 1: Add the method at the end of `SeasonRepository`, before the closing `}`** + +```csharp +public async Task> LoadTodaysDailyObjectivesAsync(int seasonId) +{ + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + + // 1. Load daily_objectives_per_day for the season + int? dailyPerDay; + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = "SELECT daily_objectives_per_day FROM seasons WHERE id = @seasonId"; + cmd.Parameters.AddWithValue("@seasonId", seasonId); + var raw = await cmd.ExecuteScalarAsync(); + dailyPerDay = raw == null || raw == DBNull.Value ? (int?)null : Convert.ToInt32(raw); + } + + // 2. Load all is_daily objectives ordered by display_order + var daily = new List<(int Id, string Name, SeasonActivityType ActivityType, long TargetValue)>(); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT id, name, activity_type, target_value " + + "FROM season_objectives " + + "WHERE season_id = @seasonId AND is_daily = 1 " + + "ORDER BY display_order"; + cmd.Parameters.AddWithValue("@seasonId", seasonId); + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + daily.Add(( + reader.GetInt32(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + (SeasonActivityType)reader.GetInt32(2), + reader.GetInt64(3) + )); + } + } + + if (daily.Count == 0) + return []; + + // 3. Compute pool using same seeded Fisher-Yates as SeasonService.SelectDailyPool + List<(int Id, string Name, SeasonActivityType ActivityType, long TargetValue)> pool; + if (!dailyPerDay.HasValue || dailyPerDay.Value >= daily.Count) + { + pool = daily; + } + else + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + int seed = seasonId * 397 ^ today.DayNumber; + var rng = new Random(seed); + var shuffled = daily.ToList(); + for (int i = shuffled.Count - 1; i > 0; i--) + { + int j = rng.Next(i + 1); + (shuffled[i], shuffled[j]) = (shuffled[j], shuffled[i]); + } + pool = shuffled.Take(dailyPerDay.Value).ToList(); + } + + if (pool.Count == 0) + return []; + + // 4. Query completion counts for pool IDs scoped to today's day_window + var ids = pool.Select(o => o.Id).ToList(); + var idParams = string.Join(",", ids.Select((_, i) => $"@id{i}")); + var counts = new Dictionary(); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + $"SELECT o.id, COUNT(DISTINCT p.character_id) AS completions_today " + + $"FROM season_objectives o " + + $"LEFT JOIN season_objective_progress p " + + $" ON p.objective_id = o.id " + + $" AND p.season_id = @seasonId " + + $" AND p.day_window = CAST(GETUTCDATE() AS date) " + + $" AND p.completed = 1 " + + $"WHERE o.season_id = @seasonId AND o.id IN ({idParams}) " + + $"GROUP BY o.id"; + cmd.Parameters.AddWithValue("@seasonId", seasonId); + for (int i = 0; i < ids.Count; i++) + cmd.Parameters.AddWithValue($"@id{i}", ids[i]); + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + counts[reader.GetInt32(0)] = reader.GetInt32(1); + } + + // Restore display_order (shuffle randomised the pool; daily is ordered by display_order) + var displayIndex = daily + .Select((o, idx) => (o.Id, idx)) + .ToDictionary(x => x.Id, x => x.idx); + + return pool + .OrderBy(o => displayIndex.GetValueOrDefault(o.Id, int.MaxValue)) + .Select(o => new TodaysDailyObjectiveRow( + o.Name, + o.ActivityType, + o.TargetValue, + counts.GetValueOrDefault(o.Id, 0))) + .ToList(); +} +``` + +- [ ] **Step 2: Build the Admin Tool project to verify no errors** + +``` +dotnet build src/Perpetuum.AdminTool/Perpetuum.AdminTool.csproj -c Release -p:Platform=x64 +``` + +Expected: build succeeds with 0 errors. + +- [ ] **Step 3: Commit** + +``` +git add src/Perpetuum.AdminTool/Seasons/SeasonRepository.cs +git commit -m "feat: add LoadTodaysDailyObjectivesAsync to Admin Tool SeasonRepository (IMPROVEMENT-024)" +``` + +--- + +## Task 4: SeasonStatisticsViewModel — wire up the new collection + +**Files:** +- Modify: `src/Perpetuum.AdminTool/ViewModels/SeasonStatisticsViewModel.cs` + +### Context + +The existing VM uses `[ObservableProperty]` for scalar values and `ObservableCollection` for grids. Follow the same pattern. Add a `HasTodaysDailyObjectives` bool property (backed by `[ObservableProperty]`) so the XAML can bind visibility via the already-present `BooleanToVisibilityConverter`. + +- [ ] **Step 1: Add the collection and visibility flag to the class body** + +After the existing `public ObservableCollection ObjectiveCompletion { get; } = new();` line, add: + +```csharp +public ObservableCollection TodaysDailyObjectives { get; } = new(); +[ObservableProperty] private bool _hasTodaysDailyObjectives; +``` + +- [ ] **Step 2: Add the load call inside `LoadAsync`, after the existing `ObjectiveCompletion` block** + +The existing block looks like: +```csharp +ObjectiveCompletion.Clear(); +foreach (var r in await _repo.LoadObjectiveCompletionAsync(seasonId)) ObjectiveCompletion.Add(r); +``` + +Immediately after it, add: +```csharp +TodaysDailyObjectives.Clear(); +foreach (var r in await _repo.LoadTodaysDailyObjectivesAsync(seasonId)) + TodaysDailyObjectives.Add(r); +HasTodaysDailyObjectives = TodaysDailyObjectives.Count > 0; +``` + +- [ ] **Step 3: Build the Admin Tool project to verify no errors** + +``` +dotnet build src/Perpetuum.AdminTool/Perpetuum.AdminTool.csproj -c Release -p:Platform=x64 +``` + +Expected: build succeeds with 0 errors. + +- [ ] **Step 4: Commit** + +``` +git add src/Perpetuum.AdminTool/ViewModels/SeasonStatisticsViewModel.cs +git commit -m "feat: add TodaysDailyObjectives collection to SeasonStatisticsViewModel (IMPROVEMENT-024)" +``` + +--- + +## Task 5: SeasonDetailView.xaml — Today's Daily Objectives section + +**Files:** +- Modify: `src/Perpetuum.AdminTool/Views/SeasonDetailView.xaml` + +### Context + +The Statistics tab's `` (inside the ``) currently ends after the "Objective Completion Rates" ``. Add a new section below it. Use the existing `BoolToVis` converter (already declared in `UserControl.Resources`) and the same `DataGrid` style as the existing grids. + +`ActivityType` is a `SeasonActivityType` enum — WPF will call `.ToString()` on it, which produces the enum member name (e.g. `NpcKill`). This is readable enough for an admin tool; no converter needed. + +- [ ] **Step 1: Locate the insertion point** + +Find the closing `` that ends the Statistics tab's scroll content — it is the `` immediately before the `` at approximately line 455 of `SeasonDetailView.xaml`: + +```xml + + + +``` + +- [ ] **Step 2: Insert the new section before the closing ``** + +Replace that closing tag sequence with: + +```xml + + + + + + + + + + + + + + + +``` + +- [ ] **Step 3: Build the Admin Tool project to verify no errors** + +``` +dotnet build src/Perpetuum.AdminTool/Perpetuum.AdminTool.csproj -c Release -p:Platform=x64 +``` + +Expected: build succeeds with 0 errors. + +- [ ] **Step 4: Commit** + +``` +git add src/Perpetuum.AdminTool/Views/SeasonDetailView.xaml +git commit -m "feat: add Today's Daily Objectives section to Statistics tab (IMPROVEMENT-024)" +``` + +--- + +## Task 6: Full solution build verification + +- [ ] **Step 1: Build the full solution** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: build succeeds with 0 errors across all projects. + +- [ ] **Step 2: Commit (if any fixup changes were needed)** + +``` +git add -p +git commit -m "fix: IMPROVEMENT-024 build fixups" +``` + +Skip this step if no changes were needed. + +--- + +## Manual Validation + +### Sub-feature 1 — Cold-boot announcement + +1. Ensure an active season exists in the DB with at least one `is_daily = 1` objective and `daily_objectives_per_day` set on the `seasons` row. +2. Start the server cold (`dotnet run -- --GameRoot "..."`). +3. Log in to the game with a character who is in the `Seasons Info` channel. +4. **Verify:** An announcement appears in `Seasons Info` listing today's active daily objectives. +5. Wait 5+ minutes for the periodic `RefreshCache` to fire (watch server logs if available). +6. **Verify:** No second announcement appears for the same day's pool. +7. Restart the server the following UTC day. +8. **Verify:** The announcement reflects the new day's pool (different seed → potentially different objectives). +9. Start the server with no active season. +10. **Verify:** No announcement fires, no error in logs. + +### Sub-feature 2 — Admin Tool Statistics tab + +1. Open the Admin Tool and connect to the same DB. +2. Navigate to the season configured above → Statistics tab → click **Refresh**. +3. **Verify:** A "Today's Daily Objectives" section appears below "Objective Completion Rates" listing the correct pool size (≤ `daily_objectives_per_day` objectives). +4. **Verify:** Objective names, activity types, and targets match what is configured in the DB. +5. Have a character complete one of today's daily objectives in-game. +6. Click **Refresh** again. +7. **Verify:** The "Completed Today" count for that objective increments by 1. +8. Navigate to a season with no `is_daily` objectives. +9. **Verify:** The "Today's Daily Objectives" section is absent (not just empty — fully hidden). + +### Regression checks + +- The existing "Objective Completion Rates" section still shows all objectives with all-time completion counts — verify it is unaffected. +- The Participation Health, Tier Distribution, and Top 10 sections are unaffected. +- The daily pool rollover announcement in `Update()` (fires at UTC midnight when the date changes) is unaffected — the `RefreshCache` guard only fires on cold boot. + +--- + +## Backlog update + +After validation passes, update `docs/backlog/improvements.md`: +- Change `IMPROVEMENT-024` status from `TODO` to `DONE`. +- Move the entry to `docs/backlog/completed.md`. From ba0a5773f834aa76032fac30ae936d260875665d Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 23 May 2026 20:23:49 +0500 Subject: [PATCH 03/60] feat: announce daily objective pool on server cold boot (IMPROVEMENT-024) --- src/Perpetuum/Services/Seasons/SeasonService.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Perpetuum/Services/Seasons/SeasonService.cs b/src/Perpetuum/Services/Seasons/SeasonService.cs index a22d806..81f6a85 100644 --- a/src/Perpetuum/Services/Seasons/SeasonService.cs +++ b/src/Perpetuum/Services/Seasons/SeasonService.cs @@ -151,8 +151,16 @@ internal void RefreshCache() } else if (previous?.Id != season.Id || _dailyPool.Date == DateOnly.MinValue) { + bool isFirstLoad = _dailyPool.Date == DateOnly.MinValue; var today = DateOnly.FromDateTime(DateTime.UtcNow); _dailyPool = new DailyPool(SelectDailyPool(season, _activeObjectives, today), today); + if (isFirstLoad) + { + int totalDaily = _activeObjectives.Count(o => o.IsDaily); + var poolObjs = _activeObjectives.Where(o => _dailyPool.Ids.Contains(o.Id)).ToList(); + if (poolObjs.Count > 0) + AnnounceDailyPool(poolObjs, totalDaily); + } } if (_lastNotifiedSeasonId != season.Id) From 9a280a366ee71358caf6f6e4bf6ac07c17dfbbc0 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 23 May 2026 20:27:24 +0500 Subject: [PATCH 04/60] feat: add TodaysDailyObjectiveRow record (IMPROVEMENT-024) --- .../Seasons/TodaysDailyObjectiveRow.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/Perpetuum.AdminTool/Seasons/TodaysDailyObjectiveRow.cs diff --git a/src/Perpetuum.AdminTool/Seasons/TodaysDailyObjectiveRow.cs b/src/Perpetuum.AdminTool/Seasons/TodaysDailyObjectiveRow.cs new file mode 100644 index 0000000..11e9408 --- /dev/null +++ b/src/Perpetuum.AdminTool/Seasons/TodaysDailyObjectiveRow.cs @@ -0,0 +1,10 @@ +using Perpetuum.Services.Seasons; + +namespace Perpetuum.AdminTool.Seasons +{ + public record TodaysDailyObjectiveRow( + string Name, + SeasonActivityType ActivityType, + long TargetValue, + int CompletionsToday); +} From 0804d11387c29365bde31e1f25cfd8bb94e37e7b Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 23 May 2026 20:31:59 +0500 Subject: [PATCH 05/60] feat: add LoadTodaysDailyObjectivesAsync to Admin Tool SeasonRepository (IMPROVEMENT-024) --- .../Seasons/SeasonRepository.cs | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/src/Perpetuum.AdminTool/Seasons/SeasonRepository.cs b/src/Perpetuum.AdminTool/Seasons/SeasonRepository.cs index 47e5c23..dcf272b 100644 --- a/src/Perpetuum.AdminTool/Seasons/SeasonRepository.cs +++ b/src/Perpetuum.AdminTool/Seasons/SeasonRepository.cs @@ -272,6 +272,109 @@ public async Task LoadAvgPointsPerDayAsync(int seasonId) if (v == null || v == System.DBNull.Value) return 0.0; return System.Convert.ToDouble(v); } + + public async Task> LoadTodaysDailyObjectivesAsync(int seasonId) + { + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + + // 1. Load daily_objectives_per_day for the season + int? dailyPerDay; + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = "SELECT daily_objectives_per_day FROM seasons WHERE id = @seasonId"; + cmd.Parameters.AddWithValue("@seasonId", seasonId); + var raw = await cmd.ExecuteScalarAsync(); + dailyPerDay = raw == null || raw == DBNull.Value ? (int?)null : (int)(short)raw; + } + + // 2. Load all is_daily objectives ordered by display_order + var daily = new List<(int Id, string Name, SeasonActivityType ActivityType, long TargetValue)>(); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT id, name, activity_type, target_value " + + "FROM season_objectives " + + "WHERE season_id = @seasonId AND is_daily = 1 " + + "ORDER BY display_order"; + cmd.Parameters.AddWithValue("@seasonId", seasonId); + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + daily.Add(( + reader.GetInt32(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + (SeasonActivityType)reader.GetInt32(2), + reader.GetInt64(3) + )); + } + } + + if (daily.Count == 0) + return []; + + // 3. Compute pool using same seeded Fisher-Yates as SeasonService.SelectDailyPool + List<(int Id, string Name, SeasonActivityType ActivityType, long TargetValue)> pool; + if (!dailyPerDay.HasValue || dailyPerDay.Value >= daily.Count) + { + pool = daily; + } + else + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + // Precedence: * binds tighter than ^ — result is (seasonId * 397) XOR today.DayNumber + int seed = seasonId * 397 ^ today.DayNumber; + var rng = new Random(seed); + var shuffled = daily.ToList(); + for (int i = shuffled.Count - 1; i > 0; i--) + { + int j = rng.Next(i + 1); + (shuffled[i], shuffled[j]) = (shuffled[j], shuffled[i]); + } + pool = shuffled.Take(dailyPerDay.Value).ToList(); + } + + if (pool.Count == 0) + return []; + + // 4. Query completion counts for pool IDs scoped to today's day_window + var ids = pool.Select(o => o.Id).ToList(); + var idParams = string.Join(",", ids.Select((_, i) => $"@id{i}")); + var counts = new Dictionary(); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + $"SELECT o.id, COUNT(DISTINCT p.character_id) AS completions_today " + + $"FROM season_objectives o " + + $"LEFT JOIN season_objective_progress p " + + $" ON p.objective_id = o.id " + + $" AND p.season_id = @seasonId " + + $" AND p.day_window = CAST(GETUTCDATE() AS date) " + + $" AND p.completed = 1 " + + $"WHERE o.season_id = @seasonId AND o.id IN ({idParams}) " + + $"GROUP BY o.id"; + cmd.Parameters.AddWithValue("@seasonId", seasonId); + for (int i = 0; i < ids.Count; i++) + cmd.Parameters.AddWithValue($"@id{i}", ids[i]); + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + counts[reader.GetInt32(0)] = reader.GetInt32(1); + } + + // Restore display_order (shuffle randomised pool; daily is ordered by display_order) + var displayIndex = daily + .Select((o, idx) => (o.Id, idx)) + .ToDictionary(x => x.Id, x => x.idx); + + return pool + .OrderBy(o => displayIndex.GetValueOrDefault(o.Id, int.MaxValue)) + .Select(o => new TodaysDailyObjectiveRow( + o.Name, + o.ActivityType, + o.TargetValue, + counts.GetValueOrDefault(o.Id, 0))) + .ToList(); + } } public record TierDistributionRow(int TierNumber, string TierName, int ClaimCount); From e14efdb51571c88666ebb9e54b4daae26a4f3142 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 23 May 2026 20:35:42 +0500 Subject: [PATCH 06/60] feat: add TodaysDailyObjectives collection to SeasonStatisticsViewModel (IMPROVEMENT-024) --- .../ViewModels/SeasonStatisticsViewModel.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Perpetuum.AdminTool/ViewModels/SeasonStatisticsViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/SeasonStatisticsViewModel.cs index a36c6f4..6624bf5 100644 --- a/src/Perpetuum.AdminTool/ViewModels/SeasonStatisticsViewModel.cs +++ b/src/Perpetuum.AdminTool/ViewModels/SeasonStatisticsViewModel.cs @@ -20,6 +20,8 @@ public partial class SeasonStatisticsViewModel : ObservableObject public ObservableCollection TierDistribution { get; } = new(); public ObservableCollection Top10 { get; } = new(); public ObservableCollection ObjectiveCompletion { get; } = new(); + public ObservableCollection TodaysDailyObjectives { get; } = new(); + [ObservableProperty] private bool _hasTodaysDailyObjectives; public SeasonStatisticsViewModel(SeasonRepository repo) { @@ -46,6 +48,11 @@ public async Task LoadAsync(int seasonId) ObjectiveCompletion.Clear(); foreach (var r in await _repo.LoadObjectiveCompletionAsync(seasonId)) ObjectiveCompletion.Add(r); + + TodaysDailyObjectives.Clear(); + foreach (var r in await _repo.LoadTodaysDailyObjectivesAsync(seasonId)) + TodaysDailyObjectives.Add(r); + HasTodaysDailyObjectives = TodaysDailyObjectives.Count > 0; } finally { IsLoading = false; } } From 7227c6b98fb4aef0434f997f373fc17e9e48c26d Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 23 May 2026 20:37:01 +0500 Subject: [PATCH 07/60] feat: add Today's Daily Objectives section to Statistics tab (IMPROVEMENT-024) --- .../Views/SeasonDetailView.xaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/Perpetuum.AdminTool/Views/SeasonDetailView.xaml b/src/Perpetuum.AdminTool/Views/SeasonDetailView.xaml index 69156c7..3f1bfee 100644 --- a/src/Perpetuum.AdminTool/Views/SeasonDetailView.xaml +++ b/src/Perpetuum.AdminTool/Views/SeasonDetailView.xaml @@ -452,6 +452,21 @@ + + + + + + + + + + + + From 6bbc96f62ad74be2f072c6c161a7279036e019d8 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 23 May 2026 20:44:30 +0500 Subject: [PATCH 08/60] fix: align GetObjectives sort order and clarify isFirstLoad scope (IMPROVEMENT-024) Co-Authored-By: Claude Sonnet 4.6 --- src/Perpetuum/Services/Seasons/SeasonRepository.cs | 2 +- src/Perpetuum/Services/Seasons/SeasonService.cs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Perpetuum/Services/Seasons/SeasonRepository.cs b/src/Perpetuum/Services/Seasons/SeasonRepository.cs index 3035e52..94320ca 100644 --- a/src/Perpetuum/Services/Seasons/SeasonRepository.cs +++ b/src/Perpetuum/Services/Seasons/SeasonRepository.cs @@ -55,7 +55,7 @@ public List GetObjectives(int seasonId) { return Db.Query("SELECT id, season_id, name, description, activity_type, " + "target_value, bonus_points, display_order, is_daily, package_id, target_definition_id " + - "FROM season_objectives WHERE season_id = @seasonId") + "FROM season_objectives WHERE season_id = @seasonId ORDER BY display_order") .SetParameter("@seasonId", seasonId) .Execute() .Select(r => new SeasonObjective diff --git a/src/Perpetuum/Services/Seasons/SeasonService.cs b/src/Perpetuum/Services/Seasons/SeasonService.cs index 81f6a85..503edaf 100644 --- a/src/Perpetuum/Services/Seasons/SeasonService.cs +++ b/src/Perpetuum/Services/Seasons/SeasonService.cs @@ -151,6 +151,7 @@ internal void RefreshCache() } else if (previous?.Id != season.Id || _dailyPool.Date == DateOnly.MinValue) { + // Fires on cold boot AND on the first RefreshCache after a season is activated via admin command. bool isFirstLoad = _dailyPool.Date == DateOnly.MinValue; var today = DateOnly.FromDateTime(DateTime.UtcNow); _dailyPool = new DailyPool(SelectDailyPool(season, _activeObjectives, today), today); From 90cd610bffaae8f0360ed0dc47a002020ccb5740 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 23 May 2026 20:50:49 +0500 Subject: [PATCH 09/60] docs: mark IMPROVEMENT-024 as DONE, move to completed backlog Co-Authored-By: Claude Sonnet 4.6 --- docs/backlog/completed.md | 427 +++++++++++++++++++++++++++++++++++ docs/backlog/improvements.md | 393 +++++++------------------------- 2 files changed, 503 insertions(+), 317 deletions(-) diff --git a/docs/backlog/completed.md b/docs/backlog/completed.md index ef12ee5..ef4d31b 100644 --- a/docs/backlog/completed.md +++ b/docs/backlog/completed.md @@ -248,6 +248,29 @@ refresh already calls `LookupCache.RefreshAllAsync` but does not call `Entities. --- +## IMPROVEMENT-024 - Server Restart: Daily Objective Announcement and Admin Tool Statistics + +Status: DONE +Priority: HIGH +Area: Seasons / Objectives / Admin Tool + +### Description +Two related improvements to daily objective visibility: + +1. **Server restart announcement** — on startup (or first pool computation after season activation), if an active season with daily objectives is configured, announce today's active objectives via the Seasons Info channel. Guard: fires only when `_dailyPool.Date == DateOnly.MinValue` (uninitialized pool), not on periodic 5-minute cache refreshes. + +2. **Admin Tool Season Statistics tab** — added "Today's Daily Objectives" section showing today's active pool and per-objective completion counts. Pool computed in C# using identical seeded Fisher-Yates algorithm as the server (`seed = seasonId * 397 ^ day.DayNumber`). Server's `GetObjectives` query aligned with `ORDER BY display_order` to ensure both sides shuffle from the same input order. + +### Implementation +- `src/Perpetuum/Services/Seasons/SeasonService.cs` — `isFirstLoad` guard in `RefreshCache()` +- `src/Perpetuum/Services/Seasons/SeasonRepository.cs` — added `ORDER BY display_order` to `GetObjectives` +- `src/Perpetuum.AdminTool/Seasons/TodaysDailyObjectiveRow.cs` — new record +- `src/Perpetuum.AdminTool/Seasons/SeasonRepository.cs` — `LoadTodaysDailyObjectivesAsync` +- `src/Perpetuum.AdminTool/ViewModels/SeasonStatisticsViewModel.cs` — `TodaysDailyObjectives` collection +- `src/Perpetuum.AdminTool/Views/SeasonDetailView.xaml` — new Statistics tab section + +--- + ## ISSUE-012 - New Robot dialog: incorrect entity filtering in clone pickers Status: DONE @@ -409,3 +432,407 @@ Without `IsRobot` defaulting to true, operators must manually check it every tim - `CategoryFlagsNode.ContainsOrEquals` handles both exact match and descendant matching, so sub-types within each category are included automatically. - `StatsPanelViewModel.LoadFromClone` already supports the "Original" column display — no changes needed to that class. - The main entity clone picker (existing) is not affected; it continues to use the robots-only `BuildRobotItems` filter (see ISSUE-012). + +--- + +## ISSUE-013 - Robot creation does not populate options field with part definitions + +Status: DONE +Priority: HIGH +Area: Game Content / Robots + +### Problem +When a new robot is added, the `options` field for the robot entity is not populated with its part definitions in `GenXY` format. The options field must contain entries such as: + +``` +#head=n3036 +#chassis=n3037 +#leg=n3038 +#inventory=n332 +``` + +If new robot parts are created as part of the robot creation process, the definitions generated for those parts must be referenced in these options entries. + +### Impact +Robots without correctly populated options are non-functional in-game — the server cannot resolve their component parts, preventing spawning, equipping, or use of the robot. + +### Proposed Fix +- Identify where robot entity creation writes the `options` field (content SQL pipeline or admin tool robot creation flow). +- Ensure that after part definitions are created (head, chassis, leg, inventory), their resolved definition IDs are written back to the robot's `options` field using the `#head=nXXXX` / `#chassis=nXXXX` / `#leg=nXXXX` / `#inventory=nXXXX` format. +- If part definitions are generated dynamically, the options population step must run after the part definitions exist and reference their actual IDs. + +### Notes +Part definition IDs must be resolved dynamically — do not hardcode. +Follows the `GenXY` naming convention where `n` prefix denotes a definition reference by numeric ID. + +--- + +## ISSUE-014 - Robot part clone does not copy or expose options field for editing + +Status: DONE +Priority: HIGH +Area: Game Content / Robots / Admin Tool + +### Problem +When cloning a robot part, the `options` field is not carried over from the source part and is not presented in the editor. The clone workflow leaves the options field empty and provides no way to review or modify it before committing. + +### Impact +Cloned robot parts silently lose their options data, requiring manual correction after the fact. This is error-prone and inconsistent with the rest of the clone workflow. + +### Proposed Fix +- Copy the source part's `options` field into the clone candidate at the point of clone creation. +- Expose the options field in the clone editor using the same old/new pattern already used on the Basic tab: display the original value as read-only on the left, and provide an editable new value field on the right. +- Reuse the existing old/new field component — do not introduce a new pattern. + +### Notes +Follow the existing Basic tab old/new UI pattern exactly for consistency. +The old (source) value must be read-only; only the new value field is editable. + +--- + +## ISSUE-015 - Seasons Objectives tab: selected target not rendered in table cell + +Status: DONE +Priority: HIGH +Area: Seasons / Admin Tool + +### Problem +On the Admin Tool Seasons Objectives tab, when an objective has a target selected, the chosen value is not displayed in the table cell. The value is stored and visible when the user clicks into the cell (via the picker), but the table column renders as blank. + +### Impact +Operators cannot confirm at a glance which target is assigned to each objective. They must click every cell individually to audit or verify configurations, making bulk review error-prone and slow. + +### Proposed Fix +- Locate the cell template / data binding for the target column in the Objectives tab DataGrid. +- Identify why the display path does not render the selected value (likely a missing `DisplayMemberPath`, wrong binding path, or the display value not being propagated back to the row model after picker selection). +- Ensure the table cell shows the human-readable target label (same value visible in the picker) once a target is selected, without requiring the user to click the cell. + +### Notes +The picker itself works correctly — the issue is purely in how the selected value is reflected back to the table row display. +Check whether the binding uses a converter or a nested property that is not notifying change on selection commit. + +--- + +## ISSUE-016 - Saving Daily Objectives Per Day in AdminTool causes varchar to datetime cast error + +Status: DONE +Priority: CRITICAL +Area: Seasons / Admin Tool + +### Problem +In the AdminTool Seasons view, saving the Daily Objectives Per Day field produces a SQL cast error: implicit or explicit conversion from varchar to datetime fails. The save operation aborts and the value is not persisted. + +### Impact +Operators cannot configure Daily Objectives Per Day at all — the field is effectively broken. Any season that requires this setting cannot be properly administered. + +### Root Cause +The `start_time` and `end_time` string literals in `SeasonChanges.BuildInsert` / `BuildUpdate` and `SeasonWizardViewModel.BuildSeasonScript` used the format `'yyyy-MM-dd HH:mm:ss'` (space separator). SQL Server's implicit varchar-to-datetime conversion for this format is locale/DATEFORMAT-sensitive. The ISO 8601 format `'yyyy-MM-ddTHH:mm:ss'` (T separator) is always accepted by SQL Server regardless of collation or DATEFORMAT. The `daily_objectives_per_day` field itself (`SqlLiteral.OfNullableInt`) is correct — it generates a numeric literal or NULL. The error surfaced when users first exercised the Save General path after the new field gave them a reason to use it. + +### Fix +Changed `yyyy-MM-dd HH:mm:ss` → `yyyy-MM-ddTHH:mm:ss` in: +- `SeasonChanges.cs` `BuildInsert` and `BuildUpdate` (both start_time and end_time) +- `SeasonWizardViewModel.cs` `BuildSeasonScript` + +### Notes +Field was recently introduced (commits `837d188`, `0e59ae9`, `6d5432c`, `b442883`). +`daily_objectives_per_day` column type is `smallint [null]` — confirmed correct in schema docs. + +--- + +## ISSUE-017 - Seasons Objectives tab: Activity type selector does not show all active activity types + +Status: DONE +Priority: CRITICAL +Area: Seasons / Admin Tool + +### Problem +On the Admin Tool Seasons Objectives tab, the Activity type selector (dropdown/picker) did not display all active activity types. The Phase 1 (non-combat) and Phase 2 (combat) types added to `SeasonActivityType` were never added to the UI option lists. + +### Root Cause +`SeasonDetailViewModel.ActivityTypeOptions` and `SeasonWizardViewModel.ObjectiveActivityTypeOptions` were both hardcoded lists of 9 types. `SeasonActivityType` has 21 values — 12 were absent from both lists: `Prototyping`, `ReverseEngineering`, `Production`, `ArtifactFound`, `EpEarned`, `DamageDone`, `DamageReceived`, `ArmorRestored`, `EnergyDrainDealt`, `EnergyDrainReceived`, `EnergyTransferDealt`, `EnergyTransferReceived`. + +### Fix +Added all 12 missing types to `ActivityTypeOptions` in `SeasonDetailViewModel.cs` and `ObjectiveActivityTypeOptions` in `SeasonWizardViewModel.cs`. Labels match `SeasonActivityRateRow.ActivityTypeLabel`. + +--- + +## ISSUE-018 - SeasonRepository.GetActiveSeason throws InvalidCastException on daily_objectives_per_day + +Status: DONE +Priority: CRITICAL +Area: Seasons / Server + +### Problem +The server crashed on every `SeasonService.Update` tick with `System.InvalidCastException: Unable to cast object of type 'System.Int16' to type 'System.Nullable\`1[System.Int32]'` when an active season existed. + +### Root Cause +`daily_objectives_per_day` is `smallint [null]` in the DB — SQL Server returns a boxed `System.Int16`. `DataRecordExtensions.GetValue` does a direct unbox cast `(T)record.GetValue(index)`. The CLR cannot unbox an `Int16` as `Nullable` — the unbox target must match the stored type exactly. The crash occurred in all three season-loading methods: `GetActiveSeason`, `GetSeasonById`, and `GetPendingRecurringSeason`. + +The AdminTool's `SeasonRepository` already handled this correctly with explicit `reader.GetInt16(11)` → `(int)` widening. + +### Fix +Changed all three `record.GetValue("daily_objectives_per_day")` calls to `(int?)record.GetValue("daily_objectives_per_day")`. This reads the value with the correct CLR type (`Int16`) and widens to `int?` at the call site. `Season.DailyObjectivesPerDay` stays `int?` — no downstream changes required. + +### Notes +`recurrence_gap_days` is `int [null]` — `GetValue` is correct there and is not affected. +`GetValue` has no numeric widening; other smallint/tinyint columns read as `int?` will hit the same issue if introduced. + +--- + +## ISSUE-019 - CI build fails for AdminToolInstaller: NETSDK1047 missing RID target in assets file + +Status: DONE +Priority: HIGH +Area: Build / CI + +### Problem +The CI pipeline step `dotnet build src/Perpetuum.AdminToolInstaller/Perpetuum.AdminToolInstaller.wixproj --no-restore --configuration Release -p:Platform=x64` fails with: + +``` +NETSDK1047: Assets file '...Perpetuum.AdminTool\obj\project.assets.json' doesn't have a target for 'net8.0-windows/win-x64'. +Ensure that restore has run and that you have included 'net8.0-windows' in the TargetFrameworks for your project. +You may also need to include 'win-x64' in your project's RuntimeIdentifiers. +``` + +### Impact +The AdminTool installer cannot be built in CI, blocking release packaging of the AdminTool. + +### Root Cause +The build step uses `--no-restore`, so NuGet restore never runs for the `Perpetuum.AdminTool` dependency. The assets file in `obj/` is either absent or was produced by a prior restore without the `win-x64` RID, so the SDK cannot resolve the `net8.0-windows/win-x64` target. + +### Proposed Fix +One or more of: +1. Add a `dotnet restore` step for `Perpetuum.AdminToolInstaller.wixproj` (or the full solution) before the `--no-restore` build, with `-p:RuntimeIdentifier=win-x64`. +2. Ensure `Perpetuum.AdminTool.csproj` declares `win-x64` so restore always produces the required RID target. +3. Alternatively, drop `--no-restore` from the AdminToolInstaller build step and rely on the SDK to restore inline. + +### Notes +The error path is `D:\a\...` (GitHub Actions runner). The fix must be applied to `.github/workflows/dotnet.yml` and/or the `.csproj`. + +--- + +## IMPROVEMENT-005 - Seasons: Additional Activity Types + +Status: DONE +Priority: MEDIUM +Area: Seasons / Activities + +### Description +Expanded 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. + +### 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 + +### 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 +Distance Travelled was deferred — see [[IMPROVEMENT-015]]. + +--- + +## IMPROVEMENT-006 - Daily Objectives + +Status: DONE +Priority: MEDIUM +Area: Seasons / Objectives + +### Description +Introduced daily objectives: a set of objectives that reset and re-issue automatically every day. The system reuses and extends existing objective infrastructure, adding only the recurrence scheduling layer on top. + +### Implementation +Extended `season_objectives` with `is_daily` (bit) and `package_id` (int, nullable). Added `day_window` (date, sentinel `1900-01-01` for regular, `UtcNow.Date` for daily) to `season_objective_progress` and rebuilt its PK to `(character_id, season_id, objective_id, day_window)`. No reset scheduler needed — fresh row per day via existing MERGE. Optional reward package delivered on daily completion via `InsertRedeemableItems`. Admin Tool gains Is Daily checkbox column, Reward Package combobox column, and All/One-time/Daily filter. Branch: `p36.1`. + +### Notes +Depends on [[ISSUE-001]] — daily reset boundary must use UTC to be consistent across deployments. +See [[IMPROVEMENT-005]] for new activity types that could back daily objective targets. +See [[IMPROVEMENT-001]] for recurring season design — daily objectives are a finer-grained recurrence within a season. +Reset time is hardcoded UTC midnight (configurable reset time deferred). + +--- + +## IMPROVEMENT-009 - Targeted Objectives + +Status: DONE +Priority: LOW +Area: Seasons / Objectives +Spec: `docs/superpowers/specs/2026-05-19-improvement-009-targeted-objectives-design.md` + +### Description +Extended the objective system to support targeted objectives, where a specific subject must be matched for progress to count. The target is activity-type-dependent — for example, a mining objective can target a specific ore type, a kill objective can target an NPC role or rank, a production objective can target an item category, and so on. + +### Impact +Targeted objectives allow season designers to create more varied and specific challenges, directing player behaviour toward particular content rather than rewarding any activity of a given type. + +### Notes +Depends on [[IMPROVEMENT-005]] for the activity types that targeted objectives filter against. +NPC rank/role filtering (see [[IMPROVEMENT-007]], [[IMPROVEMENT-008]]) was not implemented in this pass — NPC kill targets require those systems to be built first. + +--- + +## IMPROVEMENT-012 - Seasons Tiers tab: on-the-fly save generating a single change script + +Status: DONE +Priority: HIGH +Area: Seasons / Admin Tool +Spec: `docs/superpowers/specs/2026-05-16-improvement-012-tiers-tab-queue-save-design.md` + +### Description +The Tiers tab in the Seasons Admin Tool was refactored to adopt the same on-the-fly save mechanic used by Activity Rates and Objectives tabs — producing a single consolidated change script per save. All three tabs now behave consistently. + +### Implementation +Audited Activity Rates and Objectives save pattern (diff computation, script generation, transaction wrapper) and extended it to cover tier definitions (name, point threshold, reward). The generated script follows the same format and conventions as Activity Rates and Objectives saves. + +### 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. +Preserved existing tier DB schema — this improvement changed the save UI mechanic only, not the underlying data model. + +--- + +## IMPROVEMENT-017 - New Item script filename includes definition name + +Status: DONE +Priority: LOW +Area: Admin Tool / New Item Dialog +Spec: `docs/superpowers/specs/2026-05-18-improvement-017-script-filename-prefixes-design.md` + +### Description +When saving a new item in SqlScript mode, the output `.sql` file is now named `__